From 32601a6bc3e48d4bfb085ee3ae451e31f8905912 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 07:03:46 -0700 Subject: [PATCH 1/2] Shape Builder: a merge walks the arrangement once per batch of faces A merge found the contours of the faces it unites with one contour pass over the whole arrangement for each coverage among them, and the search for slivers next to them did the same for each neighboring coverage. Each in-place remnant of a merge is such a merge too, so a click on many overlapping shapes made one pass over the whole arrangement for each coverage, for the merged faces and again for each remnant. Both now use the batching that Divide's regions use: one pass for each batch of coverages whose faces share no vertex. The batching looks a face's coverage up by a hash of the inputs that cover it, computed as it walks them. The search for slivers looks up the merged faces' coverages the same way. Merging a frame no longer takes in an island of the same coverage in its hole: each face is its outer contour and the holes directly in it, as the regions are. --- crates/pathops/src/boolean.rs | 10 +- crates/pathops/src/pathfinder.rs | 225 +++++++++++++++++++++++++------ 2 files changed, 190 insertions(+), 45 deletions(-) diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 2d6dd4be5..dc3a5cf47 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -14,6 +14,12 @@ use vectorcraft_geom::{Anchor, FillRule, PathData, SubPath}; use crate::PathOpsError; use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent}; +#[cfg(test)] +thread_local! { + /// The contour passes made over an arrangement on this thread, each of which walks all of it. + pub(crate) static CONTOUR_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Default curve-refit tolerance (document points) used to merge split curve pieces back together. /// Similar in spirit to Illustrator's Pathfinder "Precision" option (0.028 pt). pub const DEFAULT_PRECISION: f64 = 0.01; @@ -314,11 +320,13 @@ impl Arrangement { /// [`Arrangement::contours`] with the test on the windings themselves (see [`Self::covered`]). pub(crate) fn contours_where(&self, pred: impl Fn(&Multi) -> bool) -> Contours { + #[cfg(test)] + CONTOUR_PASSES.with(|n| n.set(n.get() + 1)); self.top.contours(pred) } pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours { - self.top.contours(|w| pred(&self.mask(w))) + self.contours_where(|w| pred(&self.mask(w))) } /// All distinct non-empty coverage masks that occur next to some edge, sorted. diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index 4a2ac67f0..07a307860 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use kurbo::{BezPath, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Shape as _}; -use linesweeper::topology::ContourIdx; +use linesweeper::topology::{ContourIdx, Contours}; use vectorcraft_geom::{FillRule, PathData}; use crate::boolean::{ @@ -200,18 +200,42 @@ pub fn pathfinder_with(op: PathfinderOp, shapes: &[Shape], precision: f64) -> Ve } } -/// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face grouped as -/// `arr.contours(|m| m == mask).grouped()` groups it. +/// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face an outer contour +/// and the holes of its mask directly inside it ([`face_groups`]). +fn regions_of(arr: &Arrangement) -> Vec { + let masks = arr.distinct_masks(); + let refs: Vec<&[bool]> = masks.iter().map(Vec::as_slice).collect(); + let mut faces: Vec> = vec![Vec::new(); masks.len()]; + each_face(arr, &refs, |m, c, group| { + if let Some(f) = faces.get_mut(m) { + f.push(contours_to_path(c, group.iter().copied(), &arr.tidy)); + } + }); + let mut out = Vec::new(); + for (mask, paths) in masks.iter().zip(faces) { + let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); + out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() })); + } + out +} + +/// Calls `f` with each face of `arr` whose coverage is one of `masks` (distinct): the index of +/// its mask in `masks`, the contour pass it is in, and its contours, an outer contour and the +/// holes of its mask directly inside it ([`face_groups`]). Each mask's faces come in the order +/// of that mask's own contour pass. /// /// A contour pass walks the whole arrangement, so one pass per mask would cost masks × edges /// (two blends crossing make thousands of each). Instead the masks go into batches whose faces /// share no vertex (so no edge either): one pass per batch walks each face's contours exactly as /// its own mask's pass would, and the mask a contour bounds is the one of its batch at its first /// point. -fn regions_of(arr: &Arrangement) -> Vec { - let masks = arr.distinct_masks(); - let ids: HashMap<&[bool], usize> = masks.iter().enumerate().map(|(i, m)| (m.as_slice(), i)).collect(); - let id = |w: &Multi| ids.get(arr.mask(w).as_slice()).copied(); +fn each_face(arr: &Arrangement, masks: &[&[bool]], mut f: impl FnMut(usize, &Contours, &[ContourIdx])) { + if masks.is_empty() { + return; + } + // Each mask is found by the inputs it covers. + let ids = CoverIds::new(masks.iter().map(|m| m.iter().enumerate().filter(|(_, b)| **b).map(|(k, _)| k).collect()).collect()); + let id = |w: &Multi| ids.of(arr, w); let top = &arr.top; // The masks of the faces around each vertex. let mut around: HashMap<(u64, u64), Vec> = HashMap::new(); @@ -246,9 +270,8 @@ fn regions_of(arr: &Arrangement) -> Vec { batch[m] = taken.iter().position(|t| !t).unwrap_or(batches); batches = batches.max(batch[m] + 1); } - let mut faces: Vec> = vec![Vec::new(); masks.len()]; for b in 0..batches { - let c = top.contours(|w| id(w).is_some_and(|m| batch[m] == b)); + let c = arr.contours_where(|w| id(w).is_some_and(|m| batch[m] == b)); let owner: Option> = c .contours() .map(|ct| { @@ -260,22 +283,46 @@ fn regions_of(arr: &Arrangement) -> Vec { // Not expected: every contour starts at a vertex of the arrangement. Fall back to a // pass per mask for this batch. for (m, mask) in masks.iter().enumerate().filter(|(m, _)| batch[*m] == b) { - let c = arr.contours(|k| k == mask.as_slice()); + let c = arr.contours(|k| k == *mask); let owner = vec![m; c.contours().count()]; - faces[m].extend(face_groups(&c, &owner).into_iter().map(|(_, g)| contours_to_path(&c, g, &arr.tidy))); + for (_, group) in face_groups(&c, &owner) { + f(m, &c, &group); + } } continue; }; for (m, group) in face_groups(&c, &owner) { - faces[m].push(contours_to_path(&c, group, &arr.tidy)); + f(m, &c, &group); } } - let mut out = Vec::new(); - for (mask, paths) in masks.iter().zip(faces) { - let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); - out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() })); +} + +/// Lists of inputs, each ascending as [`Arrangement::covered`] gives them, found by the inputs +/// that cover a winding. +struct CoverIds { + /// The indices in `lists` of the lists with each hash ([`CoverIds::hash`]). + by_hash: HashMap>, + lists: Vec>, +} + +impl CoverIds { + fn new(lists: Vec>) -> Self { + let mut by_hash: HashMap> = HashMap::new(); + for (i, l) in lists.iter().enumerate() { + by_hash.entry(Self::hash(l.iter().copied())).or_default().push(i); + } + Self { by_hash, lists } + } + + fn hash(inputs: impl Iterator) -> u64 { + inputs.fold(0xcbf2_9ce4_8422_2325, |h, i| (h ^ i as u64).wrapping_mul(0x0000_0100_0000_01b3).rotate_left(29)) + } + + /// The index of the list of the inputs that cover winding `w`, if it is one of them. + fn of(&self, arr: &Arrangement, w: &Multi) -> Option { + let same = |&&i: &&usize| self.lists.get(i).is_some_and(|l| arr.covered(w).eq(l.iter().copied())); + self.by_hash.get(&Self::hash(arr.covered(w)))?.iter().find(same).copied() } - out } /// Splits a contour pass into faces: each face is an outer contour plus the holes of the same mask @@ -444,23 +491,30 @@ impl FaceMerger { } picks.push((mask, crate::planar::interior_point(&r.path)?)); } - let mut raw = BezPath::new(); - let mut done: Vec<&[bool]> = vec![]; + // `masks` holds the coverages in the order they were first picked, and `found` holds the + // faces of each that contain a picked point. + let mut masks: Vec<&[bool]> = vec![]; + let mut seen = HashSet::new(); for (mask, _) in &picks { - if done.contains(&mask.as_slice()) { - continue; + if seen.insert(mask.as_slice()) { + masks.push(mask); } - done.push(mask); - let c = arr.contours(|k| k == mask.as_slice()); - for group in c.grouped() { - let mut face = BezPath::new(); - for i in group { - face.extend(c[i].path.iter()); - } - if picks.iter().any(|(m, p)| m == mask && face.winding(*p) != 0) { - raw.extend(face.iter()); - } + } + let mut found: Vec> = vec![Vec::new(); masks.len()]; + each_face(arr, &masks, |m, c, group| { + let mut face = BezPath::new(); + for &i in group { + face.extend(c[i].path.iter()); } + if let (Some(&mask), Some(out)) = (masks.get(m), found.get_mut(m)) + && picks.iter().any(|(k, p)| k.as_slice() == mask && face.winding(*p) != 0) + { + out.push(face); + } + }); + let mut raw = BezPath::new(); + for face in found.iter().flatten() { + raw.extend(face.iter()); } Some(raw) } @@ -501,10 +555,9 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: & let shapes = arr.rules.len(); let cover = cover(faces, shapes); let merged_cover = rest.map(|r| self::cover(&r.merged, shapes)).unwrap_or_default(); - let picked: HashSet<&[usize]> = faces.iter().map(|r| r.sources.as_slice()).collect(); - let beside: HashSet<&[usize]> = rest.iter().flat_map(|r| r.merged.iter().map(|f| f.sources.as_slice())).collect(); - let coverage = |w: &Multi| arr.covered(w).collect::>(); - let is_picked = |w: &Multi| picked.contains(coverage(w).as_slice()); + let picked = CoverIds::new(faces.iter().map(|r| r.sources.clone()).collect()); + let beside = CoverIds::new(rest.iter().flat_map(|r| r.merged.iter().map(|f| f.sources.clone())).collect()); + let is_picked = |w: &Multi| picked.of(arr, w).is_some(); let within = |w: &Multi, c: &[bool]| arr.covered(w).next().is_some() && arr.covered(w).all(|i| c.get(i) == Some(&true)); let ours = |w: &Multi| rest.is_none_or(|r| arr.covered(w).any(|i| i == r.shape)); // The coverage of each face next to a picked one, if only shapes of `faces` cover it, and the @@ -523,7 +576,7 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: & } if let Some(r) = rest && within(w, &merged_cover) - && beside.contains(coverage(other).as_slice()) + && beside.of(arr, other).is_some() { let ends = [h, h.other_half()].map(|e| vertex_key(top.point(e).to_kurbo())); if ends.iter().all(|k| r.corners.contains(k)) { @@ -533,15 +586,22 @@ fn add_slivers(arr: &Arrangement, faces: &[&Region], rest: Option<&Rest>, raw: & } } let along_merged = |path: &BezPath| path.elements().iter().any(|el| el.end_point().is_some_and(|p| taken.contains(&vertex_key(p)))); - for mask in next_to { - let c = arr.contours(|k| k == mask.as_slice()); - for group in c.grouped() { - if contours_to_path(&c, group.iter().copied(), &arr.tidy).is_empty() && !group.iter().any(|&i| along_merged(&c[i].path)) { - for i in group { - raw.extend(c[i].path.iter()); - } + let masks: Vec<&[bool]> = next_to.iter().map(Vec::as_slice).collect(); + let mut found: Vec> = vec![Vec::new(); masks.len()]; + each_face(arr, &masks, |m, c, group| { + if contours_to_path(c, group.iter().copied(), &arr.tidy).is_empty() + && !group.iter().any(|&i| along_merged(&c[i].path)) + && let Some(out) = found.get_mut(m) + { + let mut face = BezPath::new(); + for &i in group { + face.extend(c[i].path.iter()); } + out.push(face); } + }); + for face in found.iter().flatten() { + raw.extend(face.iter()); } } @@ -853,10 +913,12 @@ fn mid_point(c: &[Seg]) -> Point { #[cfg(test)] mod tests { use super::*; + use crate::boolean::CONTOUR_PASSES; use proptest::prelude::*; use vectorcraft_geom::{Rect, SubPath, shapes}; - /// The faces one contour pass per mask finds: what [`regions_of`] must match exactly. + /// The faces one contour pass per mask finds: what [`regions_of`] must match exactly when no + /// face lies inside a hole of its own mask (`grouped` puts such an island in the frame's face). fn regions_one_mask_at_a_time(arr: &Arrangement) -> Vec { let mut out = Vec::new(); for mask in arr.distinct_masks() { @@ -1064,6 +1126,81 @@ mod tests { assert!(later.subpaths.iter().flat_map(|sp| &sp.anchors).all(|a| a.p != moved), "{later:?}"); } + /// A merge walks the arrangement once for each batch of the faces' coverages. 49 rectangles in + /// a row, each overlapping the next, make 97 faces, each with a coverage of its own. + #[test] + fn merging_many_faces_walks_the_arrangement_a_few_times() { + let v: Vec = (0..49) + .map(|k| { + let x = 10.0 * f64::from(k); + Shape::new(rect(x, 0.0, x + 15.0, 40.0), FillRule::NonZero, 1) + }) + .collect(); + let faces = regions(&v); + assert_eq!(faces.len(), 97); + // The merge takes every face but the one only rectangle 20 covers. That face lies next to + // merged faces, so the merge also looks for slivers there. + let left = faces.iter().position(|r| r.sources == [20]).unwrap(); + let picked: Vec<&Region> = faces.iter().enumerate().filter(|(k, _)| *k != left).map(|(_, r)| r).collect(); + let merger = FaceMerger::new(&v); + CONTOUR_PASSES.with(|n| n.set(0)); + let merged = merger.merge(&picked); + let passes = CONTOUR_PASSES.with(std::cell::Cell::get); + assert!(passes <= 8, "{passes} contour passes"); + // The result is the row, 495 pt long, less the 5 pt that only rectangle 20 covers. + assert_eq!(merged.subpaths.len(), 2, "{merged:?}"); + let area = crate::area(&merged, FillRule::NonZero); + assert!((area - 490.0 * 40.0).abs() < 1e-6, "{area}"); + } + + /// The lists of inputs a winding covers are found by those inputs, also among lists that share + /// a hash: here every list is filed under every hash. + #[test] + fn cover_ids_tell_apart_lists_with_one_hash() { + let v: Vec = (0..3) + .map(|k| { + let x = 10.0 * f64::from(k); + Shape::new(rect(x, 0.0, x + 15.0, 40.0), FillRule::NonZero, 1) + }) + .collect(); + let arr = arrangement(&v, DEFAULT_PRECISION).unwrap(); + let lists = vec![vec![0], vec![0, 1], vec![1], vec![1, 2], vec![2]]; + let mut ids = CoverIds::new(lists.clone()); + let all: Vec = (0..lists.len()).collect(); + for l in &lists { + ids.by_hash.insert(CoverIds::hash(l.iter().copied()), all.clone()); + } + let mut seen = vec![false; lists.len()]; + for s in arr.top.segment_indices() { + let h = s.first_half(); + for w in [arr.top.winding_clockwise(h), arr.top.winding_counter_clockwise(h)] { + let covered: Vec = arr.covered(w).collect(); + let want = lists.iter().position(|l| *l == covered); + assert_eq!(ids.of(&arr, w), want, "{covered:?}"); + if let Some(i) = want { + seen[i] = true; + } + } + } + assert!(seen.iter().all(|s| *s), "every list is next to an edge: {seen:?}"); + } + + /// A frame and the island in its hole, both of one even-odd shape, are two faces with the + /// same coverage: merging the frame leaves the island out. + #[test] + fn merging_a_frame_leaves_out_the_island_in_its_hole() { + let mut p = rect(0.0, 0.0, 100.0, 100.0); + p.subpaths.extend(rect(20.0, 20.0, 80.0, 80.0).subpaths); + p.subpaths.extend(rect(40.0, 40.0, 60.0, 60.0).subpaths); + let v = [Shape::new(p, FillRule::EvenOdd, 1)]; + let faces = regions(&v); + assert_eq!(faces.len(), 2); + let frame = faces.iter().find(|r| r.contains(Point::new(10.0, 10.0))).unwrap(); + let merged = FaceMerger::new(&v).merge(&[frame]); + let area = crate::area(&merged, FillRule::NonZero); + assert!((area - (100.0 * 100.0 - 60.0 * 60.0)).abs() < 1e-6, "{area}"); + } + #[test] fn divide_open_paths_cut_faces_without_taking_the_cutters_paint() { let line = |a: (f64, f64), b: (f64, f64)| shapes::line(Point::from(a), Point::from(b)); From 4116fdbe479ca64839c46afa803f0ec336516ffd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 22:20:14 -0700 Subject: [PATCH 2/2] Shape Builder: a merge looks up the picked points of a coverage once The search for the faces a merge unites compared each reported face's coverage with every picked point's coverage. The picked points are now grouped by coverage once, before the walk, and each face is tested only against the points of its own coverage. --- crates/pathops/src/pathfinder.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index 07a307860..13476fbbc 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -491,13 +491,19 @@ impl FaceMerger { } picks.push((mask, crate::planar::interior_point(&r.path)?)); } - // `masks` holds the coverages in the order they were first picked, and `found` holds the - // faces of each that contain a picked point. + // `masks` holds the coverages in the order they were first picked, `points` the picked + // points of each, and `found` the faces of each that contain one of its points. let mut masks: Vec<&[bool]> = vec![]; - let mut seen = HashSet::new(); - for (mask, _) in &picks { - if seen.insert(mask.as_slice()) { + let mut points: Vec> = vec![]; + let mut index: HashMap<&[bool], usize> = HashMap::new(); + for (mask, p) in &picks { + let m = *index.entry(mask.as_slice()).or_insert_with(|| { masks.push(mask); + points.push(Vec::new()); + masks.len() - 1 + }); + if let Some(at) = points.get_mut(m) { + at.push(*p); } } let mut found: Vec> = vec![Vec::new(); masks.len()]; @@ -506,8 +512,8 @@ impl FaceMerger { for &i in group { face.extend(c[i].path.iter()); } - if let (Some(&mask), Some(out)) = (masks.get(m), found.get_mut(m)) - && picks.iter().any(|(k, p)| k.as_slice() == mask && face.winding(*p) != 0) + if let (Some(at), Some(out)) = (points.get(m), found.get_mut(m)) + && at.iter().any(|p| face.winding(*p) != 0) { out.push(face); }