Files
effectcraft/crates/path/src/boolean.rs
T
Dylan M. Taylor b7dee07cf4 Path booleans: sparse winding numbers and bounded merge runs (#2946)
Winding numbers were dense vectors up to each segment's path index, and the
topology keeps one per segment, so memory grew with the square of the path
count. They now store only the nonzero (path, winding) pairs.

tidy_segments re-checked the whole run on every merge attempt, so a long
chain of collinear pieces cost quadratic time. A run now merges at most 64
pieces; longer runs leave a few extra joints on the same line or curve.
2026-11-03 09:57:48 -05:00

454 lines
16 KiB
Rust

//! 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<Item = &'a BezPath>) -> 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<Point>,
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<Item = &'a BezPath>) -> 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<Multi>,
rules: Vec<FillRule>,
pub(crate) tidy: Tidy,
}
impl Arrangement {
pub(crate) fn new(paths: &[(BezPath, FillRule)]) -> Option<Self> {
let closed: Vec<BezPath> = paths.iter().map(|(p, _)| close_subpaths(p)).collect();
if !closed.iter().all(finite) {
return None;
}
let eps = eps_for(&closed);
let top = Topology::<Multi>::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<bool> {
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<Contours> {
let bp = close_subpaths(p);
if !finite(&bp) {
return None;
}
let top = Topology::<i32>::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<Item = &'a BezPath>, tidy: &Tidy) -> BezPath {
let mut out = BezPath::new();
for c in contours {
if is_sliver(c, tidy.precision) {
continue;
}
let segs: Vec<Seg> = 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<Seg>, tidy: &Tidy) -> Vec<Seg> {
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<Seg> = 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<Seg> = 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<Seg> {
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<CubicBez> {
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::<f64>().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<Seg> = (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);
}
}