Skip to main content

nodedb_spatial/predicates/
intersection.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! ST_Intersection — compute the area shared by two geometries.
4//!
5//! Uses Sutherland-Hodgman polygon clipping for polygon-polygon intersection.
6//! For other geometry type combinations, delegates to simpler cases or
7//! returns empty geometry.
8//!
9//! Sutherland-Hodgman works correctly for convex clipping polygons. For
10//! concave clip polygons, the result may include extra edges — this is a
11//! known limitation shared by most lightweight GIS implementations. Full
12//! Weiler-Atherton is needed for perfect concave-concave intersection but
13//! adds significant complexity. Sutherland-Hodgman covers the vast majority
14//! of practical use cases (convex query regions, rectangular bboxes, etc.).
15//!
16//! Reference: Sutherland & Hodgman, "Reentrant Polygon Clipping" (1974)
17
18use nodedb_types::geometry::Geometry;
19
20/// ST_Intersection(a, b) — return the geometry representing the shared area.
21///
22/// Returns GeometryCollection with an empty geometries vec if there is no
23/// intersection.
24pub fn st_intersection(a: &Geometry, b: &Geometry) -> Geometry {
25    match (a, b) {
26        // Polygon–Polygon: Sutherland-Hodgman clipping.
27        (
28            Geometry::Polygon {
29                coordinates: rings_a,
30            },
31            Geometry::Polygon {
32                coordinates: rings_b,
33            },
34        ) => {
35            let Some(ext_a) = rings_a.first() else {
36                return empty_geometry();
37            };
38            let Some(ext_b) = rings_b.first() else {
39                return empty_geometry();
40            };
41            let clipped = sutherland_hodgman(ext_a, ext_b);
42            if clipped.len() < 3 {
43                return empty_geometry();
44            }
45            // Close the ring.
46            let mut ring = clipped;
47            if ring.first() != ring.last()
48                && let Some(&first) = ring.first()
49            {
50                ring.push(first);
51            }
52            Geometry::Polygon {
53                coordinates: vec![ring],
54            }
55        }
56
57        // Point–Polygon or reverse: if point is inside polygon, return point.
58        (Geometry::Point { coordinates: pt }, Geometry::Polygon { coordinates: rings })
59        | (Geometry::Polygon { coordinates: rings }, Geometry::Point { coordinates: pt }) => {
60            if let Some(ext) = rings.first()
61                && (nodedb_types::geometry::point_in_polygon(pt[0], pt[1], ext)
62                    || crate::predicates::edge::point_on_ring_boundary(*pt, ext))
63            {
64                return Geometry::Point { coordinates: *pt };
65            }
66            empty_geometry()
67        }
68
69        // Point–Point: if identical, return point.
70        (Geometry::Point { coordinates: ca }, Geometry::Point { coordinates: cb }) => {
71            if (ca[0] - cb[0]).abs() < 1e-12 && (ca[1] - cb[1]).abs() < 1e-12 {
72                Geometry::Point { coordinates: *ca }
73            } else {
74                empty_geometry()
75            }
76        }
77
78        // LineString–Polygon or reverse: clip line to polygon boundary.
79        (Geometry::LineString { coordinates: line }, Geometry::Polygon { coordinates: rings })
80        | (Geometry::Polygon { coordinates: rings }, Geometry::LineString { coordinates: line }) => {
81            clip_linestring_to_polygon(line, rings)
82        }
83
84        // All other combinations: fall back to checking intersection,
85        // return one of the inputs if they intersect.
86        _ => {
87            if crate::predicates::st_intersects(a, b) {
88                // Return the smaller geometry as the "intersection".
89                // This is an approximation for complex type combinations.
90                Geometry::GeometryCollection {
91                    geometries: vec![a.clone(), b.clone()],
92                }
93            } else {
94                empty_geometry()
95            }
96        }
97    }
98}
99
100/// Empty geometry (no intersection).
101fn empty_geometry() -> Geometry {
102    Geometry::GeometryCollection {
103        geometries: Vec::new(),
104    }
105}
106
107/// Sutherland-Hodgman polygon clipping.
108///
109/// Clips `subject` polygon against `clip` polygon. Both are coordinate rings
110/// (closed or unclosed). Returns the clipped polygon vertices (unclosed).
111///
112/// The clip polygon's edges define half-planes. For each edge, vertices of
113/// the subject that are "inside" (left of the edge) are kept. Vertices
114/// that cross from inside to outside (or vice versa) generate intersection
115/// points on the clip edge.
116fn sutherland_hodgman(subject: &[[f64; 2]], clip: &[[f64; 2]]) -> Vec<[f64; 2]> {
117    if subject.is_empty() || clip.is_empty() {
118        return Vec::new();
119    }
120
121    let mut output = strip_closing(subject);
122    let clip_edges = strip_closing(clip);
123
124    let n = clip_edges.len();
125    for i in 0..n {
126        if output.is_empty() {
127            return Vec::new();
128        }
129
130        let edge_start = clip_edges[i];
131        let edge_end = clip_edges[(i + 1) % n];
132
133        let input = output;
134        output = Vec::with_capacity(input.len());
135
136        let m = input.len();
137        for j in 0..m {
138            let current = input[j];
139            let previous = input[(j + m - 1) % m];
140
141            let curr_inside = is_inside(current, edge_start, edge_end);
142            let prev_inside = is_inside(previous, edge_start, edge_end);
143
144            if curr_inside {
145                if !prev_inside {
146                    // Entering: add intersection point.
147                    if let Some(pt) = line_intersection(previous, current, edge_start, edge_end) {
148                        output.push(pt);
149                    }
150                }
151                output.push(current);
152            } else if prev_inside {
153                // Leaving: add intersection point.
154                if let Some(pt) = line_intersection(previous, current, edge_start, edge_end) {
155                    output.push(pt);
156                }
157            }
158        }
159    }
160
161    output
162}
163
164/// Check if a point is on the "inside" (left side) of a directed edge.
165fn is_inside(point: [f64; 2], edge_start: [f64; 2], edge_end: [f64; 2]) -> bool {
166    // Cross product: (edge_end - edge_start) × (point - edge_start)
167    // Positive = left side = inside for CCW winding.
168    let cross = (edge_end[0] - edge_start[0]) * (point[1] - edge_start[1])
169        - (edge_end[1] - edge_start[1]) * (point[0] - edge_start[0]);
170    cross >= 0.0
171}
172
173/// Compute the intersection point of two line segments (as infinite lines).
174fn line_intersection(a1: [f64; 2], a2: [f64; 2], b1: [f64; 2], b2: [f64; 2]) -> Option<[f64; 2]> {
175    let dx_a = a2[0] - a1[0];
176    let dy_a = a2[1] - a1[1];
177    let dx_b = b2[0] - b1[0];
178    let dy_b = b2[1] - b1[1];
179
180    let denom = dx_a * dy_b - dy_a * dx_b;
181    if denom.abs() < 1e-15 {
182        return None; // Parallel lines.
183    }
184
185    let t = ((b1[0] - a1[0]) * dy_b - (b1[1] - a1[1]) * dx_b) / denom;
186    Some([a1[0] + t * dx_a, a1[1] + t * dy_a])
187}
188
189/// Strip the closing vertex from a ring if present.
190fn strip_closing(ring: &[[f64; 2]]) -> Vec<[f64; 2]> {
191    if ring.len() >= 2 && ring.first() == ring.last() {
192        ring[..ring.len() - 1].to_vec()
193    } else {
194        ring.to_vec()
195    }
196}
197
198/// Clip a linestring to a polygon: keep only the parts inside.
199fn clip_linestring_to_polygon(line: &[[f64; 2]], rings: &[Vec<[f64; 2]>]) -> Geometry {
200    let Some(exterior) = rings.first() else {
201        return empty_geometry();
202    };
203
204    // Collect points that are inside the polygon.
205    let mut inside_points: Vec<[f64; 2]> = Vec::new();
206    for pt in line {
207        if nodedb_types::geometry::point_in_polygon(pt[0], pt[1], exterior)
208            || crate::predicates::edge::point_on_ring_boundary(*pt, exterior)
209        {
210            inside_points.push(*pt);
211        }
212    }
213
214    if inside_points.len() < 2 {
215        return empty_geometry();
216    }
217
218    Geometry::LineString {
219        coordinates: inside_points,
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn square(min: f64, max: f64) -> Geometry {
228        Geometry::polygon(vec![vec![
229            [min, min],
230            [max, min],
231            [max, max],
232            [min, max],
233            [min, min],
234        ]])
235    }
236
237    #[test]
238    fn overlapping_squares() {
239        let a = square(0.0, 10.0);
240        let b = square(5.0, 15.0);
241        let result = st_intersection(&a, &b);
242        if let Geometry::Polygon { coordinates } = &result {
243            assert!(!coordinates[0].is_empty());
244            // Intersection should be approximately [5,5] to [10,10].
245            let xs: Vec<f64> = coordinates[0].iter().map(|c| c[0]).collect();
246            let ys: Vec<f64> = coordinates[0].iter().map(|c| c[1]).collect();
247            let min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min);
248            let max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
249            let min_y = ys.iter().cloned().fold(f64::INFINITY, f64::min);
250            let max_y = ys.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
251            assert!((min_x - 5.0).abs() < 0.01, "min_x={min_x}");
252            assert!((max_x - 10.0).abs() < 0.01, "max_x={max_x}");
253            assert!((min_y - 5.0).abs() < 0.01, "min_y={min_y}");
254            assert!((max_y - 10.0).abs() < 0.01, "max_y={max_y}");
255        } else {
256            panic!("expected Polygon, got {:?}", result.geometry_type());
257        }
258    }
259
260    #[test]
261    fn contained_square() {
262        let a = square(0.0, 10.0);
263        let b = square(2.0, 8.0);
264        let result = st_intersection(&a, &b);
265        if let Geometry::Polygon { coordinates } = &result {
266            // Result should be approximately b itself.
267            let xs: Vec<f64> = coordinates[0].iter().map(|c| c[0]).collect();
268            let min_x = xs.iter().cloned().fold(f64::INFINITY, f64::min);
269            let max_x = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
270            assert!((min_x - 2.0).abs() < 0.01);
271            assert!((max_x - 8.0).abs() < 0.01);
272        } else {
273            panic!("expected Polygon");
274        }
275    }
276
277    #[test]
278    fn disjoint_returns_empty() {
279        let a = square(0.0, 5.0);
280        let b = square(10.0, 15.0);
281        let result = st_intersection(&a, &b);
282        if let Geometry::GeometryCollection { geometries } = &result {
283            assert!(geometries.is_empty());
284        } else {
285            panic!("expected empty GeometryCollection");
286        }
287    }
288
289    #[test]
290    fn point_inside_polygon() {
291        let poly = square(0.0, 10.0);
292        let pt = Geometry::point(5.0, 5.0);
293        let result = st_intersection(&poly, &pt);
294        assert_eq!(result.geometry_type(), "Point");
295    }
296
297    #[test]
298    fn point_outside_polygon() {
299        let poly = square(0.0, 10.0);
300        let pt = Geometry::point(20.0, 20.0);
301        let result = st_intersection(&poly, &pt);
302        if let Geometry::GeometryCollection { geometries } = &result {
303            assert!(geometries.is_empty());
304        }
305    }
306
307    #[test]
308    fn identical_points() {
309        let a = Geometry::point(5.0, 5.0);
310        let b = Geometry::point(5.0, 5.0);
311        let result = st_intersection(&a, &b);
312        assert_eq!(result.geometry_type(), "Point");
313    }
314
315    #[test]
316    fn different_points() {
317        let a = Geometry::point(0.0, 0.0);
318        let b = Geometry::point(1.0, 1.0);
319        let result = st_intersection(&a, &b);
320        if let Geometry::GeometryCollection { geometries } = &result {
321            assert!(geometries.is_empty());
322        }
323    }
324
325    #[test]
326    fn linestring_clipped_to_polygon() {
327        let poly = square(0.0, 10.0);
328        // Line fully inside the polygon.
329        let line = Geometry::line_string(vec![[2.0, 5.0], [5.0, 5.0], [8.0, 5.0]]);
330        let result = st_intersection(&poly, &line);
331        assert_eq!(result.geometry_type(), "LineString");
332        if let Geometry::LineString { coordinates } = &result {
333            assert_eq!(coordinates.len(), 3);
334        }
335    }
336}