From 10909656d9bc650aaf7921b37a310675acd6ac86 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 13:20:20 -0800 Subject: [PATCH 1/2] 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. --- crates/pathops/src/boolean.rs | 186 ++++++++++++++++++++++++++++++---- docs/target-app-parity.md | 1 + 2 files changed, 167 insertions(+), 20 deletions(-) diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 7321626cd..75e1e46c6 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -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 = const { std::cell::Cell::new(0) }; + /// The sweeps [`normalize_bez`] made on this thread. + static NORMALIZE_SWEEPS: std::cell::Cell = 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 Result { + #[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 { - let mut all = BezPath::new(); - for (p, r) in paths { - let bp = fill_bezpath(p); + let bps: Vec = 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::>().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 = 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 { + 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::>().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 = 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 = (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::(), + 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() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2effc1142..9092a88b2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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). From 4060f14ce0f95b7bae8f9e350a00b3216290b226 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 16:19:01 -0800 Subject: [PATCH 2/2] Path ops: Unite sweeps a single path again when two segments lie within twice the sweep tolerance The second sweep of `unite_all` joins contours that lie within the sweep tolerance of each other, and the first sweep can leave such contours apart. `solid::tests::meets_within_twice_the_boolean_tolerance` needs that join. A single input now skips the second sweep only when no two of its segments lie within twice the tolerance. The check compares at most 512 pairs per segment and runs the second sweep past that. `unite_parts` tests the single-input case before it builds the combined path, and it takes `parts` and `inputs` by value so that both are freed before the second sweep. --- crates/pathops/src/boolean.rs | 214 +++++++++++++++++++++++++++++++--- docs/target-app-parity.md | 2 +- 2 files changed, 202 insertions(+), 14 deletions(-) diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 75e1e46c6..e12c9fb39 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -995,12 +995,13 @@ pub(crate) fn try_unite_all_upright(paths: &[(&PathData, FillRule)], precision: } parts.push((normalize_bez(bp, *r)?, bp.segments().count())); } - unite_parts(&parts, &bps, precision) + 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. +/// A single input whose own sweep split none of its segments, with no two of them within twice the +/// sweep tolerance, is not swept a second time. pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData { unite_all_with(paths, DEFAULT_PRECISION) } @@ -1027,16 +1028,29 @@ pub(crate) fn unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) let Ok(c) = normalize_bez(bp, *r) else { continue }; parts.push((c, bp.segments().count())); } - unite_parts(&parts, &bps, precision).unwrap_or_default() + 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 { +/// When there is one part, its sweep split none of the input's segments and none of them lies +/// within twice the sweep tolerance of another, 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, and contours that lie within +/// that distance of each other. +fn unite_parts(parts: Vec<(Contours, usize)>, inputs: Vec, precision: f64) -> Result { + if parts.is_empty() { + return Ok(PathData::default()); + } + let tidy = Tidy::keeping(precision, inputs.iter()); + if let [(c, input_segments)] = parts.as_slice() + && splits_nothing(c, *input_segments, &tidy) + && eps_for(inputs.iter()).is_ok_and(|eps| !comes_close(c, 2.0 * eps)) + { + return Ok(all_contours_to_path(c, &tidy)); + } + drop(inputs); let mut all = BezPath::new(); for (c, _) in parts { for ct in c.contours() { @@ -1046,12 +1060,6 @@ fn unite_parts(parts: &[(Contours, usize)], inputs: &[BezPath], precision: f64) if all.elements().is_empty() { return Ok(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)) } @@ -1071,6 +1079,122 @@ fn splits_nothing(c: &Contours, input_segments: usize, tidy: &Tidy) -> bool { n == input_segments } +/// The pairs of segments [`comes_close`] compares for each segment. Past it the answer is yes. +const MAX_CLOSE_PAIRS: usize = 512; + +/// One segment of a contour for [`comes_close`]: its control points' extents along and across the +/// side the segments are sorted on, and its place in its contour. +struct CloseSeg { + c: CubicBez, + line: bool, + lo: f64, + hi: f64, + across_lo: f64, + across_hi: f64, + contour: usize, + index: usize, + len: usize, +} + +impl CloseSeg { + fn new(seg: Seg, contour: usize, index: usize, len: usize) -> Self { + let pts = [seg.c.p0, seg.c.p1, seg.c.p2, seg.c.p3]; + let (lo, hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), p| (l.min(p.x), h.max(p.x))); + let (across_lo, across_hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), p| (l.min(p.y), h.max(p.y))); + Self { c: seg.c, line: seg.line, lo, hi, across_lo, across_hi, contour, index, len } + } +} + +/// Does a segment of the contours `c` lie within `touch` of another? A sweep can join such +/// segments or leave them apart depending on the order it meets them in, so a second sweep can +/// answer differently from the first. Segments whose control points can't be told apart from +/// `touch` count as close, which costs only that second sweep. +fn comes_close(c: &Contours, touch: f64) -> bool { + let mut segs = Vec::new(); + for (contour, ct) in c.contours().enumerate() { + let len = ct.path.segments().count(); + for (index, s) in ct.path.segments().enumerate() { + segs.push(CloseSeg::new(to_seg(s), contour, index, len)); + } + } + // Sort along the longer side of the whole, where fewer segments overlap. + let extent = |lo: fn(&CloseSeg) -> f64, hi: fn(&CloseSeg) -> f64| { + segs.iter().map(hi).fold(f64::NEG_INFINITY, f64::max) - segs.iter().map(lo).fold(f64::INFINITY, f64::min) + }; + if extent(|s| s.across_lo, |s| s.across_hi) > extent(|s| s.lo, |s| s.hi) { + for s in &mut segs { + std::mem::swap(&mut s.lo, &mut s.across_lo); + std::mem::swap(&mut s.hi, &mut s.across_hi); + } + } + segs.sort_by(|a, b| a.lo.total_cmp(&b.lo)); + let (mut pairs, budget) = (0usize, segs.len().saturating_mul(MAX_CLOSE_PAIRS)); + for (i, a) in segs.iter().enumerate() { + for b in segs.iter().skip(i + 1) { + if b.lo > a.hi + touch { + break; + } + pairs += 1; + if pairs > budget { + return true; + } + if a.across_lo > b.across_hi + touch || b.across_lo > a.across_hi + touch { + continue; + } + let neighbors = a.contour == b.contour && ((a.index + 1) % a.len == b.index || (b.index + 1) % b.len == a.index); + let close = if neighbors { neighbors_run_together(a, b, touch) } else { !hulls_apart(&a.c, &b.c, touch) }; + if close { + return true; + } + } + } + false +} + +/// The sine of the angle below which two segments leaving a shared end count as parallel. +const PARALLEL_SINE: f64 = 1e-3; + +/// Do segments `a` and `b`, which share an end, run along each other within `touch`? Straight +/// segments do when the far end of either lies within `touch` of the other. A curve does when the +/// two leave their shared end in the same direction, or when their extents overlap by more than +/// `touch` both ways. +fn neighbors_run_together(a: &CloseSeg, b: &CloseSeg, touch: f64) -> bool { + use kurbo::ParamCurveNearest; + if a.line && b.line { + let (shared, p, q) = if a.c.p3 == b.c.p0 { (a.c.p3, a.c.p0, b.c.p3) } else { (a.c.p0, a.c.p3, b.c.p0) }; + let near = |p: Point, q: Point| kurbo::Line::new(shared, q).nearest(p, 0.0).distance_sq.sqrt() <= touch; + return near(q, p) || near(p, q); + } + let (from_a, from_b) = if a.c.p3 == b.c.p0 { + ([a.c.p3, a.c.p2, a.c.p1, a.c.p0], [b.c.p0, b.c.p1, b.c.p2, b.c.p3]) + } else { + ([a.c.p0, a.c.p1, a.c.p2, a.c.p3], [b.c.p3, b.c.p2, b.c.p1, b.c.p0]) + }; + let away = |pts: [Point; 4]| pts.iter().skip(1).map(|p| *p - pts[0]).find(|v| v.hypot2() > 0.0); + if let (Some(u), Some(v)) = (away(from_a), away(from_b)) + && u.dot(v) > 0.0 + && u.cross(v).abs() <= PARALLEL_SINE * u.hypot() * v.hypot() + { + return true; + } + a.hi.min(b.hi) - a.lo.max(b.lo) > touch && a.across_hi.min(b.across_hi) - a.across_lo.max(b.across_lo) > touch +} + +/// Is there a direction, along or across a side of either segment's control polygon or chord, in +/// which the control points of `a` and `b` lie more than `touch` apart? +fn hulls_apart(a: &CubicBez, b: &CubicBez, touch: f64) -> bool { + let (pa, pb) = ([a.p0, a.p1, a.p2, a.p3], [b.p0, b.p1, b.p2, b.p3]); + let apart = |d: kurbo::Vec2| { + let span = + |pts: &[Point; 4]| pts.iter().map(|p| p.to_vec2().dot(d)).fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), v| (l.min(v), h.max(v))); + let ((la, ha), (lb, hb)) = (span(&pa), span(&pb)); + let gap = touch * d.hypot(); + gap > 0.0 && (lb - ha > gap || la - hb > gap) + }; + let sides = |p: [Point; 4]| [p[3] - p[0], p[1] - p[0], p[2] - p[1], p[3] - p[2]]; + sides(pa).into_iter().chain(sides(pb)).any(|d| apart(d) || apart(kurbo::Vec2::new(-d.y, d.x))) +} + /// [`area`] that reports a refused input instead of an area of zero. pub fn try_area(path: &PathData, rule: FillRule) -> Result { let c = normalize_bez(&fill_bezpath(path), rule)?; @@ -1388,6 +1512,70 @@ mod tests { PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)) } + /// A sweep can leave segments within its tolerance apart where a second sweep joins them. + #[test] + fn a_lone_input_with_segments_within_the_tolerance_is_swept_again() { + use vectorcraft_geom::SubPath; + let triangles = |gap: f64, lift: f64| { + PathData::new(vec![ + SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true), + SubPath::polyline(&[Point::new(10.0 + gap, lift), Point::new(10.0 + gap, 10.0 + lift), Point::new(gap, 10.0 + lift)], true), + ]) + }; + let mut bp = BezPath::new(); + bp.extend(kurbo::Circle::new((0.0, 0.0), 10.0).path_elements(0.1)); + bp.extend(kurbo::Circle::new((20.000001, 0.0), 10.0).path_elements(0.1)); + let circles = PathData::from_bezpath(&bp); + for (name, path, joined) in + [("snapped", triangles(0.99e-6, 0.99e-6), Some(1)), ("same heights", triangles(0.9e-6, 0.0), Some(1)), ("circles", circles, None)] + { + let want = unite_swept_twice(&[(&path, FillRule::NonZero)], DEFAULT_PRECISION); + if let Some(n) = joined { + assert_eq!(want.subpaths.len(), n, "{name}"); + } + for (got, sweeps) in unite_counting_sweeps(&[(&path, FillRule::NonZero)]) { + assert_eq!(sweeps, 2, "{name}"); + assert_eq!(got, want, "{name}"); + } + } + // Farther apart than that, the sweep's own contours stand. + let apart = triangles(1e-5, 0.0); + let want = unite_swept_twice(&[(&apart, FillRule::NonZero)], DEFAULT_PRECISION); + assert_eq!(want.subpaths.len(), 2); + for (got, sweeps) in unite_counting_sweeps(&[(&apart, FillRule::NonZero)]) { + assert_eq!(sweeps, 1); + assert_eq!(got, want); + } + } + + #[test] + fn a_lone_part_beside_a_refused_input_keeps_its_union() { + use vectorcraft_geom::SubPath; + let polygon = |pts: &[(f64, f64)]| PathData::single(SubPath::polyline(&pts.iter().map(|p| Point::new(p.0, p.1)).collect::>(), true)); + let square = polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]); + let refused = polygon(&[(f64::NAN, 0.0), (1.0, 1.0), (0.0, 1.0)]); + let want = unite_swept_twice(&[(&square, FillRule::NonZero)], DEFAULT_PRECISION); + assert_eq!(unite_all(&[(&square, FillRule::NonZero), (&refused, FillRule::NonZero)]), want); + } + + #[test] + fn neighboring_segments_run_together_when_one_lies_along_the_other() { + let line = |a: (f64, f64), b: (f64, f64)| CloseSeg::new(Seg::line(a.into(), b.into()), 0, 0, 2); + let curve = |p: [(f64, f64); 4]| CloseSeg::new(Seg { c: CubicBez::new(p[0], p[1], p[2], p[3]), line: false }, 0, 0, 2); + let touch = 2e-6; + // The two sides of a needle's tip, and an ordinary corner. + assert!(neighbors_run_together(&line((10.0, 10.0), (30.0, 30.0)), &line((30.0, 30.0), (10.0, 10.000001)), touch)); + assert!(!neighbors_run_together(&line((0.0, 0.0), (10.0, 0.0)), &line((10.0, 0.0), (10.0, 10.0)), touch)); + // Curves that fold back over each other, and two quarters of a circle. + let out = curve([(0.0, 0.0), (5.0, 8.0), (10.0, 8.0), (20.0, 0.0)]); + assert!(neighbors_run_together(&out, &curve([(20.0, 0.0), (10.0, 6.0), (5.0, 6.0), (0.0, 1.0)]), touch)); + // A curved hairpin that lies within the tolerance along its length. + let there = curve([(0.0, 0.0), (5.0, 1e-7), (15.0, 1e-7), (20.0, 0.0)]); + assert!(neighbors_run_together(&there, &curve([(20.0, 0.0), (15.0, 2e-7), (5.0, 2e-7), (0.0, 1e-6)]), touch)); + let quarter = curve([(10.0, 0.0), (10.0, 5.5), (5.5, 10.0), (0.0, 10.0)]); + assert!(!neighbors_run_together(&quarter, &curve([(0.0, 10.0), (-5.5, 10.0), (-10.0, 5.5), (-10.0, 0.0)]), touch)); + } + #[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)] { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9092a88b2..1ac0b06e5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1474,7 +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). + 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 and no two of its segments lie within twice the sweep tolerance. The output is the same. Unite on one compound path of 4,000 rings takes 485 to 542 ms instead of 872 to 1,008 ms, and on one zigzag polygon of 20,000 teeth 70 to 76 ms instead of 118 to 130 ms (release build, medians of three alternating rounds of five runs, while other builds were running). 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).