mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 01:14:29 -06:00
Path ops: Unite sweeps a single path once when its own sweep leaves its segments as they are
`unite_all` and `try_unite_all` swept each input under its own fill rule and then swept all the resulting contours again under NonZero. With one input whose sweep split and dropped none of its segments, the second sweep ran over the same simple contours and doubled the time. Such an input is now refit from the first sweep's contours. Inputs that cross themselves and unions of several inputs are still swept again, and the input paths are converted once instead of twice.
This commit is contained in:
+166
-20
@@ -20,6 +20,8 @@ use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent};
|
||||
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<usize> = const { std::cell::Cell::new(0) };
|
||||
/// The sweeps [`normalize_bez`] made on this thread.
|
||||
static NORMALIZE_SWEEPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Default curve-refit tolerance (document points) used to merge split curve pieces back together.
|
||||
@@ -822,6 +824,8 @@ pub fn try_normalize(path: &PathData, rule: FillRule) -> Result<PathData, PathOp
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_bez(bp: &BezPath, rule: FillRule) -> Result<Contours, PathOpsError> {
|
||||
#[cfg(test)]
|
||||
NORMALIZE_SWEEPS.with(|n| n.set(n.get() + 1));
|
||||
let eps = eps_for([bp])?;
|
||||
let mut bp = bp.clone();
|
||||
snap_horizontals(&mut [&mut bp], eps);
|
||||
@@ -983,25 +987,20 @@ pub fn try_unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> Re
|
||||
|
||||
/// [`try_unite_all_with`] with the sweeps run on `paths` as they are.
|
||||
pub(crate) fn try_unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> Result<PathData, PathOpsError> {
|
||||
let mut all = BezPath::new();
|
||||
for (p, r) in paths {
|
||||
let bp = fill_bezpath(p);
|
||||
let bps: Vec<BezPath> = paths.iter().map(|(p, _)| fill_bezpath(p)).collect();
|
||||
let mut parts = Vec::new();
|
||||
for (bp, (_, r)) in bps.iter().zip(paths) {
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
for ct in normalize_bez(&bp, *r)?.contours() {
|
||||
all.extend(ct.path.iter());
|
||||
}
|
||||
parts.push((normalize_bez(bp, *r)?, bp.segments().count()));
|
||||
}
|
||||
if all.elements().is_empty() {
|
||||
return Ok(PathData::default());
|
||||
}
|
||||
let tidy = Tidy::keeping(precision, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
|
||||
Ok(all_contours_to_path(&normalize_bez(&all, FillRule::NonZero)?, &tidy))
|
||||
unite_parts(&parts, &bps, precision)
|
||||
}
|
||||
|
||||
/// Union of many filled paths. Each input is first normalised on its own, then all are swept
|
||||
/// together with a single integer winding number, which keeps this fast for thousands of shapes.
|
||||
/// A single input whose own sweep split none of its segments is not swept a second time.
|
||||
pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData {
|
||||
unite_all_with(paths, DEFAULT_PRECISION)
|
||||
}
|
||||
@@ -1019,25 +1018,57 @@ pub fn unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> PathDa
|
||||
|
||||
/// [`unite_all_with`] with the sweeps run on `paths` as they are.
|
||||
pub(crate) fn unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> PathData {
|
||||
let mut all = BezPath::new();
|
||||
for (p, r) in paths {
|
||||
let bp = fill_bezpath(p);
|
||||
let bps: Vec<BezPath> = paths.iter().map(|(p, _)| fill_bezpath(p)).collect();
|
||||
let mut parts = Vec::new();
|
||||
for (bp, (_, r)) in bps.iter().zip(paths) {
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(c) = normalize_bez(&bp, *r) else { continue };
|
||||
let Ok(c) = normalize_bez(bp, *r) else { continue };
|
||||
parts.push((c, bp.segments().count()));
|
||||
}
|
||||
unite_parts(&parts, &bps, precision).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The union of `parts`, each the contours [`normalize_bez`] made of one input with that input's
|
||||
/// segment count, refit to `precision` keeping the anchors of `inputs`.
|
||||
///
|
||||
/// When there is one part and its sweep split none of the input's segments, its contours are the
|
||||
/// union and are refit as they are. Otherwise all the contours are swept again together under
|
||||
/// NonZero, which also merges the short pieces the first sweeps leave where a path crosses itself.
|
||||
fn unite_parts(parts: &[(Contours, usize)], inputs: &[BezPath], precision: f64) -> Result<PathData, PathOpsError> {
|
||||
let mut all = BezPath::new();
|
||||
for (c, _) in parts {
|
||||
for ct in c.contours() {
|
||||
all.extend(ct.path.iter());
|
||||
}
|
||||
}
|
||||
if all.elements().is_empty() {
|
||||
return PathData::default();
|
||||
return Ok(PathData::default());
|
||||
}
|
||||
let tidy = Tidy::keeping(precision, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
|
||||
match normalize_bez(&all, FillRule::NonZero) {
|
||||
Ok(c) => all_contours_to_path(&c, &tidy),
|
||||
Err(_) => PathData::default(),
|
||||
let tidy = Tidy::keeping(precision, inputs.iter());
|
||||
if let [(c, input_segments)] = parts
|
||||
&& splits_nothing(c, *input_segments, &tidy)
|
||||
{
|
||||
return Ok(all_contours_to_path(c, &tidy));
|
||||
}
|
||||
Ok(all_contours_to_path(&normalize_bez(&all, FillRule::NonZero)?, &tidy))
|
||||
}
|
||||
|
||||
/// Are the contours `c` the input's own segments: as many as `input_segments`, each starting at an
|
||||
/// anchor `tidy` keeps? A segment split where paths cross gives a piece that starts away from the
|
||||
/// anchors, and a piece dropped inside another path leaves fewer segments than the input.
|
||||
fn splits_nothing(c: &Contours, input_segments: usize, tidy: &Tidy) -> bool {
|
||||
let mut n = 0;
|
||||
for ct in c.contours() {
|
||||
for s in ct.path.segments() {
|
||||
if !tidy.is_kept(to_seg(s).c.p0) {
|
||||
return false;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n == input_segments
|
||||
}
|
||||
|
||||
/// [`area`] that reports a refused input instead of an area of zero.
|
||||
@@ -1316,6 +1347,121 @@ mod tests {
|
||||
assert!(arrangement_adds_up(&arr, [&s, &t]));
|
||||
}
|
||||
|
||||
/// The union with every input swept on its own and then all their contours swept together.
|
||||
fn unite_swept_twice(paths: &[(&PathData, FillRule)], precision: f64) -> PathData {
|
||||
let bps: Vec<BezPath> = paths.iter().map(|(p, _)| fill_bezpath(p)).collect();
|
||||
let mut all = BezPath::new();
|
||||
for (bp, (_, r)) in bps.iter().zip(paths) {
|
||||
for ct in normalize_bez(bp, *r).unwrap().contours() {
|
||||
all.extend(ct.path.iter());
|
||||
}
|
||||
}
|
||||
all_contours_to_path(&normalize_bez(&all, FillRule::NonZero).unwrap(), &Tidy::keeping(precision, bps.iter()))
|
||||
}
|
||||
|
||||
/// The unions of `paths` by [`unite_all_with`] and [`try_unite_all_with`], each with the sweeps it made.
|
||||
fn unite_counting_sweeps(paths: &[(&PathData, FillRule)]) -> [(PathData, usize); 2] {
|
||||
let counted = |f: &dyn Fn() -> PathData| {
|
||||
NORMALIZE_SWEEPS.with(|n| n.set(0));
|
||||
let out = f();
|
||||
(out, NORMALIZE_SWEEPS.with(std::cell::Cell::get))
|
||||
};
|
||||
[counted(&|| unite_all_with(paths, DEFAULT_PRECISION)), counted(&|| try_unite_all_with(paths, DEFAULT_PRECISION).unwrap())]
|
||||
}
|
||||
|
||||
/// Disjoint rings, each a circle with a circular hole, as one even-odd compound path.
|
||||
fn rings(n: usize) -> PathData {
|
||||
let mut bp = BezPath::new();
|
||||
for i in 0..n {
|
||||
let c = Point::new((i % 10) as f64 * 30.0, (i / 10) as f64 * 30.0);
|
||||
bp.extend(kurbo::Circle::new(c, 12.0).path_elements(0.1));
|
||||
bp.extend(kurbo::Circle::new(c, 5.0).path_elements(0.1));
|
||||
}
|
||||
PathData::from_bezpath(&bp)
|
||||
}
|
||||
|
||||
/// A polygon with `n` teeth of different heights along its top.
|
||||
fn zigzag(n: usize) -> PathData {
|
||||
let mut pts: Vec<Point> = (0..=n).map(|i| Point::new(i as f64 * 4.0, if i % 2 == 0 { 0.0 } else { -10.0 - (i * 37 % 11) as f64 })).collect();
|
||||
pts.push(Point::new(n as f64 * 4.0, 20.0));
|
||||
pts.push(Point::new(0.0, 20.0));
|
||||
PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_input_its_own_sweep_does_not_split_is_swept_once() {
|
||||
for (name, path, rule) in [("rings", rings(40), FillRule::EvenOdd), ("zigzag", zigzag(301), FillRule::NonZero)] {
|
||||
let want = unite_swept_twice(&[(&path, rule)], DEFAULT_PRECISION);
|
||||
assert!(!want.is_empty(), "{name}");
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&path, rule)]) {
|
||||
assert_eq!(sweeps, 1, "{name}");
|
||||
assert_eq!(got, want, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inputs_that_cross_or_are_several_are_swept_again_together() {
|
||||
// Overlapping circles in one compound path: the first sweep splits them where they cross.
|
||||
let mut bp = BezPath::new();
|
||||
for i in 0..12 {
|
||||
bp.extend(kurbo::Circle::new(((i % 4) as f64 * 15.0, (i / 4) as f64 * 15.0), 10.0).path_elements(0.1));
|
||||
}
|
||||
let overlapping = PathData::from_bezpath(&bp);
|
||||
let want = unite_swept_twice(&[(&overlapping, FillRule::NonZero)], DEFAULT_PRECISION);
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&overlapping, FillRule::NonZero)]) {
|
||||
assert_eq!(sweeps, 2);
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
// Two overlapping squares in one compound path, each with an anchor where the other crosses it: the first sweep
|
||||
// splits no segment and starts every piece at an anchor, but drops the pieces inside the other square.
|
||||
let mut bp = BezPath::new();
|
||||
for pts in [
|
||||
[(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (10.0, 10.0), (5.0, 10.0), (0.0, 10.0)],
|
||||
[(5.0, 5.0), (10.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0), (5.0, 10.0)],
|
||||
] {
|
||||
bp.move_to(pts[0]);
|
||||
for p in &pts[1..] {
|
||||
bp.line_to(*p);
|
||||
}
|
||||
bp.close_path();
|
||||
}
|
||||
let anchored = PathData::from_bezpath(&bp);
|
||||
let want = unite_swept_twice(&[(&anchored, FillRule::NonZero)], DEFAULT_PRECISION);
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&anchored, FillRule::NonZero)]) {
|
||||
assert_eq!(sweeps, 2);
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
// Two plain overlapping squares in one compound path: as many segments come out as go in, because the sweep drops
|
||||
// the two pieces inside the other square and adds two where the outlines cross.
|
||||
let mut bp = BezPath::new();
|
||||
for (x, y) in [(0.0, 0.0), (5.0, 5.0)] {
|
||||
bp.move_to((x, y));
|
||||
bp.line_to((x + 10.0, y));
|
||||
bp.line_to((x + 10.0, y + 10.0));
|
||||
bp.line_to((x, y + 10.0));
|
||||
bp.close_path();
|
||||
}
|
||||
let balanced = PathData::from_bezpath(&bp);
|
||||
assert_eq!(
|
||||
normalize_bez(&fill_bezpath(&balanced), FillRule::NonZero).unwrap().contours().map(|c| c.path.segments().count()).sum::<usize>(),
|
||||
8
|
||||
);
|
||||
let want = unite_swept_twice(&[(&balanced, FillRule::NonZero)], DEFAULT_PRECISION);
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&balanced, FillRule::NonZero)]) {
|
||||
assert_eq!(sweeps, 2);
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
// Two inputs that do not cross each other or themselves are still swept together.
|
||||
let (a, b) = (square(0.0, 0.0, 10.0), square(20.0, 0.0, 10.0));
|
||||
let both = [(&a, FillRule::NonZero), (&b, FillRule::NonZero)];
|
||||
let want = unite_swept_twice(&both, DEFAULT_PRECISION);
|
||||
for (got, sweeps) in unite_counting_sweeps(&both) {
|
||||
assert_eq!(sweeps, 3);
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(panic = "unwind")]
|
||||
fn a_panic_in_the_contour_pass_becomes_an_error() {
|
||||
|
||||
@@ -1474,6 +1474,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Offset Path looks up the segments near each candidate hole or island in a tree of the source path's segment bounds when it decides whether to drop that contour, instead of testing the bounds of every segment. The output paths are the same. On compound and open paths of 25,000 to 40,000 anchors with 6,400 to 11,976 candidate contours, that check takes 11 to 13 ms instead of 140 to 649 ms (medians of five interleaved runs). The limit on overlapping segment pairs is the same, so a path that stops with "Offset Path: the path is too complex to offset" still stops there.
|
||||
Finding the distinct regions of an arrangement lists the shapes that cover each region instead of keeping a flag for every shape, so that step's time follows how many shapes cover each region rather than how many shapes there are. Divide, Hard Mix, Soft Mix, Shape Builder, Flatten Transparency, trapping, Intertwine and the Pathfinder effects are among the commands that use that step. The regions, their order and the output are the same. Divide on 3,000 overlapping rectangles takes 0.84 to 1.13 billion CPU cycles on its thread instead of 1.25 to 1.46 billion (dev build, six runs of each, alternating, while other builds were running).
|
||||
The contour passes of the two-path boolean operations, Offset Path, compound shapes and Intertwine test the list of shapes that cover each face instead of a flag for every shape, so each test's time follows how many shapes cover the face rather than how many shapes there are. The contours, their order and the output are the same. The many-shape boolean that compound shapes use takes 0.75 to 0.83 billion CPU cycles on its thread instead of 1.28 to 1.33 billion to unite 10,000 squares that do not touch, and 2.27 to 2.30 billion instead of 2.43 to 2.47 billion for 1,500 members of two squares each over 3,000 overlapping squares (dev build, six runs of each, alternating, while other builds were running).
|
||||
Pathfinder › Unite and the other commands that unite filled paths sweep a single input once instead of twice when its own sweep neither splits nor drops any of its segments. The output is the same. Unite on one compound path of 4,000 rings takes 283 to 312 ms instead of 505 to 531 ms, and on one zigzag polygon of 20,000 teeth 39 to 50 ms instead of 67 to 72 ms (release build, medians of three alternating rounds of five runs).
|
||||
With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets.
|
||||
The renderer outlines the stroke of a path object with more than 1,024 path elements, without dashes or a width profile, only in and around the view, with a margin of the view's size on each side, and outlines it again when the view moves past that margin. The pixels are the same. At 1600 × 1200, the frame after one anchor of an open path of 200,000 anchors moves takes 28 to 30 ms instead of 103 ms, and with a closed path of 50,000 anchors 10 ms instead of 30 ms (`cargo run --release -p vectorcraft-engine --example anchor_bench`).
|
||||
The renderer outlines a dashed stroke of such a path the same way, without a width profile, and draws its dashes and dots in the same places as when the whole path is dashed, so the pixels are the same. A pattern too fine to draw is drawn solid, as before, and its stroke is outlined near the view too. At 1600 × 1200, the frame after one anchor moves takes 14 ms instead of 102 ms with a filled closed path of 50,000 anchors and a stroke of 6 pt dashes and 4 pt gaps (`cargo run --release -p vectorcraft-engine --example anchor_bench`, which now includes that document).
|
||||
|
||||
Reference in New Issue
Block a user