Skip to main content

rustial_engine/
geometry_ops.rs

1// ---------------------------------------------------------------------------
2//! # Geometry operations -- spatial computations on geographic geometries
3//!
4//! Provides core spatial analysis functions that operate on the engine's
5//! [`Geometry`] / [`GeoCoord`] types:
6//!
7//! - **Bounding box** -- [`geometry_bbox`], [`Geometry::bbox`]
8//! - **Centroid** -- [`geometry_centroid`], [`Geometry::centroid`]
9//! - **Area** -- [`polygon_area`], [`ring_area`], [`Geometry::area`]
10//! - **Length** -- [`linestring_length`], [`Geometry::length`]
11//! - **Bearing** -- [`bearing`], [`initial_bearing`]
12//! - **Interpolation** -- [`interpolate_great_circle`]
13//!
14//! All distance/area calculations use the Haversine formula on the
15//! WGS-84 sphere (radius 6,378,137 m) for a good balance of speed
16//! and accuracy.
17// ---------------------------------------------------------------------------
18
19use crate::geometry::{Geometry, Polygon};
20use rustial_math::{GeoBounds, GeoCoord};
21
22/// Mean radius of the Earth in meters (WGS-84 semi-major axis).
23const EARTH_RADIUS: f64 = 6_378_137.0;
24
25// ---------------------------------------------------------------------------
26// Bounding box
27// ---------------------------------------------------------------------------
28
29/// Compute the geographic bounding box of a geometry.
30///
31/// Returns `None` for empty geometries.
32pub fn geometry_bbox(geometry: &Geometry) -> Option<GeoBounds> {
33    let mut min_lat = f64::INFINITY;
34    let mut max_lat = f64::NEG_INFINITY;
35    let mut min_lon = f64::INFINITY;
36    let mut max_lon = f64::NEG_INFINITY;
37
38    fn visit(
39        coord: &GeoCoord,
40        min_lat: &mut f64,
41        max_lat: &mut f64,
42        min_lon: &mut f64,
43        max_lon: &mut f64,
44    ) {
45        if coord.lat < *min_lat {
46            *min_lat = coord.lat;
47        }
48        if coord.lat > *max_lat {
49            *max_lat = coord.lat;
50        }
51        if coord.lon < *min_lon {
52            *min_lon = coord.lon;
53        }
54        if coord.lon > *max_lon {
55            *max_lon = coord.lon;
56        }
57    }
58
59    fn visit_coords(
60        coords: &[GeoCoord],
61        min_lat: &mut f64,
62        max_lat: &mut f64,
63        min_lon: &mut f64,
64        max_lon: &mut f64,
65    ) {
66        for c in coords {
67            visit(c, min_lat, max_lat, min_lon, max_lon);
68        }
69    }
70
71    fn visit_geometry(
72        g: &Geometry,
73        min_lat: &mut f64,
74        max_lat: &mut f64,
75        min_lon: &mut f64,
76        max_lon: &mut f64,
77    ) {
78        match g {
79            Geometry::Point(p) => visit(&p.coord, min_lat, max_lat, min_lon, max_lon),
80            Geometry::LineString(ls) => {
81                visit_coords(&ls.coords, min_lat, max_lat, min_lon, max_lon)
82            }
83            Geometry::Polygon(p) => {
84                visit_coords(&p.exterior, min_lat, max_lat, min_lon, max_lon);
85                for hole in &p.interiors {
86                    visit_coords(hole, min_lat, max_lat, min_lon, max_lon);
87                }
88            }
89            Geometry::MultiPoint(mp) => {
90                for p in &mp.points {
91                    visit(&p.coord, min_lat, max_lat, min_lon, max_lon);
92                }
93            }
94            Geometry::MultiLineString(mls) => {
95                for ls in &mls.lines {
96                    visit_coords(&ls.coords, min_lat, max_lat, min_lon, max_lon);
97                }
98            }
99            Geometry::MultiPolygon(mp) => {
100                for p in &mp.polygons {
101                    visit_coords(&p.exterior, min_lat, max_lat, min_lon, max_lon);
102                }
103            }
104            Geometry::GeometryCollection(gc) => {
105                for g in gc {
106                    visit_geometry(g, min_lat, max_lat, min_lon, max_lon);
107                }
108            }
109        }
110    }
111
112    visit_geometry(
113        geometry,
114        &mut min_lat,
115        &mut max_lat,
116        &mut min_lon,
117        &mut max_lon,
118    );
119
120    if min_lat.is_infinite() {
121        None
122    } else {
123        Some(GeoBounds::from_coords(min_lon, min_lat, max_lon, max_lat))
124    }
125}
126
127// ---------------------------------------------------------------------------
128// Area
129// ---------------------------------------------------------------------------
130
131/// Signed area of a ring on a sphere using the Haversine-based excess formula.
132///
133/// Positive for counter-clockwise rings, negative for clockwise.
134/// The result is in **square meters** on the WGS-84 sphere.
135pub fn ring_area(coords: &[GeoCoord]) -> f64 {
136    let n = coords.len();
137    if n < 3 {
138        return 0.0;
139    }
140
141    // Use the spherical excess formula:
142    //   A = R² * |Σ (λ[i+1] - λ[i-1]) * sin(φ[i])|
143    // This is the planar-approximation formula that works well for small polygons.
144    // For a more rigorous approach we use the shoelace formula in radians
145    // on the sphere.
146    let mut area = 0.0;
147    for i in 0..n {
148        let j = if i == 0 { n - 1 } else { i - 1 };
149        let k = if i == n - 1 { 0 } else { i + 1 };
150        let lon_j = coords[j].lon.to_radians();
151        let lon_k = coords[k].lon.to_radians();
152        let lat_i = coords[i].lat.to_radians();
153        area += (lon_k - lon_j) * lat_i.sin();
154    }
155    area * EARTH_RADIUS * EARTH_RADIUS * 0.5
156}
157
158/// Area of a polygon (exterior minus holes) in **square meters**.
159///
160/// Always returns a non-negative value.
161pub fn polygon_area(polygon: &Polygon) -> f64 {
162    let mut area = ring_area(&polygon.exterior).abs();
163    for hole in &polygon.interiors {
164        area -= ring_area(hole).abs();
165    }
166    area.abs()
167}
168
169// ---------------------------------------------------------------------------
170// Length
171// ---------------------------------------------------------------------------
172
173/// Haversine distance between two geographic coordinates in meters.
174pub(crate) fn haversine(a: &GeoCoord, b: &GeoCoord) -> f64 {
175    let dlat = (b.lat - a.lat).to_radians();
176    let dlon = (b.lon - a.lon).to_radians();
177    let lat1 = a.lat.to_radians();
178    let lat2 = b.lat.to_radians();
179    let h = (dlat * 0.5).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon * 0.5).sin().powi(2);
180    2.0 * EARTH_RADIUS * h.sqrt().asin()
181}
182
183/// Total length of a linestring in meters (Haversine).
184pub fn linestring_length(coords: &[GeoCoord]) -> f64 {
185    if coords.len() < 2 {
186        return 0.0;
187    }
188    coords.windows(2).map(|w| haversine(&w[0], &w[1])).sum()
189}
190
191// ---------------------------------------------------------------------------
192// Centroid
193// ---------------------------------------------------------------------------
194
195/// Centroid of a geometry.
196///
197/// - **Point** -- the point itself.
198/// - **LineString** -- midpoint weighted by segment length.
199/// - **Polygon** -- area-weighted centroid of the exterior ring.
200/// - **Multi*** -- weighted average of component centroids.
201/// - **GeometryCollection** -- unweighted mean of non-empty children.
202///
203/// Returns `None` for empty geometries.
204pub fn geometry_centroid(geometry: &Geometry) -> Option<GeoCoord> {
205    match geometry {
206        Geometry::Point(p) => Some(p.coord),
207        Geometry::LineString(ls) => linestring_centroid(&ls.coords),
208        Geometry::Polygon(p) => ring_centroid(&p.exterior),
209        Geometry::MultiPoint(mp) => {
210            if mp.points.is_empty() {
211                return None;
212            }
213            let (sum_lat, sum_lon) = mp.points.iter().fold((0.0, 0.0), |(lat, lon), p| {
214                (lat + p.coord.lat, lon + p.coord.lon)
215            });
216            let n = mp.points.len() as f64;
217            Some(GeoCoord::from_lat_lon(sum_lat / n, sum_lon / n))
218        }
219        Geometry::MultiLineString(mls) => {
220            let mut total_len = 0.0;
221            let mut wlat = 0.0;
222            let mut wlon = 0.0;
223            for ls in &mls.lines {
224                let len = linestring_length(&ls.coords);
225                if let Some(c) = linestring_centroid(&ls.coords) {
226                    wlat += c.lat * len;
227                    wlon += c.lon * len;
228                    total_len += len;
229                }
230            }
231            if total_len == 0.0 {
232                return None;
233            }
234            Some(GeoCoord::from_lat_lon(wlat / total_len, wlon / total_len))
235        }
236        Geometry::MultiPolygon(mp) => {
237            let mut total_area = 0.0;
238            let mut wlat = 0.0;
239            let mut wlon = 0.0;
240            for poly in &mp.polygons {
241                let area = polygon_area(poly);
242                if let Some(c) = ring_centroid(&poly.exterior) {
243                    wlat += c.lat * area;
244                    wlon += c.lon * area;
245                    total_area += area;
246                }
247            }
248            if total_area == 0.0 {
249                return None;
250            }
251            Some(GeoCoord::from_lat_lon(wlat / total_area, wlon / total_area))
252        }
253        Geometry::GeometryCollection(gc) => {
254            let centroids: Vec<GeoCoord> = gc.iter().filter_map(geometry_centroid).collect();
255            if centroids.is_empty() {
256                return None;
257            }
258            let n = centroids.len() as f64;
259            let (slat, slon) = centroids
260                .iter()
261                .fold((0.0, 0.0), |(lat, lon), c| (lat + c.lat, lon + c.lon));
262            Some(GeoCoord::from_lat_lon(slat / n, slon / n))
263        }
264    }
265}
266
267/// Centroid of a linestring weighted by segment lengths.
268fn linestring_centroid(coords: &[GeoCoord]) -> Option<GeoCoord> {
269    if coords.is_empty() {
270        return None;
271    }
272    if coords.len() == 1 {
273        return Some(coords[0]);
274    }
275    let mut total_len = 0.0;
276    let mut wlat = 0.0;
277    let mut wlon = 0.0;
278    for w in coords.windows(2) {
279        let len = haversine(&w[0], &w[1]);
280        let mid_lat = (w[0].lat + w[1].lat) * 0.5;
281        let mid_lon = (w[0].lon + w[1].lon) * 0.5;
282        wlat += mid_lat * len;
283        wlon += mid_lon * len;
284        total_len += len;
285    }
286    if total_len == 0.0 {
287        return Some(coords[0]);
288    }
289    Some(GeoCoord::from_lat_lon(wlat / total_len, wlon / total_len))
290}
291
292/// Centroid of a closed ring (signed-area weighted).
293fn ring_centroid(coords: &[GeoCoord]) -> Option<GeoCoord> {
294    let n = coords.len();
295    if n < 3 {
296        return None;
297    }
298    let mut cx = 0.0;
299    let mut cy = 0.0;
300    let mut signed_area = 0.0;
301    for i in 0..n {
302        let j = (i + 1) % n;
303        let xi = coords[i].lon;
304        let yi = coords[i].lat;
305        let xj = coords[j].lon;
306        let yj = coords[j].lat;
307        let a = xi * yj - xj * yi;
308        signed_area += a;
309        cx += (xi + xj) * a;
310        cy += (yi + yj) * a;
311    }
312    if signed_area.abs() < 1e-20 {
313        return None;
314    }
315    signed_area *= 0.5;
316    cx /= 6.0 * signed_area;
317    cy /= 6.0 * signed_area;
318    Some(GeoCoord::from_lat_lon(cy, cx))
319}
320
321// ---------------------------------------------------------------------------
322// Bearing
323// ---------------------------------------------------------------------------
324
325/// Initial bearing (forward azimuth) from `a` to `b` in **degrees** (0–360).
326///
327/// North = 0°, East = 90°, South = 180°, West = 270°.
328pub fn bearing(from: &GeoCoord, to: &GeoCoord) -> f64 {
329    let lat1 = from.lat.to_radians();
330    let lat2 = to.lat.to_radians();
331    let dlon = (to.lon - from.lon).to_radians();
332    let y = dlon.sin() * lat2.cos();
333    let x = lat1.cos() * lat2.sin() - lat1.sin() * lat2.cos() * dlon.cos();
334    (y.atan2(x).to_degrees() + 360.0) % 360.0
335}
336
337/// Alias for [`bearing`] -- initial (forward) azimuth in degrees.
338pub fn initial_bearing(from: &GeoCoord, to: &GeoCoord) -> f64 {
339    bearing(from, to)
340}
341
342// ---------------------------------------------------------------------------
343// Interpolation
344// ---------------------------------------------------------------------------
345
346/// Interpolate a point along the great-circle path between `from` and `to`.
347///
348/// `fraction` ranges from 0.0 (`from`) to 1.0 (`to`).  Values outside
349/// `[0, 1]` extrapolate beyond the endpoints.
350pub fn interpolate_great_circle(from: &GeoCoord, to: &GeoCoord, fraction: f64) -> GeoCoord {
351    if fraction == 0.0 {
352        return *from;
353    }
354    if fraction == 1.0 {
355        return *to;
356    }
357    let lat1 = from.lat.to_radians();
358    let lon1 = from.lon.to_radians();
359    let lat2 = to.lat.to_radians();
360    let lon2 = to.lon.to_radians();
361
362    let d = {
363        let dlat = lat2 - lat1;
364        let dlon = lon2 - lon1;
365        let a = (dlat * 0.5).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon * 0.5).sin().powi(2);
366        2.0 * a.sqrt().asin()
367    };
368
369    if d.abs() < 1e-15 {
370        return *from;
371    }
372
373    let a = ((1.0 - fraction) * d).sin() / d.sin();
374    let b = (fraction * d).sin() / d.sin();
375
376    let x = a * lat1.cos() * lon1.cos() + b * lat2.cos() * lon2.cos();
377    let y = a * lat1.cos() * lon1.sin() + b * lat2.cos() * lon2.sin();
378    let z = a * lat1.sin() + b * lat2.sin();
379
380    let lat = z.atan2((x * x + y * y).sqrt()).to_degrees();
381    let lon = y.atan2(x).to_degrees();
382    GeoCoord::from_lat_lon(lat, lon)
383}
384
385// ---------------------------------------------------------------------------
386// Geometry trait extensions
387// ---------------------------------------------------------------------------
388
389impl Geometry {
390    /// Compute the bounding box of this geometry.
391    pub fn bbox(&self) -> Option<GeoBounds> {
392        geometry_bbox(self)
393    }
394
395    /// Compute the centroid of this geometry.
396    pub fn centroid(&self) -> Option<GeoCoord> {
397        geometry_centroid(self)
398    }
399
400    /// Compute the area of this geometry in square meters.
401    ///
402    /// Returns 0.0 for non-areal geometries (Points, LineStrings).
403    pub fn area(&self) -> f64 {
404        match self {
405            Geometry::Polygon(p) => polygon_area(p),
406            Geometry::MultiPolygon(mp) => mp.polygons.iter().map(polygon_area).sum(),
407            _ => 0.0,
408        }
409    }
410
411    /// Compute the length of this geometry in meters.
412    ///
413    /// Returns 0.0 for non-linear geometries (Points, Polygons).
414    pub fn length(&self) -> f64 {
415        match self {
416            Geometry::LineString(ls) => linestring_length(&ls.coords),
417            Geometry::MultiLineString(mls) => mls
418                .lines
419                .iter()
420                .map(|ls| linestring_length(&ls.coords))
421                .sum(),
422            _ => 0.0,
423        }
424    }
425}
426
427// ---------------------------------------------------------------------------
428// Tests
429// ---------------------------------------------------------------------------
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::geometry::{LineString, MultiPoint, Point};
435
436    fn square_polygon() -> Polygon {
437        Polygon {
438            exterior: vec![
439                GeoCoord::from_lat_lon(0.0, 0.0),
440                GeoCoord::from_lat_lon(0.0, 1.0),
441                GeoCoord::from_lat_lon(1.0, 1.0),
442                GeoCoord::from_lat_lon(1.0, 0.0),
443                GeoCoord::from_lat_lon(0.0, 0.0),
444            ],
445            interiors: vec![],
446        }
447    }
448
449    // -- bbox --
450
451    #[test]
452    fn bbox_point() {
453        let g = Geometry::Point(Point {
454            coord: GeoCoord::from_lat_lon(10.0, 20.0),
455        });
456        let bb = g.bbox().unwrap();
457        assert_eq!(bb.south(), 10.0);
458        assert_eq!(bb.north(), 10.0);
459        assert_eq!(bb.west(), 20.0);
460        assert_eq!(bb.east(), 20.0);
461    }
462
463    #[test]
464    fn bbox_polygon() {
465        let g = Geometry::Polygon(square_polygon());
466        let bb = g.bbox().unwrap();
467        assert_eq!(bb.south(), 0.0);
468        assert_eq!(bb.north(), 1.0);
469        assert_eq!(bb.west(), 0.0);
470        assert_eq!(bb.east(), 1.0);
471    }
472
473    #[test]
474    fn bbox_empty_collection() {
475        let g = Geometry::GeometryCollection(vec![]);
476        assert!(g.bbox().is_none());
477    }
478
479    // -- centroid --
480
481    #[test]
482    fn centroid_point() {
483        let g = Geometry::Point(Point {
484            coord: GeoCoord::from_lat_lon(10.0, 20.0),
485        });
486        let c = g.centroid().unwrap();
487        assert_eq!(c.lat, 10.0);
488        assert_eq!(c.lon, 20.0);
489    }
490
491    #[test]
492    fn centroid_square_polygon() {
493        let g = Geometry::Polygon(square_polygon());
494        let c = g.centroid().unwrap();
495        assert!((c.lat - 0.5).abs() < 1e-10);
496        assert!((c.lon - 0.5).abs() < 1e-10);
497    }
498
499    #[test]
500    fn centroid_linestring() {
501        let g = Geometry::LineString(LineString {
502            coords: vec![
503                GeoCoord::from_lat_lon(0.0, 0.0),
504                GeoCoord::from_lat_lon(0.0, 2.0),
505            ],
506        });
507        let c = g.centroid().unwrap();
508        assert!((c.lat - 0.0).abs() < 1e-10);
509        assert!((c.lon - 1.0).abs() < 1e-10);
510    }
511
512    #[test]
513    fn centroid_multi_point() {
514        let g = Geometry::MultiPoint(MultiPoint {
515            points: vec![
516                Point {
517                    coord: GeoCoord::from_lat_lon(0.0, 0.0),
518                },
519                Point {
520                    coord: GeoCoord::from_lat_lon(2.0, 2.0),
521                },
522            ],
523        });
524        let c = g.centroid().unwrap();
525        assert!((c.lat - 1.0).abs() < 1e-10);
526        assert!((c.lon - 1.0).abs() < 1e-10);
527    }
528
529    // -- area --
530
531    #[test]
532    fn area_square_at_equator() {
533        let area = polygon_area(&square_polygon());
534        // 1° × 1° at equator ≈ 111 km × 111 km ≈ 1.234e10 m²
535        assert!(area > 1e10, "area should be > 1e10, got {area}");
536        assert!(area < 2e10, "area should be < 2e10, got {area}");
537    }
538
539    #[test]
540    fn area_point_is_zero() {
541        let g = Geometry::Point(Point {
542            coord: GeoCoord::from_lat_lon(0.0, 0.0),
543        });
544        assert_eq!(g.area(), 0.0);
545    }
546
547    #[test]
548    fn area_polygon_with_hole() {
549        let poly = Polygon {
550            exterior: vec![
551                GeoCoord::from_lat_lon(0.0, 0.0),
552                GeoCoord::from_lat_lon(0.0, 2.0),
553                GeoCoord::from_lat_lon(2.0, 2.0),
554                GeoCoord::from_lat_lon(2.0, 0.0),
555                GeoCoord::from_lat_lon(0.0, 0.0),
556            ],
557            interiors: vec![vec![
558                GeoCoord::from_lat_lon(0.5, 0.5),
559                GeoCoord::from_lat_lon(0.5, 1.5),
560                GeoCoord::from_lat_lon(1.5, 1.5),
561                GeoCoord::from_lat_lon(1.5, 0.5),
562                GeoCoord::from_lat_lon(0.5, 0.5),
563            ]],
564        };
565        let full = polygon_area(&Polygon {
566            exterior: poly.exterior.clone(),
567            interiors: vec![],
568        });
569        let with_hole = polygon_area(&poly);
570        assert!(
571            with_hole < full,
572            "area with hole should be less than full area"
573        );
574        // Hole is 1° × 1°, exterior is 2° × 2° → ratio ≈ 3/4.
575        let ratio = with_hole / full;
576        assert!(
577            (ratio - 0.75).abs() < 0.05,
578            "ratio should be ~0.75, got {ratio}"
579        );
580    }
581
582    // -- length --
583
584    #[test]
585    fn length_equator_one_degree() {
586        let g = Geometry::LineString(LineString {
587            coords: vec![
588                GeoCoord::from_lat_lon(0.0, 0.0),
589                GeoCoord::from_lat_lon(0.0, 1.0),
590            ],
591        });
592        let len = g.length();
593        // ~111 km at equator.
594        assert!(len > 110_000.0);
595        assert!(len < 112_000.0);
596    }
597
598    #[test]
599    fn length_point_is_zero() {
600        let g = Geometry::Point(Point {
601            coord: GeoCoord::from_lat_lon(0.0, 0.0),
602        });
603        assert_eq!(g.length(), 0.0);
604    }
605
606    // -- bearing --
607
608    #[test]
609    fn bearing_due_north() {
610        let from = GeoCoord::from_lat_lon(0.0, 0.0);
611        let to = GeoCoord::from_lat_lon(1.0, 0.0);
612        let b = bearing(&from, &to);
613        assert!((b - 0.0).abs() < 0.01 || (b - 360.0).abs() < 0.01);
614    }
615
616    #[test]
617    fn bearing_due_east() {
618        let from = GeoCoord::from_lat_lon(0.0, 0.0);
619        let to = GeoCoord::from_lat_lon(0.0, 1.0);
620        let b = bearing(&from, &to);
621        assert!((b - 90.0).abs() < 0.01, "expected ~90°, got {b}°");
622    }
623
624    // -- interpolation --
625
626    #[test]
627    fn interpolate_endpoints() {
628        let a = GeoCoord::from_lat_lon(0.0, 0.0);
629        let b = GeoCoord::from_lat_lon(10.0, 10.0);
630        let start = interpolate_great_circle(&a, &b, 0.0);
631        let end = interpolate_great_circle(&a, &b, 1.0);
632        assert_eq!(start.lat, a.lat);
633        assert_eq!(start.lon, a.lon);
634        assert_eq!(end.lat, b.lat);
635        assert_eq!(end.lon, b.lon);
636    }
637
638    #[test]
639    fn interpolate_midpoint_on_equator() {
640        let a = GeoCoord::from_lat_lon(0.0, 0.0);
641        let b = GeoCoord::from_lat_lon(0.0, 10.0);
642        let mid = interpolate_great_circle(&a, &b, 0.5);
643        assert!((mid.lat - 0.0).abs() < 0.01);
644        assert!((mid.lon - 5.0).abs() < 0.01);
645    }
646}