Skip to main content

nodedb_spatial/predicates/
intersection.rs

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