geometry_strategy/spherical/area.rs
1//! Spherical surface area via the trapezoidal (spherical-excess) rule.
2//!
3//! Mirrors `boost::geometry::strategy::area::spherical` from
4//! `boost/geometry/strategies/spherical/area.hpp` together with the
5//! per-segment kernel `formula::area_formulas::spherical<false>` in
6//! `boost/geometry/formulas/area_formulas.hpp:365-416`.
7//!
8//! For each polygon edge `(p1, p2)` whose endpoints differ in
9//! longitude, Boost accumulates the segment's spherical excess via the
10//! trapezoidal formula
11//! (`area_formulas.hpp:347-356`):
12//!
13//! ```text
14//! e = 2·atan( ((tan(lat1/2) + tan(lat2/2)) / (1 + tan(lat1/2)·tan(lat2/2)))
15//! · tan(Δlon/2) )
16//! ```
17//!
18//! where `Δlon` is normalised to `(-π, π]`. The running sum of excesses
19//! is multiplied by `R²` to give the surface area
20//! (`strategies/spherical/area.hpp:80-107`). On a unit sphere
21//! (`radius = 1`) the result is the *solid angle* the polygon subtends;
22//! a polygon covering `1/8` of the sphere returns `4π/8 = π/2`
23//! (`test/algorithms/area/area_sph_geo.cpp:93-106`).
24//!
25//! # Sign convention
26//!
27//! Follows the Cartesian [`ShoelaceArea`](crate::area::ShoelaceArea):
28//! a ring traversed in its declared [`PointOrder`] yields a positive
29//! area, the opposite traversal a negative one — Boost's
30//! `closed_clockwise_view` wrap, expressed here as a sign flip for a
31//! [`PointOrder::CounterClockwise`] ring.
32//!
33//! # Caveats
34//!
35//! This implements Boost's `LongSegment = false` branch — the default.
36//! The pole-encircling correction (`m_crosses_prime_meridian`) is *not*
37//! reproduced: polygons that wind around a pole or cross the antimeridian
38//! an odd number of times are out of scope, matching the "convex
39//! spherical polygon" clamp documented on the spherical centroid. Holes
40//! contribute negatively, same as the Cartesian shoelace.
41
42use geometry_cs::{CoordinateSystem, SphericalFamily};
43use geometry_tag::SameAs;
44use geometry_trait::{Closure, Point, PointOrder, Polygon, Ring};
45
46use crate::area::AreaStrategy;
47
48// Rust coherence cannot prove a single type is not both a `Ring` and a
49// `Polygon`, so — exactly as the Cartesian `ShoelaceArea` /
50// `ShoelacePolygonArea` split (see `crate::area` module docs) — the
51// spherical area is two sibling types, one per geometry kind.
52
53#[cfg(feature = "std")]
54use crate::normalise::{HasAngularUnits, lonlat_radians};
55
56/// Spherical surface area via the trapezoidal spherical-excess rule.
57///
58/// Carries the sphere `radius`; the result of
59/// [`AreaStrategy::area`] is in *squared radius units* (m² for
60/// [`SphericalArea::EARTH`], steradians for [`SphericalArea::UNIT`]).
61/// `Default::default()` produces [`SphericalArea::EARTH`].
62///
63/// Mirrors `boost::geometry::strategy::area::spherical<>` from
64/// `strategies/spherical/area.hpp`.
65#[derive(Debug, Clone, Copy)]
66pub struct SphericalArea {
67 /// Sphere radius. The area comes back in these units squared.
68 pub radius: f64,
69}
70
71impl SphericalArea {
72 /// Mean Earth radius in metres, matching Boost's spherical Earth
73 /// convention (`test/algorithms/area/area_sph_geo.cpp` uses
74 /// per-test radii; `6_371_000` m is the WGS84 mean radius).
75 pub const EARTH: Self = Self {
76 radius: 6_371_000.0,
77 };
78
79 /// Unit sphere (`radius = 1`): the area is then the solid angle
80 /// (steradians) the polygon subtends
81 /// (`area_sph_geo.cpp:93-106`).
82 pub const UNIT: Self = Self { radius: 1.0 };
83}
84
85impl Default for SphericalArea {
86 #[inline]
87 fn default() -> Self {
88 Self::EARTH
89 }
90}
91
92/// Spherical surface area for a [`Polygon`] — outer ring area plus the
93/// sum of (oppositely-wound, hence negatively-signed) interior-ring
94/// areas.
95///
96/// Separate from [`SphericalArea`] for the same coherence reason
97/// [`ShoelacePolygonArea`](crate::area::ShoelacePolygonArea) is
98/// separate from [`ShoelaceArea`](crate::area::ShoelaceArea).
99#[derive(Debug, Clone, Copy)]
100pub struct SphericalPolygonArea {
101 /// Sphere radius, forwarded to the per-ring [`SphericalArea`].
102 pub radius: f64,
103}
104
105impl SphericalPolygonArea {
106 /// Mean Earth radius in metres. See [`SphericalArea::EARTH`].
107 pub const EARTH: Self = Self {
108 radius: 6_371_000.0,
109 };
110
111 /// Unit sphere. See [`SphericalArea::UNIT`].
112 pub const UNIT: Self = Self { radius: 1.0 };
113}
114
115impl Default for SphericalPolygonArea {
116 #[inline]
117 fn default() -> Self {
118 Self::EARTH
119 }
120}
121
122// ---- Ring ------------------------------------------------------------
123
124// `std`-gated: the excess kernel it calls (`excess_accumulator` /
125// `segment_excess`) needs `f64::tan`/`atan`, which `geometry-coords`
126// does not shim under `libm`. Mirrors the identical gate on the
127// geographic sibling (`geographic::area::GeographicArea`'s impl).
128#[cfg(feature = "std")]
129impl<R> AreaStrategy<R> for SphericalArea
130where
131 R: Ring,
132 <R::Point as Point>::Cs: CoordinateSystem,
133 <<R::Point as Point>::Cs as CoordinateSystem>::Family: SameAs<SphericalFamily>,
134 R::Point: Point<Scalar = f64>,
135 <R::Point as Point>::Cs: HasAngularUnits,
136{
137 type Out = f64;
138
139 #[inline]
140 fn area(&self, r: &R) -> f64 {
141 let excess = excess_accumulator::<R>(r);
142 let signed = excess * self.radius * self.radius;
143 match r.point_order() {
144 PointOrder::Clockwise => signed,
145 PointOrder::CounterClockwise => -signed,
146 }
147 }
148}
149
150// ---- Polygon ---------------------------------------------------------
151//
152// Mirrors Boost's polygon area recursion: outer ring positive, each
153// inner ring subtracted. Interior rings are conventionally wound
154// opposite the exterior, so `ShoelaceArea`'s "plain sum of signed ring
155// areas" trick applies here too — the excess of an oppositely-wound
156// hole already carries the opposite sign.
157
158#[cfg(feature = "std")]
159impl<P> AreaStrategy<P> for SphericalPolygonArea
160where
161 P: Polygon,
162 SphericalArea: AreaStrategy<P::Ring, Out = f64>,
163{
164 type Out = f64;
165
166 #[inline]
167 fn area(&self, p: &P) -> f64 {
168 let ring = SphericalArea {
169 radius: self.radius,
170 };
171 let mut total = ring.area(p.exterior());
172 for inner in p.interiors() {
173 total += ring.area(inner);
174 }
175 total
176 }
177}
178
179/// Sum the per-segment spherical excess over the consecutive vertex
180/// pairs of `r`, mirroring the `apply` accumulation in
181/// `strategies/spherical/area.hpp:127-150` and the `spherical<false>`
182/// kernel in `area_formulas.hpp:365-416`.
183///
184/// For an open ring the implicit `last -> first` closing pair is added
185/// explicitly, mirroring the way [`crate::area`] closes an open ring.
186///
187/// The kernel only reads `(lon, lat)` in radians, so it is *family-
188/// agnostic* — the geographic authalic-sphere area
189/// ([`crate::geographic::GeographicArea`]) reuses it on the authalic
190/// sphere. The family fences live on the public `AreaStrategy` impls,
191/// not here.
192#[cfg(feature = "std")]
193#[inline]
194pub(crate) fn excess_accumulator<R>(r: &R) -> f64
195where
196 R: Ring,
197 R::Point: Point<Scalar = f64>,
198 <R::Point as Point>::Cs: HasAngularUnits,
199{
200 let mut acc = 0.0;
201 let it = r.points();
202 let next = it.clone().skip(1);
203 for (a, b) in it.zip(next) {
204 acc += segment_excess::<R::Point>(a, b);
205 }
206 if matches!(r.closure(), Closure::Open) {
207 let mut first_it = r.points();
208 if let Some(first) = first_it.next() {
209 if let Some(last) = r.points().last() {
210 acc += segment_excess::<R::Point>(last, first);
211 }
212 }
213 }
214 acc
215}
216
217/// One segment's spherical excess via Boost's trapezoidal formula
218/// (`area_formulas.hpp:347-356`, `:395-403`). Returns `0` for a
219/// meridional edge (`lon1 == lon2`), mirroring the `! math::equals(
220/// get<0>(p1), get<0>(p2))` guard at
221/// `strategies/spherical/area.hpp:129`.
222// The `lon1 == lon2` check mirrors Boost's `! math::equals(get<0>(p1),
223// get<0>(p2))` guard at `strategies/spherical/area.hpp:129`
224// letter-for-letter — an intentional exact float comparison, the same
225// stance the Andoyer distance kernel takes.
226#[allow(clippy::float_cmp)]
227#[cfg(feature = "std")]
228#[inline]
229fn segment_excess<P>(a: &P, b: &P) -> f64
230where
231 P: Point<Scalar = f64>,
232 P::Cs: HasAngularUnits,
233{
234 let (lon1, lat1) = lonlat_radians(a);
235 let (lon2, lat2) = lonlat_radians(b);
236
237 // Meridional segments contribute no excess — mirrors the
238 // `! equals(get<0>(p1), get<0>(p2))` guard in area.hpp:129.
239 if lon1 == lon2 {
240 return 0.0;
241 }
242
243 // Δlon normalised to (-π, π], mirroring
244 // `math::normalize_longitude` in area_formulas.hpp:378.
245 let mut dlon = lon2 - lon1;
246 let pi = core::f64::consts::PI;
247 let two_pi = 2.0 * pi;
248 while dlon > pi {
249 dlon -= two_pi;
250 }
251 while dlon <= -pi {
252 dlon += two_pi;
253 }
254
255 let tan_lat1 = (lat1 / 2.0).tan();
256 let tan_lat2 = (lat2 / 2.0).tan();
257 2.0 * (((tan_lat1 + tan_lat2) / (1.0 + tan_lat1 * tan_lat2)) * (dlon / 2.0).tan()).atan()
258}
259
260#[cfg(all(test, feature = "std"))]
261mod tests {
262 //! Reference values from
263 //! `boost/geometry/test/algorithms/area/area_sph_geo.cpp:93-116`.
264 #![allow(
265 clippy::float_cmp,
266 reason = "areas are compared with an explicit relative tolerance, not `==`"
267 )]
268
269 use super::{SphericalArea, SphericalPolygonArea};
270 use crate::area::AreaStrategy;
271 use geometry_adapt::{Adapt, WithCs};
272 use geometry_cs::{Degree, Spherical};
273 use geometry_model::{Polygon, Ring};
274
275 type Sp = WithCs<Adapt<[f64; 2]>, Spherical<Degree>>;
276
277 #[inline]
278 fn sp(lon: f64, lat: f64) -> Sp {
279 WithCs::new(Adapt([lon, lat]))
280 }
281
282 /// `area_sph_geo.cpp:93-106` — `POLYGON((0 0,0 90,90 0,0 0))` on a
283 /// unit sphere covers `1/8` of it: `4π/8 = π/2 ≈ 1.5708`.
284 #[test]
285 fn unit_sphere_octant_is_pi_over_2() {
286 let r: Ring<Sp> = Ring::from_vec(vec![sp(0., 0.), sp(0., 90.), sp(90., 0.), sp(0., 0.)]);
287 let got = SphericalArea::UNIT.area(&r);
288 let expected = core::f64::consts::FRAC_PI_2;
289 assert!(
290 (got - expected).abs() / expected < 1e-6,
291 "got {got} expected {expected}"
292 );
293 }
294
295 /// `area_sph_geo.cpp:109-116` — the same octant on a radius-2
296 /// sphere scales by `2² = 4`.
297 #[test]
298 fn radius_2_sphere_octant_scales_by_4() {
299 let r: Ring<Sp> = Ring::from_vec(vec![sp(0., 0.), sp(0., 90.), sp(90., 0.), sp(0., 0.)]);
300 let got = SphericalArea { radius: 2.0 }.area(&r);
301 let expected = 4.0 * core::f64::consts::FRAC_PI_2;
302 assert!(
303 (got - expected).abs() / expected < 1e-6,
304 "got {got} expected {expected}"
305 );
306 }
307
308 /// The same octant traversed in the opposite direction on a
309 /// default (clockwise) ring yields a negated area — mirrors the
310 /// Cartesian `ShoelaceArea` sign convention.
311 #[test]
312 fn reversed_octant_is_negative() {
313 let r: Ring<Sp> = Ring::from_vec(vec![sp(0., 0.), sp(90., 0.), sp(0., 90.), sp(0., 0.)]);
314 let got = SphericalArea::UNIT.area(&r);
315 let expected = -core::f64::consts::FRAC_PI_2;
316 assert!((got - expected).abs() / expected.abs() < 1e-6, "got {got}");
317 }
318
319 /// Polygon path: octant with no holes equals the ring area.
320 #[test]
321 fn polygon_octant_matches_ring() {
322 let pg: Polygon<Sp> = Polygon::new(Ring::from_vec(vec![
323 sp(0., 0.),
324 sp(0., 90.),
325 sp(90., 0.),
326 sp(0., 0.),
327 ]));
328 let got = SphericalPolygonArea::UNIT.area(&pg);
329 let expected = core::f64::consts::FRAC_PI_2;
330 assert!((got - expected).abs() / expected < 1e-6, "got {got}");
331 }
332
333 /// Both strategies default to the mean-Earth radius.
334 #[test]
335 fn defaults_are_mean_earth_radius() {
336 assert_eq!(SphericalArea::default().radius, 6_371_000.0);
337 assert_eq!(SphericalPolygonArea::default().radius, 6_371_000.0);
338 }
339}