#1172: Pathfinder Trim, Merge and Crop 9-12x faster, same output

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
arhammusheerandClaude Opus 5.5 authored and bflatastic committed 2026-10-11 23:26:53 +07:00
1 parent 2bb7646a39
commit 4c5809302d
3 files changed
+165 -33

No files matched your search

+46 -24
View File
@@ -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<i32>);
/// 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<Item = (usize, i32)> + '_ {
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<bool> {
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<Item = usize> + '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 {
+25 -9
View File
@@ -75,8 +75,10 @@ fn arrangement(shapes: &[Shape]) -> Option<Arrangement> {
Arrangement::new(bps).ok()
}
fn topmost(m: &[bool]) -> Option<usize> {
m.iter().rposition(|&b| b)
/// The one item of `it`, if it has exactly one.
fn only(mut it: impl Iterator<Item = usize>) -> Option<usize> {
let first = it.next()?;
it.next().is_none().then_some(first)
}
fn one(path: PathData, key: u64) -> Vec<Shape> {
@@ -102,10 +104,16 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
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<Shape> {
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<u64> = Vec::new();
for s in shapes {
@@ -123,7 +134,8 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
}
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<Shape> {
}
(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()
+94
View File
@@ -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<Shape> {
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,
];