geometry_overlay/relate.rs
1//! OVL6.T1 / OVL6.T2 — the DE-9IM relate matrix and the spatial
2//! predicates derived from it.
3//!
4//! Mirrors `boost/geometry/algorithms/relate.hpp`,
5//! `algorithms/relation.hpp`, and `detail/relate/`. The DE-9IM matrix
6//! records, for each pair drawn from {Interior, Boundary, Exterior} of
7//! two geometries, the **dimension** of their intersection. The
8//! `crosses` / `overlaps` / `touches` predicates
9//! (`algorithms/{crosses,overlaps,touches}.hpp`) are then thin tests on
10//! that matrix.
11//!
12//! v1 scope: polygon × polygon (areal × areal), computed from the turn
13//! graph plus interior sampling. The matrix is filled well enough for
14//! the three predicates; the full mask-string interface is deferred.
15
16use geometry_coords::CoordinateScalar;
17use geometry_cs::{CartesianFamily, CoordinateSystem};
18use geometry_model::{Polygon, Ring};
19use geometry_tag::SameAs;
20use geometry_trait::{Point, PointMut, Polygon as PolygonTrait, Ring as RingTrait};
21
22use crate::operation::OverlayError;
23use crate::predicate::range_guard::polygon_in_range;
24use crate::surface_point::point_on_surface;
25use crate::turn::info::Method;
26use crate::turn::{RingKind, get_turns_ring_ring};
27
28/// The dimension of an intersection cell in a [`De9im`] matrix.
29///
30/// Mirrors the per-cell value of Boost's relate matrix: `F` (empty) or
31/// a dimension digit `0` / `1` / `2` (`detail/relate/result.hpp`).
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Dimension {
34 /// Empty intersection — Boost's `F`.
35 Empty,
36 /// Point (0-dimensional) intersection — Boost's `0`.
37 Point,
38 /// Curve (1-dimensional) intersection — Boost's `1`.
39 Curve,
40 /// Area (2-dimensional) intersection — Boost's `2`.
41 Area,
42}
43
44impl Dimension {
45 /// Whether the cell is non-empty (Boost's `T` — "true, any
46 /// dimension").
47 #[must_use]
48 pub fn is_set(self) -> bool {
49 self != Dimension::Empty
50 }
51}
52
53/// A DE-9IM 3×3 intersection matrix between two geometries.
54///
55/// Rows are the first geometry's {Interior, Boundary, Exterior}, columns
56/// the second's. `m[r][c]` is the dimension of the intersection of the
57/// first's feature `r` with the second's feature `c`. Mirrors Boost's
58/// `relate::matrix` (`detail/relate/result.hpp`).
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct De9im {
61 /// `[row][col]` over `[Interior, Boundary, Exterior]`.
62 pub m: [[Dimension; 3]; 3],
63}
64
65/// Row / column indices into a [`De9im`] matrix.
66pub mod feature {
67 /// Interior row/column index.
68 pub const INTERIOR: usize = 0;
69 /// Boundary row/column index.
70 pub const BOUNDARY: usize = 1;
71 /// Exterior row/column index.
72 pub const EXTERIOR: usize = 2;
73}
74
75impl De9im {
76 /// The `[Interior][Interior]` cell — do the two interiors meet, and
77 /// in what dimension.
78 #[must_use]
79 pub fn interior_interior(&self) -> Dimension {
80 self.m[feature::INTERIOR][feature::INTERIOR]
81 }
82
83 /// The `[Boundary][Boundary]` cell.
84 #[must_use]
85 pub fn boundary_boundary(&self) -> Dimension {
86 self.m[feature::BOUNDARY][feature::BOUNDARY]
87 }
88
89 /// The `[Interior][Exterior]` cell — is part of the first's interior
90 /// outside the second.
91 #[must_use]
92 pub fn interior_exterior(&self) -> Dimension {
93 self.m[feature::INTERIOR][feature::EXTERIOR]
94 }
95
96 /// The `[Exterior][Interior]` cell.
97 #[must_use]
98 pub fn exterior_interior(&self) -> Dimension {
99 self.m[feature::EXTERIOR][feature::INTERIOR]
100 }
101}
102
103/// Compute the DE-9IM matrix relating two polygons.
104///
105/// Fills the matrix from the turn graph (do the boundaries cross, and
106/// where) and interior sampling (is each interior partly inside / partly
107/// outside the other). Mirrors `boost::geometry::relation`
108/// (`algorithms/relation.hpp`) for the areal × areal case.
109///
110/// # Errors
111///
112/// Returns [`OverlayError::Unsupported`] in two cases:
113///
114/// * Either polygon has a coordinate outside the safe arithmetic range
115/// ([`SAFE_ABS_MAX`](crate::predicate::range_guard::SAFE_ABS_MAX)) — out
116/// of range the turn collector silently drops intersections, so the
117/// relation cannot be trusted.
118/// * The boundaries meet but only *non-transversally* (edge-aligned/
119/// collinear or vertex-only contact) **and** neither interior
120/// representative point falls strictly inside the other. In that state
121/// the turn graph cannot distinguish a pure boundary touch from a
122/// genuine area overlap whose crossings all land on vertices/edges, so
123/// the interior/interior cell is genuinely ambiguous — the same
124/// degenerate class the boolean overlay operations refuse (see
125/// [`crate::intersection`]).
126///
127/// Every transversal crossing, containment, or clean disjoint case is
128/// answered normally.
129///
130/// # Examples
131///
132/// ```
133/// use geometry_cs::Cartesian;
134/// use geometry_model::{polygon, Point2D, Polygon};
135/// use geometry_overlay::relate::{relate, Dimension};
136///
137/// type P = Point2D<f64, Cartesian>;
138/// let a: Polygon<P> = polygon![[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]];
139/// let b: Polygon<P> = polygon![[(1.0, 1.0), (3.0, 1.0), (3.0, 3.0), (1.0, 3.0), (1.0, 1.0)]];
140/// let matrix = relate(&a, &b).unwrap();
141/// // Overlapping squares: their interiors meet in an area.
142/// assert_eq!(matrix.interior_interior(), Dimension::Area);
143/// ```
144pub fn relate<G1, G2, P>(g1: &G1, g2: &G2) -> Result<De9im, OverlayError>
145where
146 G1: PolygonTrait<Point = P>,
147 G2: PolygonTrait<Point = P>,
148 P: PointMut + Default + Copy,
149 P::Scalar: CoordinateScalar + Into<f64>,
150 <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
151{
152 // Out-of-range coordinates make the turn collector silently drop
153 // intersections, so an emptied turn graph would be misread as
154 // disjoint (II = Empty) — a wrong answer. Refuse up front, matching
155 // the boolean overlay operations (see `crate::predicate::range_guard`).
156 if !polygon_in_range(g1) || !polygon_in_range(g2) {
157 return Err(OverlayError::Unsupported);
158 }
159
160 let r1 = g1.exterior();
161 let r2 = g2.exterior();
162 let turns = get_turns_ring_ring(r1, 0, RingKind::Exterior, r2, 1, RingKind::Exterior);
163 let boundaries_meet = !turns.is_empty();
164 // A transversal crossing (not a mere touch/collinear contact) is the
165 // signal that the interiors genuinely overlap in area.
166 let boundaries_cross_transversally = turns.iter().any(|t| t.method == Method::Crosses);
167
168 // Interior representatives.
169 let rep1 = point_on_surface(g1);
170 let rep2 = point_on_surface(g2);
171
172 // Is g1's interior point strictly inside g2, and vice versa? (Boost's
173 // `within` is strict-interior, which is what we want here.)
174 let g2_model = clone_polygon(g2);
175 let g1_model = clone_polygon(g1);
176 let rep1_in_g2 = rep1.is_some_and(|p| geometry_algorithm::within(&p, &g2_model));
177 let rep2_in_g1 = rep2.is_some_and(|p| geometry_algorithm::within(&p, &g1_model));
178
179 // Ambiguous overlap: the boundaries touch, but neither a transversal
180 // crossing nor a rep-point containment witnesses an interior overlap.
181 // A single interior sample can miss an overlap region that lies off to
182 // one side (e.g. two edge-aligned bands, or a diamond whose crossings
183 // all land on B's vertices). We cannot soundly decide II here, so we
184 // refuse rather than under-report. Note the order: transversal cross
185 // OR either containment makes the relation decidable and is handled
186 // below; only their joint absence *with* boundary contact is unsafe.
187 if boundaries_meet && !boundaries_cross_transversally && !rep1_in_g2 && !rep2_in_g1 {
188 return Err(OverlayError::Unsupported);
189 }
190
191 let interiors_overlap = boundaries_cross_transversally || rep1_in_g2 || rep2_in_g1;
192
193 let mut m = [[Dimension::Empty; 3]; 3];
194
195 // Interior/Interior: area when the interiors genuinely overlap.
196 if interiors_overlap {
197 m[feature::INTERIOR][feature::INTERIOR] = Dimension::Area;
198 }
199
200 // Boundary/Boundary: the boundaries meet at the turn points.
201 if boundaries_meet {
202 m[feature::BOUNDARY][feature::BOUNDARY] = Dimension::Point;
203 }
204
205 // Interior/Exterior: part of g1's interior lies outside g2 unless g1
206 // is wholly contained in g2. It is contained only when its interior
207 // point is inside g2 *and* the boundaries do not cross transversally.
208 let g1_contained = rep1_in_g2 && !boundaries_cross_transversally;
209 if !g1_contained {
210 m[feature::INTERIOR][feature::EXTERIOR] = Dimension::Area;
211 }
212 let g2_contained = rep2_in_g1 && !boundaries_cross_transversally;
213 if !g2_contained {
214 m[feature::EXTERIOR][feature::INTERIOR] = Dimension::Area;
215 }
216
217 // Exterior/Exterior is always the unbounded plane outside both.
218 m[feature::EXTERIOR][feature::EXTERIOR] = Dimension::Area;
219
220 Ok(De9im { m })
221}
222
223/// `touches`: the boundaries meet but the interiors do not.
224///
225/// Mirrors `boost::geometry::touches` (`algorithms/touches.hpp`) for the
226/// areal × areal case: `II = F` and the boundaries have non-empty
227/// intersection.
228///
229/// # Errors
230///
231/// Propagates [`OverlayError::Unsupported`] from [`relate`] for the
232/// ambiguous non-transversal-contact class (see [`relate`]'s docs).
233pub fn touches<G1, G2, P>(g1: &G1, g2: &G2) -> Result<bool, OverlayError>
234where
235 G1: PolygonTrait<Point = P>,
236 G2: PolygonTrait<Point = P>,
237 P: PointMut + Default + Copy,
238 P::Scalar: CoordinateScalar + Into<f64>,
239 <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
240{
241 let matrix = relate(g1, g2)?;
242 Ok(!matrix.interior_interior().is_set() && matrix.boundary_boundary().is_set())
243}
244
245/// `overlaps`: the interiors intersect, and each geometry has interior
246/// points outside the other, at the same dimension.
247///
248/// Mirrors `boost::geometry::overlaps` (`algorithms/overlaps.hpp`) for
249/// areal × areal: `II = 2`, `IE = 2`, and `EI = 2`.
250///
251/// # Errors
252///
253/// Propagates [`OverlayError::Unsupported`] from [`relate`] for the
254/// ambiguous non-transversal-contact class (see [`relate`]'s docs). This
255/// keeps `overlaps` from silently reporting `false` for two polygons that
256/// genuinely overlap along an edge-aligned or vertex-only boundary.
257pub fn overlaps<G1, G2, P>(g1: &G1, g2: &G2) -> Result<bool, OverlayError>
258where
259 G1: PolygonTrait<Point = P>,
260 G2: PolygonTrait<Point = P>,
261 P: PointMut + Default + Copy,
262 P::Scalar: CoordinateScalar + Into<f64>,
263 <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
264{
265 let matrix = relate(g1, g2)?;
266 Ok(matrix.interior_interior() == Dimension::Area
267 && matrix.interior_exterior() == Dimension::Area
268 && matrix.exterior_interior() == Dimension::Area)
269}
270
271/// `crosses`: for two areal geometries this is always `false` — crossing
272/// is defined only for geometries of differing dimension (e.g. a line
273/// crossing an area).
274///
275/// Mirrors `boost::geometry::crosses` (`algorithms/crosses.hpp`); the
276/// areal × areal arm returns `false` by definition. Provided for
277/// completeness so callers get a uniform predicate surface. It never
278/// inspects the (possibly ambiguous) turn graph, so it is infallible;
279/// the `Result` return keeps its signature uniform with the sibling
280/// predicates [`overlaps`] / [`touches`].
281///
282/// # Errors
283///
284/// Never returns an error; the `Ok(false)` is unconditional.
285#[allow(
286 clippy::unnecessary_wraps,
287 reason = "The Result is intentional: it keeps `crosses` signature-compatible with the sibling fallible predicates `overlaps`/`touches`, so callers handle one uniform surface."
288)]
289pub fn crosses<G1, G2, P>(_g1: &G1, _g2: &G2) -> Result<bool, OverlayError>
290where
291 G1: PolygonTrait<Point = P>,
292 G2: PolygonTrait<Point = P>,
293 P: Point,
294{
295 Ok(false)
296}
297
298/// Copy any [`PolygonTrait`] into a concrete `model::Polygon`.
299fn clone_polygon<G, P>(g: &G) -> Polygon<P>
300where
301 G: PolygonTrait<Point = P>,
302 P: Point + Copy,
303{
304 let outer: Ring<P> = Ring::from_vec(g.exterior().points().copied().collect());
305 let inners = g
306 .interiors()
307 .map(|r| Ring::from_vec(r.points().copied().collect()))
308 .collect();
309 Polygon::with_inners(outer, inners)
310}
311
312#[cfg(test)]
313mod tests {
314 //! OVL6.T1 / T2 done-when: matrix + predicate values. Mirrors the
315 //! case families in `test/algorithms/relate/` and the
316 //! `touches` / `overlaps` test files.
317
318 use super::{Dimension, crosses, overlaps, relate, touches};
319 use geometry_cs::Cartesian;
320 use geometry_model::{Point2D, Polygon, polygon};
321
322 type P = Point2D<f64, Cartesian>;
323
324 fn square(x: f64, y: f64, s: f64) -> Polygon<P> {
325 polygon![[(x, y), (x + s, y), (x + s, y + s), (x, y + s), (x, y)]]
326 }
327
328 #[test]
329 fn overlapping_squares_overlap() {
330 let a = square(0.0, 0.0, 2.0);
331 let b = square(1.0, 1.0, 2.0);
332 assert_eq!(relate(&a, &b).unwrap().interior_interior(), Dimension::Area);
333 assert!(overlaps(&a, &b).unwrap());
334 assert!(!touches(&a, &b).unwrap());
335 assert!(!crosses(&a, &b).unwrap());
336 }
337
338 #[test]
339 fn edge_touching_squares_are_unsupported() {
340 // Share the edge x = 2 but interiors are disjoint. The boundaries
341 // meet only collinearly and neither interior point is inside the
342 // other, so the turn graph cannot tell a pure edge-touch from an
343 // edge-aligned overlap — the relation is reported unsupported
344 // rather than a possibly-wrong boolean.
345 use crate::operation::OverlayError;
346 let a = square(0.0, 0.0, 2.0);
347 let b = square(2.0, 0.0, 2.0);
348 assert_eq!(relate(&a, &b), Err(OverlayError::Unsupported));
349 assert_eq!(touches(&a, &b), Err(OverlayError::Unsupported));
350 assert_eq!(overlaps(&a, &b), Err(OverlayError::Unsupported));
351 }
352
353 #[test]
354 fn edge_aligned_overlap_is_unsupported_not_false() {
355 // Regression: A = [0,3]x[0,1], B = [2,5]x[0,1] genuinely overlap
356 // in [2,3]x[0,1], but all boundary contacts are collinear and both
357 // interior samples land outside the other. The old code returned
358 // `overlaps = false` here (a wrong answer); now it refuses.
359 use crate::operation::OverlayError;
360 let a: Polygon<P> = polygon![[(0.0, 0.0), (3.0, 0.0), (3.0, 1.0), (0.0, 1.0), (0.0, 0.0)]];
361 let b: Polygon<P> = polygon![[(2.0, 0.0), (5.0, 0.0), (5.0, 1.0), (2.0, 1.0), (2.0, 0.0)]];
362 assert_eq!(overlaps(&a, &b), Err(OverlayError::Unsupported));
363 }
364
365 #[test]
366 fn out_of_range_coordinates_are_unsupported() {
367 // Regression: past ±2^26 the turn kernel silently drops
368 // intersections, so an emptied turn graph would be misread as
369 // disjoint (II=Empty) for genuinely overlapping huge polygons.
370 // relate must refuse rather than return that wrong matrix.
371 use crate::operation::OverlayError;
372 let a: Polygon<P> = polygon![[
373 (0.0, 0.0),
374 (2e14, 0.0),
375 (2e14, 2e14),
376 (0.0, 2e14),
377 (0.0, 0.0)
378 ]];
379 let b: Polygon<P> = polygon![[
380 (1e14, 1e14),
381 (3e14, 1e14),
382 (3e14, 3e14),
383 (1e14, 3e14),
384 (1e14, 1e14)
385 ]];
386 assert_eq!(relate(&a, &b), Err(OverlayError::Unsupported));
387 assert_eq!(overlaps(&a, &b), Err(OverlayError::Unsupported));
388 }
389
390 #[test]
391 fn disjoint_squares_neither() {
392 let a = square(0.0, 0.0, 1.0);
393 let b = square(5.0, 5.0, 1.0);
394 assert!(!touches(&a, &b).unwrap());
395 assert!(!overlaps(&a, &b).unwrap());
396 assert_eq!(
397 relate(&a, &b).unwrap().interior_interior(),
398 Dimension::Empty
399 );
400 }
401
402 #[test]
403 fn contained_square_does_not_overlap_or_touch() {
404 // small ⊂ big: interiors meet (II = area) but small has no
405 // interior outside big, so it is containment, not overlap. The
406 // rep-point containment makes this decidable (no boundary touch),
407 // so it is answered normally.
408 let big = square(0.0, 0.0, 10.0);
409 let small = square(3.0, 3.0, 2.0);
410 assert_eq!(
411 relate(&big, &small).unwrap().interior_interior(),
412 Dimension::Area
413 );
414 assert!(!overlaps(&big, &small).unwrap());
415 assert!(!touches(&big, &small).unwrap());
416 }
417}