From 3debdb13fc493971615f1685f3c69a2fede6a26a Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 07:08:20 -0800 Subject: [PATCH] prepress: one wide curve no longer slows region cleanup for every other curve * prepress: one wide curve no longer slows region cleanup for every other curve The flattener's region cleanup looks up the input curves whose bounds hold both ends of an output piece. The lookup searched back from a point by the width of the widest input curve, so one curve as wide as the page made every lookup scan nearly every input curve, and the cleanup grew quadratically with the number of curves. The curves are now grouped by bounds width, each group within a factor of two, and each group is searched within its own widest bounds. The lookup returns the same curves in the same order as before, so the regions are unchanged. * prepress: region cleanup keeps a curve held just inside its right edge Far from the origin, `x - width` can round above the left edge of a curve whose bounds hold `x`, and the width-group search then skipped that curve. The search's left edge is now rounded outward. --------- Co-authored-by: dotCooCoo --- crates/prepress/src/flatten/geom.rs | 119 +++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 12 deletions(-) diff --git a/crates/prepress/src/flatten/geom.rs b/crates/prepress/src/flatten/geom.rs index 65d1c78d4..3210ab0d1 100644 --- a/crates/prepress/src/flatten/geom.rs +++ b/crates/prepress/src/flatten/geom.rs @@ -11,7 +11,7 @@ //! Clips go into the same sweep as the shapes instead of being applied first: one sweep replaces //! a boolean per clipped shape, and a clip edge is then the same edge in every region it bounds. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Rect, Shape as _, Vec2}; use linesweeper::topology::{ContourIdx, Contours, Topology, WindingNumber}; @@ -679,10 +679,10 @@ struct Tidy { keep: Vec, /// Input curves with slightly inflated bounds. curves: Vec<(Rect, CubicBez)>, - /// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose + /// `curves` indices in groups whose bounds widths are within a factor of two, each with the + /// group's widest bounds and its indices by the left edge of their bounds: the curves whose /// bounds can hold a point, without testing them all. - by_x: Vec, - max_width: f64, + by_width: Vec<(f64, Vec)>, } impl Tidy { @@ -700,10 +700,18 @@ impl Tidy { } keep.retain(|p| p.is_finite()); kd_build(&mut keep, 0); - 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 { sliver, keep, curves, by_x, max_width } + let mut groups: BTreeMap> = BTreeMap::new(); + for (i, (r, _)) in curves.iter().enumerate() { + groups.entry(r.width().log2().floor() as i32).or_default().push(i); + } + let by_width = groups + .into_values() + .map(|mut ids| { + ids.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0)); + (ids.iter().map(|&i| curves[i].0.width()).fold(0.0, f64::max), ids) + }) + .collect(); + Self { sliver, keep, curves, by_width } } fn is_kept(&self, p: Point) -> bool { @@ -713,10 +721,28 @@ impl Tidy { /// The input curves whose bounds hold both `a` and `b`. fn curves_around(&self, a: Point, b: Point) -> impl Iterator { - 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) + self.indices_around(a, b).into_iter().map(|i| &self.curves[i].1) + } + + /// The indices of the input curves whose bounds hold both `a` and `b`, by the left edge of + /// their bounds and then by index. + fn indices_around(&self, a: Point, b: Point) -> Vec { + let mut out: Vec = + self.windows(a.x.min(b.x)).flatten().copied().filter(|&i| self.curves[i].0.contains(a) && self.curves[i].0.contains(b)).collect(); + out.sort_by(|&i, &j| self.curves[i].0.x0.total_cmp(&self.curves[j].0.x0).then(i.cmp(&j))); + out + } + + /// For each width group, the curves whose bounds can reach `x`: those starting at most the + /// group's widest bounds to its left. One wide curve widens only its own group's window. + /// The window's left edge is rounded outward, so rounding in `x - width` never drops a curve. + fn windows(&self, x: f64) -> impl Iterator { + self.by_width.iter().map(move |(width, ids)| { + let hi = ids.partition_point(|&i| self.curves[i].0.x0 <= x); + let from = (x - width.next_up()).next_down(); + let lo = ids[..hi].partition_point(|&i| self.curves[i].0.x0 < from); + &ids[lo..hi] + }) } /// `bp` with each curve piece that strays from the one input curve through both its ends @@ -1113,6 +1139,75 @@ mod tests { assert!(sum <= union * (1.0 + 1e-9) && sum > union * (1.0 - 1e-4), "{sum} vs {union}"); } + /// One open cubic per path, from `p0` to `p3` through the two handles. + fn cubic(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64)) -> BezPath { + let mut bp = BezPath::new(); + bp.move_to(p0); + bp.curve_to(p1, p2, p3); + bp + } + + #[test] + fn curves_around_finds_what_a_full_scan_finds_in_the_same_order() { + let mut seed: u64 = 0x9e37_79b9_7f4a_7c15; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + // Narrow and wide curves mixed, some sharing a left edge. + let paths: Vec = (0..400) + .map(|k| { + let size = [0.5, 3.0, 40.0, 900.0][k % 4] * (0.5 + next()); + let x = if k % 7 == 0 { 100.0 } else { next() * 1000.0 }; + let y = next() * 1000.0; + cubic((x, y), (x + size * next(), y + size), (x + size * next(), y - size), (x + size, y + size * next())) + }) + .collect(); + let tidy = Tidy::keeping(DEFAULT_SLIVER, &paths); + let mut by_x: Vec = (0..tidy.curves.len()).collect(); + by_x.sort_by(|&i, &j| tidy.curves[i].0.x0.total_cmp(&tidy.curves[j].0.x0)); + let mut found = 0; + for _ in 0..2000 { + let a = Point::new(next() * 1100.0 - 50.0, next() * 1100.0 - 50.0); + let b = a + Vec2::new(next() * 20.0 - 10.0, next() * 20.0 - 10.0); + let all: Vec = by_x.iter().copied().filter(|&i| tidy.curves[i].0.contains(a) && tidy.curves[i].0.contains(b)).collect(); + found += all.len(); + assert_eq!(tidy.indices_around(a, b), all, "{a:?} {b:?}"); + } + assert!(found > 1000, "{found}"); + } + + #[test] + fn one_wide_curve_does_not_widen_the_search_for_narrow_ones() { + // A curve across the page, and 1,000 small curves on a grid. + let mut paths = vec![cubic((0.0, 0.0), (300.0, 600.0), (600.0, -600.0), (1000.0, 0.0))]; + paths.extend((0..1000).map(|k| { + let (x, y) = ((k % 40) as f64 * 25.0, (k / 40) as f64 * 25.0); + cubic((x, y), (x + 2.0, y + 4.0), (x + 4.0, y - 4.0), (x + 6.0, y)) + })); + let tidy = Tidy::keeping(DEFAULT_SLIVER, &paths); + let scanned: usize = tidy.windows(503.0).map(<[usize]>::len).sum(); + // The wide curve, and the small curves of the one grid column within reach of x = 503. + assert!(scanned <= 1 + 25, "{scanned}"); + } + + #[test] + fn curves_around_keeps_a_curve_held_just_inside_its_right_edge() { + // Far from the origin, `x - width` rounds above the left edge of a curve whose bounds + // hold `x`. + let (l, r) = (-16_496_801_568.892_494, 7_886_835_387.982_123); + let paths = [cubic((l, 0.0), (l, 1000.0), (r, 1000.0), (r, 0.0))]; + let tidy = Tidy::keeping(DEFAULT_SLIVER, &paths); + let bounds = tidy.curves[0].0; + let a = Point::new(bounds.x1.next_down(), 0.0); + let b = Point::new(a.x, 1.0); + assert!(bounds.contains(a) && bounds.contains(b)); + assert!(a.x - tidy.by_width[0].0 > bounds.x0, "the rounding case is not reached"); + assert_eq!(tidy.indices_around(a, b), vec![0]); + } + /// Many anchors sharing an x (and some within tolerance of it): the k-d lookup answers as a scan of /// every anchor does. #[test]