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 points = r.points();
208 if let Some(first) = points.next() {
209 let last = points.last().unwrap_or(first);
210 acc += segment_excess::<R::Point>(last, first);
211 }
212 }
213 acc
214}
215
216/// One segment's spherical excess via Boost's trapezoidal formula
217/// (`area_formulas.hpp:347-356`, `:395-403`). Returns `0` for a
218/// meridional edge (`lon1 == lon2`), mirroring the `! math::equals(
219/// get<0>(p1), get<0>(p2))` guard at
220/// `strategies/spherical/area.hpp:129`.
221// The `lon1 == lon2` check mirrors Boost's `! math::equals(get<0>(p1),
222// get<0>(p2))` guard at `strategies/spherical/area.hpp:129`
223// letter-for-letter — an intentional exact float comparison, the same
224// stance the Andoyer distance kernel takes.
225#[allow(clippy::float_cmp)]
226#[cfg(feature = "std")]
227#[inline]
228fn segment_excess<P>(a: &P, b: &P) -> f64
229where
230 P: Point<Scalar = f64>,
231 P::Cs: HasAngularUnits,
232{
233 let (lon1, lat1) = lonlat_radians(a);
234 let (lon2, lat2) = lonlat_radians(b);
235
236 // Meridional segments contribute no excess — mirrors the
237 // `! equals(get<0>(p1), get<0>(p2))` guard in area.hpp:129.
238 if lon1 == lon2 {
239 return 0.0;
240 }
241
242 // Δlon normalised to (-π, π], mirroring
243 // `math::normalize_longitude` in area_formulas.hpp:378.
244 let mut dlon = lon2 - lon1;
245 let pi = core::f64::consts::PI;
246 let two_pi = 2.0 * pi;
247 while dlon > pi {
248 dlon -= two_pi;
249 }
250 while dlon <= -pi {
251 dlon += two_pi;
252 }
253
254 let tan_lat1 = (lat1 / 2.0).tan();
255 let tan_lat2 = (lat2 / 2.0).tan();
256 2.0 * (((tan_lat1 + tan_lat2) / (1.0 + tan_lat1 * tan_lat2)) * (dlon / 2.0).tan()).atan()
257}
258
259#[cfg(all(test, feature = "std"))]
260mod tests {
261 //! Reference values from
262 //! `boost/geometry/test/algorithms/area/area_sph_geo.cpp:93-116`.
263 #![allow(
264 clippy::float_cmp,
265 reason = "areas are compared with an explicit relative tolerance, not `==`"
266 )]
267
268 use super::{SphericalArea, SphericalPolygonArea};
269 use crate::area::AreaStrategy;
270 use geometry_adapt::{Adapt, WithCs};
271 use geometry_cs::{Degree, Spherical};
272 use geometry_model::{Polygon, Ring};
273
274 type Sp = WithCs<Adapt<[f64; 2]>, Spherical<Degree>>;
275
276 #[inline]
277 fn sp(lon: f64, lat: f64) -> Sp {
278 WithCs::new(Adapt([lon, lat]))
279 }
280
281 /// `area_sph_geo.cpp:93-106` — `POLYGON((0 0,0 90,90 0,0 0))` on a
282 /// unit sphere covers `1/8` of it: `4π/8 = π/2 ≈ 1.5708`.
283 #[test]
284 fn unit_sphere_octant_is_pi_over_2() {
285 let r: Ring<Sp> = Ring::from_vec(vec![sp(0., 0.), sp(0., 90.), sp(90., 0.), sp(0., 0.)]);
286 let got = SphericalArea::UNIT.area(&r);
287 let expected = core::f64::consts::FRAC_PI_2;
288 assert!(
289 (got - expected).abs() / expected < 1e-6,
290 "got {got} expected {expected}"
291 );
292 }
293
294 /// `area_sph_geo.cpp:109-116` — the same octant on a radius-2
295 /// sphere scales by `2² = 4`.
296 #[test]
297 fn radius_2_sphere_octant_scales_by_4() {
298 let r: Ring<Sp> = Ring::from_vec(vec![sp(0., 0.), sp(0., 90.), sp(90., 0.), sp(0., 0.)]);
299 let got = SphericalArea { radius: 2.0 }.area(&r);
300 let expected = 4.0 * core::f64::consts::FRAC_PI_2;
301 assert!(
302 (got - expected).abs() / expected < 1e-6,
303 "got {got} expected {expected}"
304 );
305 }
306
307 /// The same octant traversed in the opposite direction on a
308 /// default (clockwise) ring yields a negated area — mirrors the
309 /// Cartesian `ShoelaceArea` sign convention.
310 #[test]
311 fn reversed_octant_is_negative() {
312 let r: Ring<Sp> = Ring::from_vec(vec![sp(0., 0.), sp(90., 0.), sp(0., 90.), sp(0., 0.)]);
313 let got = SphericalArea::UNIT.area(&r);
314 let expected = -core::f64::consts::FRAC_PI_2;
315 assert!((got - expected).abs() / expected.abs() < 1e-6, "got {got}");
316 }
317
318 /// Polygon path: octant with no holes equals the ring area.
319 #[test]
320 fn polygon_octant_matches_ring() {
321 let pg: Polygon<Sp> = Polygon::new(Ring::from_vec(vec![
322 sp(0., 0.),
323 sp(0., 90.),
324 sp(90., 0.),
325 sp(0., 0.),
326 ]));
327 let got = SphericalPolygonArea::UNIT.area(&pg);
328 let expected = core::f64::consts::FRAC_PI_2;
329 assert!((got - expected).abs() / expected < 1e-6, "got {got}");
330 }
331
332 /// Both strategies default to the mean-Earth radius.
333 #[test]
334 fn defaults_are_mean_earth_radius() {
335 assert_eq!(SphericalArea::default().radius, 6_371_000.0);
336 assert_eq!(SphericalPolygonArea::default().radius, 6_371_000.0);
337 }
338}