mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
#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:
1 parent
2bb7646a39
commit
4c5809302d
3 files changed
+165
-33
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
Reference in new issue
Block a user