Skip to main content

brepkit_math/
polygon_boolean.rs

1//! Robust 2D boolean operations on simple polygons (union, intersection,
2//! difference).
3//!
4//! # Approach
5//!
6//! Rather than weaving a doubly-linked list through coincident vertices
7//! (the classic Greiner–Hormann hazard), this implementation builds a planar
8//! arrangement and classifies *directed sub-edges* by their **midpoints**:
9//!
10//! 1. Every edge of `A` is split at all points where it meets `B` (proper
11//!    crossings, vertex-on-edge T-junctions, and the endpoints of any
12//!    collinear-overlap), and vice versa.
13//! 2. All split points are *snapped* to a tolerance grid so that points
14//!    arising independently from `A` and `B` collapse to bit-identical
15//!    coordinates. This is what eliminates sliver artifacts from
16//!    near-coincident edges.
17//! 3. Each resulting sub-edge is classified by sampling its midpoint against
18//!    the *other* polygon: `Outside`, `Inside`, or `OnBoundary` (with the
19//!    relative direction of the shared boundary recorded).
20//! 4. Sub-edges are selected per the operation, then traced into closed loops
21//!    by following snapped coordinates.
22//! 5. Loops are classified as outer (CCW) or hole (CW) by signed area and
23//!    assembled into a [`PolygonBooleanResult`].
24//!
25//! Deciding inside/outside on a midpoint — a point in the *relative interior*
26//! of a sub-edge, away from the singular intersection vertices — is what makes
27//! the degenerate cases (collinear overlap, T-junctions, shared edges, corner
28//! touches) robust: the classification never has to disambiguate behaviour
29//! *at* a shared vertex.
30//!
31//! # Tolerance model
32//!
33//! `tol` is an absolute linear tolerance in the polygons' coordinate units.
34//! Two points within `tol` of each other are treated as identical (snapped to
35//! a shared grid cell of size `tol`); a point within `tol` of an edge is
36//! treated as lying on it; an edge pair whose overlap exceeds `tol` in length
37//! is treated as collinear-shared. Pass the same `tol` you use elsewhere for
38//! the geometry in question (e.g. `Tolerance::default().linear`, or a looser
39//! value for coarse data).
40
41use crate::predicates::winding_number;
42use crate::vec::Point2;
43
44/// Which boolean operation to perform.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum BooleanOp {
47    /// `A ∪ B` — points in either polygon.
48    Union,
49    /// `A ∩ B` — points in both polygons.
50    Intersection,
51    /// `A \ B` — points in `A` but not `B`.
52    Difference,
53}
54
55/// The result of a polygon boolean operation.
56///
57/// Winding convention: every `outer` loop is counter-clockwise (positive
58/// signed area) and every `hole` loop is clockwise (negative signed area).
59/// A point is "in" the result when it is inside an odd nesting of these loops
60/// per the even-odd rule; equivalently, inside some `outer` and not inside any
61/// `hole` contained by that outer.
62///
63/// A disjoint union yields multiple `outer` loops and no holes; a union that
64/// encloses a void yields one `outer` and one `hole`; a fully-degenerate or
65/// empty result yields both vectors empty.
66#[derive(Debug, Clone, Default, PartialEq)]
67pub struct PolygonBooleanResult {
68    /// Counter-clockwise outer boundary loops.
69    pub outer: Vec<Vec<Point2>>,
70    /// Clockwise hole loops (voids).
71    pub holes: Vec<Vec<Point2>>,
72}
73
74impl PolygonBooleanResult {
75    /// `true` when the operation produced no geometry.
76    #[must_use]
77    pub fn is_empty(&self) -> bool {
78        self.outer.is_empty() && self.holes.is_empty()
79    }
80
81    /// Total signed area: sum of outer-loop areas minus hole-loop areas.
82    ///
83    /// Because outers are CCW (positive) and holes CW (negative), this is the
84    /// plain sum of every loop's signed area, and equals the covered area.
85    #[must_use]
86    pub fn area(&self) -> f64 {
87        let mut total = 0.0;
88        for loop_pts in &self.outer {
89            total += signed_area(loop_pts);
90        }
91        for loop_pts in &self.holes {
92            total += signed_area(loop_pts);
93        }
94        total
95    }
96}
97
98/// Union of two simple polygons.
99///
100/// Both inputs should be simple (non-self-intersecting) polygons; orientation
101/// is normalized internally, so either winding is accepted. Returns the outer
102/// loop(s) of the union; holes (if the union encloses a void) are dropped from
103/// this convenience wrapper — use [`polygon_boolean`] if you need them.
104///
105/// Returns an empty `Vec` if either input is degenerate (fewer than 3
106/// non-collinear vertices) or the arrangement could not be traced.
107#[must_use]
108pub fn polygon_union(a: &[Point2], b: &[Point2], tol: f64) -> Vec<Vec<Point2>> {
109    polygon_boolean(a, b, BooleanOp::Union, tol).outer
110}
111
112/// General boolean of two simple polygons.
113///
114/// Orientation of the inputs is normalized internally (either winding is
115/// accepted). For [`BooleanOp::Difference`] the operation is `A \ B`.
116///
117/// Returns an empty [`PolygonBooleanResult`] if an input is degenerate or the
118/// arrangement could not be traced into closed loops; it never panics and
119/// never returns a silently-wrong partial result.
120#[must_use]
121#[allow(clippy::too_many_lines)]
122pub fn polygon_boolean(
123    a: &[Point2],
124    b: &[Point2],
125    op: BooleanOp,
126    tol: f64,
127) -> PolygonBooleanResult {
128    let tol = if tol > 0.0 && tol.is_finite() {
129        tol
130    } else {
131        return PolygonBooleanResult::default();
132    };
133
134    let poly_a = match Polygon::normalized(a, tol) {
135        Some(p) => p,
136        None => return degenerate_fallback(a, b, op, tol),
137    };
138    let poly_b = match Polygon::normalized(b, tol) {
139        Some(p) => p,
140        None => return degenerate_fallback(a, b, op, tol),
141    };
142
143    // Split each polygon's edges at every interaction with the other, snapping
144    // all split coordinates to a shared grid so coincident points merge.
145    let snapper = Snapper::new(tol);
146    let edges_a = split_polygon(&poly_a, &poly_b, &snapper, tol);
147    let edges_b = split_polygon(&poly_b, &poly_a, &snapper, tol);
148
149    // Classify and select directed sub-edges per the operation.
150    let mut selected: Vec<DirectedEdge> = Vec::new();
151    select_edges(
152        &edges_a,
153        &poly_b,
154        op,
155        EdgeSource::A,
156        &snapper,
157        tol,
158        &mut selected,
159    );
160    select_edges(
161        &edges_b,
162        &poly_a,
163        op,
164        EdgeSource::B,
165        &snapper,
166        tol,
167        &mut selected,
168    );
169
170    if selected.is_empty() {
171        return PolygonBooleanResult::default();
172    }
173
174    let loops = trace_loops(selected, &snapper, tol);
175    classify_loops(loops, tol)
176}
177
178// ===========================================================================
179// Geometry helpers
180// ===========================================================================
181
182/// Signed area via the shoelace formula. Positive for CCW, negative for CW.
183#[must_use]
184pub fn signed_area(polygon: &[Point2]) -> f64 {
185    let n = polygon.len();
186    if n < 3 {
187        return 0.0;
188    }
189    let mut sum = 0.0;
190    for i in 0..n {
191        let p = polygon[i];
192        let q = polygon[(i + 1) % n];
193        sum += p.x().mul_add(q.y(), -(q.x() * p.y()));
194    }
195    sum * 0.5
196}
197
198fn dist_sq(a: Point2, b: Point2) -> f64 {
199    let dx = a.x() - b.x();
200    let dy = a.y() - b.y();
201    dx.mul_add(dx, dy * dy)
202}
203
204/// Distance squared from `p` to the *segment* `[a, b]` (clamped to the
205/// segment, unlike the infinite-line variant in `polygon2d`).
206fn point_segment_dist_sq(p: Point2, a: Point2, b: Point2) -> f64 {
207    let abx = b.x() - a.x();
208    let aby = b.y() - a.y();
209    let len_sq = abx.mul_add(abx, aby * aby);
210    if len_sq < f64::MIN_POSITIVE {
211        return dist_sq(p, a);
212    }
213    let t = (((p.x() - a.x()) * abx) + ((p.y() - a.y()) * aby)) / len_sq;
214    let t = t.clamp(0.0, 1.0);
215    let proj = Point2::new(a.x() + t * abx, a.y() + t * aby);
216    dist_sq(p, proj)
217}
218
219/// Parameter of the projection of `p` onto the infinite line through `[a, b]`,
220/// expressed in `[0, 1]` over the segment (may fall outside `[0, 1]`).
221fn project_param(p: Point2, a: Point2, b: Point2) -> f64 {
222    let abx = b.x() - a.x();
223    let aby = b.y() - a.y();
224    let len_sq = abx.mul_add(abx, aby * aby);
225    if len_sq < f64::MIN_POSITIVE {
226        return 0.0;
227    }
228    (((p.x() - a.x()) * abx) + ((p.y() - a.y()) * aby)) / len_sq
229}
230
231fn lerp(a: Point2, b: Point2, t: f64) -> Point2 {
232    Point2::new(a.x() + t * (b.x() - a.x()), a.y() + t * (b.y() - a.y()))
233}
234
235// ===========================================================================
236// Coordinate snapping
237// ===========================================================================
238
239/// Snaps coordinates to a grid of cell size `tol` so that points arising
240/// independently from the two polygons collapse to identical values. This is
241/// the mechanism that removes spurious micro-edges (slivers): two vertices
242/// closer than `tol` round to the same grid cell and therefore the same key.
243#[derive(Clone, Copy)]
244struct Snapper {
245    inv: f64,
246    cell: f64,
247}
248
249impl Snapper {
250    fn new(tol: f64) -> Self {
251        // Use a cell a touch larger than tol so that two points up to `tol`
252        // apart reliably land in the same cell after rounding.
253        let cell = tol.max(f64::MIN_POSITIVE);
254        Self {
255            inv: 1.0 / cell,
256            cell,
257        }
258    }
259
260    /// Integer grid key for a coordinate (used for equality / adjacency).
261    fn key(&self, p: Point2) -> (i64, i64) {
262        // round half away from zero, deterministic for finite inputs
263        let kx = (p.x() * self.inv).round();
264        let ky = (p.y() * self.inv).round();
265        (kx as i64, ky as i64)
266    }
267
268    /// Canonical snapped position for a coordinate.
269    fn snap(&self, p: Point2) -> Point2 {
270        let (kx, ky) = self.key(p);
271        Point2::new(kx as f64 * self.cell, ky as f64 * self.cell)
272    }
273}
274
275// ===========================================================================
276// Normalized polygon
277// ===========================================================================
278
279/// A CCW, duplicate-free simple polygon ready for arrangement.
280struct Polygon {
281    /// Vertices in CCW order, no two consecutive within `tol`.
282    verts: Vec<Point2>,
283}
284
285impl Polygon {
286    /// Clean and orient an input ring. Returns `None` if it is degenerate
287    /// (fewer than 3 distinct vertices or zero signed area).
288    fn normalized(input: &[Point2], tol: f64) -> Option<Self> {
289        if input.len() < 3 {
290            return None;
291        }
292        // Drop consecutive (and wrap-around) near-duplicates.
293        let mut verts: Vec<Point2> = Vec::with_capacity(input.len());
294        for &p in input {
295            if let Some(&last) = verts.last()
296                && dist_sq(p, last) <= tol * tol
297            {
298                continue;
299            }
300            verts.push(p);
301        }
302        while verts.len() >= 2 {
303            let first = verts[0];
304            let last = verts[verts.len() - 1];
305            if dist_sq(first, last) <= tol * tol {
306                verts.pop();
307            } else {
308                break;
309            }
310        }
311        if verts.len() < 3 {
312            return None;
313        }
314
315        let area = signed_area(&verts);
316        if area.abs() <= tol * tol {
317            return None;
318        }
319        if area < 0.0 {
320            verts.reverse();
321        }
322        Some(Self { verts })
323    }
324
325    fn len(&self) -> usize {
326        self.verts.len()
327    }
328
329    fn vert(&self, i: usize) -> Point2 {
330        self.verts[i % self.verts.len()]
331    }
332
333    fn as_slice(&self) -> &[Point2] {
334        &self.verts
335    }
336}
337
338// ===========================================================================
339// Edge splitting
340// ===========================================================================
341
342/// A directed sub-edge produced by splitting, before classification.
343struct SubEdge {
344    start: Point2,
345    end: Point2,
346}
347
348/// Split every edge of `subject` at all parameters where it interacts with any
349/// edge of `other` (crossings, T-junctions, collinear-overlap endpoints).
350/// Endpoints are snapped; zero-length results are dropped.
351fn split_polygon(subject: &Polygon, other: &Polygon, snapper: &Snapper, tol: f64) -> Vec<SubEdge> {
352    let mut out = Vec::new();
353    let n = subject.len();
354    for i in 0..n {
355        let a1 = subject.vert(i);
356        let a2 = subject.vert(i + 1);
357
358        // Collect split parameters in (0, 1) along this edge.
359        let mut params: Vec<f64> = Vec::new();
360        let m = other.len();
361        for j in 0..m {
362            let b1 = other.vert(j);
363            let b2 = other.vert(j + 1);
364            collect_edge_split_params(a1, a2, b1, b2, tol, &mut params);
365        }
366
367        // Snap-deduplicate parameters and clamp to the open interval.
368        params.retain(|&t| t > 0.0 && t < 1.0);
369        params.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
370        dedup_params(&mut params, a1, a2, tol);
371
372        // Emit sub-edges between consecutive break points.
373        let mut prev = snapper.snap(a1);
374        let mut cuts: Vec<Point2> = Vec::with_capacity(params.len());
375        for &t in &params {
376            cuts.push(snapper.snap(lerp(a1, a2, t)));
377        }
378        cuts.push(snapper.snap(a2));
379        for pt in cuts {
380            if dist_sq(prev, pt) > tol * tol {
381                out.push(SubEdge {
382                    start: prev,
383                    end: pt,
384                });
385            }
386            prev = pt;
387        }
388    }
389    out
390}
391
392/// Append the parameters along edge `[a1, a2]` (in `[0, 1]`) at which it should
393/// be split because of edge `[b1, b2]`: proper crossings, the projection of
394/// each `b` endpoint that lies on the segment (T-junction), and the endpoints
395/// of a collinear overlap.
396fn collect_edge_split_params(
397    a1: Point2,
398    a2: Point2,
399    b1: Point2,
400    b2: Point2,
401    tol: f64,
402    params: &mut Vec<f64>,
403) {
404    let tol_sq = tol * tol;
405
406    // Endpoints of B that lie on segment A → T-junctions / shared vertices.
407    for &bp in &[b1, b2] {
408        if point_segment_dist_sq(bp, a1, a2) <= tol_sq {
409            let t = project_param(bp, a1, a2);
410            if t > 0.0 && t < 1.0 {
411                params.push(t);
412            }
413        }
414    }
415
416    // Collinear overlap: if both B endpoints are on the *line* of A, the
417    // overlap interval's interior endpoints become split points. (Endpoint
418    // T-junctions above already cover the shared-vertex case; this adds the
419    // case where A extends past the overlap on one or both sides.)
420    let d_b1 = point_line_dist_sq(b1, a1, a2);
421    let d_b2 = point_line_dist_sq(b2, a1, a2);
422    if d_b1 <= tol_sq && d_b2 <= tol_sq {
423        // Both endpoints collinear with A; their projections clip the overlap.
424        let tb1 = project_param(b1, a1, a2);
425        let tb2 = project_param(b2, a1, a2);
426        for t in [tb1, tb2] {
427            if t > 0.0 && t < 1.0 {
428                params.push(t);
429            }
430        }
431        return;
432    }
433
434    // Proper (transversal) intersection in the interior of both segments.
435    if let Some((ta, _tb)) = segment_intersection_params(a1, a2, b1, b2)
436        && ta > 0.0
437        && ta < 1.0
438    {
439        params.push(ta);
440    }
441}
442
443/// Squared distance from `p` to the *infinite line* through `[a, b]`.
444fn point_line_dist_sq(p: Point2, a: Point2, b: Point2) -> f64 {
445    let dx = b.x() - a.x();
446    let dy = b.y() - a.y();
447    let len_sq = dx.mul_add(dx, dy * dy);
448    if len_sq < f64::MIN_POSITIVE {
449        return dist_sq(p, a);
450    }
451    let cross = (p.x() - a.x()).mul_add(dy, -((p.y() - a.y()) * dx));
452    (cross * cross) / len_sq
453}
454
455/// Parameters `(ta, tb)` of a proper line-line intersection, or `None` when
456/// the segments are parallel/collinear (handled separately).
457fn segment_intersection_params(
458    a1: Point2,
459    a2: Point2,
460    b1: Point2,
461    b2: Point2,
462) -> Option<(f64, f64)> {
463    let dax = a2.x() - a1.x();
464    let day = a2.y() - a1.y();
465    let dbx = b2.x() - b1.x();
466    let dby = b2.y() - b1.y();
467    let denom = dax.mul_add(dby, -(day * dbx));
468    if denom.abs() < f64::MIN_POSITIVE {
469        return None;
470    }
471    let rx = b1.x() - a1.x();
472    let ry = b1.y() - a1.y();
473    let ta = rx.mul_add(dby, -(ry * dbx)) / denom;
474    let tb = rx.mul_add(day, -(ry * dax)) / denom;
475    if (0.0..=1.0).contains(&tb) {
476        Some((ta, tb))
477    } else {
478        None
479    }
480}
481
482/// Remove parameters whose snapped 3D positions coincide within `tol`.
483fn dedup_params(params: &mut Vec<f64>, a1: Point2, a2: Point2, tol: f64) {
484    if params.is_empty() {
485        return;
486    }
487    let tol_sq = tol * tol;
488    let mut kept: Vec<f64> = Vec::with_capacity(params.len());
489    for &t in params.iter() {
490        let pt = lerp(a1, a2, t);
491        let is_dup = kept
492            .last()
493            .is_some_and(|&pt_t| dist_sq(lerp(a1, a2, pt_t), pt) <= tol_sq);
494        if !is_dup {
495            kept.push(t);
496        }
497    }
498    *params = kept;
499}
500
501// ===========================================================================
502// Edge classification + selection
503// ===========================================================================
504
505#[derive(Clone, Copy, PartialEq, Eq)]
506enum EdgeSource {
507    A,
508    B,
509}
510
511/// A selected directed edge feeding the loop tracer.
512struct DirectedEdge {
513    start: Point2,
514    end: Point2,
515}
516
517/// Position of a sub-edge midpoint relative to the other polygon.
518enum MidClass {
519    Inside,
520    Outside,
521    /// The sub-edge lies on the other polygon's boundary; `same_dir` is true
522    /// when both boundaries run in the same direction along this sub-edge.
523    OnBoundary {
524        same_dir: bool,
525    },
526}
527
528#[allow(clippy::too_many_arguments)]
529fn select_edges(
530    edges: &[SubEdge],
531    other: &Polygon,
532    op: BooleanOp,
533    source: EdgeSource,
534    snapper: &Snapper,
535    tol: f64,
536    out: &mut Vec<DirectedEdge>,
537) {
538    for e in edges {
539        let class = classify_midpoint(e, other, snapper, tol);
540        let keep = match (op, source, &class) {
541            // --- Union: boundary of A∪B = parts of each outside the other,
542            // plus each shared edge once (counted on A, same direction). ---
543            (BooleanOp::Union, _, MidClass::Outside) => Keep::Forward,
544            (BooleanOp::Union, EdgeSource::A, MidClass::OnBoundary { same_dir: true }) => {
545                Keep::Forward
546            }
547            (BooleanOp::Union, _, _) => Keep::Drop,
548
549            // --- Intersection: boundary = parts of each inside the other,
550            // plus each shared (same-direction) edge once (on A). ---
551            (BooleanOp::Intersection, _, MidClass::Inside) => Keep::Forward,
552            (BooleanOp::Intersection, EdgeSource::A, MidClass::OnBoundary { same_dir: true }) => {
553                Keep::Forward
554            }
555            (BooleanOp::Intersection, _, _) => Keep::Drop,
556
557            // --- Difference A\B: A's parts outside B (forward), B's parts
558            // inside A (reversed, so the hole winds CW), plus opposite-
559            // direction shared edges (on A, forward). ---
560            (BooleanOp::Difference, EdgeSource::A, MidClass::Outside) => Keep::Forward,
561            (BooleanOp::Difference, EdgeSource::B, MidClass::Inside) => Keep::Reverse,
562            (BooleanOp::Difference, EdgeSource::A, MidClass::OnBoundary { same_dir: false }) => {
563                Keep::Forward
564            }
565            (BooleanOp::Difference, _, _) => Keep::Drop,
566        };
567
568        match keep {
569            Keep::Forward => out.push(DirectedEdge {
570                start: e.start,
571                end: e.end,
572            }),
573            Keep::Reverse => out.push(DirectedEdge {
574                start: e.end,
575                end: e.start,
576            }),
577            Keep::Drop => {}
578        }
579    }
580}
581
582enum Keep {
583    Forward,
584    Reverse,
585    Drop,
586}
587
588/// Classify a sub-edge by its midpoint against `other`.
589fn classify_midpoint(e: &SubEdge, other: &Polygon, snapper: &Snapper, tol: f64) -> MidClass {
590    let mid = Point2::new(
591        f64::midpoint(e.start.x(), e.end.x()),
592        f64::midpoint(e.start.y(), e.end.y()),
593    );
594
595    // On-boundary test: is the midpoint within tol of some edge of `other`,
596    // collinear with it? If so the whole sub-edge is a shared boundary segment
597    // (it was split precisely so that it does not straddle a boundary vertex).
598    let tol_sq = tol * tol;
599    let edir = e.end - e.start;
600    let mut on_boundary: Option<bool> = None;
601    let m = other.len();
602    for j in 0..m {
603        let b1 = other.vert(j);
604        let b2 = other.vert(j + 1);
605        if point_segment_dist_sq(mid, b1, b2) <= tol_sq {
606            // Same direction iff the dot of edge directions is positive.
607            let bdir = b2 - b1;
608            let dot = edir.x().mul_add(bdir.x(), edir.y() * bdir.y());
609            on_boundary = Some(dot >= 0.0);
610            break;
611        }
612    }
613    if let Some(same_dir) = on_boundary {
614        return MidClass::OnBoundary { same_dir };
615    }
616
617    // Interior test by winding number on the snapped ring (consistent keys).
618    let snapped: Vec<Point2> = other.as_slice().iter().map(|&p| snapper.snap(p)).collect();
619    if winding_number(snapper.snap(mid), &snapped) != 0 {
620        MidClass::Inside
621    } else {
622        MidClass::Outside
623    }
624}
625
626// ===========================================================================
627// Loop tracing
628// ===========================================================================
629
630/// Trace selected directed edges into closed loops by following snapped
631/// coordinates. At a junction shared by several outgoing edges (a pinch
632/// vertex, e.g. two polygons touching at a corner) the next edge is chosen by
633/// the most counter-clockwise turn from the incoming direction; this hugs one
634/// face at a time and separates the faces meeting at the pinch instead of
635/// weaving them into a figure-eight.
636fn trace_loops(edges: Vec<DirectedEdge>, snapper: &Snapper, tol: f64) -> Vec<Vec<Point2>> {
637    use std::collections::HashMap;
638
639    // Adjacency: snapped start key → list of edge indices leaving it.
640    let mut adjacency: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
641    for (idx, e) in edges.iter().enumerate() {
642        adjacency.entry(snapper.key(e.start)).or_default().push(idx);
643    }
644
645    let mut used = vec![false; edges.len()];
646    let mut loops: Vec<Vec<Point2>> = Vec::new();
647
648    for start_idx in 0..edges.len() {
649        if used[start_idx] {
650            continue;
651        }
652        let mut loop_pts: Vec<Point2> = Vec::new();
653        let mut current = start_idx;
654        let mut guard = 0usize;
655        let max_steps = edges.len() + 1;
656
657        loop {
658            if used[current] {
659                break;
660            }
661            used[current] = true;
662            let e = &edges[current];
663            loop_pts.push(e.start);
664            let end_key = snapper.key(e.end);
665
666            // Find the best unused outgoing edge from `end`.
667            let Some(candidates) = adjacency.get(&end_key) else {
668                break;
669            };
670            let incoming_dir = e.end - e.start;
671            let mut best: Option<usize> = None;
672            let mut best_score = f64::NEG_INFINITY;
673            for &cand in candidates {
674                if used[cand] {
675                    continue;
676                }
677                let ce = &edges[cand];
678                let out_dir = ce.end - ce.start;
679                let score = turn_score(incoming_dir, out_dir);
680                if score > best_score {
681                    best_score = score;
682                    best = Some(cand);
683                }
684            }
685
686            match best {
687                Some(next) => current = next,
688                None => break,
689            }
690
691            guard += 1;
692            if guard > max_steps {
693                break;
694            }
695
696            // Closed the loop: arrived back at the first edge of this walk.
697            if current == start_idx {
698                break;
699            }
700        }
701
702        // Accept only genuinely closed, non-degenerate loops.
703        if loop_pts.len() >= 3 {
704            let area = signed_area(&loop_pts);
705            if area.abs() > tol * tol {
706                loops.push(loop_pts);
707            }
708        }
709    }
710
711    loops
712}
713
714/// Signed turn angle (radians, in `(-pi, pi]`) from the `incoming` direction to
715/// the `outgoing` direction. Larger = more counter-clockwise; the tracer picks
716/// the maximum so it consistently takes the leftmost branch at a junction.
717fn turn_score(incoming: crate::vec::Vec2, outgoing: crate::vec::Vec2) -> f64 {
718    let inx = incoming.x();
719    let iny = incoming.y();
720    let outx = outgoing.x();
721    let outy = outgoing.y();
722    let dot = inx.mul_add(outx, iny * outy);
723    let cross = inx.mul_add(outy, -(iny * outx));
724    cross.atan2(dot)
725}
726
727// ===========================================================================
728// Loop classification
729// ===========================================================================
730
731/// Split traced loops into CCW outers and CW holes per their signed area.
732fn classify_loops(loops: Vec<Vec<Point2>>, tol: f64) -> PolygonBooleanResult {
733    let mut result = PolygonBooleanResult::default();
734    for loop_pts in loops {
735        let area = signed_area(&loop_pts);
736        if area.abs() <= tol * tol {
737            continue;
738        }
739        if area > 0.0 {
740            result.outer.push(loop_pts);
741        } else {
742            result.holes.push(loop_pts);
743        }
744    }
745    result
746}
747
748// ===========================================================================
749// Degenerate fallbacks
750// ===========================================================================
751
752/// When one input is degenerate (collapses to < 3 vertices / zero area), the
753/// boolean reduces to a trivial case rather than failing outright.
754fn degenerate_fallback(
755    a: &[Point2],
756    b: &[Point2],
757    op: BooleanOp,
758    tol: f64,
759) -> PolygonBooleanResult {
760    let pa = Polygon::normalized(a, tol);
761    let pb = Polygon::normalized(b, tol);
762    match (pa, pb) {
763        (None, None) => PolygonBooleanResult::default(),
764        (Some(p), None) => {
765            // B is empty: A∪∅ = A, A∩∅ = ∅, A\∅ = A.
766            match op {
767                BooleanOp::Union | BooleanOp::Difference => single_outer(p),
768                BooleanOp::Intersection => PolygonBooleanResult::default(),
769            }
770        }
771        (None, Some(p)) => {
772            // A is empty: ∅∪B = B, ∅∩B = ∅, ∅\B = ∅.
773            match op {
774                BooleanOp::Union => single_outer(p),
775                BooleanOp::Intersection | BooleanOp::Difference => PolygonBooleanResult::default(),
776            }
777        }
778        // Both valid: caller should not have routed here, but be safe.
779        (Some(_), Some(_)) => PolygonBooleanResult::default(),
780    }
781}
782
783fn single_outer(p: Polygon) -> PolygonBooleanResult {
784    PolygonBooleanResult {
785        outer: vec![p.verts],
786        holes: Vec::new(),
787    }
788}
789
790#[cfg(test)]
791#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
792mod tests {
793    use super::*;
794
795    fn sq(x0: f64, y0: f64, s: f64) -> Vec<Point2> {
796        vec![
797            Point2::new(x0, y0),
798            Point2::new(x0 + s, y0),
799            Point2::new(x0 + s, y0 + s),
800            Point2::new(x0, y0 + s),
801        ]
802    }
803
804    fn rect(x0: f64, y0: f64, w: f64, h: f64) -> Vec<Point2> {
805        vec![
806            Point2::new(x0, y0),
807            Point2::new(x0 + w, y0),
808            Point2::new(x0 + w, y0 + h),
809            Point2::new(x0, y0 + h),
810        ]
811    }
812
813    const TOL: f64 = 1e-9;
814
815    fn assert_area_close(got: f64, expected: f64, eps: f64) {
816        assert!(
817            (got - expected).abs() <= eps,
818            "area mismatch: got {got}, expected {expected}"
819        );
820    }
821
822    #[test]
823    fn overlapping_squares_union_area() {
824        // A = [0,2]^2, B = [1,3]^2; overlap = [1,2]^2 (area 1).
825        let a = sq(0.0, 0.0, 2.0);
826        let b = sq(1.0, 1.0, 2.0);
827        let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
828        assert_eq!(res.outer.len(), 1, "expected one merged outer loop");
829        assert!(res.holes.is_empty(), "no holes expected");
830        assert_area_close(res.area(), 4.0 + 4.0 - 1.0, 1e-7);
831    }
832
833    #[test]
834    fn overlapping_squares_intersection_area() {
835        let a = sq(0.0, 0.0, 2.0);
836        let b = sq(1.0, 1.0, 2.0);
837        let res = polygon_boolean(&a, &b, BooleanOp::Intersection, TOL);
838        assert_eq!(res.outer.len(), 1);
839        assert_area_close(res.area(), 1.0, 1e-7);
840    }
841
842    #[test]
843    fn overlapping_squares_difference_area() {
844        let a = sq(0.0, 0.0, 2.0);
845        let b = sq(1.0, 1.0, 2.0);
846        let res = polygon_boolean(&a, &b, BooleanOp::Difference, TOL);
847        // A minus the overlap [1,2]^2 → area 4 - 1 = 3, L-shaped, no hole.
848        assert!(res.holes.is_empty());
849        assert_area_close(res.area(), 3.0, 1e-7);
850    }
851
852    #[test]
853    fn disjoint_squares_union_two_loops() {
854        let a = sq(0.0, 0.0, 1.0);
855        let b = sq(5.0, 5.0, 1.0);
856        let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
857        assert_eq!(res.outer.len(), 2, "disjoint union → two outer loops");
858        assert!(res.holes.is_empty());
859        assert_area_close(res.area(), 2.0, 1e-7);
860    }
861
862    #[test]
863    fn disjoint_squares_intersection_empty() {
864        let a = sq(0.0, 0.0, 1.0);
865        let b = sq(5.0, 5.0, 1.0);
866        let res = polygon_boolean(&a, &b, BooleanOp::Intersection, TOL);
867        assert!(res.is_empty(), "disjoint intersection is empty");
868    }
869
870    #[test]
871    fn nested_union_is_outer() {
872        // B fully inside A; union = A.
873        let a = sq(0.0, 0.0, 10.0);
874        let b = sq(3.0, 3.0, 2.0);
875        let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
876        assert_eq!(res.outer.len(), 1);
877        assert!(res.holes.is_empty());
878        assert_area_close(res.area(), 100.0, 1e-6);
879    }
880
881    #[test]
882    fn nested_intersection_is_inner() {
883        let a = sq(0.0, 0.0, 10.0);
884        let b = sq(3.0, 3.0, 2.0);
885        let res = polygon_boolean(&a, &b, BooleanOp::Intersection, TOL);
886        assert_eq!(res.outer.len(), 1);
887        assert_area_close(res.area(), 4.0, 1e-7);
888    }
889
890    #[test]
891    fn nested_difference_makes_hole() {
892        // A with B punched out → outer = A, hole = B, net area 96.
893        let a = sq(0.0, 0.0, 10.0);
894        let b = sq(3.0, 3.0, 2.0);
895        let res = polygon_boolean(&a, &b, BooleanOp::Difference, TOL);
896        assert_eq!(res.outer.len(), 1, "outer boundary preserved");
897        assert_eq!(res.holes.len(), 1, "punched void is a hole");
898        assert_area_close(res.area(), 96.0, 1e-6);
899    }
900
901    #[test]
902    fn shared_partial_edge_sliver_no_artifacts() {
903        // The snapClip case: two rectangles sharing a partial edge with a
904        // ~0.01 overlap. A = [0,10]x[0,5]; B = [0,10]x[5,8] but lifted down by
905        // 0.01 so its bottom edge y=4.99 overlaps A's top region by a sliver.
906        // The union must be a single clean polygon with no micro-edges.
907        let a = rect(0.0, 0.0, 10.0, 5.0);
908        let b = rect(0.0, 4.99, 10.0, 3.01); // top at y=8.0
909        let res = polygon_boolean(&a, &b, BooleanOp::Union, 0.02);
910        assert_eq!(res.outer.len(), 1, "sliver overlap → one merged rectangle");
911        assert!(res.holes.is_empty(), "no sliver holes");
912        // Merged rectangle is [0,10]x[0,8] = 80; overlap area ~0.1 removed once.
913        assert_area_close(res.area(), 80.0, 0.2);
914        // No degenerate micro-edges in the output.
915        for loop_pts in &res.outer {
916            for i in 0..loop_pts.len() {
917                let p = loop_pts[i];
918                let q = loop_pts[(i + 1) % loop_pts.len()];
919                assert!(
920                    dist_sq(p, q) > (0.02 * 0.02),
921                    "found a sliver micro-edge of length {}",
922                    dist_sq(p, q).sqrt()
923                );
924            }
925        }
926    }
927
928    #[test]
929    fn shared_full_edge_union() {
930        // Two unit squares sharing a full edge (x=1) exactly → merged 1x2.
931        let a = sq(0.0, 0.0, 1.0);
932        let b = sq(1.0, 0.0, 1.0);
933        let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
934        assert_eq!(res.outer.len(), 1, "shared-edge union is one rectangle");
935        assert!(res.holes.is_empty());
936        assert_area_close(res.area(), 2.0, 1e-7);
937    }
938
939    #[test]
940    fn t_junction_vertex_on_edge() {
941        // B's bottom edge midpoint vertex sits on A's top edge (T-junction):
942        // A = [0,4]x[0,2]; B = [1,3]x[2,4] shares the segment y=2, x∈[1,3].
943        let a = rect(0.0, 0.0, 4.0, 2.0);
944        let b = rect(1.0, 2.0, 2.0, 2.0);
945        let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
946        assert_eq!(res.outer.len(), 1, "T-junction union is one polygon");
947        assert!(res.holes.is_empty());
948        assert_area_close(res.area(), 8.0 + 4.0, 1e-7);
949    }
950
951    #[test]
952    fn touching_at_corner_union() {
953        // Squares meeting only at the corner (2,2). Topologically they join
954        // at a pinch point; the covered area is simply the sum, and no
955        // spurious hole is introduced at the pinch.
956        let a = sq(0.0, 0.0, 2.0);
957        let b = sq(2.0, 2.0, 2.0);
958        let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
959        assert_area_close(res.area(), 8.0, 1e-7);
960        assert!(
961            res.holes.is_empty(),
962            "corner pinch must not fabricate a hole"
963        );
964        // Every returned outer must enclose positive area (no zero-area pinch
965        // loops leaking through).
966        for loop_pts in &res.outer {
967            assert!(
968                signed_area(loop_pts) > 1e-7,
969                "degenerate outer loop emitted"
970            );
971        }
972    }
973
974    #[test]
975    fn identical_polygons_union_is_same() {
976        let a = sq(0.0, 0.0, 3.0);
977        let res = polygon_boolean(&a, &a, BooleanOp::Union, TOL);
978        assert_eq!(res.outer.len(), 1, "self-union is the polygon");
979        assert!(res.holes.is_empty());
980        assert_area_close(res.area(), 9.0, 1e-7);
981    }
982
983    #[test]
984    fn identical_polygons_intersection_is_same() {
985        let a = sq(0.0, 0.0, 3.0);
986        let res = polygon_boolean(&a, &a, BooleanOp::Intersection, TOL);
987        assert_eq!(res.outer.len(), 1);
988        assert_area_close(res.area(), 9.0, 1e-7);
989    }
990
991    #[test]
992    fn identical_polygons_difference_is_empty() {
993        let a = sq(0.0, 0.0, 3.0);
994        let res = polygon_boolean(&a, &a, BooleanOp::Difference, TOL);
995        assert!(res.is_empty(), "A \\ A is empty");
996    }
997
998    #[test]
999    fn degenerate_input_too_few_points() {
1000        let a = vec![Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)];
1001        let b = sq(0.0, 0.0, 1.0);
1002        let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
1003        // A is empty → union is just B.
1004        assert_eq!(res.outer.len(), 1);
1005        assert_area_close(res.area(), 1.0, 1e-9);
1006    }
1007
1008    #[test]
1009    fn degenerate_zero_tolerance_rejected() {
1010        let a = sq(0.0, 0.0, 1.0);
1011        let b = sq(0.5, 0.5, 1.0);
1012        let res = polygon_boolean(&a, &b, BooleanOp::Union, 0.0);
1013        assert!(res.is_empty(), "non-positive tolerance returns empty");
1014    }
1015
1016    #[test]
1017    fn union_wrapper_returns_outer_only() {
1018        let a = sq(0.0, 0.0, 2.0);
1019        let b = sq(1.0, 1.0, 2.0);
1020        let loops = polygon_union(&a, &b, TOL);
1021        assert_eq!(loops.len(), 1);
1022        assert_area_close(signed_area(&loops[0]), 7.0, 1e-7);
1023    }
1024
1025    #[test]
1026    fn cw_input_is_normalized() {
1027        // A clockwise square should be accepted (orientation normalized).
1028        let a_cw = vec![
1029            Point2::new(0.0, 0.0),
1030            Point2::new(0.0, 2.0),
1031            Point2::new(2.0, 2.0),
1032            Point2::new(2.0, 0.0),
1033        ];
1034        let b = sq(1.0, 1.0, 2.0);
1035        let res = polygon_boolean(&a_cw, &b, BooleanOp::Union, TOL);
1036        assert_eq!(res.outer.len(), 1);
1037        assert_area_close(res.area(), 7.0, 1e-7);
1038    }
1039
1040    use proptest::prelude::*;
1041
1042    proptest! {
1043        #![proptest_config(ProptestConfig::with_cases(200))]
1044
1045        /// Union area never exceeds the sum of the parts (overlap is not
1046        /// double-counted) and is at least the larger of the two.
1047        #[test]
1048        fn prop_union_area_bounded(
1049            ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
1050            bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
1051        ) {
1052            let a = sq(ax, ay, asz);
1053            let b = sq(bx, by, bsz);
1054            let area_a = asz * asz;
1055            let area_b = bsz * bsz;
1056            let res = polygon_boolean(&a, &b, BooleanOp::Union, 1e-9);
1057            if !res.is_empty() {
1058                let u = res.area();
1059                prop_assert!(
1060                    u <= area_a + area_b + 1e-6,
1061                    "union {u} exceeds sum {}", area_a + area_b
1062                );
1063                prop_assert!(
1064                    u >= area_a.max(area_b) - 1e-6,
1065                    "union {u} smaller than larger part {}", area_a.max(area_b)
1066                );
1067            }
1068        }
1069
1070        /// Intersection area never exceeds either part.
1071        #[test]
1072        fn prop_intersection_area_bounded(
1073            ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
1074            bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
1075        ) {
1076            let a = sq(ax, ay, asz);
1077            let b = sq(bx, by, bsz);
1078            let area_a = asz * asz;
1079            let area_b = bsz * bsz;
1080            let res = polygon_boolean(&a, &b, BooleanOp::Intersection, 1e-9);
1081            let i = res.area();
1082            prop_assert!(
1083                i <= area_a.min(area_b) + 1e-6,
1084                "intersection {i} exceeds smaller part {}", area_a.min(area_b)
1085            );
1086        }
1087
1088        /// Inclusion–exclusion: |A∪B| + |A∩B| == |A| + |B| for axis-aligned
1089        /// squares (areas are exact regardless of overlap topology).
1090        #[test]
1091        fn prop_inclusion_exclusion(
1092            ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
1093            bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
1094        ) {
1095            let a = sq(ax, ay, asz);
1096            let b = sq(bx, by, bsz);
1097            let area_a = asz * asz;
1098            let area_b = bsz * bsz;
1099            let u = polygon_boolean(&a, &b, BooleanOp::Union, 1e-9);
1100            let i = polygon_boolean(&a, &b, BooleanOp::Intersection, 1e-9);
1101            if !u.is_empty() {
1102                let lhs = u.area() + i.area();
1103                prop_assert!(
1104                    (lhs - (area_a + area_b)).abs() <= 1e-4,
1105                    "inclusion-exclusion off: {lhs} vs {}", area_a + area_b
1106                );
1107            }
1108        }
1109
1110        /// Difference partition: |A\B| + |A∩B| == |A| for axis-aligned squares.
1111        #[test]
1112        fn prop_difference_partitions_a(
1113            ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
1114            bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
1115        ) {
1116            let a = sq(ax, ay, asz);
1117            let b = sq(bx, by, bsz);
1118            let area_a = asz * asz;
1119            let d = polygon_boolean(&a, &b, BooleanOp::Difference, 1e-9);
1120            let i = polygon_boolean(&a, &b, BooleanOp::Intersection, 1e-9);
1121            let lhs = d.area() + i.area();
1122            prop_assert!(
1123                (lhs - area_a).abs() <= 1e-4,
1124                "difference partition off: {lhs} vs {area_a}"
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn diagonal_triangle_square_intersection() {
1131        // A triangle overlapping a square with genuinely diagonal edges, so the
1132        // arrangement must cut edges off the snapping grid (not just at integer
1133        // coordinates). Square [0,4]^2; triangle (2,-1)-(6,3)-(2,7) — a
1134        // rightward wedge whose left vertex sits inside the square.
1135        let square = sq(0.0, 0.0, 4.0);
1136        let tri = vec![
1137            Point2::new(2.0, -1.0),
1138            Point2::new(6.0, 3.0),
1139            Point2::new(2.0, 7.0),
1140        ];
1141        let inter = polygon_boolean(&square, &tri, BooleanOp::Intersection, TOL);
1142        assert!(!inter.is_empty(), "diagonal overlap must intersect");
1143        // Cross-check against the convex-clip result (both convex here).
1144        let clipped = crate::polygon2d::sutherland_hodgman_clip(&square, &tri);
1145        let expected = signed_area(&clipped).abs();
1146        assert!(expected > 0.0, "sanity: clip area positive");
1147        assert_area_close(inter.area(), expected, 1e-6);
1148    }
1149
1150    #[test]
1151    fn rotated_square_overlap_union_intersection() {
1152        // A 45-degree diamond overlapping an axis-aligned square: every
1153        // intersection lands at a non-integer coordinate. Verify inclusion-
1154        // exclusion holds, proving the off-grid arrangement is exact.
1155        let square = sq(0.0, 0.0, 4.0);
1156        let diamond = vec![
1157            Point2::new(2.0, -1.0),
1158            Point2::new(5.0, 2.0),
1159            Point2::new(2.0, 5.0),
1160            Point2::new(-1.0, 2.0),
1161        ];
1162        let area_sq = 16.0;
1163        let area_di = signed_area(&diamond).abs();
1164        let u = polygon_boolean(&square, &diamond, BooleanOp::Union, TOL);
1165        let i = polygon_boolean(&square, &diamond, BooleanOp::Intersection, TOL);
1166        assert!(!u.is_empty() && !i.is_empty());
1167        assert_area_close(u.area() + i.area(), area_sq + area_di, 1e-6);
1168    }
1169}