Skip to main content

nodedb_spatial/
operations.rs

1//! Geometry construction and transformation operations.
2//!
3//! ST_Buffer, ST_Envelope, ST_Union — produce new geometries from existing ones.
4
5use nodedb_types::geometry::Geometry;
6use nodedb_types::{BoundingBox, geometry_bbox};
7
8/// ST_Buffer(geom, distance_meters) → Polygon.
9///
10/// Expands geometry by distance using equirectangular approximation.
11/// - Point → 32-sided circle polygon
12/// - LineString → buffered corridor (parallel offset + end caps)
13/// - Polygon → expanded polygon (vertex offset outward)
14///
15/// Meter-to-degree conversion: `Δlng = meters / (111320 * cos(lat))`,
16/// `Δlat = meters / 110540`. Accurate to <0.5% for ±70° latitude.
17pub fn st_buffer(geom: &Geometry, distance_meters: f64, segments: usize) -> Geometry {
18    let segments = segments.max(4);
19
20    match geom {
21        Geometry::Point { coordinates } => {
22            buffer_point(coordinates[0], coordinates[1], distance_meters, segments)
23        }
24        Geometry::LineString { coordinates } => {
25            buffer_linestring(coordinates, distance_meters, segments)
26        }
27        Geometry::Polygon { coordinates } => buffer_polygon(coordinates, distance_meters, segments),
28        // Multi* types: buffer each component and collect.
29        Geometry::MultiPoint { coordinates } => {
30            let polys: Vec<Vec<Vec<[f64; 2]>>> = coordinates
31                .iter()
32                .map(|pt| {
33                    if let Geometry::Polygon { coordinates: rings } =
34                        buffer_point(pt[0], pt[1], distance_meters, segments)
35                    {
36                        rings
37                    } else {
38                        vec![]
39                    }
40                })
41                .collect();
42            Geometry::MultiPolygon { coordinates: polys }
43        }
44        _ => {
45            // For unsupported types, fall back to buffering the bbox.
46            let bb = geometry_bbox(geom).expand_meters(distance_meters);
47            bbox_to_polygon(&bb)
48        }
49    }
50}
51
52/// ST_Envelope(geom) → Polygon — bounding box as a polygon.
53pub fn st_envelope(geom: &Geometry) -> Geometry {
54    let bb = geometry_bbox(geom);
55    bbox_to_polygon(&bb)
56}
57
58/// ST_Union(a, b) → Geometry — merge two geometries.
59///
60/// For this version, collects into appropriate Multi* type or
61/// GeometryCollection. True polygon clipping (Weiler-Atherton) is
62/// deferred — this handles the common cases correctly.
63pub fn st_union(a: &Geometry, b: &Geometry) -> Geometry {
64    match (a, b) {
65        // Point + Point → MultiPoint
66        (Geometry::Point { coordinates: ca }, Geometry::Point { coordinates: cb }) => {
67            Geometry::MultiPoint {
68                coordinates: vec![*ca, *cb],
69            }
70        }
71        // LineString + LineString → MultiLineString
72        (Geometry::LineString { coordinates: la }, Geometry::LineString { coordinates: lb }) => {
73            Geometry::MultiLineString {
74                coordinates: vec![la.clone(), lb.clone()],
75            }
76        }
77        // Polygon + Polygon → MultiPolygon
78        (Geometry::Polygon { coordinates: ra }, Geometry::Polygon { coordinates: rb }) => {
79            Geometry::MultiPolygon {
80                coordinates: vec![ra.clone(), rb.clone()],
81            }
82        }
83        // Same-type Multi* + element → extend
84        (Geometry::MultiPoint { coordinates: pts }, Geometry::Point { coordinates: pt }) => {
85            let mut coords = pts.clone();
86            coords.push(*pt);
87            Geometry::MultiPoint {
88                coordinates: coords,
89            }
90        }
91        (Geometry::Point { coordinates: pt }, Geometry::MultiPoint { coordinates: pts }) => {
92            let mut coords = vec![*pt];
93            coords.extend_from_slice(pts);
94            Geometry::MultiPoint {
95                coordinates: coords,
96            }
97        }
98        // Everything else → GeometryCollection
99        _ => Geometry::GeometryCollection {
100            geometries: vec![a.clone(), b.clone()],
101        },
102    }
103}
104
105// ── Buffer helpers ──
106
107/// Buffer a point into an approximate circle polygon.
108fn buffer_point(lng: f64, lat: f64, meters: f64, segments: usize) -> Geometry {
109    let dlat = meters / 110_540.0;
110    let dlng = meters / (111_320.0 * lat.to_radians().cos().max(0.001));
111
112    let mut ring = Vec::with_capacity(segments + 1);
113    for i in 0..segments {
114        let angle = 2.0 * std::f64::consts::PI * (i as f64) / (segments as f64);
115        ring.push([lng + dlng * angle.cos(), lat + dlat * angle.sin()]);
116    }
117    // Close the ring.
118    ring.push(ring[0]);
119
120    Geometry::Polygon {
121        coordinates: vec![ring],
122    }
123}
124
125/// Buffer a linestring by creating a corridor (parallel offset on both sides + end caps).
126fn buffer_linestring(line: &[[f64; 2]], meters: f64, segments: usize) -> Geometry {
127    if line.is_empty() {
128        return Geometry::Polygon {
129            coordinates: vec![vec![]],
130        };
131    }
132    if line.len() == 1 {
133        return buffer_point(line[0][0], line[0][1], meters, segments);
134    }
135
136    // Build the offset polygon: left side forward, right side backward, with
137    // semicircular end caps.
138    let mut left_side = Vec::new();
139    let mut right_side = Vec::new();
140
141    for i in 0..line.len() - 1 {
142        let (nl, nr) = offset_segment(line[i], line[i + 1], meters);
143        left_side.push(nl.0);
144        left_side.push(nl.1);
145        right_side.push(nr.0);
146        right_side.push(nr.1);
147    }
148
149    // Build polygon: left side forward → end cap → right side backward → start cap.
150    let mut ring = Vec::new();
151    ring.extend_from_slice(&left_side);
152
153    // End cap (semicircle around last point).
154    let last = line[line.len() - 1];
155    let cap_segments = segments / 2;
156    let end_dir = direction(line[line.len() - 2], last);
157    for i in 0..=cap_segments {
158        let angle = end_dir - std::f64::consts::FRAC_PI_2
159            + std::f64::consts::PI * (i as f64) / (cap_segments as f64);
160        let dlat = meters / 110_540.0;
161        let dlng = meters / (111_320.0 * last[1].to_radians().cos().max(0.001));
162        ring.push([last[0] + dlng * angle.cos(), last[1] + dlat * angle.sin()]);
163    }
164
165    // Right side backward.
166    for pt in right_side.iter().rev() {
167        ring.push(*pt);
168    }
169
170    // Start cap (semicircle around first point).
171    let first = line[0];
172    let start_dir = direction(first, line[1]);
173    for i in 0..=cap_segments {
174        let angle = start_dir
175            + std::f64::consts::FRAC_PI_2
176            + std::f64::consts::PI * (i as f64) / (cap_segments as f64);
177        let dlat = meters / 110_540.0;
178        let dlng = meters / (111_320.0 * first[1].to_radians().cos().max(0.001));
179        ring.push([first[0] + dlng * angle.cos(), first[1] + dlat * angle.sin()]);
180    }
181
182    // Close.
183    if let Some(&first_pt) = ring.first() {
184        ring.push(first_pt);
185    }
186
187    Geometry::Polygon {
188        coordinates: vec![ring],
189    }
190}
191
192/// Buffer a polygon by offsetting each vertex outward.
193fn buffer_polygon(rings: &[Vec<[f64; 2]>], meters: f64, _segments: usize) -> Geometry {
194    let mut new_rings = Vec::with_capacity(rings.len());
195
196    for (ring_idx, ring) in rings.iter().enumerate() {
197        if ring.len() < 3 {
198            new_rings.push(ring.clone());
199            continue;
200        }
201        let is_exterior = ring_idx == 0;
202        // For exterior: expand outward. For holes: shrink inward.
203        let sign = if is_exterior { 1.0 } else { -1.0 };
204        let offset_m = meters * sign;
205
206        let mut new_ring = Vec::with_capacity(ring.len());
207        let n = if ring.first() == ring.last() {
208            ring.len() - 1
209        } else {
210            ring.len()
211        };
212
213        for i in 0..n {
214            let prev = ring[(i + n - 1) % n];
215            let curr = ring[i];
216            let next = ring[(i + 1) % n];
217
218            // Bisector direction (average of the two edge normals).
219            let n1 = edge_outward_normal(prev, curr);
220            let n2 = edge_outward_normal(curr, next);
221            let bisect = [(n1[0] + n2[0]) / 2.0, (n1[1] + n2[1]) / 2.0];
222            let len = (bisect[0] * bisect[0] + bisect[1] * bisect[1])
223                .sqrt()
224                .max(1e-12);
225            let unit = [bisect[0] / len, bisect[1] / len];
226
227            let dlat = offset_m / 110_540.0;
228            let dlng = offset_m / (111_320.0 * curr[1].to_radians().cos().max(0.001));
229
230            new_ring.push([curr[0] + unit[0] * dlng, curr[1] + unit[1] * dlat]);
231        }
232
233        // Close.
234        if let Some(&first_pt) = new_ring.first() {
235            new_ring.push(first_pt);
236        }
237        new_rings.push(new_ring);
238    }
239
240    Geometry::Polygon {
241        coordinates: new_rings,
242    }
243}
244
245/// Compute offset segments (left and right parallel lines at `meters` distance).
246/// A pair of offset segments: (left_segment, right_segment).
247type OffsetPair = (([f64; 2], [f64; 2]), ([f64; 2], [f64; 2]));
248
249fn offset_segment(a: [f64; 2], b: [f64; 2], meters: f64) -> OffsetPair {
250    let normal = edge_outward_normal(a, b);
251    let dlat = meters / 110_540.0;
252    let mid_lat = ((a[1] + b[1]) / 2.0).to_radians().cos().max(0.001);
253    let dlng = meters / (111_320.0 * mid_lat);
254
255    let left = (
256        [a[0] + normal[0] * dlng, a[1] + normal[1] * dlat],
257        [b[0] + normal[0] * dlng, b[1] + normal[1] * dlat],
258    );
259    let right = (
260        [a[0] - normal[0] * dlng, a[1] - normal[1] * dlat],
261        [b[0] - normal[0] * dlng, b[1] - normal[1] * dlat],
262    );
263    (left, right)
264}
265
266/// Outward-pointing unit normal for an edge (a→b).
267///
268/// For CCW rings (standard exterior), the outward direction is the right
269/// normal (dy, -dx). This is correct because CCW traversal has the
270/// interior on the left.
271fn edge_outward_normal(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
272    let dx = b[0] - a[0];
273    let dy = b[1] - a[1];
274    let len = (dx * dx + dy * dy).sqrt().max(1e-12);
275    // Right normal: (dy, -dx) normalized — outward for CCW rings.
276    [dy / len, -dx / len]
277}
278
279/// Direction angle (radians) from a to b.
280fn direction(a: [f64; 2], b: [f64; 2]) -> f64 {
281    (b[1] - a[1]).atan2(b[0] - a[0])
282}
283
284/// Convert a BoundingBox to a Polygon.
285fn bbox_to_polygon(bb: &BoundingBox) -> Geometry {
286    Geometry::Polygon {
287        coordinates: vec![vec![
288            [bb.min_lng, bb.min_lat],
289            [bb.max_lng, bb.min_lat],
290            [bb.max_lng, bb.max_lat],
291            [bb.min_lng, bb.max_lat],
292            [bb.min_lng, bb.min_lat],
293        ]],
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn buffer_point_circle() {
303        let circle = st_buffer(&Geometry::point(0.0, 0.0), 1000.0, 32);
304        if let Geometry::Polygon { coordinates } = &circle {
305            assert_eq!(coordinates.len(), 1);
306            // 32 segments + 1 closing point.
307            assert_eq!(coordinates[0].len(), 33);
308            // All points should be roughly 1000m from center.
309            for pt in &coordinates[0][..32] {
310                let d = nodedb_types::geometry::haversine_distance(0.0, 0.0, pt[0], pt[1]);
311                assert!((d - 1000.0).abs() < 50.0, "d={d}");
312            }
313        } else {
314            panic!("expected Polygon");
315        }
316    }
317
318    #[test]
319    fn buffer_point_4_segments() {
320        let circle = st_buffer(&Geometry::point(10.0, 50.0), 500.0, 4);
321        if let Geometry::Polygon { coordinates } = &circle {
322            assert_eq!(coordinates[0].len(), 5); // 4 + close
323        } else {
324            panic!("expected Polygon");
325        }
326    }
327
328    #[test]
329    fn envelope_polygon() {
330        let poly = Geometry::polygon(vec![vec![[1.0, 2.0], [5.0, 2.0], [3.0, 8.0], [1.0, 2.0]]]);
331        let env = st_envelope(&poly);
332        if let Geometry::Polygon { coordinates } = &env {
333            let ring = &coordinates[0];
334            assert_eq!(ring[0], [1.0, 2.0]); // min_lng, min_lat
335            assert_eq!(ring[2], [5.0, 8.0]); // max_lng, max_lat
336        } else {
337            panic!("expected Polygon");
338        }
339    }
340
341    #[test]
342    fn envelope_point() {
343        let env = st_envelope(&Geometry::point(5.0, 10.0));
344        if let Geometry::Polygon { coordinates } = &env {
345            // Zero-area bbox → degenerate polygon.
346            assert_eq!(coordinates[0][0], [5.0, 10.0]);
347            assert_eq!(coordinates[0][2], [5.0, 10.0]);
348        } else {
349            panic!("expected Polygon");
350        }
351    }
352
353    #[test]
354    fn union_points() {
355        let a = Geometry::point(1.0, 2.0);
356        let b = Geometry::point(3.0, 4.0);
357        let u = st_union(&a, &b);
358        if let Geometry::MultiPoint { coordinates } = &u {
359            assert_eq!(coordinates.len(), 2);
360        } else {
361            panic!("expected MultiPoint, got {:?}", u.geometry_type());
362        }
363    }
364
365    #[test]
366    fn union_polygons() {
367        let a = Geometry::polygon(vec![vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]);
368        let b = Geometry::polygon(vec![vec![[2.0, 2.0], [3.0, 2.0], [3.0, 3.0], [2.0, 2.0]]]);
369        let u = st_union(&a, &b);
370        if let Geometry::MultiPolygon { coordinates } = &u {
371            assert_eq!(coordinates.len(), 2);
372        } else {
373            panic!("expected MultiPolygon");
374        }
375    }
376
377    #[test]
378    fn union_mixed_types() {
379        let a = Geometry::point(1.0, 2.0);
380        let b = Geometry::polygon(vec![vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]);
381        let u = st_union(&a, &b);
382        if let Geometry::GeometryCollection { geometries } = &u {
383            assert_eq!(geometries.len(), 2);
384        } else {
385            panic!("expected GeometryCollection");
386        }
387    }
388
389    #[test]
390    fn buffer_linestring_corridor() {
391        let line = Geometry::line_string(vec![[0.0, 0.0], [0.1, 0.0]]);
392        let buf = st_buffer(&line, 1000.0, 16);
393        if let Geometry::Polygon { coordinates } = &buf {
394            assert!(!coordinates[0].is_empty());
395            // Should have more points than a simple rectangle due to end caps.
396            assert!(coordinates[0].len() > 10);
397        } else {
398            panic!("expected Polygon");
399        }
400    }
401
402    #[test]
403    fn buffer_polygon_expands() {
404        let poly = Geometry::polygon(vec![vec![
405            [0.0, 0.0],
406            [1.0, 0.0],
407            [1.0, 1.0],
408            [0.0, 1.0],
409            [0.0, 0.0],
410        ]]);
411        let buf = st_buffer(&poly, 1000.0, 32);
412        if let Geometry::Polygon { .. } = &buf {
413            let orig_bb = geometry_bbox(&poly);
414            let buf_bb = geometry_bbox(&buf);
415            // Buffered should be larger.
416            assert!(buf_bb.min_lng < orig_bb.min_lng);
417            assert!(buf_bb.max_lng > orig_bb.max_lng);
418            assert!(buf_bb.min_lat < orig_bb.min_lat);
419            assert!(buf_bb.max_lat > orig_bb.max_lat);
420        } else {
421            panic!("expected Polygon");
422        }
423    }
424}