Skip to main content

nodedb_types/
geometry.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! GeoJSON-compatible geometry types.
4//!
5//! Supports Point, LineString, Polygon, MultiPoint, MultiLineString,
6//! MultiPolygon, and GeometryCollection. Stored as GeoJSON for JSON
7//! compatibility. Includes distance (Haversine), area, bearing, and
8//! centroid calculations.
9
10use serde::{Deserialize, Serialize};
11
12/// A 2D coordinate (longitude, latitude) following GeoJSON convention.
13/// Note: GeoJSON uses [lng, lat] order, NOT [lat, lng].
14#[derive(
15    Debug,
16    Clone,
17    Copy,
18    PartialEq,
19    Serialize,
20    Deserialize,
21    zerompk::ToMessagePack,
22    zerompk::FromMessagePack,
23)]
24pub struct Coord {
25    pub lng: f64,
26    pub lat: f64,
27}
28
29impl Coord {
30    pub fn new(lng: f64, lat: f64) -> Self {
31        Self { lng, lat }
32    }
33}
34
35/// GeoJSON-compatible geometry types.
36#[derive(
37    Debug,
38    Clone,
39    PartialEq,
40    Serialize,
41    Deserialize,
42    zerompk::ToMessagePack,
43    zerompk::FromMessagePack,
44)]
45#[serde(tag = "type")]
46#[non_exhaustive]
47pub enum Geometry {
48    Point {
49        coordinates: [f64; 2],
50    },
51    LineString {
52        coordinates: Vec<[f64; 2]>,
53    },
54    Polygon {
55        coordinates: Vec<Vec<[f64; 2]>>,
56    },
57    MultiPoint {
58        coordinates: Vec<[f64; 2]>,
59    },
60    MultiLineString {
61        coordinates: Vec<Vec<[f64; 2]>>,
62    },
63    MultiPolygon {
64        coordinates: Vec<Vec<Vec<[f64; 2]>>>,
65    },
66    GeometryCollection {
67        geometries: Vec<Geometry>,
68    },
69}
70
71impl Geometry {
72    /// Create a Point from (longitude, latitude).
73    pub fn point(lng: f64, lat: f64) -> Self {
74        Geometry::Point {
75            coordinates: [lng, lat],
76        }
77    }
78
79    /// Create a LineString from a series of [lng, lat] pairs.
80    pub fn line_string(coords: Vec<[f64; 2]>) -> Self {
81        Geometry::LineString {
82            coordinates: coords,
83        }
84    }
85
86    /// Create a Polygon from exterior ring (and optional holes).
87    ///
88    /// The first ring is the exterior, subsequent rings are holes.
89    /// Each ring must be a closed loop (first point == last point).
90    pub fn polygon(rings: Vec<Vec<[f64; 2]>>) -> Self {
91        Geometry::Polygon { coordinates: rings }
92    }
93
94    /// Get the type name of this geometry.
95    pub fn geometry_type(&self) -> &'static str {
96        match self {
97            Geometry::Point { .. } => "Point",
98            Geometry::LineString { .. } => "LineString",
99            Geometry::Polygon { .. } => "Polygon",
100            Geometry::MultiPoint { .. } => "MultiPoint",
101            Geometry::MultiLineString { .. } => "MultiLineString",
102            Geometry::MultiPolygon { .. } => "MultiPolygon",
103            Geometry::GeometryCollection { .. } => "GeometryCollection",
104        }
105    }
106}
107
108/// Parse a GeoJSON string into a [`Geometry`].
109///
110/// Shared by every storage/read path that may encounter geometry stored as a
111/// JSON string rather than a native object — SQL `ST_Point(...)` inserts
112/// serialize to a GeoJSON string, while schemaless document writes and
113/// `Value::Geometry` keep the native form. Centralizing the string-parse core
114/// here keeps the three call sites (document index build, spatial read path,
115/// columnar geometry index) from drifting on which parser they use.
116///
117/// Uses `sonic_rs` per workspace policy (never `serde_json::from_str` for
118/// runtime JSON parsing). Returns `None` on malformed/non-geometry JSON.
119pub fn from_geojson_str(s: &str) -> Option<Geometry> {
120    sonic_rs::from_str(s).ok()
121}
122
123// ── Geo math functions ──
124
125const EARTH_RADIUS_M: f64 = 6_371_000.0;
126
127/// Haversine distance between two points in meters.
128///
129/// Input: (lng1, lat1) and (lng2, lat2) in degrees.
130pub fn haversine_distance(lng1: f64, lat1: f64, lng2: f64, lat2: f64) -> f64 {
131    let lat1_r = lat1.to_radians();
132    let lat2_r = lat2.to_radians();
133    let dlat = (lat2 - lat1).to_radians();
134    let dlng = (lng2 - lng1).to_radians();
135
136    let a = (dlat / 2.0).sin().powi(2) + lat1_r.cos() * lat2_r.cos() * (dlng / 2.0).sin().powi(2);
137    let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
138    EARTH_RADIUS_M * c
139}
140
141/// Haversine bearing from point A to point B in degrees (0-360).
142pub fn haversine_bearing(lng1: f64, lat1: f64, lng2: f64, lat2: f64) -> f64 {
143    let lat1_r = lat1.to_radians();
144    let lat2_r = lat2.to_radians();
145    let dlng = (lng2 - lng1).to_radians();
146
147    let y = dlng.sin() * lat2_r.cos();
148    let x = lat1_r.cos() * lat2_r.sin() - lat1_r.sin() * lat2_r.cos() * dlng.cos();
149    let bearing = y.atan2(x).to_degrees();
150    (bearing + 360.0) % 360.0
151}
152
153/// Check if a point is inside a polygon (ray casting algorithm).
154pub fn point_in_polygon(lng: f64, lat: f64, ring: &[[f64; 2]]) -> bool {
155    let mut inside = false;
156    let n = ring.len();
157    let mut j = n.wrapping_sub(1);
158    for i in 0..n {
159        let yi = ring[i][1];
160        let yj = ring[j][1];
161        if ((yi > lat) != (yj > lat))
162            && (lng < (ring[j][0] - ring[i][0]) * (lat - yi) / (yj - yi) + ring[i][0])
163        {
164            inside = !inside;
165        }
166        j = i;
167    }
168    inside
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn point_creation() {
177        let p = Geometry::point(-73.9857, 40.7484);
178        assert_eq!(p.geometry_type(), "Point");
179        if let Geometry::Point { coordinates } = &p {
180            assert!((coordinates[0] - (-73.9857)).abs() < 1e-6);
181            assert!((coordinates[1] - 40.7484).abs() < 1e-6);
182        }
183    }
184
185    #[test]
186    fn haversine_nyc_to_london() {
187        // NYC: -74.006, 40.7128 → London: -0.1278, 51.5074
188        let d = haversine_distance(-74.006, 40.7128, -0.1278, 51.5074);
189        // ~5,570 km
190        assert!((d - 5_570_000.0).abs() < 50_000.0, "got {d}m");
191    }
192
193    #[test]
194    fn haversine_same_point() {
195        let d = haversine_distance(0.0, 0.0, 0.0, 0.0);
196        assert!(d.abs() < 1e-6);
197    }
198
199    #[test]
200    fn bearing_north() {
201        let b = haversine_bearing(0.0, 0.0, 0.0, 1.0);
202        assert!((b - 0.0).abs() < 1.0, "expected ~0, got {b}");
203    }
204
205    #[test]
206    fn bearing_east() {
207        let b = haversine_bearing(0.0, 0.0, 1.0, 0.0);
208        assert!((b - 90.0).abs() < 1.0, "expected ~90, got {b}");
209    }
210
211    #[test]
212    fn point_in_polygon_inside() {
213        let ring = vec![
214            [0.0, 0.0],
215            [10.0, 0.0],
216            [10.0, 10.0],
217            [0.0, 10.0],
218            [0.0, 0.0],
219        ];
220        assert!(point_in_polygon(5.0, 5.0, &ring));
221        assert!(!point_in_polygon(15.0, 5.0, &ring));
222    }
223
224    #[test]
225    fn geojson_serialize() {
226        let p = Geometry::point(1.0, 2.0);
227        let json = sonic_rs::to_string(&p).unwrap();
228        assert!(json.contains("\"type\":\"Point\""));
229        assert!(json.contains("\"coordinates\":[1.0,2.0]"));
230    }
231
232    #[test]
233    fn geojson_roundtrip() {
234        let original = Geometry::polygon(vec![vec![
235            [0.0, 0.0],
236            [1.0, 0.0],
237            [1.0, 1.0],
238            [0.0, 1.0],
239            [0.0, 0.0],
240        ]]);
241        let json = sonic_rs::to_string(&original).unwrap();
242        let parsed: Geometry = sonic_rs::from_str(&json).unwrap();
243        assert_eq!(original, parsed);
244    }
245}