Skip to main content

nodedb_types/
geometry.rs

1//! GeoJSON-compatible geometry types.
2//!
3//! Supports Point, LineString, Polygon, MultiPoint, MultiLineString,
4//! MultiPolygon, and GeometryCollection. Stored as GeoJSON for JSON
5//! compatibility. Includes distance (Haversine), area, bearing, and
6//! centroid calculations.
7
8use serde::{Deserialize, Serialize};
9
10/// A 2D coordinate (longitude, latitude) following GeoJSON convention.
11/// Note: GeoJSON uses [lng, lat] order, NOT [lat, lng].
12#[derive(
13    Debug,
14    Clone,
15    Copy,
16    PartialEq,
17    Serialize,
18    Deserialize,
19    zerompk::ToMessagePack,
20    zerompk::FromMessagePack,
21)]
22pub struct Coord {
23    pub lng: f64,
24    pub lat: f64,
25}
26
27impl Coord {
28    pub fn new(lng: f64, lat: f64) -> Self {
29        Self { lng, lat }
30    }
31}
32
33/// GeoJSON-compatible geometry types.
34#[derive(
35    Debug,
36    Clone,
37    PartialEq,
38    Serialize,
39    Deserialize,
40    zerompk::ToMessagePack,
41    zerompk::FromMessagePack,
42)]
43#[serde(tag = "type")]
44pub enum Geometry {
45    Point {
46        coordinates: [f64; 2],
47    },
48    LineString {
49        coordinates: Vec<[f64; 2]>,
50    },
51    Polygon {
52        coordinates: Vec<Vec<[f64; 2]>>,
53    },
54    MultiPoint {
55        coordinates: Vec<[f64; 2]>,
56    },
57    MultiLineString {
58        coordinates: Vec<Vec<[f64; 2]>>,
59    },
60    MultiPolygon {
61        coordinates: Vec<Vec<Vec<[f64; 2]>>>,
62    },
63    GeometryCollection {
64        geometries: Vec<Geometry>,
65    },
66}
67
68impl Geometry {
69    /// Create a Point from (longitude, latitude).
70    pub fn point(lng: f64, lat: f64) -> Self {
71        Geometry::Point {
72            coordinates: [lng, lat],
73        }
74    }
75
76    /// Create a LineString from a series of [lng, lat] pairs.
77    pub fn line_string(coords: Vec<[f64; 2]>) -> Self {
78        Geometry::LineString {
79            coordinates: coords,
80        }
81    }
82
83    /// Create a Polygon from exterior ring (and optional holes).
84    ///
85    /// The first ring is the exterior, subsequent rings are holes.
86    /// Each ring must be a closed loop (first point == last point).
87    pub fn polygon(rings: Vec<Vec<[f64; 2]>>) -> Self {
88        Geometry::Polygon { coordinates: rings }
89    }
90
91    /// Get the type name of this geometry.
92    pub fn geometry_type(&self) -> &'static str {
93        match self {
94            Geometry::Point { .. } => "Point",
95            Geometry::LineString { .. } => "LineString",
96            Geometry::Polygon { .. } => "Polygon",
97            Geometry::MultiPoint { .. } => "MultiPoint",
98            Geometry::MultiLineString { .. } => "MultiLineString",
99            Geometry::MultiPolygon { .. } => "MultiPolygon",
100            Geometry::GeometryCollection { .. } => "GeometryCollection",
101        }
102    }
103
104    /// Compute the centroid of the geometry.
105    pub fn centroid(&self) -> Option<[f64; 2]> {
106        match self {
107            Geometry::Point { coordinates } => Some(*coordinates),
108            Geometry::LineString { coordinates } => {
109                if coordinates.is_empty() {
110                    return None;
111                }
112                let n = coordinates.len() as f64;
113                let sum_lng: f64 = coordinates.iter().map(|c| c[0]).sum();
114                let sum_lat: f64 = coordinates.iter().map(|c| c[1]).sum();
115                Some([sum_lng / n, sum_lat / n])
116            }
117            Geometry::Polygon { coordinates } => {
118                // Centroid of exterior ring.
119                coordinates.first().and_then(|ring| {
120                    if ring.is_empty() {
121                        return None;
122                    }
123                    let n = ring.len() as f64;
124                    let sum_lng: f64 = ring.iter().map(|c| c[0]).sum();
125                    let sum_lat: f64 = ring.iter().map(|c| c[1]).sum();
126                    Some([sum_lng / n, sum_lat / n])
127                })
128            }
129            Geometry::MultiPoint { coordinates } => {
130                if coordinates.is_empty() {
131                    return None;
132                }
133                let n = coordinates.len() as f64;
134                let sum_lng: f64 = coordinates.iter().map(|c| c[0]).sum();
135                let sum_lat: f64 = coordinates.iter().map(|c| c[1]).sum();
136                Some([sum_lng / n, sum_lat / n])
137            }
138            _ => None,
139        }
140    }
141}
142
143// ── Geo math functions ──
144
145const EARTH_RADIUS_M: f64 = 6_371_000.0;
146
147/// Haversine distance between two points in meters.
148///
149/// Input: (lng1, lat1) and (lng2, lat2) in degrees.
150pub fn haversine_distance(lng1: f64, lat1: f64, lng2: f64, lat2: f64) -> f64 {
151    let lat1_r = lat1.to_radians();
152    let lat2_r = lat2.to_radians();
153    let dlat = (lat2 - lat1).to_radians();
154    let dlng = (lng2 - lng1).to_radians();
155
156    let a = (dlat / 2.0).sin().powi(2) + lat1_r.cos() * lat2_r.cos() * (dlng / 2.0).sin().powi(2);
157    let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
158    EARTH_RADIUS_M * c
159}
160
161/// Haversine bearing from point A to point B in degrees (0-360).
162pub fn haversine_bearing(lng1: f64, lat1: f64, lng2: f64, lat2: f64) -> f64 {
163    let lat1_r = lat1.to_radians();
164    let lat2_r = lat2.to_radians();
165    let dlng = (lng2 - lng1).to_radians();
166
167    let y = dlng.sin() * lat2_r.cos();
168    let x = lat1_r.cos() * lat2_r.sin() - lat1_r.sin() * lat2_r.cos() * dlng.cos();
169    let bearing = y.atan2(x).to_degrees();
170    (bearing + 360.0) % 360.0
171}
172
173/// Approximate area of a polygon on Earth's surface in square meters.
174///
175/// Uses the Shoelace formula on projected coordinates (simple equirectangular).
176/// Accurate for small polygons; for large polygons use spherical excess.
177pub fn polygon_area(ring: &[[f64; 2]]) -> f64 {
178    if ring.len() < 3 {
179        return 0.0;
180    }
181    let mut sum = 0.0;
182    let n = ring.len();
183    for i in 0..n {
184        let j = (i + 1) % n;
185        // Convert to approximate meters from center.
186        let lat_avg = ((ring[i][1] + ring[j][1]) / 2.0).to_radians();
187        let x1 = ring[i][0].to_radians() * EARTH_RADIUS_M * lat_avg.cos();
188        let y1 = ring[i][1].to_radians() * EARTH_RADIUS_M;
189        let x2 = ring[j][0].to_radians() * EARTH_RADIUS_M * lat_avg.cos();
190        let y2 = ring[j][1].to_radians() * EARTH_RADIUS_M;
191        sum += x1 * y2 - x2 * y1;
192    }
193    (sum / 2.0).abs()
194}
195
196/// Check if a point is inside a polygon (ray casting algorithm).
197pub fn point_in_polygon(lng: f64, lat: f64, ring: &[[f64; 2]]) -> bool {
198    let mut inside = false;
199    let n = ring.len();
200    let mut j = n.wrapping_sub(1);
201    for i in 0..n {
202        let yi = ring[i][1];
203        let yj = ring[j][1];
204        if ((yi > lat) != (yj > lat))
205            && (lng < (ring[j][0] - ring[i][0]) * (lat - yi) / (yj - yi) + ring[i][0])
206        {
207            inside = !inside;
208        }
209        j = i;
210    }
211    inside
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn point_creation() {
220        let p = Geometry::point(-73.9857, 40.7484);
221        assert_eq!(p.geometry_type(), "Point");
222        if let Geometry::Point { coordinates } = &p {
223            assert!((coordinates[0] - (-73.9857)).abs() < 1e-6);
224            assert!((coordinates[1] - 40.7484).abs() < 1e-6);
225        }
226    }
227
228    #[test]
229    fn haversine_nyc_to_london() {
230        // NYC: -74.006, 40.7128 → London: -0.1278, 51.5074
231        let d = haversine_distance(-74.006, 40.7128, -0.1278, 51.5074);
232        // ~5,570 km
233        assert!((d - 5_570_000.0).abs() < 50_000.0, "got {d}m");
234    }
235
236    #[test]
237    fn haversine_same_point() {
238        let d = haversine_distance(0.0, 0.0, 0.0, 0.0);
239        assert!(d.abs() < 1e-6);
240    }
241
242    #[test]
243    fn bearing_north() {
244        let b = haversine_bearing(0.0, 0.0, 0.0, 1.0);
245        assert!((b - 0.0).abs() < 1.0, "expected ~0, got {b}");
246    }
247
248    #[test]
249    fn bearing_east() {
250        let b = haversine_bearing(0.0, 0.0, 1.0, 0.0);
251        assert!((b - 90.0).abs() < 1.0, "expected ~90, got {b}");
252    }
253
254    #[test]
255    fn polygon_area_simple() {
256        // ~1 degree square near equator ≈ 111km × 111km ≈ 12,321 km²
257        let ring = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]];
258        let area = polygon_area(&ring);
259        let area_km2 = area / 1e6;
260        assert!(
261            (area_km2 - 12321.0).abs() < 500.0,
262            "expected ~12321 km², got {area_km2}"
263        );
264    }
265
266    #[test]
267    fn point_in_polygon_inside() {
268        let ring = vec![
269            [0.0, 0.0],
270            [10.0, 0.0],
271            [10.0, 10.0],
272            [0.0, 10.0],
273            [0.0, 0.0],
274        ];
275        assert!(point_in_polygon(5.0, 5.0, &ring));
276        assert!(!point_in_polygon(15.0, 5.0, &ring));
277    }
278
279    #[test]
280    fn centroid_point() {
281        let p = Geometry::point(10.0, 20.0);
282        assert_eq!(p.centroid(), Some([10.0, 20.0]));
283    }
284
285    #[test]
286    fn centroid_linestring() {
287        let ls = Geometry::line_string(vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]]);
288        let c = ls.centroid().unwrap();
289        assert!((c[0] - 6.6667).abs() < 0.01);
290        assert!((c[1] - 3.3333).abs() < 0.01);
291    }
292
293    #[test]
294    fn geojson_serialize() {
295        let p = Geometry::point(1.0, 2.0);
296        let json = sonic_rs::to_string(&p).unwrap();
297        assert!(json.contains("\"type\":\"Point\""));
298        assert!(json.contains("\"coordinates\":[1.0,2.0]"));
299    }
300
301    #[test]
302    fn geojson_roundtrip() {
303        let original = Geometry::polygon(vec![vec![
304            [0.0, 0.0],
305            [1.0, 0.0],
306            [1.0, 1.0],
307            [0.0, 1.0],
308            [0.0, 0.0],
309        ]]);
310        let json = sonic_rs::to_string(&original).unwrap();
311        let parsed: Geometry = sonic_rs::from_str(&json).unwrap();
312        assert_eq!(original, parsed);
313    }
314}