Skip to main content

geometry_algorithm/
area.rs

1//! `area(&g)` — see `boost/geometry/algorithms/area.hpp`.
2//!
3//! Cartesian-only in v1; spherical / geographic area strategies arrive
4//! alongside the Haversine / Andoyer / Vincenty work in later tasks.
5//!
6//! # Why four entry points
7//!
8//! Boost overloads on the same name `area` and resolves the right
9//! per-tag `dispatch::area` arm at the call site
10//! (`algorithms/area.hpp:149-187`). Rust has no overloading, and the
11//! Cartesian strategy in `geometry-strategy::area` is intentionally
12//! split into four sibling unit-structs (one per geometry kind) to
13//! sidestep coherence — see that module's docs. The split is
14//! reflected here as four entry points keyed on the input geometry's
15//! kind; the names follow the same `<kind>_area` shape that
16//! `length` / `perimeter` already use for the same reason
17//! (`crates/geometry-algorithm/src/length.rs`).
18
19use geometry_cs::CoordinateSystem;
20use geometry_strategy::{
21    AreaStrategy, DefaultArea, DefaultAreaStrategy, ShoelaceArea, ShoelaceBoxArea,
22    ShoelaceMultiPolygonArea,
23};
24use geometry_trait::{Box, Geometry, MultiPolygon, Point, Polygon, Ring};
25
26/// Shorthand for the CS family of `G`'s point type. Keeps the `where`
27/// clauses readable; matches the projection in
28/// [`geometry_strategy::DefaultAreaStrategy`].
29type Family<G> = <<<G as Geometry>::Point as Point>::Cs as CoordinateSystem>::Family;
30
31/// Signed area of a [`Ring`] via the Cartesian shoelace formula.
32///
33/// Mirrors `boost::geometry::area(ring)` from
34/// `boost/geometry/algorithms/area.hpp` resolved through the
35/// `dispatch::area<Ring, ring_tag>` arm at
36/// `algorithms/area.hpp:154-157`.
37///
38/// # Sign convention
39///
40/// Follows Boost: rings traversed in their declared
41/// [`geometry_trait::PointOrder`] produce a positive area,
42/// rings traversed in the opposite direction produce a negative area
43/// (`test/algorithms/area/area.cpp:63-64`).
44#[inline]
45#[must_use]
46pub fn ring_area<R>(r: &R) -> <ShoelaceArea as AreaStrategy<R>>::Out
47where
48    R: Ring,
49    ShoelaceArea: AreaStrategy<R>,
50{
51    ShoelaceArea.area(r)
52}
53
54/// Signed area of a [`Polygon`]: exterior-ring area plus the sum of
55/// interior-ring areas (interior rings are conventionally wound
56/// opposite the exterior, so their signed area already cancels).
57///
58/// Mirrors `boost::geometry::area(polygon)` from
59/// `boost/geometry/algorithms/area.hpp` resolved through the
60/// `dispatch::area<Polygon, polygon_tag>` arm at
61/// `algorithms/area.hpp:160-172`.
62///
63/// The area is computed with the default strategy for the polygon's
64/// coordinate-system family via
65/// [`geometry_strategy::DefaultAreaStrategy`] — Cartesian input
66/// resolves to the shoelace
67/// [`geometry_strategy::ShoelacePolygonArea`] (identical to the v1
68/// behaviour), spherical to the spherical-excess
69/// [`geometry_strategy::SphericalPolygonArea`], geographic to the
70/// authalic-sphere [`geometry_strategy::GeographicPolygonArea`]. For an
71/// explicit strategy use [`area_with`].
72///
73/// # Behaviour on "wrong" kinds
74///
75/// This *static* entry point requires a `Polygon`; a `Point` or
76/// `LineString` argument is a compile error. Boost's runtime "area of
77/// a non-areal kind is 0" contract is honoured on the *dynamic* path:
78/// [`crate::area_dyn`] returns `0` for `Point`,
79/// `LineString`, `MultiPoint`, … (the static/dynamic split is the
80/// same coherence workaround described on [`length`](fn@crate::length)).
81#[inline]
82#[must_use]
83pub fn area<P>(p: &P) -> <DefaultAreaStrategy<P> as AreaStrategy<P>>::Out
84where
85    P: Polygon,
86    Family<P>: DefaultArea<Family<P>>,
87    DefaultAreaStrategy<P>: AreaStrategy<P> + Default,
88{
89    DefaultAreaStrategy::<P>::default().area(p)
90}
91
92/// Area of a polygon using an explicitly supplied strategy.
93///
94/// Mirrors the `area(g, strategy)` overload at
95/// `boost/geometry/algorithms/area.hpp`. Taking the strategy by value
96/// matches the by-value call shape of [`crate::distance_with`];
97/// concrete strategies are zero-sized or small `Copy` configuration
98/// objects, so this monomorphises into nothing.
99#[inline]
100#[must_use]
101#[allow(
102    clippy::needless_pass_by_value,
103    reason = "Strategies are ZST/small Copy configuration objects; by-value matches the user-facing call shape."
104)]
105pub fn area_with<G, S>(g: &G, s: S) -> S::Out
106where
107    G: Geometry,
108    S: AreaStrategy<G>,
109{
110    s.area(g)
111}
112
113/// Area of an axis-aligned [`Box`]: `(xmax - xmin) * (ymax - ymin)`.
114///
115/// Mirrors `boost::geometry::area(box)` from
116/// `boost/geometry/algorithms/area.hpp` resolved through the
117/// `dispatch::area<Box, box_tag>` arm at
118/// `algorithms/area.hpp:149-151`. Always non-negative — the Cartesian
119/// box formula is sign-blind to corner ordering
120/// (`test/algorithms/area/area.cpp:56-57`).
121#[inline]
122#[must_use]
123pub fn box_area<B>(b: &B) -> <ShoelaceBoxArea as AreaStrategy<B>>::Out
124where
125    B: Box,
126    ShoelaceBoxArea: AreaStrategy<B>,
127{
128    ShoelaceBoxArea.area(b)
129}
130
131/// Signed area of a [`MultiPolygon`]: sum of the signed areas of its
132/// member polygons.
133///
134/// Mirrors `boost::geometry::area(multi_polygon)` from
135/// `boost/geometry/algorithms/area.hpp` resolved through the
136/// `dispatch::area<MultiGeometry, multi_polygon_tag>` arm at
137/// `algorithms/area.hpp:175-187`.
138#[inline]
139#[must_use]
140pub fn multi_polygon_area<MPg>(mpg: &MPg) -> <ShoelaceMultiPolygonArea as AreaStrategy<MPg>>::Out
141where
142    MPg: MultiPolygon,
143    ShoelaceMultiPolygonArea: AreaStrategy<MPg>,
144{
145    ShoelaceMultiPolygonArea.area(mpg)
146}
147
148#[cfg(test)]
149mod tests {
150    //! Reference values from `geometry/test/algorithms/area/area.cpp`
151    //! (lines 45-64) and `area_sph_geo.cpp` / `area_geo.cpp` for the
152    //! spherical / geographic dispatch cases; see also the quickstart
153    //! `Area: 3.015` example.
154    #![allow(
155        clippy::float_cmp,
156        reason = "areas are compared with an explicit tolerance, not `==`"
157    )]
158
159    use super::{area, area_with, box_area, multi_polygon_area, ring_area};
160    use geometry_cs::Cartesian;
161    use geometry_model::{Box, MultiPolygon, Point2D, Polygon, Ring, polygon};
162
163    type P = Point2D<f64, Cartesian>;
164
165    /// `area.cpp:45` — rotated unit square diamond, area = 2.
166    #[test]
167    fn diamond_polygon_is_2() {
168        let p: Polygon<P> = polygon![[(1.0, 1.0), (2.0, 2.0), (3.0, 1.0), (2.0, 0.0), (1.0, 1.0)]];
169        assert!((area(&p) - 2.0).abs() < 1e-12);
170    }
171
172    /// `area.cpp:47` — pentagon, area = 16.
173    #[test]
174    fn pentagon_is_16() {
175        let p: Polygon<P> = polygon![[(0.0, 0.0), (0.0, 7.0), (4.0, 2.0), (2.0, 0.0), (0.0, 0.0)]];
176        assert!((area(&p) - 16.0).abs() < 1e-12);
177    }
178
179    /// `area.cpp:48` — unit-square CCW on default-CW polygon → -1.
180    #[test]
181    fn ccw_unit_square_is_minus_1() {
182        let p: Polygon<P> = polygon![[(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)]];
183        assert!((area(&p) - -1.0).abs() < 1e-12);
184    }
185
186    /// `area.cpp:49` — pentagon (16) with a unit-square hole (-1) = 15.
187    #[test]
188    fn pentagon_with_hole_is_15() {
189        let p: Polygon<P> = polygon![
190            [(0.0, 0.0), (0.0, 7.0), (4.0, 2.0), (2.0, 0.0), (0.0, 0.0)],
191            [(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)],
192        ];
193        assert!((area(&p) - 15.0).abs() < 1e-12);
194    }
195
196    /// `area.cpp:56-57` — a 2x2 box has area 4, regardless of corner
197    /// order.
198    #[test]
199    fn box_2x2_is_4() {
200        let b = Box::from_corners(
201            Point2D::<f64, Cartesian>::new(0.0, 0.0),
202            Point2D::<f64, Cartesian>::new(2.0, 2.0),
203        );
204        assert!((box_area(&b) - 4.0).abs() < 1e-12);
205    }
206
207    /// `area.cpp:63` — ring directly (no polygon wrapper), area = 16.
208    #[test]
209    fn ring_pentagon_is_16() {
210        let r: Ring<P> = Ring::from_vec(vec![
211            Point2D::new(0.0, 0.0),
212            Point2D::new(0.0, 7.0),
213            Point2D::new(4.0, 2.0),
214            Point2D::new(2.0, 0.0),
215            Point2D::new(0.0, 0.0),
216        ]);
217        assert!((ring_area(&r) - 16.0).abs() < 1e-12);
218    }
219
220    /// `doc/quickstart.qbk` — the canonical "Area: 3.015" example.
221    #[test]
222    fn quickstart_polygon_area_is_3_015() {
223        let p: Polygon<P> = polygon![[(2.0, 1.3), (4.1, 3.0), (5.3, 2.6), (2.9, 0.7), (2.0, 1.3)]];
224        assert!((area(&p) - 3.015).abs() < 1e-3);
225    }
226
227    /// Multi-polygon: two disjoint CW unit squares → area = 2.
228    #[test]
229    fn multi_polygon_two_unit_squares_is_2() {
230        let unit_at = |x: f64, y: f64| -> Polygon<P> {
231            polygon![[
232                (x, y),
233                (x, y + 1.0),
234                (x + 1.0, y + 1.0),
235                (x + 1.0, y),
236                (x, y)
237            ]]
238        };
239        let mpg: MultiPolygon<Polygon<P>> =
240            MultiPolygon::from_vec(vec![unit_at(0.0, 0.0), unit_at(5.0, 0.0)]);
241        assert!((multi_polygon_area(&mpg) - 2.0).abs() < 1e-12);
242    }
243
244    /// `area_sph_geo.cpp:93-106` — strategy-less `area` on a spherical
245    /// polygon resolves to `SphericalPolygonArea`; `POLYGON((0 0,0 90,
246    /// 90 0,0 0))` on the default (Earth) sphere covers `1/8` of it,
247    /// i.e. `4π/8 · R²`.
248    #[cfg(feature = "std")]
249    #[test]
250    fn spherical_area_dispatches_to_spherical_excess() {
251        use geometry_adapt::{Adapt, WithCs};
252        use geometry_cs::{Degree, Spherical};
253
254        type Sp = WithCs<Adapt<[f64; 2]>, Spherical<Degree>>;
255        let sp = |lon: f64, lat: f64| -> Sp { WithCs::new(Adapt([lon, lat])) };
256
257        let pg: Polygon<Sp> = Polygon::new(Ring::from_vec(vec![
258            sp(0., 0.),
259            sp(0., 90.),
260            sp(90., 0.),
261            sp(0., 0.),
262        ]));
263        let got = area(&pg);
264        // Default SphericalPolygonArea uses R = 6_371_000 m.
265        let r = 6_371_000.0_f64;
266        let expected = core::f64::consts::FRAC_PI_2 * r * r;
267        assert!(
268            (got - expected).abs() / expected < 1e-6,
269            "got {got} expected {expected}"
270        );
271    }
272
273    /// A spherical polygon with an oppositely-wound interior ring (hole)
274    /// subtracts the hole's area: outer octant minus a smaller hole is
275    /// strictly less than the whole octant but still positive.
276    #[cfg(feature = "std")]
277    #[test]
278    fn spherical_polygon_with_hole_subtracts_inner_area() {
279        use geometry_adapt::{Adapt, WithCs};
280        use geometry_cs::{Degree, Spherical};
281        use geometry_strategy::SphericalPolygonArea;
282
283        type Sp = WithCs<Adapt<[f64; 2]>, Spherical<Degree>>;
284        let sp = |lon: f64, lat: f64| -> Sp { WithCs::new(Adapt([lon, lat])) };
285
286        // Outer: the full octant (CW, positive). Hole: a small triangle
287        // wound CCW (opposite), so its excess is negative.
288        let outer = Ring::from_vec(vec![sp(0., 0.), sp(0., 90.), sp(90., 0.), sp(0., 0.)]);
289        let hole = Ring::from_vec(vec![sp(10., 10.), sp(30., 10.), sp(20., 20.), sp(10., 10.)]);
290        let pg: Polygon<Sp> = Polygon::with_inners(outer, vec![hole]);
291        let with_hole = area_with(&pg, SphericalPolygonArea::UNIT);
292        let whole = core::f64::consts::FRAC_PI_2;
293        assert!(with_hole < whole, "hole must reduce area: {with_hole}");
294        assert!(with_hole > 0.0, "still positive: {with_hole}");
295    }
296
297    /// An *open* spherical ring (its closing vertex omitted) is closed
298    /// implicitly: the `last -> first` edge is added, so the area
299    /// matches the closed form.
300    #[cfg(feature = "std")]
301    #[test]
302    fn spherical_open_ring_closes_implicitly() {
303        use geometry_adapt::{Adapt, WithCs};
304        use geometry_cs::{Degree, Spherical};
305        use geometry_strategy::SphericalArea;
306
307        type Sp = WithCs<Adapt<[f64; 2]>, Spherical<Degree>>;
308        let sp = |lon: f64, lat: f64| -> Sp { WithCs::new(Adapt([lon, lat])) };
309
310        let closed: Ring<Sp> =
311            Ring::from_vec(vec![sp(0., 0.), sp(0., 90.), sp(90., 0.), sp(0., 0.)]);
312        let open: Ring<Sp, true, false> =
313            Ring::from_vec(vec![sp(0., 0.), sp(0., 90.), sp(90., 0.)]);
314        let a_closed = area_with(&closed, SphericalArea::UNIT);
315        let a_open = area_with(&open, SphericalArea::UNIT);
316        assert!((a_closed - a_open).abs() < 1e-9, "{a_closed} vs {a_open}");
317    }
318
319    /// A spherical ring crossing the antimeridian drives Δlon
320    /// normalisation (the `±2π` wrap): a thin sliver straddling ±180°
321    /// must yield a small excess, not a ~2π artefact.
322    #[cfg(feature = "std")]
323    #[test]
324    fn spherical_antimeridian_crossing_normalises_dlon() {
325        use geometry_adapt::{Adapt, WithCs};
326        use geometry_cs::{Degree, Spherical};
327        use geometry_strategy::SphericalArea;
328
329        type Sp = WithCs<Adapt<[f64; 2]>, Spherical<Degree>>;
330        let sp = |lon: f64, lat: f64| -> Sp { WithCs::new(Adapt([lon, lat])) };
331
332        let r: Ring<Sp> = Ring::from_vec(vec![
333            sp(170., 0.),
334            sp(170., 10.),
335            sp(-170., 10.),
336            sp(-170., 0.),
337            sp(170., 0.),
338        ]);
339        let got = area_with(&r, SphericalArea::UNIT).abs();
340        // A 20°×10° sliver subtends far less than a hemisphere (2π).
341        assert!(got < 0.2, "expected a small sliver area, got {got}");
342    }
343
344    /// `area_geo.cpp` — strategy-less `area` on a geographic polygon
345    /// resolves to the authalic-sphere `GeographicPolygonArea`; a
346    /// 1° × 1° box near the equator on WGS84 ≈ `12_309` km² (within 2 %).
347    #[cfg(feature = "std")]
348    #[test]
349    fn geographic_area_dispatches_to_authalic_sphere() {
350        use geometry_adapt::{Adapt, WithCs};
351        use geometry_cs::{Degree, Geographic};
352
353        type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
354        let gg = |lon: f64, lat: f64| -> Gg { WithCs::new(Adapt([lon, lat])) };
355
356        let pg: Polygon<Gg> = Polygon::new(Ring::from_vec(vec![
357            gg(0., 0.),
358            gg(1., 0.),
359            gg(1., 1.),
360            gg(0., 1.),
361            gg(0., 0.),
362        ]));
363        let got = area(&pg).abs();
364        let expected = 12_309e6;
365        assert!((got - expected).abs() / expected < 0.02);
366    }
367
368    /// `area_geo.cpp` — a geographic polygon with a hole wound opposite
369    /// the outer ring: `area(polygon)` = outer − hole (the interior-ring
370    /// subtraction loop of `GeographicPolygonArea`).
371    #[cfg(feature = "std")]
372    #[test]
373    fn geographic_polygon_with_hole_subtracts_hole_area() {
374        use geometry_adapt::{Adapt, WithCs};
375        use geometry_cs::{Degree, Geographic};
376
377        type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
378        let gg = |lon: f64, lat: f64| -> Gg { WithCs::new(Adapt([lon, lat])) };
379
380        let outer = Ring::from_vec(vec![
381            gg(0., 0.),
382            gg(1., 0.),
383            gg(1., 1.),
384            gg(0., 1.),
385            gg(0., 0.),
386        ]);
387        // Same winding sense reversed → opposite-signed excess: a hole.
388        let hole: Ring<Gg> = Ring::from_vec(vec![
389            gg(0.2, 0.2),
390            gg(0.2, 0.8),
391            gg(0.8, 0.8),
392            gg(0.8, 0.2),
393            gg(0.2, 0.2),
394        ]);
395        let outer_only = area(&Polygon::new(outer.clone())).abs();
396        let hole_alone = area(&Polygon::new(hole.clone())).abs();
397        let holed = Polygon::with_inners(outer, vec![hole]);
398        let got = area(&holed).abs();
399        let expected = outer_only - hole_alone;
400        assert!(
401            (got - expected).abs() / expected < 1e-9,
402            "got {got}, expected outer − hole = {expected}"
403        );
404    }
405
406    /// A geographic ring declared *open* closes implicitly: same area
407    /// as the explicitly closed ring (the closing-edge branch of the
408    /// excess accumulator).
409    #[cfg(feature = "std")]
410    #[test]
411    fn geographic_open_ring_closes_implicitly() {
412        use geometry_adapt::{Adapt, WithCs};
413        use geometry_cs::{Degree, Geographic};
414
415        type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
416        let gg = |lon: f64, lat: f64| -> Gg { WithCs::new(Adapt([lon, lat])) };
417
418        let open: Polygon<Gg, true, false> = Polygon::new(Ring::from_vec(vec![
419            gg(0., 0.),
420            gg(1., 0.),
421            gg(1., 1.),
422            gg(0., 1.),
423        ]));
424        let closed: Polygon<Gg> = Polygon::new(Ring::from_vec(vec![
425            gg(0., 0.),
426            gg(1., 0.),
427            gg(1., 1.),
428            gg(0., 1.),
429            gg(0., 0.),
430        ]));
431        let got_open = area(&open).abs();
432        let got_closed = area(&closed).abs();
433        assert!(
434            (got_open - got_closed).abs() / got_closed < 1e-12,
435            "open {got_open} != closed {got_closed}"
436        );
437    }
438
439    /// A counter-clockwise-declared geographic polygon negates the
440    /// signed area (the `PointOrder::CounterClockwise` arm): same
441    /// vertices, opposite declared order → opposite sign.
442    #[cfg(feature = "std")]
443    #[test]
444    fn geographic_ccw_declared_polygon_negates_sign() {
445        use geometry_adapt::{Adapt, WithCs};
446        use geometry_cs::{Degree, Geographic};
447
448        type Gg = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
449        let gg = |lon: f64, lat: f64| -> Gg { WithCs::new(Adapt([lon, lat])) };
450
451        let verts = vec![gg(0., 0.), gg(1., 0.), gg(1., 1.), gg(0., 1.), gg(0., 0.)];
452        let cw: Polygon<Gg, true> = Polygon::new(Ring::from_vec(verts.clone()));
453        let ccw: Polygon<Gg, false> = Polygon::new(Ring::from_vec(verts));
454        let sum = area(&cw) + area(&ccw);
455        assert!(
456            sum.abs() < 1e-3,
457            "signed areas do not cancel: cw + ccw = {sum}"
458        );
459        assert!(area(&cw).abs() > 1e9, "area unexpectedly tiny");
460    }
461}