//! Curve-preserving boolean operations on filled [`BezPath`]s, built on `linesweeper`'s robust //! sweep-line topology (MIT OR Apache-2.0). //! //! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code, //! MIT OR Apache-2.0), reworked to take and return kurbo paths directly. //! //! Inputs are closed implicitly (a filled open path is painted as if closed). The sweep splits //! curves at intersections and y-extrema; `tidy_segments` re-joins pieces that came from one //! input curve (exactly) or one smooth run (by least-squares refit), so results carry roughly as //! few vertices as the inputs. Output contours are simple and consistently oriented (holes wind //! opposite to their outer contour), so they fill the same under either fill rule. use kurbo::{BezPath, CubicBez, PathEl, PathSeg, Point, Shape as _}; use linesweeper::topology::{Contours, Topology, WindingNumber}; use crate::FillRule; use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent}; /// Refit tolerance (pixels) used to merge split curve pieces back together. pub const DEFAULT_PRECISION: f64 = 0.01; pub(crate) fn inside(rule: FillRule, w: i32) -> bool { match rule { FillRule::NonZero => w != 0, FillRule::EvenOdd => w % 2 != 0, } } /// Winding numbers for an arbitrary number of input paths (tag = input index), stored sparsely: /// the nonzero `(tag, winding)` pairs in tag order. A segment's winding names only its own path, /// so the topology's per-segment copies stay small however many paths take part (a dense /// vector up to each segment's tag would grow with the square of the path count). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct Multi(Vec<(usize, i32)>); impl Multi { fn get(&self, i: usize) -> i32 { self.0.binary_search_by_key(&i, |e| e.0).map_or(0, |k| self.0[k].1) } } impl std::ops::Add for Multi { type Output = Multi; fn add(mut self, rhs: Self) -> Multi { self += rhs; self } } impl std::ops::AddAssign for Multi { fn add_assign(&mut self, rhs: Self) { if rhs.0.is_empty() { return; } // Merge the two tag-ordered lists, dropping windings that cancel to zero (so equal // windings have equal representations). let (a, b) = (std::mem::take(&mut self.0), rhs.0); let mut out = Vec::with_capacity(a.len() + b.len()); let (mut i, mut j) = (0, 0); while i < a.len() || j < b.len() { let e = match (a.get(i), b.get(j)) { (Some(&x), Some(&y)) if x.0 == y.0 => { i += 1; j += 1; (x.0, x.1 + y.1) } (Some(&x), Some(&y)) if x.0 < y.0 => { i += 1; x } (Some(&x), None) => { i += 1; x } (_, Some(&y)) => { j += 1; y } (None, None) => break, }; if e.1 != 0 { out.push(e); } } self.0 = out; } } impl WindingNumber for Multi { type Tag = usize; fn single(tag: usize, positive: bool) -> Self { Multi(vec![(tag, if positive { 1 } else { -1 })]) } fn of_tag(&self, tag: usize) -> Self { let w = self.get(tag); Multi(if w == 0 { Vec::new() } else { vec![(tag, w)] }) } } /// Close every subpath with at least two points; drop single points and non-finite input. pub fn close_subpaths(p: &BezPath) -> BezPath { let mut out = BezPath::new(); for sp in p.subpaths() { let drawing = sp.iter().filter(|e| matches!(e, PathEl::LineTo(_) | PathEl::QuadTo(..) | PathEl::CurveTo(..))).count(); if drawing == 0 { continue; } for el in sp { if !matches!(el, PathEl::ClosePath) { out.push(*el); } } out.close_path(); } out } fn finite(p: &BezPath) -> bool { p.elements().iter().all(|el| match *el { PathEl::MoveTo(a) | PathEl::LineTo(a) => a.is_finite(), PathEl::QuadTo(a, b) => a.is_finite() && b.is_finite(), PathEl::CurveTo(a, b, c) => a.is_finite() && b.is_finite() && c.is_finite(), PathEl::ClosePath => true, }) } /// Sweep tolerance for a set of paths (scaled with the coordinate magnitude). fn eps_for<'a>(paths: impl IntoIterator) -> f64 { let mut m: f64 = 0.0; for p in paths { let b = p.bounding_box(); m = m.max(b.x0.abs()).max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs()); } (m * f64::EPSILON * 64.0).max(1e-6) } /// Output cleanup settings: refit `precision`, anchor positions that must survive (the inputs' own /// vertices — only joints the sweep introduced are merged away) and the input curves, used to /// rebuild split pieces exactly. #[derive(Clone, Debug, Default)] pub(crate) struct Tidy { pub(crate) precision: f64, keep: Vec, curves: Vec<(kurbo::Rect, CubicBez)>, } impl Tidy { /// Merge any smooth joint. pub(crate) fn free(precision: f64) -> Self { Self { precision, keep: Vec::new(), curves: Vec::new() } } /// Keep every on-curve point of `paths`. pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator) -> Self { let mut keep = Vec::new(); let mut curves = Vec::new(); for p in paths { for s in p.segments() { let seg = to_seg(s); keep.push(seg.c.p0); if !seg.line { curves.push((seg.c.bounding_box().inflate(1e-6, 1e-6), seg.c)); } } } keep.sort_by(|a, b| a.x.total_cmp(&b.x)); Self { precision, keep, curves } } fn is_kept(&self, p: Point) -> bool { let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); let lo = self.keep.partition_point(|q| q.x < p.x - tol); self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol) } } /// An arrangement (planar subdivision) of N filled paths. pub(crate) struct Arrangement { top: Topology, rules: Vec, pub(crate) tidy: Tidy, } impl Arrangement { pub(crate) fn new(paths: &[(BezPath, FillRule)]) -> Option { let closed: Vec = paths.iter().map(|(p, _)| close_subpaths(p)).collect(); if !closed.iter().all(finite) { return None; } let eps = eps_for(&closed); let top = Topology::::from_paths(closed.iter().enumerate().map(|(i, p)| (p, i)), eps).ok()?; let tidy = Tidy::keeping(DEFAULT_PRECISION, &closed); Some(Self { top, rules: paths.iter().map(|p| p.1).collect(), tidy }) } fn mask(&self, w: &Multi) -> Vec { self.rules.iter().enumerate().map(|(i, &r)| inside(r, w.get(i))).collect() } pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours { self.top.contours(|w| pred(&self.mask(w))) } } /// Contours of a single path under its fill rule (self-overlaps resolved). pub(crate) fn normalize_contours(p: &BezPath, rule: FillRule) -> Option { let bp = close_subpaths(p); if !finite(&bp) { return None; } let top = Topology::::from_path(&bp, eps_for([&bp])).ok()?; Some(top.contours(|w| inside(rule, *w))) } /// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris. fn is_sliver(bp: &BezPath, precision: f64) -> bool { let a = bp.area().abs(); let per = bp.perimeter(1e-6); per <= 0.0 || 2.0 * a / per < precision } /// Convert selected contours into one tidy compound path. pub(crate) fn contours_to_path<'a>(contours: impl IntoIterator, tidy: &Tidy) -> BezPath { let mut out = BezPath::new(); for c in contours { if is_sliver(c, tidy.precision) { continue; } let segs: Vec = c.segments().map(to_seg).collect(); let segs = tidy_segments(segs, tidy); if segs.len() < 2 && segs.iter().all(|s| s.line) { continue; } out.move_to(segs[0].c.p0); for s in &segs { if s.line { out.line_to(s.c.p3); } else { out.curve_to(s.c.p1, s.c.p2, s.c.p3); } } out.close_path(); } out } pub(crate) fn all_contours(c: &Contours, tidy: &Tidy) -> BezPath { contours_to_path(c.contours().map(|k| &k.path), tidy) } /// One segment of a path: a cubic plus whether it is a straight line. #[derive(Clone, Copy, Debug)] pub(crate) struct Seg { pub(crate) c: CubicBez, pub(crate) line: bool, } impl Seg { fn line(a: Point, b: Point) -> Self { Self { c: CubicBez::new(a, a, b, b), line: true } } } pub(crate) fn to_seg(s: PathSeg) -> Seg { match s { PathSeg::Line(l) => Seg::line(l.p0, l.p1), PathSeg::Quad(q) => { let c = q.raise(); Seg { c, line: is_straight(&c, 1e-9) } } PathSeg::Cubic(c) => Seg { c, line: is_straight(&c, 1e-9 * (1.0 + c.p0.distance(c.p3))) }, } } /// Most segments `tidy_segments` merges into one. const MAX_MERGE_RUN: usize = 64; /// Merge segments of a closed loop that were split from one curve (or collinear lines) back /// together, dropping degenerate pieces. fn tidy_segments(segs: Vec, tidy: &Tidy) -> Vec { let scale = segs.iter().map(|s| s.c.p0.distance(s.c.p3)).fold(0.0, f64::max); let tiny = (scale * 1e-9).max(1e-9); let mut segs: Vec = segs .into_iter() .filter(|s| { let r = kurbo::Rect::from_points(s.c.p0, s.c.p3).union_pt(s.c.p1).union_pt(s.c.p2); r.width() + r.height() > tiny }) .collect(); let n = segs.len(); if n < 2 { return segs; } // Joint i sits at the start of segment i. let mergeable = |a: &Seg, b: &Seg| -> bool { if a.line != b.line || tidy.is_kept(b.c.p0) { return false; } let ta = end_tangent(&a.c); let tb = start_tangent(&b.c); ta.dot(tb) > 0.9998 && ta.cross(tb).abs() < 0.02 }; // Rotate so we start at a real corner (if there is one). if let Some(k) = (0..n).find(|&i| !mergeable(&segs[(i + n - 1) % n], &segs[i])) { segs.rotate_left(k); } let mut out: Vec = Vec::with_capacity(n); let mut i = 0; while i < n { let mut cur = segs[i]; let mut j = i + 1; // Each attempt re-checks the whole run, so a run is capped: a long chain of redundant // pieces costs linear, not quadratic, work and leaves at most a few extra joints. while j < n && j - i < MAX_MERGE_RUN && mergeable(&segs[j - 1], &segs[j]) { match try_merge(&segs[i..=j], tidy) { Some(m) => { cur = m; j += 1; } None => break, } } out.push(cur); i = j; } out } fn try_merge(run: &[Seg], tidy: &Tidy) -> Option { let precision = tidy.precision; let first = run[0].c; let last = run[run.len() - 1].c; if run[0].line { let chord = last.p3 - first.p0; let len = chord.hypot(); if len < 1e-12 { return None; } let ok = run.iter().all(|s| ((s.c.p3 - first.p0).cross(chord) / len).abs() <= precision * 0.5); return ok.then(|| Seg::line(first.p0, last.p3)); } if let Some(c) = exact_merge(run, tidy) { return Some(Seg { c, line: false }); } let mut pts = Vec::with_capacity(run.len() * 12 + 1); for (k, s) in run.iter().enumerate() { sample(&s.c, 12, &mut pts, k == 0); } let (c, err, _) = fit_single(&pts, start_tangent(&first), end_tangent(&last)); if err > precision { return None; } Some(Seg { c, line: false }) } /// If every piece of `run` lies on one input cubic, return the exact sub-curve spanning the run. fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option { use kurbo::{ParamCurve, ParamCurveNearest}; let a = run[0].c.p0; let b = run[run.len() - 1].c.p3; let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs()); let tol = 1e-6 * scale; for (r, c) in &tidy.curves { if !r.contains(a) || !r.contains(b) { continue; } let na = c.nearest(a, 1e-12); let nb = c.nearest(b, 1e-12); if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol { continue; } let on_curve = run.iter().all(|s| c.nearest(s.c.eval(0.5), 1e-12).distance_sq.sqrt() <= tol * 10.0); if !on_curve || (na.t - nb.t).abs() < 1e-12 { continue; } let sub = if na.t < nb.t { c.subsegment(na.t..nb.t) } else { let r = c.subsegment(nb.t..na.t); CubicBez::new(r.p3, r.p2, r.p1, r.p0) }; return Some(CubicBez::new(a, sub.p1, sub.p2, b)); } None } /// N-ary boolean: the region where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a /// point is inside `paths[i]` under its fill rule. Returns one compound path. pub fn boolean_n(paths: &[(BezPath, FillRule)], pred: impl Fn(&[bool]) -> bool) -> BezPath { match Arrangement::new(paths) { Some(arr) => all_contours(&arr.contours(pred), &arr.tidy), None => BezPath::new(), } } /// Resolve self-intersections / overlaps of one filled path into simple, consistently oriented /// contours. pub fn normalize(path: &BezPath, rule: FillRule) -> BezPath { match normalize_contours(path, rule) { Some(c) => all_contours(&c, &Tidy::keeping(DEFAULT_PRECISION, [path])), None => BezPath::new(), } } /// Unsigned filled area of a path under a fill rule (normalised first, so it is exact for /// self-overlapping input). pub fn filled_area(path: &BezPath, rule: FillRule) -> f64 { match normalize_contours(path, rule) { Some(c) => c.contours().map(|ct| ct.path.area()).sum::().abs(), None => 0.0, } } #[cfg(test)] mod tests { use super::*; #[test] fn sparse_windings_add_and_restrict() { let a = Multi::single(3, true) + Multi::single(1000, false); assert_eq!((a.get(3), a.get(1000), a.get(7)), (1, -1, 0)); // Only nonzero entries are stored, whatever the tag. assert_eq!(a.0.len(), 2); assert_eq!(a.of_tag(1000), Multi::single(1000, false)); assert_eq!(a.of_tag(5), Multi::default()); // Windings that cancel compare equal to none at all. assert_eq!(a.clone() + Multi::single(3, false) + Multi::single(1000, true), Multi::default()); } #[test] fn many_paths_union_keeps_each_square() { let squares: Vec<(BezPath, FillRule)> = (0..400).map(|i| (kurbo::Rect::new(i as f64 * 3.0, 0.0, i as f64 * 3.0 + 2.0, 2.0).to_path(0.1), FillRule::NonZero)).collect(); let out = boolean_n(&squares, |m| m.iter().any(|&x| x)); assert_eq!(out.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count(), 400); assert!((out.area().abs() - 400.0 * 4.0).abs() < 1e-6); } #[test] fn long_collinear_runs_merge_in_bounded_pieces() { // A square whose top edge is split into thousands of collinear pieces. let n: usize = 5000; let mut segs: Vec = (0..n).map(|k| Seg::line(Point::new(k as f64, 0.0), Point::new(k as f64 + 1.0, 0.0))).collect(); let w = n as f64; segs.push(Seg::line(Point::new(w, 0.0), Point::new(w, w))); segs.push(Seg::line(Point::new(w, w), Point::new(0.0, w))); segs.push(Seg::line(Point::new(0.0, w), Point::new(0.0, 0.0))); let out = tidy_segments(segs, &Tidy::free(DEFAULT_PRECISION)); // The top edge comes back as n / MAX_MERGE_RUN straight pieces on the same line. assert!(out.len() <= n.div_ceil(MAX_MERGE_RUN) + 3, "{}", out.len()); assert!(out.iter().all(|s| s.line)); let top: f64 = out.iter().filter(|s| s.c.p0.y == 0.0 && s.c.p3.y == 0.0).map(|s| s.c.p3.x - s.c.p0.x).sum(); assert_eq!(top, w); } }