diff --git a/crates/geom/src/freehand.rs b/crates/geom/src/freehand.rs index 53ddf9611..278898239 100644 --- a/crates/geom/src/freehand.rs +++ b/crates/geom/src/freehand.rs @@ -3,6 +3,10 @@ use crate::Point; use crate::path::{Anchor, AnchorKind}; +/// Distance tests [`simplify`] may make. Worst-case input splits one point off at a time (quadratic); +/// past this the spans still waiting keep only their end points, a coarser fit rather than a stall. +const MAX_SIMPLIFY_STEPS: usize = 16 * 1024 * 1024; + /// Ramer–Douglas–Peucker: the points that keep the polyline within `tol`. fn simplify(pts: &[Point], tol: f64) -> Vec { if pts.len() < 3 { @@ -12,7 +16,12 @@ fn simplify(pts: &[Point], tol: f64) -> Vec { keep[0] = true; keep[pts.len() - 1] = true; let mut stack = vec![(0, pts.len() - 1)]; + let mut steps = 0usize; while let Some((a, b)) = stack.pop() { + steps = steps.saturating_add(b - a); + if steps > MAX_SIMPLIFY_STEPS { + break; + } let (pa, pb) = (pts[a], pts[b]); let d = pb - pa; let len = d.hypot(); @@ -113,4 +122,13 @@ mod tests { assert!(c.iter().all(|x| x.kind == AnchorKind::Smooth)); assert!((c[0].p - c[c.len() - 1].p).hypot() > 1.0); } + + /// A convex curve splits one point at a time (quadratic): fitting stops at the step budget. + #[test] + fn unbalanced_input_stops_at_the_step_budget() { + let n = 100_000; + let curve: Vec = (0..n).map(|i| Point::new(f64::from(i), (f64::from(i) * 1e-3).powi(4))).collect(); + let a = fit(&curve, 1e-9, false); + assert!(a.len() >= 2 && a.len() < n as usize); + } } diff --git a/crates/geom/src/pathfinder.rs b/crates/geom/src/pathfinder.rs index 75aa1afce..0b8237024 100644 --- a/crates/geom/src/pathfinder.rs +++ b/crates/geom/src/pathfinder.rs @@ -92,11 +92,31 @@ fn from_flo(paths: &[SimpleBezierPath]) -> PathData { PathData::new(subpaths) } +/// Unions of more shapes than this combine in pairs; up to it each is added in turn (as always). +const BALANCED_UNION_FROM: usize = 16; + /// Combine `shapes` (back to front, all in one coordinate space). `None` when nothing is left. pub fn combine(op: Op, shapes: &[PathData]) -> Option { let flo: Vec> = shapes.iter().map(to_flo).filter(|p| !p.is_empty()).collect(); let (first, rest) = flo.split_first()?; let union = |v: &[Vec]| -> Vec { + if v.len() > BALANCED_UNION_FROM { + // Pairs, then pairs of pairs: adding each shape to everything so far reprocesses the + // growing result every time (quadratic for many shapes). + let mut level: Vec> = v.to_vec(); + while level.len() > 1 { + level = level + .chunks(2) + .map(|c| match c { + [a, b] if a.is_empty() => b.clone(), + [a, b] if b.is_empty() => a.clone(), + [a, b] => path_add(a, b, ACCURACY), + _ => c.first().cloned().unwrap_or_default(), + }) + .collect(); + } + return level.pop().unwrap_or_default(); + } let mut acc: Vec = Vec::new(); for p in v { acc = if acc.is_empty() { p.clone() } else { path_add(&acc, p, ACCURACY) }; @@ -147,6 +167,19 @@ mod tests { p.subpaths.iter().map(|s| s.area()).sum::().abs() } + #[test] + fn a_union_of_many_shapes_adds_up() { + // 40 squares in a row, each overlapping the next by 5: width 40 * 10 + 5, height 10. + let shapes: Vec = (0..40).map(|i| shapes::rectangle(Rect::new(f64::from(i) * 5.0, 0.0, f64::from(i) * 5.0 + 10.0, 10.0))).collect(); + let u = combine(Op::Add, &shapes).unwrap(); + assert!((area(&u) - 205.0 * 10.0).abs() < 1.0, "{}", area(&u)); + // Far-apart squares stay separate. + let apart: Vec = + (0..40).map(|i| shapes::rectangle(Rect::new(f64::from(i) * 50.0, 0.0, f64::from(i) * 50.0 + 10.0, 10.0))).collect(); + let u = combine(Op::Add, &apart).unwrap(); + assert_eq!(u.subpaths.len(), 40); + } + #[test] fn boolean_ops_on_overlapping_squares() { let a = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); diff --git a/crates/geom/src/stroke_style.rs b/crates/geom/src/stroke_style.rs index 26c201477..d3c25ecba 100644 --- a/crates/geom/src/stroke_style.rs +++ b/crates/geom/src/stroke_style.rs @@ -57,10 +57,18 @@ fn offset_subpath(bp: &BezPath, d: f64, tol: f64, out: &mut BezPath) { /// Bands (start, width) across a stroke, as fractions of the weight from its left edge. pub type Bands = [(f64, f64)]; +/// Most bands of a striped stroke that are drawn, and most path elements its fill may have: +/// every band strokes the whole path again, so both multiply the path's size. +pub const MAX_STRIPE_BANDS: usize = 64; +const MAX_STRIPE_ELEMENTS: usize = 2_000_000; + /// The filled area of a striped stroke of `weight` along `bp`. pub fn stripes(bp: &BezPath, weight: f64, bands: &Bands, tol: f64) -> BezPath { let mut out = BezPath::new(); - for &(start, width) in bands { + for &(start, width) in bands.iter().take(MAX_STRIPE_BANDS) { + if out.elements().len() > MAX_STRIPE_ELEMENTS { + break; + } if width <= 0.0 { continue; } @@ -139,6 +147,17 @@ pub fn hashed(bp: &BezPath, weight: f64, tol: f64) -> BezPath { #[cfg(test)] mod tests { use super::*; + + #[test] + fn stripes_draw_at_most_the_band_cap() { + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((100.0, 0.0)); + let one = stripes(&line, 4.0, &[(0.0, 0.1)], 0.1).elements().len(); + let many = vec![(0.0, 0.1); 100_000]; + let all = stripes(&line, 4.0, &many, 0.1).elements().len(); + assert_eq!(all, one * MAX_STRIPE_BANDS); + } use kurbo::Shape; fn line() -> BezPath {