From 4c5809302ddb2510ad5a79fbd3c1504a85078381 Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Sun, 11 Oct 2026 17:56:48 +0530 Subject: [PATCH] #1172: Pathfinder Trim, Merge and Crop 9-12x faster, same output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim, Merge and Crop walk the arrangement once per input shape, and every walk tested each edge side with a coverage mask built from a winding vector with one slot per input. On 300 paths, against main: - Trim: 362 → 31 ms - Merge: 366 → 31 ms - Crop: 303 → 28 ms - Unite, Intersect, Exclude, Minus Front and Minus Back: unchanged or a little faster - Multi, the arrangement's winding, keeps only the non-zero windings as sorted (input, winding) pairs, added in place. A face is covered by a few inputs, so a test reads a few entries instead of one per input. The winding arithmetic is unchanged. - Arrangement::covered lists the inputs that cover a face (the true entries of its coverage mask). Trim, Merge, Crop, Minus Front, Minus Back, Intersect and Exclude test that list; each test's old mask form is in a comment beside it. mask() still builds the same mask for the rest. pathfinder_pinned hashes the output of all ten operations on 2, 5, 12 and 30 overlapping ellipses, rectangles and stars (even-odd and non-zero, shared keys for Merge). The 40 hashes were computed with main's dense windings, and the new code gives every one of them. A wrong Crop test fails it. The pathops suite (103 tests) passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- crates/pathops/src/boolean.rs | 70 +++++++++++------ crates/pathops/src/pathfinder.rs | 34 +++++--- crates/pathops/tests/pathfinder_pinned.rs | 94 +++++++++++++++++++++++ 3 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 crates/pathops/tests/pathfinder_pinned.rs diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index d3143c8c8..79f2e5329 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -50,24 +50,22 @@ pub(crate) fn inside(rule: FillRule, w: i32) -> bool { } } -/// Winding numbers for an arbitrary number of input sets (tag = input index). -#[derive(Clone, Debug, Default)] -pub(crate) struct Multi(pub(crate) Vec); +/// Winding numbers for an arbitrary number of input sets (tag = input index): the non-zero ones, +/// as (tag, winding) sorted by tag. A face is covered by a few of the inputs, so this stays short +/// where a vector of every input's winding would make each Pathfinder test cost one step per input. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Multi(Vec<(usize, i32)>); impl Multi { fn get(&self, i: usize) -> i32 { - self.0.get(i).copied().unwrap_or(0) + self.0.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| self.0.get(k)).map_or(0, |e| e.1) + } + /// The non-zero windings, by input. + pub(crate) fn iter(&self) -> impl DoubleEndedIterator + '_ { + self.0.iter().copied() } } -impl PartialEq for Multi { - fn eq(&self, other: &Self) -> bool { - let n = self.0.len().max(other.0.len()); - (0..n).all(|i| self.get(i) == other.get(i)) - } -} -impl Eq for Multi {} - impl std::ops::Add for Multi { type Output = Multi; fn add(mut self, rhs: Self) -> Multi { @@ -78,11 +76,21 @@ impl std::ops::Add for Multi { impl std::ops::AddAssign for Multi { fn add_assign(&mut self, rhs: Self) { - if self.0.len() < rhs.0.len() { - self.0.resize(rhs.0.len(), 0); - } - for (a, b) in self.0.iter_mut().zip(rhs.0) { - *a += b; + // In place, input by input (the sweep mostly adds one input's ±1 to a short list): add to + // the input's winding, or insert it in tag order, and drop it if it reaches zero. + for (t, k) in rhs.0 { + match self.0.binary_search_by_key(&t, |e| e.0) { + Ok(i) => { + if let Some(e) = self.0.get_mut(i) { + e.1 += k; + if e.1 == 0 { + self.0.remove(i); + } + } + } + Err(i) if k != 0 => self.0.insert(i, (t, k)), + Err(_) => {} + } } } } @@ -90,14 +98,11 @@ impl std::ops::AddAssign for Multi { impl WindingNumber for Multi { type Tag = usize; fn single(tag: usize, positive: bool) -> Self { - let mut v = vec![0; tag + 1]; - v[tag] = if positive { 1 } else { -1 }; - Multi(v) + Multi(vec![(tag, if positive { 1 } else { -1 })]) } fn of_tag(&self, tag: usize) -> Self { - let mut v = vec![0; tag + 1]; - v[tag] = self.get(tag); - Multi(v) + let w = self.get(tag); + Multi(if w == 0 { vec![] } else { vec![(tag, w)] }) } } @@ -288,7 +293,24 @@ impl Arrangement { } pub(crate) fn mask(&self, w: &Multi) -> Vec { - self.rules.iter().enumerate().map(|(i, &r)| inside(r, w.get(i))).collect() + // A zero winding is outside under both rules, so only the non-zero ones can set a flag. + let mut m = vec![false; self.rules.len()]; + for (i, k) in w.iter() { + if let (Some(&r), Some(b)) = (self.rules.get(i), m.get_mut(i)) { + *b = inside(r, k); + } + } + m + } + + /// The inputs that cover a face of winding `w` (`mask(w)`'s true indices), back to front. + pub(crate) fn covered<'a>(&'a self, w: &'a Multi) -> impl DoubleEndedIterator + 'a { + w.iter().filter(|&(i, k)| self.rules.get(i).is_some_and(|&r| inside(r, k))).map(|(i, _)| i) + } + + /// [`Arrangement::contours`] with the test on the windings themselves (see [`Self::covered`]). + pub(crate) fn contours_where(&self, pred: impl Fn(&Multi) -> bool) -> Contours { + self.top.contours(pred) } pub(crate) fn contours(&self, pred: impl Fn(&[bool]) -> bool) -> Contours { diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index 123fb2c55..1bd65c8f1 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -75,8 +75,10 @@ fn arrangement(shapes: &[Shape]) -> Option { Arrangement::new(bps).ok() } -fn topmost(m: &[bool]) -> Option { - m.iter().rposition(|&b| b) +/// The one item of `it`, if it has exactly one. +fn only(mut it: impl Iterator) -> Option { + let first = it.next()?; + it.next().is_none().then_some(first) } fn one(path: PathData, key: u64) -> Vec { @@ -102,10 +104,16 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec { let p = &arr.tidy; match op { PathfinderOp::Unite => unite(), - PathfinderOp::MinusFront => one(all_contours_to_path(&arr.contours(|m| m[0] && !m[1..].iter().any(|&b| b)), p), shapes[0].key), - PathfinderOp::MinusBack => one(all_contours_to_path(&arr.contours(|m| m[n - 1] && !m[..n - 1].iter().any(|&b| b)), p), front_key), - PathfinderOp::Intersect => one(all_contours_to_path(&arr.contours(|m| m.iter().all(|&b| b)), p), front_key), - PathfinderOp::Exclude => one(all_contours_to_path(&arr.contours(|m| m.iter().filter(|&&b| b).count() % 2 == 1), p), front_key), + // The tests read the covering inputs straight off the windings ([`Arrangement::covered`]); + // each is the coverage-mask test in its comment. + // m[0] && !m[1..].any() + PathfinderOp::MinusFront => one(all_contours_to_path(&arr.contours_where(|w| only(arr.covered(w)) == Some(0)), p), shapes[0].key), + // m[n - 1] && !m[..n - 1].any() + PathfinderOp::MinusBack => one(all_contours_to_path(&arr.contours_where(|w| only(arr.covered(w)) == Some(n - 1)), p), front_key), + // m.all() + PathfinderOp::Intersect => one(all_contours_to_path(&arr.contours_where(|w| arr.covered(w).count() == n), p), front_key), + // m.count() is odd + PathfinderOp::Exclude => one(all_contours_to_path(&arr.contours_where(|w| arr.covered(w).count() % 2 == 1), p), front_key), PathfinderOp::Divide => regions_of(&arr) .into_iter() .filter_map(|r| { @@ -113,7 +121,10 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec { Some(Shape::new(r.path, FillRule::NonZero, key)) }) .collect(), - PathfinderOp::Trim => (0..n).flat_map(|i| one(all_contours_to_path(&arr.contours(|m| topmost(m) == Some(i)), p), shapes[i].key)).collect(), + // topmost(m) == Some(i) + PathfinderOp::Trim => { + (0..n).flat_map(|i| one(all_contours_to_path(&arr.contours_where(|w| arr.covered(w).next_back() == Some(i)), p), shapes[i].key)).collect() + } PathfinderOp::Merge => { let mut keys: Vec = Vec::new(); for s in shapes { @@ -123,7 +134,8 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec { } let mut out = Vec::new(); for k in keys { - let c = arr.contours(|m| topmost(m).is_some_and(|t| shapes[t].key == k)); + // topmost(m) is a shape with key k + let c = arr.contours_where(|w| arr.covered(w).next_back().is_some_and(|t| shapes[t].key == k)); for g in c.grouped() { out.extend(one(contours_to_path(&c, g, p), k)); } @@ -136,7 +148,11 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec { } (0..n - 1) .flat_map(|i| { - let c = arr.contours(|m| m[n - 1] && topmost(&m[..n - 1]) == Some(i)); + // m[n - 1] && topmost(&m[..n - 1]) == Some(i): the front shape, then i below it + let c = arr.contours_where(|w| { + let mut c = arr.covered(w).rev(); + c.next() == Some(n - 1) && c.next() == Some(i) + }); one(all_contours_to_path(&c, p), shapes[i].key) }) .collect() diff --git a/crates/pathops/tests/pathfinder_pinned.rs b/crates/pathops/tests/pathfinder_pinned.rs new file mode 100644 index 000000000..b23c1499d --- /dev/null +++ b/crates/pathops/tests/pathfinder_pinned.rs @@ -0,0 +1,94 @@ +//! The exact output of every Pathfinder operation, pinned so that work on the windings and the +//! coverage tests can't change it: overlapping ellipses, rectangles and stars (even-odd and +//! non-zero, repeated keys for Merge) at a few counts. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use vectorcraft_geom::{FillRule, Point, Rect, shapes}; +use vectorcraft_pathops::{PathfinderOp, Shape, pathfinder}; + +fn shapes_of(n: usize, seed: u64) -> Vec { + let mut r = seed | 1; + let mut next = || { + r ^= r << 13; + r ^= r >> 7; + r ^= r << 17; + (r % 1_000_000) as f64 / 1_000_000.0 + }; + (0..n) + .map(|i| { + let (x, y, s) = (next() * 200.0, next() * 150.0, 15.0 + next() * 40.0); + let path = match i % 3 { + 0 => shapes::ellipse(Rect::from_center_size(Point::new(x, y), (2.0 * s, 1.4 * s))), + 1 => shapes::rectangle(Rect::new(x, y, x + 1.5 * s, y + s)), + _ => shapes::star(Point::new(x, y), s, s / 2.0, 5, 0.3), + }; + let rule = if i % 4 == 3 { FillRule::EvenOdd } else { FillRule::NonZero }; + Shape::new(path, rule, (i % 5) as u64) + }) + .collect() +} + +fn fnv(s: &str) -> u64 { + s.bytes().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3)) +} + +#[test] +fn every_pathfinder_operation_is_pinned() { + use PathfinderOp::*; + let ops = [Unite, MinusFront, MinusBack, Intersect, Exclude, Divide, Trim, Merge, Crop, Outline]; + let mut got = vec![]; + for (n, seed) in [(2, 3), (5, 7), (12, 11), (30, 13)] { + let input = shapes_of(n, seed); + for op in ops { + let out = pathfinder(op, &input); + let text: String = out.iter().map(|s| format!("{}|{:?}|{:?};", s.key, s.rule, s.path)).collect(); + got.push(fnv(&text)); + } + } + assert_eq!(got.len(), PINNED.len()); + assert_eq!(got, PINNED); +} + +/// Computed with the dense windings (main at b08482d6). +const PINNED: [u64; 40] = [ + 0x5e62_baae_d8b4_dba5, + 0x2bbc_e138_a97c_5a28, + 0xce78_992e_750b_1092, + 0x30cf_cc0b_861c_48b4, + 0xe6e4_2a29_1e16_5e86, + 0x390b_0633_5066_6292, + 0x06b7_3884_f961_c343, + 0x06b7_3884_f961_c343, + 0x0384_d599_5a03_dafd, + 0x3dcd_029a_dd3f_fa38, + 0x8249_71f8_fda4_2bb6, + 0xe7f2_8633_cef9_a76c, + 0x1a11_084e_721e_ac3b, + 0xcbf2_9ce4_8422_2325, + 0xa786_d973_a0d7_ca2a, + 0x389b_c89a_0ccf_578f, + 0xff9d_cf53_d872_681e, + 0xff9d_cf53_d872_681e, + 0xcbf2_9ce4_8422_2325, + 0x6be3_02a6_d9c9_e80e, + 0x3525_cabe_b235_aaef, + 0xf969_aa07_a44d_1770, + 0xed13_60c7_0aaa_7854, + 0xcbf2_9ce4_8422_2325, + 0x6550_a381_db6b_a35f, + 0x1ba9_6dfb_89ea_ac78, + 0x1344_b467_dd20_5f02, + 0x948f_e2a1_d657_0d4c, + 0x49a4_5c3c_2d96_14af, + 0x16a7_ce9e_4a3c_0974, + 0x0b12_c643_a446_da04, + 0xcbf2_9ce4_8422_2325, + 0xeb6a_6614_cea5_a508, + 0xcbf2_9ce4_8422_2325, + 0xb9c4_2803_7db9_bd4f, + 0x34b6_90d5_e443_b799, + 0xdfaf_ecbd_2d58_3227, + 0x6d5e_6e8d_b05b_8688, + 0x59dc_9268_a73f_e92e, + 0x5a83_5cbd_1f8c_5143, +];