Skip to main content

nodedb_spatial/
measures.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Geodesic measures over geometry: area and centroid.
4//!
5//! Area uses the spherical-excess formula rather than a projected shoelace,
6//! so it stays correct for polygons spanning arbitrary extents — the same
7//! basis as [`crate::st_distance`], which is haversine. Centroid follows OGC
8//! semantics: the result is the centroid of the highest-dimension components
9//! present, so a `GeometryCollection` of a polygon and a stray point returns
10//! the polygon's centroid, not a blend of the two.
11
12use nodedb_types::geometry::Geometry;
13
14/// Mean Earth radius in meters, matching `nodedb_types::geometry`'s haversine.
15const EARTH_RADIUS_M: f64 = 6_371_000.0;
16
17/// Geodesic area of a geometry in square meters.
18///
19/// Areal geometries (`Polygon`, `MultiPolygon`) measure their exterior ring
20/// minus their holes. Point and linear geometries have zero area, matching
21/// PostGIS. A `GeometryCollection` sums the areas of its members.
22pub fn st_area(geom: &Geometry) -> f64 {
23    match geom {
24        Geometry::Polygon { coordinates } => polygon_area(coordinates),
25        Geometry::MultiPolygon { coordinates } => {
26            coordinates.iter().map(|rings| polygon_area(rings)).sum()
27        }
28        Geometry::GeometryCollection { geometries } => geometries.iter().map(st_area).sum(),
29        Geometry::Point { .. }
30        | Geometry::LineString { .. }
31        | Geometry::MultiPoint { .. }
32        | Geometry::MultiLineString { .. } => 0.0,
33        // `Geometry` is `#[non_exhaustive]`. A geometry kind added upstream is
34        // not known to be areal, and reporting a fabricated area for it would
35        // be worse than reporting none.
36        _ => 0.0,
37    }
38}
39
40/// Area of one polygon: exterior ring less every hole.
41fn polygon_area(rings: &[Vec<[f64; 2]>]) -> f64 {
42    let mut iter = rings.iter();
43    let Some(exterior) = iter.next() else {
44        return 0.0;
45    };
46    let holes: f64 = iter.map(|ring| ring_area(ring)).sum();
47    (ring_area(exterior) - holes).max(0.0)
48}
49
50/// Unsigned area of a single closed ring via spherical excess.
51///
52/// For a ring of vertices `(λᵢ, φᵢ)` in radians the enclosed area is
53/// `R²/2 · |Σ (λᵢ₊₁ − λᵢ)(2 + sin φᵢ + sin φᵢ₊₁)|`. Longitude deltas are
54/// normalized into `[-π, π]` so a ring crossing the antimeridian measures its
55/// true extent instead of wrapping the long way around the globe.
56fn ring_area(ring: &[[f64; 2]]) -> f64 {
57    if ring.len() < 3 {
58        return 0.0;
59    }
60    let mut total = 0.0;
61    for i in 0..ring.len() {
62        let a = ring[i];
63        let b = ring[(i + 1) % ring.len()];
64        let mut dlng = (b[0] - a[0]).to_radians();
65        if dlng > std::f64::consts::PI {
66            dlng -= std::f64::consts::TAU;
67        } else if dlng < -std::f64::consts::PI {
68            dlng += std::f64::consts::TAU;
69        }
70        total += dlng * (2.0 + a[1].to_radians().sin() + b[1].to_radians().sin());
71    }
72    (total * EARTH_RADIUS_M * EARTH_RADIUS_M / 2.0).abs()
73}
74
75/// Centroid of a geometry, as a `Point`.
76///
77/// Follows OGC dimension precedence: areal components win over linear, which
78/// win over punctual. Returns `None` only when the geometry carries no
79/// vertices at all, or when every component of its winning dimension is
80/// degenerate (zero area / zero length) — those fall back to the next
81/// dimension down rather than returning nothing.
82pub fn st_centroid(geom: &Geometry) -> Option<Geometry> {
83    let [lng, lat] = centroid_coords(geom)?;
84    Some(Geometry::point(lng, lat))
85}
86
87/// A weighted point accumulator: `(Σ w·lng, Σ w·lat, Σ w)`.
88#[derive(Default, Clone, Copy)]
89struct Weighted {
90    lng: f64,
91    lat: f64,
92    weight: f64,
93}
94
95impl Weighted {
96    fn add(&mut self, point: [f64; 2], weight: f64) {
97        self.lng += point[0] * weight;
98        self.lat += point[1] * weight;
99        self.weight += weight;
100    }
101
102    fn merge(&mut self, other: Self) {
103        self.lng += other.lng;
104        self.lat += other.lat;
105        self.weight += other.weight;
106    }
107
108    fn resolve(self) -> Option<[f64; 2]> {
109        if self.weight > 0.0 {
110            Some([self.lng / self.weight, self.lat / self.weight])
111        } else {
112            None
113        }
114    }
115}
116
117fn centroid_coords(geom: &Geometry) -> Option<[f64; 2]> {
118    areal_centroid(geom)
119        .and_then(Weighted::resolve)
120        .or_else(|| linear_centroid(geom).and_then(Weighted::resolve))
121        .or_else(|| punctual_centroid(geom).and_then(Weighted::resolve))
122}
123
124/// Area-weighted centroid of every areal component, or `None` if there are no
125/// areal components at all.
126fn areal_centroid(geom: &Geometry) -> Option<Weighted> {
127    match geom {
128        Geometry::Polygon { coordinates } => Some(polygon_centroid(coordinates)),
129        Geometry::MultiPolygon { coordinates } => {
130            let mut acc = Weighted::default();
131            for rings in coordinates {
132                acc.merge(polygon_centroid(rings));
133            }
134            Some(acc)
135        }
136        Geometry::GeometryCollection { geometries } => collect(geometries, areal_centroid),
137        Geometry::Point { .. }
138        | Geometry::LineString { .. }
139        | Geometry::MultiPoint { .. }
140        | Geometry::MultiLineString { .. } => None,
141        // `Geometry` is `#[non_exhaustive]`; an unknown kind is not areal, so
142        // it falls through to the linear and punctual dimensions below.
143        _ => None,
144    }
145}
146
147/// Length-weighted centroid of every linear component.
148fn linear_centroid(geom: &Geometry) -> Option<Weighted> {
149    match geom {
150        Geometry::LineString { coordinates } => Some(line_centroid(coordinates)),
151        Geometry::MultiLineString { coordinates } => {
152            let mut acc = Weighted::default();
153            for line in coordinates {
154                acc.merge(line_centroid(line));
155            }
156            Some(acc)
157        }
158        Geometry::GeometryCollection { geometries } => collect(geometries, linear_centroid),
159        Geometry::Point { .. }
160        | Geometry::MultiPoint { .. }
161        | Geometry::Polygon { .. }
162        | Geometry::MultiPolygon { .. } => None,
163        // `Geometry` is `#[non_exhaustive]`; an unknown kind is not linear, so
164        // it falls through to the punctual dimension.
165        _ => None,
166    }
167}
168
169/// Arithmetic mean of every vertex, the last-resort dimension. Every geometry
170/// contributes here, so a degenerate polygon still yields a centroid.
171fn punctual_centroid(geom: &Geometry) -> Option<Weighted> {
172    let mut acc = Weighted::default();
173    for point in vertices(geom) {
174        acc.add(point, 1.0);
175    }
176    Some(acc)
177}
178
179/// Merge the per-component results of `f` across a collection, yielding `None`
180/// when no member contributed at that dimension.
181fn collect(geometries: &[Geometry], f: fn(&Geometry) -> Option<Weighted>) -> Option<Weighted> {
182    let mut acc = Weighted::default();
183    let mut any = false;
184    for member in geometries {
185        if let Some(part) = f(member) {
186            acc.merge(part);
187            any = true;
188        }
189    }
190    any.then_some(acc)
191}
192
193/// Centroid of one polygon, weighting the exterior ring positively and each
194/// hole negatively so the hole's mass is removed from the result.
195fn polygon_centroid(rings: &[Vec<[f64; 2]>]) -> Weighted {
196    let mut acc = Weighted::default();
197    let mut iter = rings.iter();
198    let Some(exterior) = iter.next() else {
199        return acc;
200    };
201    if let Some(point) = ring_centroid(exterior) {
202        acc.add(point, ring_area(exterior));
203    }
204    for hole in iter {
205        if let Some(point) = ring_centroid(hole) {
206            acc.add(point, -ring_area(hole));
207        }
208    }
209    // Holes can only ever cancel the exterior down to nothing; a negative
210    // total weight would mean malformed input (holes larger than the shell),
211    // which must not flip the centroid to the far side of the polygon.
212    if acc.weight <= 0.0 {
213        return Weighted::default();
214    }
215    acc
216}
217
218/// Area-weighted centroid of a closed ring via the shoelace formula. Falls
219/// back to the vertex mean when the ring encloses no area (collinear points).
220fn ring_centroid(ring: &[[f64; 2]]) -> Option<[f64; 2]> {
221    if ring.is_empty() {
222        return None;
223    }
224    let mut twice_area = 0.0;
225    let mut lng = 0.0;
226    let mut lat = 0.0;
227    for i in 0..ring.len() {
228        let a = ring[i];
229        let b = ring[(i + 1) % ring.len()];
230        let cross = a[0] * b[1] - b[0] * a[1];
231        twice_area += cross;
232        lng += (a[0] + b[0]) * cross;
233        lat += (a[1] + b[1]) * cross;
234    }
235    if twice_area.abs() < f64::EPSILON {
236        let n = ring.len() as f64;
237        return Some([
238            ring.iter().map(|c| c[0]).sum::<f64>() / n,
239            ring.iter().map(|c| c[1]).sum::<f64>() / n,
240        ]);
241    }
242    Some([lng / (3.0 * twice_area), lat / (3.0 * twice_area)])
243}
244
245/// Length-weighted centroid of a polyline: each segment contributes its
246/// midpoint weighted by its geodesic length.
247fn line_centroid(coords: &[[f64; 2]]) -> Weighted {
248    let mut acc = Weighted::default();
249    for pair in coords.windows(2) {
250        let (a, b) = (pair[0], pair[1]);
251        let length = nodedb_types::geometry::haversine_distance(a[0], a[1], b[0], b[1]);
252        acc.add([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0], length);
253    }
254    acc
255}
256
257/// Every vertex of a geometry, in document order.
258fn vertices(geom: &Geometry) -> Vec<[f64; 2]> {
259    match geom {
260        Geometry::Point { coordinates } => vec![*coordinates],
261        Geometry::LineString { coordinates } | Geometry::MultiPoint { coordinates } => {
262            coordinates.clone()
263        }
264        Geometry::Polygon { coordinates } | Geometry::MultiLineString { coordinates } => {
265            coordinates.iter().flatten().copied().collect()
266        }
267        Geometry::MultiPolygon { coordinates } => {
268            coordinates.iter().flatten().flatten().copied().collect()
269        }
270        Geometry::GeometryCollection { geometries } => {
271            geometries.iter().flat_map(vertices).collect()
272        }
273        // `Geometry` is `#[non_exhaustive]`; an unknown kind exposes no
274        // coordinates this crate can read, so it contributes no vertices and
275        // the centroid of a geometry made only of such parts is `None`.
276        _ => Vec::new(),
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    /// One degree of latitude at the equator is ~111.19 km; a 1°×1° cell is
285    /// therefore ~1.236e10 m². Spherical excess must land within a fraction of
286    /// a percent of that.
287    #[test]
288    fn area_of_unit_degree_cell_at_equator() {
289        let poly = Geometry::polygon(vec![vec![
290            [0.0, 0.0],
291            [1.0, 0.0],
292            [1.0, 1.0],
293            [0.0, 1.0],
294            [0.0, 0.0],
295        ]]);
296        let area = st_area(&poly);
297        let expected = 1.236e10;
298        assert!(
299            (area - expected).abs() / expected < 0.01,
300            "expected ~{expected} m², got {area}"
301        );
302    }
303
304    /// Winding order must not change the magnitude of the result.
305    #[test]
306    fn area_is_orientation_independent() {
307        let ccw = Geometry::polygon(vec![vec![
308            [0.0, 0.0],
309            [1.0, 0.0],
310            [1.0, 1.0],
311            [0.0, 1.0],
312            [0.0, 0.0],
313        ]]);
314        let cw = Geometry::polygon(vec![vec![
315            [0.0, 0.0],
316            [0.0, 1.0],
317            [1.0, 1.0],
318            [1.0, 0.0],
319            [0.0, 0.0],
320        ]]);
321        assert!((st_area(&ccw) - st_area(&cw)).abs() < 1.0);
322    }
323
324    #[test]
325    fn area_subtracts_holes() {
326        let solid = Geometry::polygon(vec![vec![
327            [0.0, 0.0],
328            [4.0, 0.0],
329            [4.0, 4.0],
330            [0.0, 4.0],
331            [0.0, 0.0],
332        ]]);
333        let with_hole = Geometry::polygon(vec![
334            vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]],
335            vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0], [1.0, 1.0]],
336        ]);
337        assert!(
338            st_area(&with_hole) < st_area(&solid),
339            "hole must reduce the measured area"
340        );
341    }
342
343    #[test]
344    fn area_of_non_areal_geometry_is_zero() {
345        let line = Geometry::line_string(vec![[0.0, 0.0], [1.0, 1.0]]);
346        assert_eq!(st_area(&line), 0.0);
347        assert_eq!(st_area(&Geometry::point(1.0, 2.0)), 0.0);
348    }
349
350    #[test]
351    fn centroid_of_point_is_itself() {
352        let p = Geometry::point(10.0, 20.0);
353        assert_eq!(st_centroid(&p), Some(Geometry::point(10.0, 20.0)));
354    }
355
356    #[test]
357    fn centroid_of_square_is_its_center() {
358        let square = Geometry::polygon(vec![vec![
359            [0.0, 0.0],
360            [2.0, 0.0],
361            [2.0, 2.0],
362            [0.0, 2.0],
363            [0.0, 0.0],
364        ]]);
365        let Some(Geometry::Point { coordinates }) = st_centroid(&square) else {
366            panic!("expected a Point centroid");
367        };
368        assert!((coordinates[0] - 1.0).abs() < 1e-9);
369        assert!((coordinates[1] - 1.0).abs() < 1e-9);
370    }
371
372    /// An L-shape's centroid must sit at the area-weighted center, which the
373    /// vertex mean would get wrong.
374    #[test]
375    fn centroid_is_area_weighted_not_vertex_mean() {
376        let l_shape = Geometry::polygon(vec![vec![
377            [0.0, 0.0],
378            [4.0, 0.0],
379            [4.0, 1.0],
380            [1.0, 1.0],
381            [1.0, 4.0],
382            [0.0, 4.0],
383            [0.0, 0.0],
384        ]]);
385        let Some(Geometry::Point { coordinates }) = st_centroid(&l_shape) else {
386            panic!("expected a Point centroid");
387        };
388        // A 4x1 arm (area 4, centroid (2, 0.5)) plus a 1x3 arm (area 3,
389        // centroid (0.5, 2.5)) gives 9.5/7 ≈ 1.357 on both axes. The vertex
390        // mean would be 10/6 ≈ 1.667, so this discriminates the two.
391        let expected = 9.5 / 7.0;
392        assert!(
393            (coordinates[0] - expected).abs() < 0.01 && (coordinates[1] - expected).abs() < 0.01,
394            "expected ~({expected}, {expected}), got {coordinates:?}"
395        );
396    }
397
398    /// A collection holding both a polygon and a distant point must report the
399    /// polygon's centroid — areal dimension outranks punctual.
400    #[test]
401    fn centroid_of_collection_prefers_areal_components() {
402        let collection = Geometry::GeometryCollection {
403            geometries: vec![
404                Geometry::polygon(vec![vec![
405                    [0.0, 0.0],
406                    [2.0, 0.0],
407                    [2.0, 2.0],
408                    [0.0, 2.0],
409                    [0.0, 0.0],
410                ]]),
411                Geometry::point(100.0, 100.0),
412            ],
413        };
414        let Some(Geometry::Point { coordinates }) = st_centroid(&collection) else {
415            panic!("expected a Point centroid");
416        };
417        assert!(
418            (coordinates[0] - 1.0).abs() < 1e-6,
419            "stray point must not pull the centroid, got {coordinates:?}"
420        );
421    }
422
423    /// A zero-area polygon has no areal mass; the centroid must fall back
424    /// rather than divide by zero and vanish.
425    #[test]
426    fn centroid_of_degenerate_polygon_falls_back() {
427        let collapsed =
428            Geometry::polygon(vec![vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [0.0, 0.0]]]);
429        let centroid = st_centroid(&collapsed);
430        assert!(
431            centroid.is_some(),
432            "a collinear ring must still yield a centroid"
433        );
434    }
435
436    #[test]
437    fn centroid_of_linestring_is_length_weighted() {
438        // A long western segment and a short eastern one: the centroid must
439        // sit inside the long segment, not at the vertex mean.
440        let line = Geometry::line_string(vec![[0.0, 0.0], [10.0, 0.0], [11.0, 0.0]]);
441        let Some(Geometry::Point { coordinates }) = st_centroid(&line) else {
442            panic!("expected a Point centroid");
443        };
444        assert!(
445            coordinates[0] < 6.0,
446            "length weighting must bias toward the long segment, got {coordinates:?}"
447        );
448    }
449
450    #[test]
451    fn centroid_of_empty_geometry_is_none() {
452        let empty = Geometry::GeometryCollection { geometries: vec![] };
453        assert_eq!(st_centroid(&empty), None);
454    }
455}