From 9002f86f733126f25a818fafc4ac7448a5fe20ac Mon Sep 17 00:00:00 2001 From: theironchef <6278036+theironchef@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:10:36 -0700 Subject: [PATCH] Pathfinder: find all regions in a few contour passes, not one per mask regions() made one linesweeper contour pass per distinct coverage mask, and each pass walks the whole arrangement, so finding the regions cost masks x segments. Two crossing blends make thousands of both: the Ribbons example's EPS export never finished, and Flatten Transparency, the Flattener Preview, Divide, Shape Builder and Live Paint slowed down the same way on dense art. The masks now go into batches whose faces share no vertex (a greedy colouring of the masks meeting at each vertex), one contour pass per batch. A face's contours are walked as in its own mask's pass, since no other face of the batch touches it; each contour's mask is the one of its batch at its first point (a vertex of the arrangement), and contours group under the nearest enclosing contour of the same mask, as grouped() does in that mask's pass. The regions come out identical, in the same order. Fixes #173. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D --- crates/pathops/src/pathfinder.rs | 187 +++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 9 deletions(-) diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index 3772c158..ea36e569 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -1,10 +1,13 @@ //! Illustrator's Pathfinder panel and Shape Builder regions, over an ordered stack of shapes //! (index 0 = back-most, last = front-most). +use std::collections::HashMap; + use kurbo::{BezPath, ParamCurve, ParamCurveNearest, Point, Shape as _}; +use linesweeper::topology::ContourIdx; use vectorcraft_geom::{FillRule, PathData}; -use crate::boolean::{Arrangement, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all}; +use crate::boolean::{Arrangement, Multi, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all}; /// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour); /// results carry the key of the object whose paint they keep. @@ -142,21 +145,108 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec { } } +/// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face grouped as +/// `arr.contours(|m| m == mask).grouped()` groups it. +/// +/// 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 mut out = Vec::new(); - for mask in arr.distinct_masks() { - let c = arr.contours(|m| m == mask.as_slice()); - let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); - for g in c.grouped() { - let path = contours_to_path(&c, g, &arr.tidy); - if !path.is_empty() { - out.push(Region { path, sources: sources.clone() }); + 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(); + let top = &arr.top; + // The masks of the faces around each vertex. + let mut around: HashMap<(u64, u64), Vec> = HashMap::new(); + for s in top.segment_indices() { + let h = s.first_half(); + let sides = [id(top.winding_clockwise(h)), id(top.winding_counter_clockwise(h))]; + for end in [h, s.second_half()] { + let at = around.entry(vertex_key(top.point(end).to_kurbo())).or_default(); + for m in sides.into_iter().flatten() { + if !at.contains(&m) { + at.push(m); + } } } } + let mut near = vec![Vec::new(); masks.len()]; + for at in around.values() { + for &a in at { + near[a].extend(at.iter().copied().filter(|&b| b != a)); + } + } + // Greedy colouring: each mask takes the first batch none of its neighbours is in. + let mut batch = vec![usize::MAX; masks.len()]; + let mut batches = 0; + for m in 0..masks.len() { + let mut taken = vec![false; batches]; + for &n in &near[m] { + if let Some(t) = taken.get_mut(batch[n]) { + *t = true; + } + } + 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 owner: Option> = c + .contours() + .map(|ct| { + let Some(kurbo::PathEl::MoveTo(p)) = ct.path.elements().first() else { return None }; + around.get(&vertex_key(*p))?.iter().copied().find(|&m| batch[m] == b) + }) + .collect(); + let Some(owner) = owner else { + // 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()); + faces[m].extend(c.grouped().into_iter().map(|g| contours_to_path(&c, g, &arr.tidy))); + } + continue; + }; + // A contour belongs under the nearest enclosing contour of the same mask, as in that + // mask's own pass. + let mut children = vec![Vec::new(); owner.len()]; + let mut roots = Vec::new(); + for (i, &m) in owner.iter().enumerate() { + let mut up = c[ContourIdx(i)].parent; + while let Some(p) = up.filter(|p| owner.get(p.0) != Some(&m)) { + up = c[p].parent; + } + match up { + Some(p) => children[p.0].push(i), + None => roots.push(i), + } + } + for r in roots { + let mut group = Vec::new(); + let mut stack = vec![r]; + while let Some(i) = stack.pop() { + group.push(ContourIdx(i)); + stack.extend(children[i].iter().rev()); + } + faces[owner[r]].push(contours_to_path(&c, group, &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 } +/// A point as a map key (the arrangement's vertices are exact). +fn vertex_key(p: Point) -> (u64, u64) { + (p.x.to_bits(), p.y.to_bits()) +} + /// All faces of the planar arrangement of `shapes` (every area covered by at least one shape, /// split wherever coverage changes). pub fn regions(shapes: &[Shape]) -> Vec { @@ -277,3 +367,82 @@ fn mid_point(c: &[Seg]) -> Point { } c[0].c.p0 } + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use vectorcraft_geom::{Rect, SubPath, shapes}; + + /// The faces one contour pass per mask finds: what [`regions_of`] must match exactly. + fn regions_one_mask_at_a_time(arr: &Arrangement) -> Vec { + let mut out = Vec::new(); + for mask in arr.distinct_masks() { + let c = arr.contours(|m| m == mask.as_slice()); + let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); + for g in c.grouped() { + let path = contours_to_path(&c, g, &arr.tidy); + if !path.is_empty() { + out.push(Region { path, sources: sources.clone() }); + } + } + } + out + } + + fn same_as_one_mask_at_a_time(shapes: &[Shape]) { + let arr = arrangement(shapes).expect("arrangement"); + assert_eq!(regions_of(&arr), regions_one_mask_at_a_time(&arr)); + } + + fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> PathData { + shapes::rectangle(Rect::new(x0, y0, x1, y1)) + } + + /// Shapes on a coarse grid, so that edges and corners coincide: rectangles, rectangles with + /// a hole, ellipses and polygons that may cross themselves, under either fill rule. + fn arb_shape() -> impl Strategy { + let c = || (0..12i32).prop_map(|v| f64::from(v) * 10.0); + let r = (c(), c(), 1..6i32, 1..6i32).prop_map(|(x, y, w, h)| (x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0)); + let path = prop_oneof![ + r.clone().prop_map(|(x0, y0, x1, y1)| rect(x0, y0, x1, y1)), + r.clone().prop_map(|(x0, y0, x1, y1)| { + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let mut p = rect(x0 - 10.0, y0 - 10.0, x1 + 10.0, y1 + 10.0); + p.subpaths.extend(rect(x0, y0, cx.max(x0 + 5.0), cy.max(y0 + 5.0)).subpaths); + p + }), + r.prop_map(|(x0, y0, x1, y1)| shapes::ellipse(Rect::new(x0, y0, x1, y1))), + prop::collection::vec((c(), c()), 3..7).prop_map(|pts| { + let pts: Vec = pts.into_iter().map(|(x, y)| Point::new(x, y)).collect(); + PathData::single(SubPath::polyline(&pts, true)) + }), + ]; + (path, any::(), any::()) + .prop_map(|(p, even_odd, key)| Shape::new(p, if even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, key)) + } + + #[test] + fn crossing_strips_match_one_mask_at_a_time() { + let mut v = Vec::new(); + for i in 0..12 { + let x = 10.0 + 20.0 * f64::from(i); + v.push(Shape::new(rect(x, 0.0, x + 4.0, 250.0), FillRule::NonZero, 1)); + v.push(Shape::new(rect(0.0, x, 250.0, x + 4.0), FillRule::NonZero, 2)); + } + same_as_one_mask_at_a_time(&v); + // Every crossing, and each strip in 13 pieces. + assert_eq!(regions(&v).len(), 12 * 12 + 2 * 12 * 13); + } + + proptest! { + #![proptest_config(ProptestConfig { cases: 256, failure_persistence: None, rng_seed: proptest::test_runner::RngSeed::Fixed(0x5eed_9a7f), ..ProptestConfig::default() })] + + /// Batching the masks changes nothing: the same regions, in the same order, with the same + /// contours in each. + #[test] + fn batched_regions_match_one_mask_at_a_time(v in prop::collection::vec(arb_shape(), 1..8)) { + same_as_one_mask_at_a_time(&v); + } + } +}