diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 6fa499467..3a9aaaec8 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -255,12 +255,60 @@ pub(crate) struct Arrangement { pub(crate) struct Tidy { pub(crate) precision: f64, keep: Vec, + /// `keep` by y, sorted on first use. + keep_by_y: std::sync::OnceLock>, /// Input curves (with inflated bounds) used to rebuild split pieces exactly. curves: Vec<(kurbo::Rect, CubicBez)>, /// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose /// bounds can hold a point, without testing them all. by_x: Vec, max_width: f64, + /// The top edge of their bounds and their index, in order of that edge, with the tallest + /// bounds, made on first use. + by_y: std::sync::OnceLock<(Vec<(f64, usize)>, f64)>, + /// The wide lookups in `curves` and in `keep` so far. + curve_lookups: WideLookups, + point_lookups: WideLookups, +} + +/// The lookups in one list of [`Tidy`] with more than [`WIDE_WINDOW`] candidates along x: the +/// candidates they counted, a score that rises by [`Y_WIN`] when the window along y was the one +/// used and falls by one when it was not (within [`Y_SCORE_LIMIT`] either way), and the lookups +/// made while the score was below zero. +#[derive(Debug, Default)] +struct WideLookups { + scanned: std::sync::atomic::AtomicUsize, + score: std::sync::atomic::AtomicIsize, + below: std::sync::atomic::AtomicUsize, +} + +impl Clone for WideLookups { + fn clone(&self) -> Self { + let get = |n: &std::sync::atomic::AtomicUsize| n.load(std::sync::atomic::Ordering::Relaxed).into(); + Self { scanned: get(&self.scanned), score: self.score().into(), below: get(&self.below) } + } +} + +impl WideLookups { + fn score(&self) -> isize { + self.score.load(std::sync::atomic::Ordering::Relaxed) + } + /// Does a lookup with `window` candidates along x measure the window along y? It does past + /// [`WIDE_WINDOW`] candidates, once wide lookups have scanned [`SORT_COST`] candidates for each + /// of the `len` items that the sort along y orders, while the score is zero or more, and in + /// one of every [`Y_RETRY`] lookups while it is below. + fn search_y(&self, window: usize, len: usize) -> bool { + use std::sync::atomic::Ordering::Relaxed; + window > WIDE_WINDOW + && self.scanned.fetch_add(window, Relaxed) + window >= len.saturating_mul(SORT_COST) + && (self.score() >= 0 || self.below.fetch_add(1, Relaxed) % Y_RETRY == Y_RETRY - 1) + } + /// Counts a lookup that measured along y, and whether that window was the one used. + fn used_y(&self, used: bool) { + let step = if used { Y_WIN } else { -1 }; + let next = (self.score() + step).clamp(-Y_SCORE_LIMIT, Y_SCORE_LIMIT); + self.score.store(next, std::sync::atomic::Ordering::Relaxed); + } } impl Tidy { @@ -286,12 +334,46 @@ impl Tidy { let mut by_x: Vec = (0..curves.len()).collect(); by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0)); let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max); - Self { precision, keep, curves, by_x, max_width } + Self { precision, keep, curves, by_x, max_width, ..Default::default() } } + fn keep_by_y(&self) -> &[Point] { + self.keep_by_y.get_or_init(|| { + let mut keep = self.keep.clone(); + keep.sort_unstable_by(|a, b| a.y.total_cmp(&b.y)); + keep + }) + } + fn by_y(&self) -> (&[(f64, usize)], f64) { + let (by_y, max_height) = self.by_y.get_or_init(|| { + let mut by_y: Vec<(f64, usize)> = self.curves.iter().enumerate().map(|(i, (r, _))| (r.y0, i)).collect(); + by_y.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); + (by_y, self.curves.iter().map(|(r, _)| r.height()).fold(0.0, f64::max)) + }); + (by_y, *max_height) + } + /// Is a kept point within the tolerance of `p`? The points within it along x are tested, or + /// those along y when [`WideLookups::search_y`] holds and there are fewer along y. fn is_kept(&self, p: Point) -> bool { let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); let lo = self.keep.partition_point(|q| q.x < p.x - tol); - self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol) + // Usually one or two points lie within the tolerance along x; count them one by one up to + // the wide window, and search past it. + let rest = &self.keep[lo..]; + let mut within = rest.iter().take(WIDE_WINDOW + 1).take_while(|q| q.x <= p.x + tol).count(); + if within > WIDE_WINDOW { + within = rest.partition_point(|q| q.x <= p.x + tol); + } + let hi = lo + within; + if self.point_lookups.search_y(hi - lo, self.keep.len()) { + let keep_by_y = self.keep_by_y(); + let lo_y = keep_by_y.partition_point(|q| q.y < p.y - tol); + let hi_y = lo_y + keep_by_y[lo_y..].partition_point(|q| q.y <= p.y + tol); + self.point_lookups.used_y(hi_y - lo_y < hi - lo); + if hi_y - lo_y < hi - lo { + return keep_by_y[lo_y..hi_y].iter().any(|q| (q.x - p.x).abs() <= tol); + } + } + self.keep[lo..hi].iter().any(|q| (q.y - p.y).abs() <= tol) } } @@ -579,13 +661,52 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option { None } +/// Past this many candidates along x, a lookup in [`Tidy`] adds them to the count of scanned ones. +const WIDE_WINDOW: usize = 64; + +/// The candidates scanned per item of a list before [`Tidy`] sorts that list along y: about the +/// comparisons per item of sorting 100,000 items. +const SORT_COST: usize = 16; + +/// How much a lookup that used the window along y raises the score in [`WideLookups`]. +const Y_WIN: isize = 4; + +/// The highest and the lowest score in [`WideLookups`] (negated). +const Y_SCORE_LIMIT: isize = 256; + +/// While the score in [`WideLookups`] is below zero, one lookup in this many measures along y. +const Y_RETRY: usize = 32; + impl Tidy { - /// The input curves whose bounds hold both `a` and `b`. + /// The input curves whose bounds hold both `a` and `b`, in the order of `by_x`. The candidates + /// are the curves whose bounds start within the widest bounds' reach of the points along x, or + /// the same along y when [`WideLookups::search_y`] holds and there are under a quarter as many + /// along y. fn curves_around(&self, a: Point, b: Point) -> impl Iterator { + let holds = move |(r, _): &&(kurbo::Rect, CubicBez)| r.contains(a) && r.contains(b); let x = a.x.min(b.x); let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x); let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width); - self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c) + let along_y = self.curve_lookups.search_y(hi - lo, self.curves.len()).then(|| { + let (by_y, max_height) = self.by_y(); + let y = a.y.min(b.y); + let hi_y = by_y.partition_point(|&(y0, _)| y0 <= y); + let lo_y = by_y[..hi_y].partition_point(|&(y0, _)| y0 < y - max_height); + &by_y[lo_y..hi_y] + }); + let along_y = along_y.filter(|ys| { + let used = ys.len().saturating_mul(4) < hi - lo; + self.curve_lookups.used_y(used); + used + }); + let along_y = along_y.map(|ys| { + let mut found: Vec<(usize, &(kurbo::Rect, CubicBez))> = ys.iter().map(|&(_, i)| (i, &self.curves[i])).filter(|(_, c)| holds(c)).collect(); + // `by_x` was sorted stably from index order, so this puts them in its order. + found.sort_by(|(i, p), (j, q)| p.0.x0.total_cmp(&q.0.x0).then(i.cmp(j))); + found.into_iter().map(|(_, c)| c) + }); + let along_x = along_y.is_none().then(|| self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(holds)); + along_x.into_iter().flatten().chain(along_y.into_iter().flatten()).map(|(_, c)| c) } /// `bp` with each curve piece that strays from the one input curve through both its ends @@ -821,6 +942,17 @@ pub fn try_unite_all(paths: &[(&PathData, FillRule)]) -> Result Result { + match crate::sweep_axis::turned_fills(paths) { + Some(turned) => { + let refs: Vec<(&PathData, FillRule)> = turned.iter().map(|(p, r)| (p, *r)).collect(); + try_unite_all_upright(&refs, precision).map(crate::sweep_axis::unturn) + } + None => try_unite_all_upright(paths, precision), + } +} + +/// [`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); @@ -846,6 +978,17 @@ pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData { /// [`unite_all`] refit to `precision`, dropping slivers thinner than it. pub fn unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> PathData { + match crate::sweep_axis::turned_fills(paths) { + Some(turned) => { + let refs: Vec<(&PathData, FillRule)> = turned.iter().map(|(p, r)| (p, *r)).collect(); + crate::sweep_axis::unturn(unite_all_upright(&refs, precision)) + } + None => unite_all_upright(paths, precision), + } +} + +/// [`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); @@ -920,6 +1063,108 @@ mod tests { assert_eq!(tidy.repair(&on), on); } + #[test] + fn input_curves_and_kept_points_are_found_in_wide_and_tall_art() { + // Rows of short curves across the page, and the same stood on end: one lookup has a + // narrow window along x, the other along y. + let mut wide = BezPath::new(); + for row in 0..4 { + let y = row as f64 * 3.0; + wide.move_to((0.0, y)); + for k in 0..200 { + let x = k as f64; + wide.curve_to((x + 0.3, y + 1.0), (x + 0.7, y + 1.0), (x + 1.0, y)); + } + } + let tall = kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]) * &wide; + for art in [&wide, &tall] { + let tidy = Tidy::keeping(DEFAULT_PRECISION, [art]); + let mut sorted: Vec = (0..tidy.curves.len()).collect(); + sorted.sort_by(|&i, &j| tidy.curves[i].0.x0.total_cmp(&tidy.curves[j].0.x0).then(i.cmp(&j))); + for (_, c) in tidy.curves.iter().step_by(7) { + use kurbo::ParamCurve; + // A curve's start point is also held by the curve before it, whose bounds start a + // whole curve further back. + for (a, b) in [(c.p0, c.p3), (c.p0, c.p0), (c.eval(0.25), c.eval(0.75)), (c.p0, c.p0 + kurbo::Vec2::new(2.5, 2.5))] { + let by_hand: Vec<&CubicBez> = + sorted.iter().map(|&i| &tidy.curves[i]).filter(|(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c).collect(); + assert_eq!(tidy.curves_around(a, b).collect::>(), by_hand, "{a:?} {b:?}"); + } + for p in [c.p0, c.p0 + kurbo::Vec2::new(1e-7, -1e-7), c.p0 + kurbo::Vec2::new(0.0, 1e-3), c.p0 + kurbo::Vec2::new(1e-3, 0.0)] { + let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); + let by_hand = tidy.keep.iter().any(|q| (q.x - p.x).abs() <= tol && (q.y - p.y).abs() <= tol); + assert_eq!(tidy.is_kept(p), by_hand, "{p:?}"); + } + } + // The tall art's lookups reach the sorted lists along y; the wide art's never do. + let tall_art = std::ptr::eq(art, &tall); + assert_eq!(tidy.by_y.get().is_some(), tall_art); + assert_eq!(tidy.keep_by_y.get().is_some(), tall_art); + } + } + + #[test] + fn a_few_wide_lookups_do_not_sort_along_y() { + let mut tall = BezPath::new(); + for x in 0..2 { + let x = f64::from(x) * 3.0; + tall.move_to((x, 0.0)); + for k in 0..200 { + let y = f64::from(k); + tall.curve_to((x + 1.0, y + 0.3), (x + 1.0, y + 0.7), (x, y + 1.0)); + } + } + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&tall]); + let a = Point::new(0.0, 50.0); + let found = |tidy: &Tidy| tidy.curves_around(a, a).count(); + // Bounds at x = 0: the column's 200 curves are candidates along x, and two of them hold `a`. + assert_eq!(found(&tidy), 2); + assert!(tidy.is_kept(a)); + assert!(tidy.by_y.get().is_none()); + assert!(tidy.keep_by_y.get().is_none()); + // Enough of them to pay for the sort, and the answers stay the same. + let scanned = |l: &WideLookups| l.scanned.load(std::sync::atomic::Ordering::Relaxed); + while scanned(&tidy.curve_lookups) < SORT_COST * tidy.curves.len() || scanned(&tidy.point_lookups) < SORT_COST * tidy.keep.len() { + assert_eq!(found(&tidy), 2); + assert!(tidy.is_kept(a)); + } + assert_eq!(found(&tidy), 2); + assert!(tidy.is_kept(a)); + assert!(tidy.by_y.get().is_some()); + assert!(tidy.keep_by_y.get().is_some()); + // Along y the windows are narrow, so the lookups keep measuring there. + assert!(tidy.curve_lookups.score() > 0 && tidy.point_lookups.score() > 0); + } + + #[test] + fn lookups_measure_along_y_rarely_where_it_narrows_nothing() { + // A square grid of short curves: about as many candidates lie along y as along x. + let mut grid = BezPath::new(); + for row in 0..100 { + let y = f64::from(row) * 2.0; + grid.move_to((0.0, y)); + for k in 0..100 { + let x = f64::from(k); + grid.curve_to((x + 0.3, y + 0.5), (x + 0.7, y + 0.5), (x + 1.0, y)); + } + } + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&grid]); + let lookups: u32 = 20_000; + for k in 0..lookups { + let a = Point::new(f64::from(k % 97) + 0.5, f64::from(k % 89) * 2.0 + 0.2); + let found: Vec<&CubicBez> = tidy.curves_around(a, a).collect(); + if k % 500 == 0 { + let by_hand: Vec<&CubicBez> = tidy.by_x.iter().map(|&i| &tidy.curves[i]).filter(|(r, _)| r.contains(a)).map(|(_, c)| c).collect(); + assert_eq!(found, by_hand, "{a:?}"); + } + } + // The window along y was measured until the score fell below zero, then once in Y_RETRY lookups. + assert!(tidy.by_y.get().is_some()); + assert_eq!(tidy.curve_lookups.score(), -Y_SCORE_LIMIT); + let below = tidy.curve_lookups.below.load(std::sync::atomic::Ordering::Relaxed); + assert!(below > lookups as usize / 2, "{below} of {lookups} lookups below zero"); + } + #[test] fn a_lens_between_two_curves_through_the_same_points_stays() { // Two input curves meeting at both ends bound a real face: neither piece is "repaired" diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 3d1b17fef..7f55e44b2 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -14,6 +14,7 @@ mod fit; mod offset; mod pathfinder; mod planar; +mod sweep_axis; mod trap; pub use boolean::{ diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 2c0db1f2a..01c3d03cb 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -85,6 +85,14 @@ pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_l /// The area covered by stroke outlines (each filled with the non-zero rule, open subpaths /// closed) as one clean path: overlaps removed and the stroker's pieces refitted. pub fn stroke_region(outlines: &[BezPath]) -> PathData { + match crate::sweep_axis::turned_outlines(outlines) { + Some(turned) => crate::sweep_axis::unturn(stroke_region_upright(&turned)), + None => stroke_region_upright(outlines), + } +} + +/// [`stroke_region`] with the sweep run on `outlines` as they are. +pub(crate) fn stroke_region_upright(outlines: &[BezPath]) -> PathData { let tidy = Tidy::free(DEFAULT_PRECISION); let parts: Vec = outlines.iter().map(close_all).filter(|o| !o.elements().is_empty()).collect(); if let [one] = &parts[..] { diff --git a/crates/pathops/src/sweep_axis.rs b/crates/pathops/src/sweep_axis.rs new file mode 100644 index 000000000..364325afc --- /dev/null +++ b/crates/pathops/src/sweep_axis.rs @@ -0,0 +1,393 @@ +//! Which way the overlap sweep runs. `linesweeper` moves a horizontal line down the page, and the +//! time it spends at each event grows with the number of segments on that line at the time. A long, +//! low path (rows of a zigzag, a stroked line of type) can put thousands of segments on the line at +//! once, where the same path turned a quarter turn puts far fewer on it. [`turned_outlines`] and +//! [`turned_fills`] estimate both directions before the sweep; when the turned one is clearly +//! cheaper they return the input turned, the caller runs its operation on that, and [`unturn`] +//! turns the result back. A quarter turn swaps and negates coordinates, so no point moves by +//! rounding. + +use kurbo::{Affine, BezPath, PathSeg, Rect}; +use vectorcraft_geom::{Anchor, FillRule, PathData, SubPath}; + +/// Inputs with fewer segments than this are always swept as they are. +const MIN_SEGMENTS: usize = 2048; + +/// The turned direction is used when its estimate is below the upright one divided by this. +const MIN_GAIN: f64 = 1.5; + +/// The most segments the estimate measures. +const MAX_SAMPLE: usize = 4096; + +/// (x, y) to (−y, x). +const TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); + +/// (x, y) to (y, −x): the inverse of [`TURN`]. +const UNTURN: Affine = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); + +#[cfg(test)] +thread_local! { + /// The inputs turned on this thread. + pub(crate) static TURNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// For each of the spans from `starts[i]` to `ends[i]`, the number of spans that overlap it +/// (touching counts, itself included), summed: the segments a sweep across these extents has on +/// its line while each one is there. Sorts both lists. +fn load(starts: &mut [f64], ends: &mut [f64]) -> u64 { + starts.sort_unstable_by(f64::total_cmp); + ends.sort_unstable_by(f64::total_cmp); + // Spans that start at or before each end, less spans that end before each start: every span + // that ends before a start also starts before it, so it was counted in the first sum. + let (mut begun, mut i) = (0u64, 0usize); + for &e in ends.iter() { + while starts.get(i).is_some_and(|&s| s <= e) { + i += 1; + } + begun += i as u64; + } + let (mut over, mut j) = (0u64, 0usize); + for &s in starts.iter() { + while ends.get(j).is_some_and(|&e| e < s) { + j += 1; + } + over += j as u64; + } + begun.saturating_sub(over) +} + +/// The segments of `outlines`. +fn outline_segments(outlines: &[BezPath]) -> impl Iterator + '_ { + outlines.iter().flat_map(BezPath::segments) +} + +/// The box of a segment's control points. +fn segment_box(s: PathSeg) -> Rect { + match s { + PathSeg::Line(l) => Rect::from_points(l.p0, l.p1), + PathSeg::Quad(q) => Rect::from_points(q.p0, q.p2).union_pt(q.p1), + PathSeg::Cubic(c) => Rect::from_points(c.p0, c.p3).union_pt(c.p1).union_pt(c.p2), + } +} + +/// The subpaths of `paths` that have a fill: those of two or more anchors. +fn filled_subpaths<'a>(paths: &'a [(&PathData, FillRule)]) -> impl Iterator + 'a { + paths.iter().flat_map(|(p, _)| &p.subpaths).filter(|sp| sp.anchors.len() >= 2) +} + +/// The start and end anchor of each segment in the fills of `paths`, every subpath of two or more +/// anchors closed, as [`fill_bezpath`](crate::boolean::fill_bezpath) builds them. +fn fill_segments<'a>(paths: &'a [(&PathData, FillRule)]) -> impl Iterator + 'a { + filled_subpaths(paths).flat_map(|sp| { + let n = sp.anchors.len(); + sp.anchors.iter().enumerate().map(move |(i, a)| (a, &sp.anchors[(i + 1) % n])) + }) +} + +/// The box of the control points of the fill segment from `a` to `b`. +fn fill_segment_box((a, b): (&Anchor, &Anchor)) -> Rect { + Rect::from_points(a.p, b.p).union_pt(a.h_out).union_pt(b.h_in) +} + +/// Is the sweep over the `count` `segments`, each with the box `bbox` gives it, clearly cheaper +/// turned a quarter turn? Past [`MAX_SAMPLE`] segments, one segment from each run of +/// `count / MAX_SAMPLE` (rounded up) stands for the run, at a position within it that changes from +/// run to run, and only those segments' boxes are built. +fn turn_pays(count: usize, segments: impl Iterator, bbox: impl Fn(T) -> Rect) -> bool { + if count < MIN_SEGMENTS { + return false; + } + let stride = count.div_ceil(MAX_SAMPLE); + let mut pick = 0; + let boxes: Vec = segments + .enumerate() + .filter(|&(i, _)| { + if i % stride == 0 { + pick = sample_offset(i / stride, stride); + } + i % stride == pick + }) + .map(|(_, s)| bbox(s)) + .collect(); + if !boxes.iter().all(Rect::is_finite) { + return false; + } + let (mut x0, mut x1): (Vec, Vec) = boxes.iter().map(|b| (b.x0, b.x1)).unzip(); + let (mut y0, mut y1): (Vec, Vec) = boxes.iter().map(|b| (b.y0, b.y1)).unzip(); + let pays = load(&mut x0, &mut x1) as f64 * MIN_GAIN < load(&mut y0, &mut y1) as f64; + #[cfg(test)] + if pays { + TURNS.with(|n| n.set(n.get() + 1)); + } + pays +} + +/// The position, below `stride`, of the segment measured in run `run`: a hash of `run` (the +/// SplitMix64 finalizer), so input that repeats every few segments is measured at every position. +fn sample_offset(run: usize, stride: usize) -> usize { + let mut z = (run as u64).wrapping_add(0x9e37_79b9_7f4a_7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + ((z ^ (z >> 31)) % stride as u64) as usize +} + +/// `outlines` turned a quarter turn, when sweeping them that way is clearly cheaper. +pub(crate) fn turned_outlines(outlines: &[BezPath]) -> Option> { + let count = outlines.iter().map(|o| o.segments().count()).sum(); + turn_pays(count, outline_segments(outlines), segment_box).then(|| outlines.iter().map(|o| TURN * o).collect()) +} + +/// `paths` turned a quarter turn, when sweeping their fills that way is clearly cheaper. +pub(crate) fn turned_fills(paths: &[(&PathData, FillRule)]) -> Option> { + let count = filled_subpaths(paths).map(|sp| sp.anchors.len()).sum(); + turn_pays(count, fill_segments(paths), fill_segment_box).then(|| paths.iter().map(|(p, r)| (p.transformed(TURN), *r)).collect()) +} + +/// A result computed on turned input, turned back. +pub(crate) fn unturn(mut path: PathData) -> PathData { + path.transform(UNTURN); + path +} + +#[cfg(test)] +mod tests { + use kurbo::{Join, Stroke, StrokeOpts}; + use vectorcraft_geom::{Anchor, Point, Vec2}; + + use super::*; + use crate::boolean::{BoolOp, DEFAULT_PRECISION, fill_bezpath, try_area, try_boolean, try_unite_all_upright, unite_all_upright}; + use crate::offset::stroke_region_upright; + use crate::{stroke_region, try_unite_all_with, unite_all_with}; + + /// A small xorshift generator, so the random cases are the same on every run. + struct Rng(u64); + + impl Rng { + fn unit(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + } + + /// `rows` rows of a zigzag `cols` anchors long, each anchor moved by up to `jitter`: a long, low path. + fn zigzag(rows: usize, cols: usize, jitter: f64, rng: &mut Rng, closed: bool) -> PathData { + let mut anchors = Vec::new(); + for row in 0..rows { + for col in 0..cols { + let c = if row % 2 == 0 { col } else { cols - 1 - col }; + let x = c as f64 * 1.6 + (rng.unit() - 0.5) * jitter; + let y = row as f64 * 6.0 + if col % 2 == 0 { 0.0 } else { 2.0 } + (rng.unit() - 0.5) * jitter; + let p = Point::new(x, y); + let dir = if row % 2 == 0 { 0.5 } else { -0.5 }; + anchors.push(Anchor::smooth(p, p + Vec2::new(dir, 0.0))); + } + } + PathData::single(SubPath::new(anchors, closed)) + } + + /// The same zigzag stood on end: a tall, narrow path. + fn upright(p: &PathData) -> PathData { + p.transformed(Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0])) + } + + /// A unit square with its lower left corner at (`x`, `y`). + fn square(x: f64, y: f64) -> PathData { + let corners = [(x, y), (x + 1.0, y), (x + 1.0, y + 1.0), (x, y + 1.0)]; + PathData::single(SubPath::new(corners.map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), true)) + } + + fn outline(p: &PathData) -> BezPath { + kurbo::stroke(p.to_bezpath().iter(), &Stroke::new(1.0).with_join(Join::Round), &StrokeOpts::default(), 1e-3) + } + + fn segments(p: &BezPath) -> usize { + p.segments().count() + } + + fn turns_during(f: impl FnOnce()) -> usize { + let before = TURNS.with(std::cell::Cell::get); + f(); + TURNS.with(std::cell::Cell::get) - before + } + + /// `a` and `b` have the same pieces and cover the same area to within the refit precision: the + /// area in one and not the other is less than a band [`DEFAULT_PRECISION`] wide along the outline. + fn assert_same_region(a: &PathData, b: &PathData, what: &str) { + use kurbo::Shape; + assert_eq!(a.subpaths.len(), b.subpaths.len(), "{what}: subpaths"); + let band = DEFAULT_PRECISION * a.to_bezpath().perimeter(1e-3); + let area_a = try_area(a, FillRule::NonZero).unwrap(); + let area_b = try_area(b, FillRule::NonZero).unwrap(); + assert!((area_a - area_b).abs() <= band, "{what}: area {area_a} against {area_b}, band {band}"); + let apart = try_boolean(a, FillRule::NonZero, b, FillRule::NonZero, BoolOp::Xor, DEFAULT_PRECISION).unwrap(); + let apart = try_area(&apart, FillRule::NonZero).unwrap(); + assert!(apart <= band, "{what}: {apart} of {area_a} differs, band {band}"); + } + + #[test] + fn a_long_low_path_is_swept_turned_and_a_tall_or_small_one_as_it_is() { + let mut rng = Rng(7); + let low = zigzag(3, 300, 0.0, &mut rng, false); + let stroke = outline(&low); + // More segments than the estimate measures, so it decides from a sample. + assert!(segments(&stroke) > MAX_SAMPLE, "{} segments", segments(&stroke)); + assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&stroke)))), 1); + let fill = zigzag(3, 800, 0.0, &mut rng, true); + assert_eq!(turns_during(|| drop(unite_all_with(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION))), 1); + assert_eq!(turns_during(|| drop(try_unite_all_with(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION))), 1); + // Stood on end, the same paths are already narrow across the sweep. + let tall = outline(&upright(&low)); + assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&tall)))), 0); + assert_eq!(turns_during(|| drop(unite_all_with(&[(&upright(&fill), FillRule::NonZero)], DEFAULT_PRECISION))), 0); + // A square grid costs the same either way. + let grid: Vec = (0..900).map(|i| square((i % 30) as f64 * 3.0, (i / 30) as f64 * 3.0)).collect(); + let grid: Vec<(&PathData, FillRule)> = grid.iter().map(|p| (p, FillRule::NonZero)).collect(); + assert_eq!(turns_during(|| drop(unite_all_with(&grid, DEFAULT_PRECISION))), 0); + // Below the size where the sweep costs much, nothing is turned. + let short = outline(&zigzag(1, 40, 0.0, &mut rng, false)); + assert!(segments(&short) < MIN_SEGMENTS); + assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&short)))), 0); + } + + #[test] + fn a_repeating_path_is_measured_at_every_position() { + // Short segments in a row along the bottom, with a long one stacked above the row after + // every `period - 1` of them. The short ones alone are cheaper to sweep turned; with the + // long ones, the path is cheaper upright. + let add = |path: &mut BezPath, a: (f64, f64), b: (f64, f64)| { + path.move_to(a); + path.line_to(b); + }; + for period in [2, 4] { + let n = MAX_SAMPLE; + let width = (n * period) as f64 * 3.0; + let mut both = BezPath::new(); + let mut shorts = BezPath::new(); + for i in 0..n { + for k in 0..period - 1 { + let x = (i * period + k) as f64 * 3.0; + add(&mut both, (x, 0.0), (x + 1.0, 0.0)); + add(&mut shorts, (x, 0.0), (x + 1.0, 0.0)); + } + add(&mut both, (0.0, 10.0 + i as f64), (width, 10.0 + i as f64)); + } + assert_eq!(segments(&both), period * MAX_SAMPLE, "period {period}"); + assert!(turned_outlines(&[shorts]).is_some(), "period {period}: shorts"); + assert!(turned_outlines(&[both]).is_none(), "period {period}: both"); + } + } + + #[test] + fn the_load_counts_every_overlapping_pair() { + let mut rng = Rng(5); + for n in [0, 1, 2, 7, 60] { + // Coarse values, so many spans share ends and touch. + let spans: Vec<(f64, f64)> = (0..n) + .map(|_| { + let a = (rng.unit() * 10.0).round(); + let b = (rng.unit() * 10.0).round(); + (a.min(b), a.max(b)) + }) + .collect(); + let by_hand = spans.iter().map(|&(lo, hi)| spans.iter().filter(|&&(s, e)| s <= hi && e >= lo).count() as u64).sum::(); + let (mut starts, mut ends): (Vec, Vec) = spans.iter().copied().unzip(); + assert_eq!(load(&mut starts, &mut ends), by_hand, "{n} spans"); + } + } + + #[test] + fn fill_boxes_are_the_boxes_of_the_fills_segments() { + let mut rng = Rng(9); + let open = zigzag(2, 30, 1.0, &mut rng, false); + let closed = zigzag(1, 20, 1.0, &mut rng, true); + let lone = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(4.0, 4.0))], false)); + let paths = [(&open, FillRule::NonZero), (&closed, FillRule::EvenOdd), (&lone, FillRule::NonZero)]; + let fills: Vec = paths.iter().map(|(p, _)| fill_bezpath(p)).collect(); + assert_eq!(fill_segments(&paths).map(fill_segment_box).collect::>(), outline_segments(&fills).map(segment_box).collect::>()); + assert_eq!(filled_subpaths(&paths).map(|sp| sp.anchors.len()).sum::(), fills.iter().map(|f| f.segments().count()).sum::()); + } + + #[test] + fn turning_and_turning_back_moves_no_point() { + let mut rng = Rng(11); + let p = zigzag(2, 50, 3.0, &mut rng, true); + assert_eq!(unturn(p.transformed(TURN)), p); + let bp = outline(&p); + assert_eq!(UNTURN * (TURN * &bp), bp); + } + + #[test] + fn turned_results_match_the_upright_sweep_on_random_paths() { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + for case in 0..3 { + let rows = 2 + case; + let low = zigzag(rows, 120 + 40 * case, 0.8, &mut rng, false); + let stroke = [outline(&low)]; + assert!(turned_outlines(&stroke).is_some(), "case {case}: not turned"); + assert_same_region(&stroke_region(&stroke), &stroke_region_upright(&stroke), &format!("case {case}: stroke")); + let fill = zigzag(rows, 1100, 0.8, &mut rng, true); + let one = [(&fill, FillRule::NonZero)]; + assert!(turned_fills(&one).is_some(), "case {case}: fill not turned"); + assert_same_region(&unite_all_with(&one, DEFAULT_PRECISION), &unite_all_upright(&one, DEFAULT_PRECISION), &format!("case {case}: unite")); + assert_same_region( + &try_unite_all_with(&one, DEFAULT_PRECISION).unwrap(), + &try_unite_all_upright(&one, DEFAULT_PRECISION).unwrap(), + &format!("case {case}: try_unite"), + ); + } + } + + #[test] + fn tall_and_ordinary_art_give_exactly_the_upright_result() { + let mut rng = Rng(13); + let tall = [outline(&upright(&zigzag(3, 300, 0.4, &mut rng, false)))]; + assert_eq!(stroke_region(&tall), stroke_region_upright(&tall), "tall stroke"); + let tall_fill = upright(&zigzag(3, 800, 0.4, &mut rng, true)); + let one = [(&tall_fill, FillRule::NonZero)]; + assert_eq!(unite_all_with(&one, DEFAULT_PRECISION), unite_all_upright(&one, DEFAULT_PRECISION), "tall unite"); + // A square grid of overlapping squares, and a small path below the size that is measured. + let grid: Vec = (0..900).map(|i| square((i % 30) as f64 * 0.75, (i / 30) as f64 * 0.75)).collect(); + let grid: Vec<(&PathData, FillRule)> = grid.iter().map(|p| (p, FillRule::NonZero)).collect(); + assert_eq!(unite_all_with(&grid, DEFAULT_PRECISION), unite_all_upright(&grid, DEFAULT_PRECISION), "grid"); + assert_eq!(try_unite_all_with(&grid, DEFAULT_PRECISION).unwrap(), try_unite_all_upright(&grid, DEFAULT_PRECISION).unwrap(), "grid, try"); + let small = [outline(&zigzag(1, 40, 0.4, &mut rng, false))]; + assert_eq!(stroke_region(&small), stroke_region_upright(&small), "small"); + } + + #[test] + fn edge_cases_give_the_upright_result() { + // Nothing to sweep. + assert!(turned_outlines(&[]).is_none()); + assert!(stroke_region(&[]).is_empty()); + assert!(unite_all_with(&[], DEFAULT_PRECISION).is_empty()); + // A single segment. + let line = BezPath::from_vec(vec![kurbo::PathEl::MoveTo((0.0, 0.0).into()), kurbo::PathEl::LineTo((5_000.0, 0.0).into())]); + let one = [outline(&PathData::from_bezpath(&line))]; + assert!(turned_outlines(&one).is_none()); + assert_same_region(&stroke_region(&one), &stroke_region_upright(&one), "one segment"); + + // No overlaps: a row of separate squares. + let squares: Vec = (0..600).map(|i| square(i as f64 * 3.0, 0.0)).collect(); + let refs: Vec<(&PathData, FillRule)> = squares.iter().map(|p| (p, FillRule::NonZero)).collect(); + assert!(turned_fills(&refs).is_some()); + let apart = unite_all_with(&refs, DEFAULT_PRECISION); + assert_eq!(apart.subpaths.len(), 600); + assert_same_region(&apart, &unite_all_upright(&refs, DEFAULT_PRECISION), "separate squares"); + + // All anchors on one line: no area either way. + let flat = PathData::single(SubPath::new((0..2100).map(|i| Anchor::corner(Point::new(i as f64, 0.0))).collect(), true)); + let flat = [(&flat, FillRule::NonZero)]; + assert!(turned_fills(&flat).is_some()); + assert!(unite_all_with(&flat, DEFAULT_PRECISION).is_empty()); + assert!(unite_all_upright(&flat, DEFAULT_PRECISION).is_empty()); + + // Coincident segments: the same path twice unites to that path. + let mut rng = Rng(3); + let fill = zigzag(2, 1100, 0.0, &mut rng, true); + let twice = [(&fill, FillRule::NonZero), (&fill, FillRule::NonZero)]; + assert!(turned_fills(&twice).is_some()); + assert_same_region(&unite_all_with(&twice, DEFAULT_PRECISION), &unite_all_upright(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION), "twice"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a44f55e7f..2819ff81c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -960,6 +960,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Geometry and effects:** - **Open-path anchor Delete:** Delete/Backspace removes selected anchors and their adjoining segments, leaving drawable runs as separate open subpaths of the original object. Surviving curves and appearance stay intact; one Undo restores the document and selection, and a second Delete removes the selected remainder. Explicit Remove Anchor Points still reconnects/refits. Four core regressions, one full-frame Delete/Backspace regression and three existing removal/segment controls pass, covering these distinctions, multiple deletions, transformed geometry and mixed whole-object selection. Behaviour references: the legacy public [Illustrator reference guide, Add or delete an anchor point](https://helpx.adobe.com/content/dam/help/en/pdf/illustrator_reference.pdf) distinguishes keyboard deletion from dedicated removal; the current [Add or remove anchor points](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/add-or-remove-anchor-points.html) describes the dedicated tools. Other G11 gaps and scores/hours remain unchanged. - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). + Outline Stroke and Unite remove the overlaps of a long, low path with the sweep turned along it: when the estimated number of segments the sweep holds at once is under two thirds as large for the path turned a quarter turn (paths of 2,048 segments or more), the overlaps are removed on the turned path and the result is turned back, which moves no point by rounding. The lookups of the input curve that a piece of the result lies on, and of the input anchors that are kept, can search along y instead of x: once lookups with more than 64 candidates along x have counted 16 for each input curve or anchor, a lookup searches along y when it finds fewer candidates there (under a quarter as many for curves), and where that has rarely happened it measures along y in only one lookup of 32. On one open path of 200,000 anchors in rows of 1,000, Outline Stroke takes 17.3 s of processor time instead of 31.3 s and Unite 2.9 s instead of 6.5 s in a release build. A turned result has as many subpaths as the result without the turn, and the area where the two differ is no larger than the refit precision times the outline's length (`crates/pathops/src/sweep_axis.rs` tests). - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged. - Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect. - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)).