Skip to main content

wisp/scene/path/
boolean.rs

1//! Path boolean ops (M-BOOL.0 / AUT-161 → M-BOOL.5 / AUT-166).
2//!
3//! In-house polygon-clipping engine. The full design rationale —
4//! algorithm choice, alternatives rejected, follow-up tickets — is
5//! captured in `_docs/adr/M-BOOL-backend.md`.
6//!
7//! # Quick API
8//!
9//! ```ignore
10//! use wisp::path::boolean::{combine, BooleanOp, BoolOptions};
11//! use wisp::path::PathBuilder;
12//!
13//! let circle_a = /* a closed Path */;
14//! let circle_b = /* a closed Path */;
15//! let union = combine(&circle_a, &circle_b, BooleanOp::Union, BoolOptions::default());
16//! ```
17//!
18//! # Algorithm (v1, polygon-only)
19//!
20//! 1. Flatten each `Path` to a list of closed polylines (one per
21//!    `MoveTo`-rooted subpath).
22//! 2. Build a directed-edge list per polyline, labelled `Subject`
23//!    (path A) or `Clip` (path B).
24//! 3. Find every pair-wise edge intersection in O(n·m). Subdivide
25//!    both edges at each intersection so every output fragment has
26//!    integer-multiplicity endpoints.
27//! 4. For each fragment, evaluate "inside A?" and "inside B?" at the
28//!    fragment's midpoint via the parity (even-odd) point-in-polygon
29//!    test against the *other* polygon's full edge list.
30//! 5. Keep each fragment iff it lies on the boundary of the desired
31//!    output region — i.e. the op rule evaluates differently on the
32//!    two sides of the fragment.
33//! 6. Stitch retained fragments tip-to-tail into closed contours;
34//!    emit one `MoveTo`+`LineTo*`+`Close` subpath per contour.
35//!
36//! # Known v1 limitations
37//!
38//! All deferred to follow-up tickets (AUT-167..179):
39//!
40//! - Bezier curves flatten via [`crate::scene::path::Path::flatten`]
41//!   before processing; curvature is lost. M-BOOL.7 lands a
42//!   `flatten_subpaths` that preserves multi-subpath structure.
43//! - Holes + `FillRule::NonZero` semantics are stubbed:
44//!   `BoolOptions::fill_rule` is accepted but only `EvenOdd` is
45//!   honoured today. M-BOOL.8 implements winding-number tracking.
46//! - Self-intersecting inputs → undefined output. Match Clipper2 v1.
47//! - O(n·m) intersection finding is fine for our typical path sizes
48//!   (50–500 vertices). M-BOOL.17 benchmarks set the bar for a
49//!   future Bentley-Ottmann sweep-line if needed.
50
51use glam::Vec2;
52
53use crate::scene::path::{Path, PathBuilder, PathCommand};
54
55// ──────────────────────────────────────────────────────────────────
56// Fluent builder on Path (M-BOOL.9 / AUT-170)
57// ──────────────────────────────────────────────────────────────────
58//
59// The ticket text spec'd these on `Graphics`, but `Graphics` carries
60// draw-call primitives (`draw_rect`, `draw_ellipse`, `draw_line`)
61// not Path data — there's no single Path inside a Graphics. The
62// natural home for fluent boolean ops is `Path` itself; a future
63// `Graphics::from_path` lands when M-BOOL.10's `BooleanGroup` scene
64// node arrives.
65
66impl Path {
67    /// Fluent `A ∪ B`. Equivalent to
68    /// `combine(self, other, BooleanOp::Union, BoolOptions::default())`.
69    #[must_use]
70    pub fn union_with(&self, other: &Path) -> Path {
71        combine(self, other, BooleanOp::Union, BoolOptions::default())
72    }
73
74    /// Fluent `A ∩ B`.
75    #[must_use]
76    pub fn intersect_with(&self, other: &Path) -> Path {
77        combine(self, other, BooleanOp::Intersection, BoolOptions::default())
78    }
79
80    /// Fluent `A − B`. Named `cut` for Pixi-parity per the ticket.
81    #[must_use]
82    pub fn cut(&self, other: &Path) -> Path {
83        combine(self, other, BooleanOp::Difference, BoolOptions::default())
84    }
85
86    /// Fluent `A ⊕ B`.
87    #[must_use]
88    pub fn xor_with(&self, other: &Path) -> Path {
89        combine(self, other, BooleanOp::Xor, BoolOptions::default())
90    }
91}
92
93/// The four primitive boolean ops on two paths.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum BooleanOp {
96    /// `A ∪ B` — everything in either path.
97    Union,
98    /// `A ∩ B` — only where both paths overlap.
99    Intersection,
100    /// `A − B` — `A` minus the overlap with `B`.
101    Difference,
102    /// `A ⊕ B` — symmetric difference (in either but not both).
103    Xor,
104}
105
106/// Fill-rule policy for self-overlapping inputs.
107///
108/// `EvenOdd` is parity-based: a point is "in" if a ray from it
109/// crosses the boundary an odd number of times. Matches existing
110/// `wisp::Graphics` defaults.
111///
112/// `NonZero` is winding-number-based: signed crossings sum non-zero.
113/// Currently accepted but treated as `EvenOdd` until M-BOOL.8 lands.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
115pub enum FillRule {
116    /// Parity rule. Default — matches `Graphics` today.
117    #[default]
118    EvenOdd,
119    /// Winding-number rule. Deferred to M-BOOL.8.
120    NonZero,
121}
122
123/// Tuning knobs for [`combine`].
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct BoolOptions {
126    /// Geometric tolerance — points closer than this are treated as
127    /// identical for intersection / endpoint-snap logic. In whatever
128    /// unit the path lives in (NDC for typical wisp usage).
129    pub tolerance: f32,
130    /// How "interior" is determined when classifying fragments. See
131    /// [`FillRule`].
132    pub fill_rule: FillRule,
133    /// Bezier-flattening tolerance forwarded to
134    /// [`crate::scene::path::Path::flatten`]. Smaller = more output
135    /// vertices but smoother curves.
136    pub flatten_tolerance: f32,
137}
138
139impl Default for BoolOptions {
140    fn default() -> Self {
141        Self {
142            tolerance: 1e-4,
143            fill_rule: FillRule::EvenOdd,
144            flatten_tolerance: 0.005,
145        }
146    }
147}
148
149/// Combine two paths via the given boolean op.
150///
151/// Returns a new `Path` whose subpaths bound the requested region.
152/// An empty result (e.g. `Intersection` of disjoint shapes) is a
153/// `Path` with no commands.
154#[must_use]
155pub fn combine(a: &Path, b: &Path, op: BooleanOp, opts: BoolOptions) -> Path {
156    let polys_a = subpaths(a, opts.flatten_tolerance);
157    let polys_b = subpaths(b, opts.flatten_tolerance);
158
159    if polys_a.is_empty() && polys_b.is_empty() {
160        return Path::from_commands(Vec::new());
161    }
162    // Op-specific empty-input shortcuts.
163    if polys_a.is_empty() {
164        return match op {
165            BooleanOp::Intersection | BooleanOp::Difference => Path::from_commands(Vec::new()),
166            BooleanOp::Union | BooleanOp::Xor => rebuild_from_polylines(&polys_b),
167        };
168    }
169    if polys_b.is_empty() {
170        return match op {
171            BooleanOp::Intersection => Path::from_commands(Vec::new()),
172            BooleanOp::Union | BooleanOp::Difference | BooleanOp::Xor => {
173                rebuild_from_polylines(&polys_a)
174            }
175        };
176    }
177
178    let mut edges = build_edges(&polys_a, EdgeLabel::Subject);
179    edges.extend(build_edges(&polys_b, EdgeLabel::Clip));
180    let fragments = split_at_intersections(&edges, opts.tolerance);
181
182    let kept: Vec<Edge> = fragments
183        .into_iter()
184        .filter(|frag| keep_fragment(frag, &polys_a, &polys_b, op))
185        .collect();
186
187    let contours = stitch(&kept, opts.tolerance);
188    contours_to_path(&contours)
189}
190
191/// Convenience N-ary fold (M-BOOL.6 / AUT-167).
192///
193/// `combine_n(&[a, b, c, d], op)` is `combine(combine(combine(a, b), c), d)`.
194/// Empty slice returns an empty `Path`; single-element slice returns
195/// that path unchanged.
196#[must_use]
197pub fn combine_n(paths: &[&Path], op: BooleanOp, opts: BoolOptions) -> Path {
198    match paths {
199        [] => Path::from_commands(Vec::new()),
200        [only] => (*only).clone(),
201        [first, rest @ ..] => {
202            let mut acc = (*first).clone();
203            for next in rest {
204                acc = combine(&acc, next, op, opts);
205            }
206            acc
207        }
208    }
209}
210
211// ──────────────────────────────────────────────────────────────────
212// Implementation
213// ──────────────────────────────────────────────────────────────────
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216enum EdgeLabel {
217    Subject,
218    Clip,
219}
220
221#[derive(Debug, Clone, Copy)]
222struct Edge {
223    from: Vec2,
224    to: Vec2,
225    label: EdgeLabel,
226}
227
228/// Decompose a `Path` into closed polylines, one per `MoveTo`-rooted
229/// subpath. Open subpaths (without `Close`) are silently dropped —
230/// boolean ops are only meaningful on closed regions.
231fn subpaths(path: &Path, flatten_tolerance: f32) -> Vec<Vec<Vec2>> {
232    let mut subs: Vec<Vec<Vec2>> = Vec::new();
233    let mut current: Option<Vec<Vec2>> = None;
234    let mut subpath_start: Option<Vec2> = None;
235    let mut last_point = Vec2::ZERO;
236    for cmd in path.commands() {
237        match *cmd {
238            PathCommand::MoveTo(p) => {
239                // An open subpath in progress is dropped — boolean ops
240                // are only meaningful on closed regions.
241                current = Some(vec![p]);
242                subpath_start = Some(p);
243                last_point = p;
244            }
245            PathCommand::LineTo(p) => {
246                if let Some(v) = current.as_mut() {
247                    v.push(p);
248                }
249                last_point = p;
250            }
251            PathCommand::QuadTo { control, end } => {
252                if let Some(v) = current.as_mut() {
253                    flatten_quad(last_point, control, end, flatten_tolerance, v);
254                }
255                last_point = end;
256            }
257            PathCommand::CubicTo { c1, c2, end } => {
258                if let Some(v) = current.as_mut() {
259                    flatten_cubic(last_point, c1, c2, end, flatten_tolerance, v);
260                }
261                last_point = end;
262            }
263            PathCommand::Close => {
264                if let (Some(mut v), Some(start)) = (current.take(), subpath_start) {
265                    if v.last().copied().unwrap_or(start) != start {
266                        v.push(start);
267                    }
268                    // Drop near-duplicate trailing point so the
269                    // close edge doesn't have zero length.
270                    if v.len() >= 2 && (v[v.len() - 1] - v[v.len() - 2]).length() < 1e-9 {
271                        v.pop();
272                    }
273                    if v.len() >= 3 {
274                        subs.push(v);
275                    }
276                }
277                if let Some(start) = subpath_start {
278                    last_point = start;
279                }
280            }
281        }
282    }
283    subs
284}
285
286fn build_edges(polys: &[Vec<Vec2>], label: EdgeLabel) -> Vec<Edge> {
287    let mut edges = Vec::new();
288    for poly in polys {
289        for window in poly.windows(2) {
290            let from = window[0];
291            let to = window[1];
292            if (to - from).length() > f32::EPSILON {
293                edges.push(Edge { from, to, label });
294            }
295        }
296        // Wrap from last → first (close).
297        if poly.len() >= 2 {
298            let from = poly[poly.len() - 1];
299            let to = poly[0];
300            if (to - from).length() > f32::EPSILON {
301                edges.push(Edge { from, to, label });
302            }
303        }
304    }
305    edges
306}
307
308/// Split every edge at its intersections with every other edge.
309/// Returns the (possibly larger) fragment list.
310fn split_at_intersections(edges: &[Edge], tolerance: f32) -> Vec<Edge> {
311    // Collect intersection parameters per edge.
312    let mut splits: Vec<Vec<f32>> = vec![Vec::new(); edges.len()];
313    for i in 0..edges.len() {
314        for j in (i + 1)..edges.len() {
315            if let Some((t_i, t_j, _pt)) = segment_intersection(
316                edges[i].from,
317                edges[i].to,
318                edges[j].from,
319                edges[j].to,
320                tolerance,
321            ) {
322                if t_i > tolerance && t_i < 1.0 - tolerance {
323                    splits[i].push(t_i);
324                }
325                if t_j > tolerance && t_j < 1.0 - tolerance {
326                    splits[j].push(t_j);
327                }
328            }
329        }
330    }
331
332    let mut fragments = Vec::new();
333    for (i, edge) in edges.iter().enumerate() {
334        let mut params: Vec<f32> = splits[i].clone();
335        params.push(0.0);
336        params.push(1.0);
337        params.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
338        params.dedup_by(|a, b| (*a - *b).abs() < tolerance);
339        for w in params.windows(2) {
340            let t0 = w[0];
341            let t1 = w[1];
342            if t1 - t0 < tolerance {
343                continue;
344            }
345            let p0 = edge.from + (edge.to - edge.from) * t0;
346            let p1 = edge.from + (edge.to - edge.from) * t1;
347            if (p1 - p0).length() < tolerance {
348                continue;
349            }
350            fragments.push(Edge {
351                from: p0,
352                to: p1,
353                label: edge.label,
354            });
355        }
356    }
357    fragments
358}
359
360/// Decide whether a fragment is retained for the given op.
361fn keep_fragment(frag: &Edge, polys_a: &[Vec<Vec2>], polys_b: &[Vec<Vec2>], op: BooleanOp) -> bool {
362    // Evaluate at the fragment midpoint nudged perpendicularly to
363    // either side of the edge. If the two sides disagree on
364    // "inside", this fragment is on the output boundary.
365    let mid = (frag.from + frag.to) * 0.5;
366    let dir = (frag.to - frag.from).normalize_or_zero();
367    let normal = Vec2::new(-dir.y, dir.x);
368    let eps = 1e-4;
369    let p_pos = mid + normal * eps;
370    let p_neg = mid - normal * eps;
371
372    let lhs_in_a = inside_any(p_pos, polys_a);
373    let rhs_in_a = inside_any(p_neg, polys_a);
374    let lhs_in_b = inside_any(p_pos, polys_b);
375    let rhs_in_b = inside_any(p_neg, polys_b);
376
377    op_rule(op, lhs_in_a, lhs_in_b) != op_rule(op, rhs_in_a, rhs_in_b)
378}
379
380fn op_rule(op: BooleanOp, in_a: bool, in_b: bool) -> bool {
381    match op {
382        BooleanOp::Union => in_a || in_b,
383        BooleanOp::Intersection => in_a && in_b,
384        BooleanOp::Difference => in_a && !in_b,
385        BooleanOp::Xor => in_a ^ in_b,
386    }
387}
388
389fn inside_any(p: Vec2, polys: &[Vec<Vec2>]) -> bool {
390    let mut crossings = 0;
391    for poly in polys {
392        crossings += ray_crossings(p, poly);
393    }
394    crossings % 2 == 1
395}
396
397/// Parity / even-odd point-in-polygon. Returns the number of times a
398/// rightward ray from `point` crosses any edge of the polygon.
399fn ray_crossings(point: Vec2, poly: &[Vec2]) -> usize {
400    let mut crossings = 0;
401    let len = poly.len();
402    if len < 3 {
403        return 0;
404    }
405    for i in 0..len {
406        let edge_start = poly[i];
407        let edge_end = poly[(i + 1) % len];
408        let intersects = ((edge_start.y > point.y) != (edge_end.y > point.y)) && {
409            let t = (point.y - edge_start.y) / (edge_end.y - edge_start.y);
410            let x_at = edge_start.x + t * (edge_end.x - edge_start.x);
411            point.x < x_at
412        };
413        if intersects {
414            crossings += 1;
415        }
416    }
417    crossings
418}
419
420/// Robust-ish two-segment intersection. Returns
421/// `(t_along_first, t_along_second, intersection_point)` when the
422/// segments cross in the interior of both. Endpoint-only touches
423/// are filtered out by the caller via `t > tolerance &&
424/// t < 1 - tolerance`. Returns `None` for parallel, collinear, or
425/// near-collinear pairs.
426fn segment_intersection(
427    a0: Vec2,
428    a1: Vec2,
429    b0: Vec2,
430    b1: Vec2,
431    tolerance: f32,
432) -> Option<(f32, f32, Vec2)> {
433    let a_dir = a1 - a0;
434    let b_dir = b1 - b0;
435    let denom = a_dir.x * b_dir.y - a_dir.y * b_dir.x;
436    if denom.abs() < tolerance.max(1e-9) {
437        return None;
438    }
439    let delta = b0 - a0;
440    let mut t = (delta.x * b_dir.y - delta.y * b_dir.x) / denom;
441    let mut u = (delta.x * a_dir.y - delta.y * a_dir.x) / denom;
442    if !(-tolerance..=1.0 + tolerance).contains(&t) {
443        return None;
444    }
445    if !(-tolerance..=1.0 + tolerance).contains(&u) {
446        return None;
447    }
448    t = t.clamp(0.0, 1.0);
449    u = u.clamp(0.0, 1.0);
450    let pt = a0 + a_dir * t;
451    Some((t, u, pt))
452}
453
454/// Walk fragments tip-to-tail, building closed loops.
455fn stitch(fragments: &[Edge], tolerance: f32) -> Vec<Vec<Vec2>> {
456    let n = fragments.len();
457    let mut used = vec![false; n];
458    let mut contours: Vec<Vec<Vec2>> = Vec::new();
459
460    for i in 0..n {
461        if used[i] {
462            continue;
463        }
464        let mut contour = vec![fragments[i].from, fragments[i].to];
465        used[i] = true;
466        loop {
467            let tail = *contour.last().unwrap();
468            // Find an unused fragment whose `from` matches the tail.
469            let mut next: Option<usize> = None;
470            for (j, frag) in fragments.iter().enumerate() {
471                if used[j] {
472                    continue;
473                }
474                if (frag.from - tail).length() < tolerance {
475                    next = Some(j);
476                    break;
477                }
478                if (frag.to - tail).length() < tolerance {
479                    // Reversed traversal.
480                    next = Some(j);
481                    break;
482                }
483            }
484            let Some(j) = next else { break };
485            used[j] = true;
486            let frag = fragments[j];
487            let next_pt = if (frag.from - tail).length() < tolerance {
488                frag.to
489            } else {
490                frag.from
491            };
492            if (next_pt - contour[0]).length() < tolerance {
493                // Closed.
494                break;
495            }
496            contour.push(next_pt);
497        }
498        if contour.len() >= 3 {
499            contours.push(contour);
500        }
501    }
502
503    contours
504}
505
506fn contours_to_path(contours: &[Vec<Vec2>]) -> Path {
507    if contours.is_empty() {
508        return Path::from_commands(Vec::new());
509    }
510    let mut builder = PathBuilder::new();
511    for contour in contours {
512        if contour.is_empty() {
513            continue;
514        }
515        builder = builder.move_to(contour[0]);
516        for p in &contour[1..] {
517            builder = builder.line_to(*p);
518        }
519        builder = builder.close();
520    }
521    builder.build()
522}
523
524fn rebuild_from_polylines(polys: &[Vec<Vec2>]) -> Path {
525    let mut builder = PathBuilder::new();
526    for poly in polys {
527        if poly.is_empty() {
528            continue;
529        }
530        builder = builder.move_to(poly[0]);
531        for p in &poly[1..] {
532            builder = builder.line_to(*p);
533        }
534        builder = builder.close();
535    }
536    builder.build()
537}
538
539// Re-export from the parent so callers don't need a second import.
540fn flatten_quad(p0: Vec2, p1: Vec2, p2: Vec2, tolerance: f32, out: &mut Vec<Vec2>) {
541    if perp_distance(p1, p0, p2) <= tolerance {
542        out.push(p2);
543        return;
544    }
545    let p01 = (p0 + p1) * 0.5;
546    let p12 = (p1 + p2) * 0.5;
547    let mid = (p01 + p12) * 0.5;
548    flatten_quad(p0, p01, mid, tolerance, out);
549    flatten_quad(mid, p12, p2, tolerance, out);
550}
551
552fn flatten_cubic(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, tolerance: f32, out: &mut Vec<Vec2>) {
553    let d1 = perp_distance(p1, p0, p3);
554    let d2 = perp_distance(p2, p0, p3);
555    if d1.max(d2) <= tolerance {
556        out.push(p3);
557        return;
558    }
559    let q01 = (p0 + p1) * 0.5;
560    let q12 = (p1 + p2) * 0.5;
561    let q23 = (p2 + p3) * 0.5;
562    let r012 = (q01 + q12) * 0.5;
563    let r123 = (q12 + q23) * 0.5;
564    let mid = (r012 + r123) * 0.5;
565    flatten_cubic(p0, q01, r012, mid, tolerance, out);
566    flatten_cubic(mid, r123, q23, p3, tolerance, out);
567}
568
569fn perp_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 {
570    let ab = b - a;
571    let len = ab.length();
572    if len < f32::EPSILON {
573        return (p - a).length();
574    }
575    let cross = (p.x - a.x) * ab.y - (p.y - a.y) * ab.x;
576    cross.abs() / len
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    /// Closed square centred at `c` with half-side `h`.
584    fn square(c: Vec2, h: f32) -> Path {
585        PathBuilder::new()
586            .move_to(Vec2::new(c.x - h, c.y - h))
587            .line_to(Vec2::new(c.x + h, c.y - h))
588            .line_to(Vec2::new(c.x + h, c.y + h))
589            .line_to(Vec2::new(c.x - h, c.y + h))
590            .close()
591            .build()
592    }
593
594    /// Count subpaths in a Path (`MoveTo`s).
595    fn count_subpaths(path: &Path) -> usize {
596        path.commands()
597            .iter()
598            .filter(|c| matches!(c, PathCommand::MoveTo(_)))
599            .count()
600    }
601
602    /// Does any vertex of `path` lie inside the polygon `interior`?
603    fn any_vertex_inside(path: &Path, interior: &[Vec2]) -> bool {
604        for cmd in path.commands() {
605            let p = match cmd {
606                PathCommand::MoveTo(p) | PathCommand::LineTo(p) => *p,
607                _ => continue,
608            };
609            if ray_crossings(p, interior) % 2 == 1 {
610                return true;
611            }
612        }
613        false
614    }
615
616    #[test]
617    fn defaults_are_sensible() {
618        let opts = BoolOptions::default();
619        assert!(opts.tolerance > 0.0);
620        assert_eq!(opts.fill_rule, FillRule::EvenOdd);
621        assert!(opts.flatten_tolerance > 0.0);
622    }
623
624    #[test]
625    fn union_of_disjoint_squares_returns_two_subpaths() {
626        let a = square(Vec2::new(-1.0, 0.0), 0.4);
627        let b = square(Vec2::new(1.0, 0.0), 0.4);
628        let u = combine(&a, &b, BooleanOp::Union, BoolOptions::default());
629        assert_eq!(count_subpaths(&u), 2);
630    }
631
632    #[test]
633    fn intersection_of_disjoint_squares_is_empty() {
634        let a = square(Vec2::new(-1.0, 0.0), 0.4);
635        let b = square(Vec2::new(1.0, 0.0), 0.4);
636        let i = combine(&a, &b, BooleanOp::Intersection, BoolOptions::default());
637        assert_eq!(count_subpaths(&i), 0);
638    }
639
640    #[test]
641    fn intersection_of_overlapping_squares_is_a_smaller_square() {
642        let a = square(Vec2::ZERO, 0.5); // x ∈ [-0.5, 0.5]
643        let b = square(Vec2::new(0.3, 0.0), 0.5); // x ∈ [-0.2, 0.8]
644        let i = combine(&a, &b, BooleanOp::Intersection, BoolOptions::default());
645        assert_eq!(count_subpaths(&i), 1);
646        // The intersection rectangle is x ∈ [-0.2, 0.5], y ∈ [-0.5, 0.5].
647        // Centre point should be inside the intersection contour.
648        let centre = Vec2::new(0.15, 0.0);
649        let polys: Vec<Vec<Vec2>> = subpaths(&i, 0.005);
650        assert!(inside_any(centre, &polys), "centre of overlap missing");
651        let outside = Vec2::new(-0.4, 0.0);
652        assert!(!inside_any(outside, &polys), "non-overlap region kept");
653    }
654
655    #[test]
656    fn difference_carves_b_out_of_a() {
657        let a = square(Vec2::ZERO, 0.5);
658        let b = square(Vec2::new(0.3, 0.0), 0.5);
659        let d = combine(&a, &b, BooleanOp::Difference, BoolOptions::default());
660        assert!(
661            count_subpaths(&d) >= 1,
662            "diff should leave at least one contour"
663        );
664        let polys: Vec<Vec<Vec2>> = subpaths(&d, 0.005);
665        // Point only in A: kept.
666        assert!(inside_any(Vec2::new(-0.4, 0.0), &polys));
667        // Point in overlap: removed.
668        assert!(!inside_any(Vec2::new(0.15, 0.0), &polys));
669        // Point only in B: not kept (difference is A − B).
670        assert!(!inside_any(Vec2::new(0.7, 0.0), &polys));
671    }
672
673    #[test]
674    fn xor_keeps_outer_regions_drops_overlap() {
675        let a = square(Vec2::ZERO, 0.5);
676        let b = square(Vec2::new(0.3, 0.0), 0.5);
677        let x = combine(&a, &b, BooleanOp::Xor, BoolOptions::default());
678        let polys: Vec<Vec<Vec2>> = subpaths(&x, 0.005);
679        // Overlap dropped.
680        assert!(!inside_any(Vec2::new(0.15, 0.0), &polys));
681        // Outer fragment of A kept.
682        assert!(inside_any(Vec2::new(-0.4, 0.0), &polys));
683        // Outer fragment of B kept.
684        assert!(inside_any(Vec2::new(0.7, 0.0), &polys));
685    }
686
687    #[test]
688    fn empty_path_inputs_behave_sensibly() {
689        let empty = Path::from_commands(Vec::new());
690        let a = square(Vec2::ZERO, 0.5);
691        let opts = BoolOptions::default();
692
693        // Union with empty B returns A (modulo retessellation).
694        let u = combine(&a, &empty, BooleanOp::Union, opts);
695        assert_eq!(count_subpaths(&u), 1);
696
697        // Intersection with empty B is empty.
698        let i = combine(&a, &empty, BooleanOp::Intersection, opts);
699        assert_eq!(count_subpaths(&i), 0);
700
701        // Difference of empty A − anything is empty.
702        let d = combine(&empty, &a, BooleanOp::Difference, opts);
703        assert_eq!(count_subpaths(&d), 0);
704    }
705
706    #[test]
707    fn combine_n_empty_slice_returns_empty() {
708        let r = combine_n(&[], BooleanOp::Union, BoolOptions::default());
709        assert_eq!(count_subpaths(&r), 0);
710    }
711
712    #[test]
713    fn combine_n_single_returns_same_shape() {
714        let a = square(Vec2::ZERO, 0.5);
715        let r = combine_n(&[&a], BooleanOp::Union, BoolOptions::default());
716        // Same number of MoveTo + commands.
717        assert_eq!(r.commands().len(), a.commands().len());
718    }
719
720    #[test]
721    fn combine_n_union_three_disjoint_is_three_subpaths() {
722        let a = square(Vec2::new(-1.5, 0.0), 0.3);
723        let b = square(Vec2::new(0.0, 0.0), 0.3);
724        let c = square(Vec2::new(1.5, 0.0), 0.3);
725        let r = combine_n(&[&a, &b, &c], BooleanOp::Union, BoolOptions::default());
726        assert_eq!(count_subpaths(&r), 3);
727    }
728
729    #[test]
730    fn ray_crossings_distinguishes_inside_outside() {
731        let sq = vec![
732            Vec2::new(-1.0, -1.0),
733            Vec2::new(1.0, -1.0),
734            Vec2::new(1.0, 1.0),
735            Vec2::new(-1.0, 1.0),
736        ];
737        assert_eq!(ray_crossings(Vec2::ZERO, &sq) % 2, 1, "centre is inside");
738        assert_eq!(
739            ray_crossings(Vec2::new(2.0, 0.0), &sq) % 2,
740            0,
741            "far right is outside"
742        );
743    }
744
745    #[test]
746    fn segment_intersection_finds_cross() {
747        let r = segment_intersection(
748            Vec2::new(-1.0, 0.0),
749            Vec2::new(1.0, 0.0),
750            Vec2::new(0.0, -1.0),
751            Vec2::new(0.0, 1.0),
752            1e-6,
753        );
754        let (t, u, pt) = r.expect("crossed segments must intersect");
755        assert!((t - 0.5).abs() < 1e-3);
756        assert!((u - 0.5).abs() < 1e-3);
757        assert!((pt - Vec2::ZERO).length() < 1e-3);
758    }
759
760    #[test]
761    fn segment_intersection_misses_parallel() {
762        let r = segment_intersection(
763            Vec2::new(0.0, 0.0),
764            Vec2::new(1.0, 0.0),
765            Vec2::new(0.0, 1.0),
766            Vec2::new(1.0, 1.0),
767            1e-6,
768        );
769        assert!(r.is_none(), "parallel non-collinear must not cross");
770    }
771
772    #[test]
773    fn any_vertex_inside_helper_works() {
774        let unit_sq = vec![
775            Vec2::new(0.0, 0.0),
776            Vec2::new(1.0, 0.0),
777            Vec2::new(1.0, 1.0),
778            Vec2::new(0.0, 1.0),
779        ];
780        let path_inside = square(Vec2::new(0.5, 0.5), 0.1);
781        assert!(any_vertex_inside(&path_inside, &unit_sq));
782        let path_outside = square(Vec2::new(5.0, 5.0), 0.1);
783        assert!(!any_vertex_inside(&path_outside, &unit_sq));
784    }
785
786    // ─── Fluent builder (M-BOOL.9 / AUT-170) ──────────────────────
787
788    #[test]
789    fn fluent_union_with_matches_combine() {
790        let a = square(Vec2::ZERO, 0.5);
791        let b = square(Vec2::new(0.3, 0.0), 0.5);
792        let fluent = a.union_with(&b);
793        let direct = combine(&a, &b, BooleanOp::Union, BoolOptions::default());
794        assert_eq!(fluent, direct);
795    }
796
797    #[test]
798    fn fluent_intersect_with_matches_combine() {
799        let a = square(Vec2::ZERO, 0.5);
800        let b = square(Vec2::new(0.3, 0.0), 0.5);
801        let fluent = a.intersect_with(&b);
802        let direct = combine(&a, &b, BooleanOp::Intersection, BoolOptions::default());
803        assert_eq!(fluent, direct);
804    }
805
806    #[test]
807    fn fluent_cut_matches_combine_difference() {
808        let a = square(Vec2::ZERO, 0.5);
809        let b = square(Vec2::new(0.3, 0.0), 0.5);
810        let fluent = a.cut(&b);
811        let direct = combine(&a, &b, BooleanOp::Difference, BoolOptions::default());
812        assert_eq!(fluent, direct);
813    }
814
815    #[test]
816    fn fluent_xor_with_matches_combine() {
817        let a = square(Vec2::ZERO, 0.5);
818        let b = square(Vec2::new(0.3, 0.0), 0.5);
819        let fluent = a.xor_with(&b);
820        let direct = combine(&a, &b, BooleanOp::Xor, BoolOptions::default());
821        assert_eq!(fluent, direct);
822    }
823
824    // ─── Multi-subpath (M-BOOL.7 / AUT-168) ───────────────────────
825
826    /// Build a `Path` containing two disjoint square subpaths.
827    fn two_squares(centre_a: Vec2, centre_b: Vec2, half: f32) -> Path {
828        PathBuilder::new()
829            .move_to(centre_a + Vec2::new(-half, -half))
830            .line_to(centre_a + Vec2::new(half, -half))
831            .line_to(centre_a + Vec2::new(half, half))
832            .line_to(centre_a + Vec2::new(-half, half))
833            .close()
834            .move_to(centre_b + Vec2::new(-half, -half))
835            .line_to(centre_b + Vec2::new(half, -half))
836            .line_to(centre_b + Vec2::new(half, half))
837            .line_to(centre_b + Vec2::new(-half, half))
838            .close()
839            .build()
840    }
841
842    #[test]
843    fn multi_subpath_union_preserves_disjoint_regions() {
844        // A: two disjoint squares; B: one square that hits only A's
845        // first subpath. Union should leave A's second subpath
846        // untouched.
847        let a = two_squares(Vec2::new(-1.0, 0.0), Vec2::new(1.0, 0.0), 0.3);
848        let b = square(Vec2::new(-0.9, 0.0), 0.25);
849        let u = combine(&a, &b, BooleanOp::Union, BoolOptions::default());
850        let polys: Vec<Vec<Vec2>> = subpaths(&u, 0.005);
851        // The far-right square's centre must still be inside the
852        // union.
853        assert!(
854            inside_any(Vec2::new(1.0, 0.0), &polys),
855            "right square missing from multi-subpath union"
856        );
857        // Some point that was only in B (not in A) must also be in.
858        assert!(inside_any(Vec2::new(-0.9, 0.0), &polys));
859    }
860
861    #[test]
862    fn multi_subpath_difference_carves_only_affected_subpath() {
863        // A: two squares; B: a square that overlaps only the LEFT
864        // one. Difference should keep the right square intact.
865        let a = two_squares(Vec2::new(-1.0, 0.0), Vec2::new(1.0, 0.0), 0.3);
866        let b = square(Vec2::new(-1.0, 0.0), 0.2);
867        let d = combine(&a, &b, BooleanOp::Difference, BoolOptions::default());
868        let polys: Vec<Vec<Vec2>> = subpaths(&d, 0.005);
869        // Right square still present.
870        assert!(inside_any(Vec2::new(1.0, 0.0), &polys));
871        // Centre of left square got carved.
872        assert!(!inside_any(Vec2::new(-1.0, 0.0), &polys));
873    }
874
875    #[test]
876    fn fluent_chain_compiles_and_runs() {
877        // Three-way `(A ∪ B) − C` via fluent chain.
878        let a = square(Vec2::new(-0.2, 0.0), 0.4);
879        let b = square(Vec2::new(0.2, 0.0), 0.4);
880        let c = square(Vec2::new(0.0, 0.4), 0.2);
881        let result = a.union_with(&b).cut(&c);
882        // C lies inside the union — its centre should be carved out.
883        let polys: Vec<Vec<Vec2>> = subpaths(&result, 0.005);
884        assert!(!inside_any(Vec2::new(0.0, 0.4), &polys));
885        // A's left half should survive.
886        assert!(inside_any(Vec2::new(-0.5, 0.0), &polys));
887    }
888
889    // ─── Curve flattening (M-BOOL.7 / AUT-168) ────────────────────
890
891    /// Closed circle approximated by four cubic Beziers, the classic
892    /// `k = 0.5523` corner-control constant. `c` is the centre, `r`
893    /// the radius. No straight segments — pure curve input.
894    fn circle(c: Vec2, r: f32) -> Path {
895        let k = 0.5523 * r;
896        let cx = c.x;
897        let cy = c.y;
898        PathBuilder::new()
899            .move_to(Vec2::new(cx + r, cy))
900            .cubic_to(
901                Vec2::new(cx + r, cy + k),
902                Vec2::new(cx + k, cy + r),
903                Vec2::new(cx, cy + r),
904            )
905            .cubic_to(
906                Vec2::new(cx - k, cy + r),
907                Vec2::new(cx - r, cy + k),
908                Vec2::new(cx - r, cy),
909            )
910            .cubic_to(
911                Vec2::new(cx - r, cy - k),
912                Vec2::new(cx - k, cy - r),
913                Vec2::new(cx, cy - r),
914            )
915            .cubic_to(
916                Vec2::new(cx + k, cy - r),
917                Vec2::new(cx + r, cy - k),
918                Vec2::new(cx + r, cy),
919            )
920            .close()
921            .build()
922    }
923
924    /// Count `LineTo` commands in a path — proxy for "how many edges
925    /// did the output polygon get tessellated into".
926    fn count_line_to(path: &Path) -> usize {
927        path.commands()
928            .iter()
929            .filter(|c| matches!(c, PathCommand::LineTo(_)))
930            .count()
931    }
932
933    #[test]
934    fn curve_input_union_produces_curved_outline() {
935        // Two overlapping circles — pure curves, no flat edges to
936        // collide. Union should be a single peanut-shaped contour
937        // with materially more edges than a square-on-square union
938        // (proof that the cubics were flattened, not dropped).
939        let a = circle(Vec2::new(-0.2, 0.0), 0.4);
940        let b = circle(Vec2::new(0.2, 0.0), 0.4);
941        let u = combine(&a, &b, BooleanOp::Union, BoolOptions::default());
942        assert_eq!(
943            count_subpaths(&u),
944            1,
945            "two overlapping circles union → one peanut"
946        );
947        let sq_u = combine(
948            &square(Vec2::new(-0.2, 0.0), 0.4),
949            &square(Vec2::new(0.2, 0.0), 0.4),
950            BooleanOp::Union,
951            BoolOptions::default(),
952        );
953        assert!(
954            count_line_to(&u) > count_line_to(&sq_u) + 4,
955            "circle union should carry >>{} edges (sq baseline = {})",
956            count_line_to(&sq_u) + 4,
957            count_line_to(&sq_u)
958        );
959        // Probe: midpoint between the two centres is inside (overlap
960        // zone). A point well outside is not.
961        let polys: Vec<Vec<Vec2>> = subpaths(&u, 0.005);
962        assert!(inside_any(Vec2::new(0.0, 0.0), &polys));
963        assert!(!inside_any(Vec2::new(0.9, 0.9), &polys));
964    }
965
966    #[test]
967    fn curve_input_difference_carves_circle_out_of_circle() {
968        // Crescent: A − B where B's centre is inside A. The carved
969        // hole's centre is gone; A's far side is preserved.
970        let a = circle(Vec2::ZERO, 0.4);
971        let b = circle(Vec2::new(0.2, 0.0), 0.2);
972        let d = combine(&a, &b, BooleanOp::Difference, BoolOptions::default());
973        let polys: Vec<Vec<Vec2>> = subpaths(&d, 0.005);
974        assert!(
975            !inside_any(Vec2::new(0.2, 0.0), &polys),
976            "B's centre should be carved out"
977        );
978        assert!(
979            inside_any(Vec2::new(-0.3, 0.0), &polys),
980            "A's far side should survive"
981        );
982    }
983
984    #[test]
985    fn flatten_tolerance_controls_output_resolution() {
986        // Tighter tolerance → more line segments. Looser tolerance →
987        // fewer. Use a circle union so both sides are pure curves.
988        let a = circle(Vec2::new(-0.2, 0.0), 0.4);
989        let b = circle(Vec2::new(0.2, 0.0), 0.4);
990        let tight = combine(
991            &a,
992            &b,
993            BooleanOp::Union,
994            BoolOptions {
995                flatten_tolerance: 0.001,
996                ..Default::default()
997            },
998        );
999        let loose = combine(
1000            &a,
1001            &b,
1002            BooleanOp::Union,
1003            BoolOptions {
1004                flatten_tolerance: 0.1,
1005                ..Default::default()
1006            },
1007        );
1008        let n_tight = count_line_to(&tight);
1009        let n_loose = count_line_to(&loose);
1010        assert!(
1011            n_tight > n_loose,
1012            "tighter tolerance ({n_tight} edges) should subdivide more than looser ({n_loose})"
1013        );
1014    }
1015}