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