diff --git a/crates/prepress/src/flatten/geom.rs b/crates/prepress/src/flatten/geom.rs index 0504c2c7..65d1c78d 100644 --- a/crates/prepress/src/flatten/geom.rs +++ b/crates/prepress/src/flatten/geom.rs @@ -641,11 +641,41 @@ fn tol_at(p: Point) -> f64 { 1e-6 * (1.0 + p.x.abs().max(p.y.abs())) } +fn coord(p: Point, axis: usize) -> f64 { + if axis == 0 { p.x } else { p.y } +} + +/// Arrange `points` as an implicit k-d tree: the median of each slice, by x and then y in turn, is +/// its root, with the lesser points before it and the greater after. +fn kd_build(points: &mut [Point], axis: usize) { + if points.len() < 2 { + return; + } + let mid = points.len() / 2; + points.select_nth_unstable_by(mid, |a, b| coord(*a, axis).total_cmp(&coord(*b, axis))); + let (before, after) = points.split_at_mut(mid); + kd_build(before, 1 - axis); + kd_build(&mut after[1..], 1 - axis); +} + +/// Whether a point of the tree `points` (made by [`kd_build`] from `axis`) lies in the box `lo..=hi`. +/// A box this small meets a few branches however many points share an x. +fn kd_any(points: &[Point], axis: usize, lo: Point, hi: Point) -> bool { + let mid = points.len() / 2; + let Some(&q) = points.get(mid) else { return false }; + if q.x >= lo.x && q.x <= hi.x && q.y >= lo.y && q.y <= hi.y { + return true; + } + let c = coord(q, axis); + (coord(lo, axis) <= c && kd_any(&points[..mid], 1 - axis, lo, hi)) || (coord(hi, axis) >= c && kd_any(&points[mid + 1..], 1 - axis, lo, hi)) +} + /// Output cleanup: the inputs' on-curve points (joints there stay), and their curves, to rebuild /// split pieces exactly. struct Tidy { sliver: f64, - /// Sorted by x. + /// The finite points as an implicit k-d tree (see [`kd_build`]), so a lookup doesn't scan the + /// many points that share an x. keep: Vec, /// Input curves with slightly inflated bounds. curves: Vec<(Rect, CubicBez)>, @@ -668,7 +698,8 @@ impl Tidy { } } } - keep.sort_by(|a, b| a.x.total_cmp(&b.x)); + 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); @@ -677,8 +708,7 @@ impl Tidy { fn is_kept(&self, p: Point) -> bool { let tol = tol_at(p); - 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) + kd_any(&self.keep, 0, Point::new(p.x - tol, p.y - tol), Point::new(p.x + tol, p.y + tol)) } /// The input curves whose bounds hold both `a` and `b`. @@ -790,18 +820,41 @@ fn tidy_segments(segs: Vec, tidy: &Tidy) -> Vec { if let Some(k) = (0..n).find(|&i| !mergeable(&segs[(i + n - 1) % n], &segs[i])) { segs.rotate_left(k); } + // Where the run of mergeable joints that starts at each segment ends, found once. + let mut run_end = vec![n; n]; + for i in (0..n).rev() { + run_end[i] = if i + 1 < n && mergeable(&segs[i], &segs[i + 1]) { run_end[i + 1] } else { i + 1 }; + } let mut out: Vec = Vec::with_capacity(n); let mut i = 0; while i < n { let mut cur = segs[i]; let mut j = i + 1; - while j < n && mergeable(&segs[j - 1], &segs[j]) { - match join(&segs[i..=j], tidy) { - Some(m) => { + let end = run_end[i]; + // A run is validated whole, once (its pieces split one line or curve). Only when it doesn't + // join is the longest prefix that does looked for, by halving, instead of re-checking the + // whole run for every piece added: that was quadratic in a long collinear run. + if end > j { + if let Some(m) = join(&segs[i..end], tidy) { + cur = m; + j = end; + } else { + let (mut ok, mut bad) = (i, end - 1); + let mut best = None; + while bad - ok > 1 { + let mid = ok + (bad - ok) / 2; + match join(&segs[i..=mid], tidy) { + Some(m) => { + ok = mid; + best = Some(m); + } + None => bad = mid, + } + } + if let Some(m) = best { cur = m; - j += 1; + j = ok + 1; } - None => break, } } out.push(cur); @@ -810,8 +863,16 @@ fn tidy_segments(segs: Vec, tidy: &Tidy) -> Vec { out } +#[cfg(test)] +thread_local! { + /// How many times `join` ran on this thread, for the tests that bound the work. + static JOINS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// `run` as one segment, when its pieces lie on one line or one input curve. fn join(run: &[Seg], tidy: &Tidy) -> Option { + #[cfg(test)] + JOINS.with(|joins| joins.set(joins.get() + 1)); let (first, last) = (run.first()?.c, run.last()?.c); let (a, b) = (first.p0, last.p3); let tol = tol_at(Point::new(a.x.abs().max(b.x.abs()), a.y.abs().max(b.y.abs()))); @@ -1051,4 +1112,80 @@ mod tests { // Less the slivers left out. assert!(sum <= union * (1.0 + 1e-9) && sum > union * (1.0 - 1e-4), "{sum} vs {union}"); } + + /// Many anchors sharing an x (and some within tolerance of it): the k-d lookup answers as a scan of + /// every anchor does. + #[test] + fn is_kept_agrees_with_a_scan_when_many_anchors_share_an_x() { + let mut seed: u64 = 0x2545_f491_4f6c_dd1d; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + let mut paths = Vec::new(); + for k in 0..3000 { + let x = [5.0, 5.0 + 3e-7, 100.0][k % 3]; + let mut bp = BezPath::new(); + bp.move_to((x, next() * 1000.0)); + bp.line_to((x + 1.0, next() * 1000.0)); + paths.push(bp); + } + let tidy = Tidy::keeping(DEFAULT_SLIVER, &paths); + let anchors: Vec = paths.iter().flat_map(|p| p.segments().map(|s| to_seg(s).c.p0)).collect(); + let (mut kept, mut not_kept) = (0, 0); + for k in 0..4000 { + let a = anchors[k % anchors.len()]; + let p = match k % 4 { + 0 => a, + 1 => Point::new(a.x + 5e-7, a.y + 5e-7), + 2 => Point::new(a.x, a.y + 1e-3), + _ => Point::new(next() * 120.0, next() * 1000.0), + }; + let tol = tol_at(p); + let scan = anchors.iter().any(|q| (q.x - p.x).abs() <= tol && (q.y - p.y).abs() <= tol); + assert_eq!(tidy.is_kept(p), scan, "{p:?}"); + if scan { kept += 1 } else { not_kept += 1 } + } + assert!(kept > 1000 && not_kept > 1000, "{kept} {not_kept}"); + assert!(!Tidy::keeping(DEFAULT_SLIVER, &Vec::::new()).is_kept(Point::new(0.0, 0.0))); + } + + /// A contour with a 20,000-piece collinear side is joined by validating the run once, not once per + /// piece (some 200,000,000 piece checks). + #[test] + fn a_long_collinear_run_is_validated_once() { + let n = 20_000; + let tidy = Tidy::keeping(DEFAULT_SLIVER, &Vec::::new()); + let mut segs: Vec = (0..n).map(|i| Seg::line(Point::new(i as f64, 0.0), Point::new(i as f64 + 1.0, 0.0))).collect(); + segs.push(Seg::line(Point::new(n as f64, 0.0), Point::new(0.0, 1.0))); + segs.push(Seg::line(Point::new(0.0, 1.0), Point::new(0.0, 0.0))); + let before = JOINS.with(std::cell::Cell::get); + let out = tidy_segments(segs, &tidy); + let joins = JOINS.with(std::cell::Cell::get) - before; + assert_eq!(out.len(), 3); + assert!(joins <= 4, "{joins} joins"); + assert_eq!((out[0].c.p0, out[0].c.p3), (Point::new(0.0, 0.0), Point::new(n as f64, 0.0))); + } + + /// A run that bends partway still joins its longest straight part, in a few validations. + #[test] + fn a_run_that_bends_joins_its_longest_prefix() { + let n = 5000; + let tidy = Tidy::keeping(DEFAULT_SLIVER, &Vec::::new()); + let mut segs: Vec = (0..n).map(|i| Seg::line(Point::new(i as f64, 0.0), Point::new(i as f64 + 1.0, 0.0))).collect(); + // A last piece that leaves the line by 0.01 (inside the tangent test, outside the join's tolerance). + segs.push(Seg::line(Point::new(n as f64, 0.0), Point::new(n as f64 + 100.0, 1.0))); + segs.push(Seg::line(Point::new(n as f64 + 100.0, 1.0), Point::new(0.0, 5.0))); + let before = JOINS.with(std::cell::Cell::get); + let out = tidy_segments(segs, &tidy); + let joins = JOINS.with(std::cell::Cell::get) - before; + assert!(out.len() <= 4 && joins < 200, "{} pieces, {joins} joins", out.len()); + assert!( + out.iter().any(|s| s.c.p0 == Point::new(0.0, 0.0) && s.c.p3.x >= n as f64 - 1.0), + "{:?}", + out.iter().map(|s| (s.c.p0, s.c.p3)).collect::>() + ); + } }