Skip to main content

geometry_overlay/
operation.rs

1//! OVL5 — the boolean overlay free functions.
2//!
3//! Thin orchestration over the pipeline
4//! [`get_turns`](crate::turn) → [`enrich`](mod@crate::traverse::enrich) →
5//! [`traverse`](fn@crate::traverse::traverse) →
6//! [`assemble`](mod@crate::assemble). Mirrors
7//! `boost/geometry/algorithms/intersection.hpp`, `union_.hpp`,
8//! `difference.hpp`, and `sym_difference.hpp`.
9//!
10//! # Where these live
11//!
12//! The overlay plan (`phase_03-…-overlay.md` §OVL5) placed these in
13//! `geometry-algorithm`. That would form a dependency cycle:
14//! `geometry-overlay` already depends on `geometry-algorithm` (for
15//! `within` / `ring_area`), so `geometry-algorithm` cannot depend back
16//! on `geometry-overlay`. The functions therefore live here in
17//! `geometry-overlay` and are re-exported by the `geometry` facade.
18//! This is the same class of spec-stub cycle already corrected
19//! elsewhere in the port.
20//!
21//! # Scope (v1)
22//!
23//! Polygon × polygon → `MultiPolygon`, for the clean areal case (simple
24//! polygons, transversal crossings). The overlay operates on each
25//! input's **exterior** ring; an input carrying interior rings (holes)
26//! is refused with [`OverlayError::Unsupported`] rather than silently
27//! treated as solid. Other degenerate inputs surface as
28//! [`OverlayError::Unsupported`] too; non-overlapping inputs take the
29//! documented fast paths.
30
31use alloc::vec::Vec;
32
33use geometry_coords::CoordinateScalar;
34use geometry_cs::{CartesianFamily, CoordinateSystem};
35use geometry_model::{MultiPolygon, Polygon, Ring};
36use geometry_tag::SameAs;
37use geometry_trait::{Point, PointMut, Polygon as PolygonTrait, Ring as RingTrait};
38
39use crate::assemble::assemble_multipolygon;
40use crate::predicate::range_guard::polygon_in_range;
41use crate::traverse::{OverlayOp, TraversalError, enrich, traverse};
42use crate::turn::{RingKind, get_turns_ring_ring};
43
44/// Reject a polygon pair whose coordinates leave the safe arithmetic
45/// range. Out of range, the turn collector silently drops intersections
46/// (they surface as [`SegmentIntersection::OutOfRange`] and emit no turn),
47/// so an emptied turn graph would be read as "disjoint" and yield a
48/// silently wrong result. Refusing up front keeps the "never wrong
49/// silently" contract (see [`crate::predicate::range_guard`]).
50fn both_in_range<G1, G2, P>(g1: &G1, g2: &G2) -> bool
51where
52    G1: PolygonTrait<Point = P>,
53    G2: PolygonTrait<Point = P>,
54    P: Point,
55    P::Scalar: CoordinateScalar + Into<f64>,
56{
57    polygon_in_range(g1) && polygon_in_range(g2)
58}
59
60/// Failure of a boolean overlay operation.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum OverlayError {
63    /// The overlay hit a degenerate case the v1 clean areal engine does
64    /// not handle (clustered turns, self-intersection, collinear shared
65    /// edges). Propagated from [`TraversalError`].
66    Unsupported,
67}
68
69impl From<TraversalError> for OverlayError {
70    fn from(_: TraversalError) -> Self {
71        OverlayError::Unsupported
72    }
73}
74
75/// Intersection of two polygons — the region inside **both**.
76///
77/// Mirrors `boost::geometry::intersection`
78/// (`algorithms/intersection.hpp`). Returns an empty `MultiPolygon`
79/// when the polygons do not overlap.
80///
81/// # Errors
82///
83/// [`OverlayError::Unsupported`] for degenerate inputs (see the module
84/// docs).
85///
86/// # Examples
87///
88/// ```
89/// use geometry_cs::Cartesian;
90/// use geometry_model::{polygon, Point2D, Polygon};
91/// use geometry_overlay::operation::intersection;
92/// use geometry_trait::MultiPolygon as _;
93///
94/// type P = Point2D<f64, Cartesian>;
95/// 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)]];
96/// 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)]];
97/// let out = intersection(&a, &b).unwrap();
98/// assert_eq!(out.polygons().count(), 1);
99/// ```
100pub fn intersection<G1, G2, P>(g1: &G1, g2: &G2) -> Result<MultiPolygon<Polygon<P>>, OverlayError>
101where
102    G1: PolygonTrait<Point = P>,
103    G2: PolygonTrait<Point = P>,
104    P: PointMut + Default + Copy,
105    P::Scalar: CoordinateScalar + Into<f64>,
106    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
107{
108    if has_holes(g1) || has_holes(g2) || !both_in_range(g1, g2) {
109        return Err(OverlayError::Unsupported);
110    }
111    let (r1, r2) = (g1.exterior(), g2.exterior());
112    let turns = get_turns_ring_ring(r1, 0, RingKind::Exterior, r2, 1, RingKind::Exterior);
113
114    if turns.is_empty() {
115        // No boundary crossings: the intersection is empty unless one
116        // polygon is wholly inside the other, in which case it is the
117        // inner polygon.
118        return Ok(containment_result(g1, g2, OverlayOp::Intersection));
119    }
120
121    let enriched = enrich(r1, r2, &turns);
122    let rings = traverse(&enriched, &turns, OverlayOp::Intersection)?;
123    Ok(assemble_multipolygon(rings))
124}
125
126/// Whether a polygon carries any interior ring (hole). v1 overlay
127/// operates on the exterior boundary only; an input with holes would be
128/// silently treated as solid, so the operations refuse it rather than
129/// return a wrong area.
130fn has_holes<G, P>(g: &G) -> bool
131where
132    G: PolygonTrait<Point = P>,
133    P: Point,
134{
135    g.interiors().next().is_some()
136}
137
138/// Union of two polygons — the region inside **either**.
139///
140/// Mirrors `boost::geometry::union_` (`algorithms/union_.hpp`; the C++
141/// trailing underscore dodges the keyword — Rust needs no such dodge,
142/// but `union` is reserved so the free function is named `union_poly`).
143///
144/// # Errors
145///
146/// [`OverlayError::Unsupported`] for degenerate inputs.
147///
148/// # Examples
149///
150/// ```
151/// use geometry_cs::Cartesian;
152/// use geometry_model::{polygon, Point2D, Polygon};
153/// use geometry_overlay::operation::union_poly;
154/// use geometry_trait::MultiPolygon as _;
155///
156/// type P = Point2D<f64, Cartesian>;
157/// 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)]];
158/// 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)]];
159/// let out = union_poly(&a, &b).unwrap();
160/// assert_eq!(out.polygons().count(), 1);
161/// ```
162pub fn union_poly<G1, G2, P>(g1: &G1, g2: &G2) -> Result<MultiPolygon<Polygon<P>>, OverlayError>
163where
164    G1: PolygonTrait<Point = P>,
165    G2: PolygonTrait<Point = P>,
166    P: PointMut + Default + Copy,
167    P::Scalar: CoordinateScalar + Into<f64>,
168    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
169{
170    if has_holes(g1) || has_holes(g2) || !both_in_range(g1, g2) {
171        return Err(OverlayError::Unsupported);
172    }
173    let (r1, r2) = (g1.exterior(), g2.exterior());
174    let turns = get_turns_ring_ring(r1, 0, RingKind::Exterior, r2, 1, RingKind::Exterior);
175
176    if turns.is_empty() {
177        // No crossings: either disjoint (two separate polygons) or one
178        // contains the other (the outer polygon).
179        return Ok(union_no_crossing(g1, g2));
180    }
181
182    let enriched = enrich(r1, r2, &turns);
183    let rings = traverse(&enriched, &turns, OverlayOp::Union)?;
184    Ok(assemble_multipolygon(rings))
185}
186
187/// Difference of two polygons — the region inside the first but outside
188/// the second (`A − B`).
189///
190/// Mirrors `boost::geometry::difference` (`algorithms/difference.hpp`).
191/// The subtrahend `B`'s exterior ring is reversed, so a forward-only
192/// traversal that keeps `A`'s outside arcs assembles `A − B`.
193///
194/// # Errors
195///
196/// [`OverlayError::Unsupported`] for degenerate inputs.
197///
198/// # Examples
199///
200/// ```
201/// use geometry_cs::Cartesian;
202/// use geometry_model::{polygon, Point2D, Polygon};
203/// use geometry_overlay::operation::difference;
204/// use geometry_trait::MultiPolygon as _;
205///
206/// type P = Point2D<f64, Cartesian>;
207/// 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)]];
208/// 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)]];
209/// let out = difference(&a, &b).unwrap();
210/// assert_eq!(out.polygons().count(), 1);
211/// ```
212pub fn difference<G1, G2, P>(g1: &G1, g2: &G2) -> Result<MultiPolygon<Polygon<P>>, OverlayError>
213where
214    G1: PolygonTrait<Point = P>,
215    G2: PolygonTrait<Point = P>,
216    P: PointMut + Default + Copy,
217    P::Scalar: CoordinateScalar + Into<f64>,
218    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
219{
220    if has_holes(g1) || has_holes(g2) || !both_in_range(g1, g2) {
221        return Err(OverlayError::Unsupported);
222    }
223    let r1 = g1.exterior();
224    let r2 = g2.exterior();
225    let turns = get_turns_ring_ring(r1, 0, RingKind::Exterior, r2, 1, RingKind::Exterior);
226
227    if turns.is_empty() {
228        // No crossings: A inside B → empty; disjoint → A whole; B inside
229        // A → A with B as a hole, which the exterior-only assembler
230        // cannot yet build, so it is refused rather than returned wrong.
231        return difference_no_crossing(g1, g2);
232    }
233
234    let enriched = enrich(r1, r2, &turns);
235    let rings = traverse(&enriched, &turns, OverlayOp::Difference)?;
236    Ok(assemble_multipolygon(rings))
237}
238
239/// Symmetric difference of two polygons — the region inside exactly one
240/// of them (`(A − B) ∪ (B − A)`).
241///
242/// Mirrors `boost::geometry::sym_difference`
243/// (`algorithms/sym_difference.hpp`). Computed as the union of the two
244/// one-sided differences.
245///
246/// # Errors
247///
248/// [`OverlayError::Unsupported`] for degenerate inputs.
249///
250/// # Examples
251///
252/// ```
253/// use geometry_cs::Cartesian;
254/// use geometry_model::{polygon, Point2D, Polygon};
255/// use geometry_overlay::operation::sym_difference;
256/// use geometry_trait::MultiPolygon as _;
257///
258/// type P = Point2D<f64, Cartesian>;
259/// 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)]];
260/// 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)]];
261/// let out = sym_difference(&a, &b).unwrap();
262/// assert!(out.polygons().count() >= 1);
263/// ```
264pub fn sym_difference<G1, G2, P>(g1: &G1, g2: &G2) -> Result<MultiPolygon<Polygon<P>>, OverlayError>
265where
266    G1: PolygonTrait<Point = P>,
267    G2: PolygonTrait<Point = P>,
268    P: PointMut + Default + Copy,
269    P::Scalar: CoordinateScalar + Into<f64>,
270    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
271{
272    let a_minus_b = difference(g1, g2)?;
273    let b_minus_a = difference(g2, g1)?;
274    // Union the two disjoint difference results by concatenating their
275    // polygons — `(A − B)` and `(B − A)` share no interior area, so no
276    // further overlay is needed.
277    let mut polygons: Vec<Polygon<P>> = a_minus_b.0;
278    polygons.extend(b_minus_a.0);
279    Ok(MultiPolygon(polygons))
280}
281
282/// Difference result when the two boundaries do not cross.
283///
284/// * `A` inside `B` → empty.
285/// * `B` inside `A` → `A` with `B` as a hole — the exterior-only
286///   assembler cannot build this, so it is refused with
287///   [`OverlayError::Unsupported`] rather than returned as `A` whole
288///   (which would over-report the area).
289/// * disjoint → `A` whole.
290fn difference_no_crossing<G1, G2, P>(
291    g1: &G1,
292    g2: &G2,
293) -> Result<MultiPolygon<Polygon<P>>, OverlayError>
294where
295    G1: PolygonTrait<Point = P>,
296    G2: PolygonTrait<Point = P>,
297    P: PointMut + Default + Copy,
298    P::Scalar: CoordinateScalar,
299    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
300{
301    if first_vertex_within(g1, g2) {
302        // A inside B → nothing left.
303        return Ok(MultiPolygon(Vec::new()));
304    }
305    if first_vertex_within(g2, g1) {
306        // B inside A → A with a hole; deferred.
307        return Err(OverlayError::Unsupported);
308    }
309    // Disjoint → A whole.
310    Ok(MultiPolygon(Vec::from([clone_polygon(g1)])))
311}
312
313/// The intersection / containment result when the two boundaries do not
314/// cross: the inner polygon if one is inside the other, else empty.
315fn containment_result<G1, G2, P>(g1: &G1, g2: &G2, _op: OverlayOp) -> MultiPolygon<Polygon<P>>
316where
317    G1: PolygonTrait<Point = P>,
318    G2: PolygonTrait<Point = P>,
319    P: PointMut + Default + Copy,
320    P::Scalar: CoordinateScalar,
321    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
322{
323    if first_vertex_within(g1, g2) {
324        MultiPolygon(Vec::from([clone_polygon(g1)]))
325    } else if first_vertex_within(g2, g1) {
326        MultiPolygon(Vec::from([clone_polygon(g2)]))
327    } else {
328        MultiPolygon(Vec::new())
329    }
330}
331
332/// The union result when boundaries do not cross: the outer polygon if
333/// one contains the other, else both polygons side by side.
334fn union_no_crossing<G1, G2, P>(g1: &G1, g2: &G2) -> MultiPolygon<Polygon<P>>
335where
336    G1: PolygonTrait<Point = P>,
337    G2: PolygonTrait<Point = P>,
338    P: PointMut + Default + Copy,
339    P::Scalar: CoordinateScalar,
340    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
341{
342    if first_vertex_within(g1, g2) {
343        MultiPolygon(Vec::from([clone_polygon(g2)]))
344    } else if first_vertex_within(g2, g1) {
345        MultiPolygon(Vec::from([clone_polygon(g1)]))
346    } else {
347        MultiPolygon(Vec::from([clone_polygon(g1), clone_polygon(g2)]))
348    }
349}
350
351/// Whether the first vertex of `inner`'s exterior lies within `outer`.
352fn first_vertex_within<GI, GO, P>(inner: &GI, outer: &GO) -> bool
353where
354    GI: PolygonTrait<Point = P>,
355    GO: PolygonTrait<Point = P>,
356    P: PointMut + Default + Copy,
357    P::Scalar: CoordinateScalar,
358    <P::Cs as CoordinateSystem>::Family: SameAs<CartesianFamily>,
359{
360    let Some(v) = inner.exterior().points().next() else {
361        return false;
362    };
363    // `within` is implemented for the concrete `model::Polygon`, not an
364    // arbitrary `PolygonTrait`, so materialise `outer` first.
365    let outer_model = clone_polygon(outer);
366    geometry_algorithm::within(v, &outer_model)
367}
368
369/// Copy any [`PolygonTrait`] into a concrete `model::Polygon` by reading
370/// its rings through the trait surface.
371fn clone_polygon<G, P>(g: &G) -> Polygon<P>
372where
373    G: PolygonTrait<Point = P>,
374    P: Point + Copy,
375{
376    let outer: Ring<P> = Ring::from_vec(g.exterior().points().copied().collect());
377    let inners: Vec<Ring<P>> = g
378        .interiors()
379        .map(|r| Ring::from_vec(r.points().copied().collect()))
380        .collect();
381    Polygon::with_inners(outer, inners)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::{OverlayError, intersection, union_poly};
387    use geometry_algorithm::ring_area;
388    use geometry_cs::Cartesian;
389    use geometry_model::{Point2D, Polygon, polygon};
390    use geometry_trait::{MultiPolygon as _, Polygon as _};
391
392    type P = Point2D<f64, Cartesian>;
393
394    fn close(a: f64, b: f64) -> bool {
395        (a - b).abs() <= 1e-5 * a.abs().max(b.abs()).max(1.0)
396    }
397
398    fn total_area(mp: &geometry_model::MultiPolygon<Polygon<P>>) -> f64 {
399        mp.polygons().map(|pg| ring_area(pg.exterior()).abs()).sum()
400    }
401
402    #[test]
403    fn intersection_of_offset_squares() {
404        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)]];
405        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)]];
406        let out = intersection(&a, &b).unwrap();
407        assert_eq!(out.polygons().count(), 1);
408        assert!(close(total_area(&out), 1.0), "area {}", total_area(&out));
409    }
410
411    #[test]
412    fn intersection_disjoint_is_empty() {
413        let a: Polygon<P> = polygon![[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]];
414        let b: Polygon<P> = polygon![[(5.0, 5.0), (6.0, 5.0), (6.0, 6.0), (5.0, 6.0), (5.0, 5.0)]];
415        let out = intersection(&a, &b).unwrap();
416        assert_eq!(out.polygons().count(), 0);
417    }
418
419    #[test]
420    fn intersection_contained_is_inner() {
421        let big: Polygon<P> = polygon![[
422            (0.0, 0.0),
423            (10.0, 0.0),
424            (10.0, 10.0),
425            (0.0, 10.0),
426            (0.0, 0.0)
427        ]];
428        let small: Polygon<P> =
429            polygon![[(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0), (2.0, 2.0)]];
430        let out = intersection(&big, &small).unwrap();
431        assert_eq!(out.polygons().count(), 1);
432        assert!(close(total_area(&out), 4.0), "area {}", total_area(&out));
433    }
434
435    #[test]
436    fn union_of_offset_squares_area() {
437        // |A| + |B| - |A∩B| = 4 + 4 - 1 = 7.
438        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)]];
439        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)]];
440        let out = union_poly(&a, &b).unwrap();
441        assert_eq!(out.polygons().count(), 1);
442        assert!(close(total_area(&out), 7.0), "area {}", total_area(&out));
443    }
444
445    #[test]
446    fn union_disjoint_is_two_polygons() {
447        let a: Polygon<P> = polygon![[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]];
448        let b: Polygon<P> = polygon![[(5.0, 5.0), (6.0, 5.0), (6.0, 6.0), (5.0, 6.0), (5.0, 5.0)]];
449        let out = union_poly(&a, &b).unwrap();
450        assert_eq!(out.polygons().count(), 2);
451        assert!(close(total_area(&out), 2.0), "area {}", total_area(&out));
452    }
453
454    #[test]
455    fn difference_of_offset_squares_area() {
456        // |A − B| = |A| − |A∩B| = 4 − 1 = 3.
457        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)]];
458        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)]];
459        let out = super::difference(&a, &b).unwrap();
460        assert_eq!(out.polygons().count(), 1);
461        assert!(close(total_area(&out), 3.0), "area {}", total_area(&out));
462    }
463
464    #[test]
465    fn difference_disjoint_is_first_whole() {
466        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)]];
467        let b: Polygon<P> = polygon![[(5.0, 5.0), (6.0, 5.0), (6.0, 6.0), (5.0, 6.0), (5.0, 5.0)]];
468        let out = super::difference(&a, &b).unwrap();
469        assert_eq!(out.polygons().count(), 1);
470        assert!(close(total_area(&out), 4.0), "area {}", total_area(&out));
471    }
472
473    #[test]
474    fn difference_a_inside_b_is_empty() {
475        let big: Polygon<P> = polygon![[
476            (0.0, 0.0),
477            (10.0, 0.0),
478            (10.0, 10.0),
479            (0.0, 10.0),
480            (0.0, 0.0)
481        ]];
482        let small: Polygon<P> =
483            polygon![[(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0), (2.0, 2.0)]];
484        let out = super::difference(&small, &big).unwrap();
485        assert_eq!(out.polygons().count(), 0);
486    }
487
488    #[test]
489    fn difference_with_contained_subtrahend_is_refused_not_over_reported() {
490        // B strictly inside A: A − B is A with a hole. The exterior-only
491        // assembler cannot build the hole, so it must refuse rather than
492        // return A whole (area 100 instead of the true 96).
493        let big: Polygon<P> = polygon![[
494            (0.0, 0.0),
495            (10.0, 0.0),
496            (10.0, 10.0),
497            (0.0, 10.0),
498            (0.0, 0.0)
499        ]];
500        let small: Polygon<P> =
501            polygon![[(3.0, 3.0), (5.0, 3.0), (5.0, 5.0), (3.0, 5.0), (3.0, 3.0)]];
502        assert_eq!(
503            super::difference(&big, &small),
504            Err(OverlayError::Unsupported)
505        );
506        assert_eq!(
507            super::sym_difference(&big, &small),
508            Err(OverlayError::Unsupported)
509        );
510    }
511
512    #[test]
513    fn input_with_holes_is_refused_not_silently_wrong() {
514        // A polygon with an interior ring: the exterior-only overlay would
515        // treat it as solid and return a wrong area. It must refuse.
516        let donut: Polygon<P> = polygon![
517            [
518                (0.0, 0.0),
519                (10.0, 0.0),
520                (10.0, 10.0),
521                (0.0, 10.0),
522                (0.0, 0.0)
523            ],
524            [(3.0, 3.0), (7.0, 3.0), (7.0, 7.0), (3.0, 7.0), (3.0, 3.0)]
525        ];
526        let sq: Polygon<P> = polygon![[(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0), (2.0, 2.0)]];
527        assert_eq!(intersection(&donut, &sq), Err(OverlayError::Unsupported));
528        assert_eq!(union_poly(&donut, &sq), Err(OverlayError::Unsupported));
529        assert_eq!(
530            super::difference(&donut, &sq),
531            Err(OverlayError::Unsupported)
532        );
533        assert_eq!(
534            super::sym_difference(&donut, &sq),
535            Err(OverlayError::Unsupported)
536        );
537    }
538
539    #[test]
540    fn out_of_range_coordinates_are_refused_not_silently_wrong() {
541        // Regression: two huge overlapping squares (~1e14, past the ±2^26
542        // safe range) made the turn kernel silently drop every crossing as
543        // OutOfRange; the emptied turn graph was misread as "B inside A",
544        // over-reporting the intersection area ~4× as `Ok`. All ops must
545        // refuse rather than return a silently wrong result.
546        let a: Polygon<P> = polygon![[
547            (0.0, 0.0),
548            (2e14, 0.0),
549            (2e14, 2e14),
550            (0.0, 2e14),
551            (0.0, 0.0)
552        ]];
553        let b: Polygon<P> = polygon![[
554            (1e14, 1e14),
555            (3e14, 1e14),
556            (3e14, 3e14),
557            (1e14, 3e14),
558            (1e14, 1e14)
559        ]];
560        assert_eq!(intersection(&a, &b), Err(OverlayError::Unsupported));
561        assert_eq!(union_poly(&a, &b), Err(OverlayError::Unsupported));
562        assert_eq!(super::difference(&a, &b), Err(OverlayError::Unsupported));
563        assert_eq!(
564            super::sym_difference(&a, &b),
565            Err(OverlayError::Unsupported)
566        );
567    }
568
569    #[test]
570    fn sym_difference_of_offset_squares_area() {
571        // |A △ B| = |A| + |B| − 2|A∩B| = 4 + 4 − 2·1 = 6.
572        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)]];
573        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)]];
574        let out = super::sym_difference(&a, &b).unwrap();
575        assert!(close(total_area(&out), 6.0), "area {}", total_area(&out));
576    }
577
578    #[test]
579    fn union_contained_is_outer() {
580        let big: Polygon<P> = polygon![[
581            (0.0, 0.0),
582            (10.0, 0.0),
583            (10.0, 10.0),
584            (0.0, 10.0),
585            (0.0, 0.0)
586        ]];
587        let small: Polygon<P> =
588            polygon![[(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0), (2.0, 2.0)]];
589        let out = union_poly(&big, &small).unwrap();
590        assert_eq!(out.polygons().count(), 1);
591        assert!(close(total_area(&out), 100.0), "area {}", total_area(&out));
592    }
593}