diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index 1bfeb1b6c..880322fb5 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -367,6 +367,7 @@ pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool { /// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two /// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only /// the part of the hatch inside it is generated; `None` = all of it. +#[cfg(test)] pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec], px: f64, view: Option<&Bounds2>) -> Vec> { pattern_lines_until(d, h, loops, px, view, None) } @@ -380,6 +381,21 @@ pub(crate) fn pattern_lines_until( px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, +) -> Vec> { + pattern_lines_within(d, h, loops, px, view, cancel, MAX_RUNS, MAX_HATCH_WORK) +} + +/// [`pattern_lines_until`] with the most runs and the dash-work allowance given. +#[allow(clippy::too_many_arguments)] +fn pattern_lines_within( + d: &Drawing, + h: &Hatch, + loops: &[Vec], + px: f64, + view: Option<&Bounds2>, + cancel: Option<&AtomicBool>, + max_runs: usize, + work_limit: u64, ) -> Vec> { let lines = lines_of(d, h); let scale = scale_of(h); @@ -388,7 +404,7 @@ pub(crate) fn pattern_lines_until( // Shared work budget across all families and spans, counting dash-processing steps even when // they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound // their cost (CWE-400). - let mut work: u64 = MAX_HATCH_WORK; + let mut work: u64 = work_limit; for fam in &lines { // A family with dashes but no positive (ink) element can never draw: skip it so its gaps // are not swept at all. @@ -403,8 +419,8 @@ pub(crate) fn pattern_lines_until( continue; } let dashes: Vec = fam.dashes.iter().map(|d| d * scale).collect(); - Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work); - if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) { + Family { max_runs, origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work); + if out.len() >= max_runs || work == 0 || is_set(cancel) { break; } } @@ -415,6 +431,8 @@ pub(crate) fn pattern_lines_until( const SWATCH_REPEATS: f64 = 6.0; /// Most runs a swatch holds. const MAX_SWATCH_RUNS: usize = 4000; +/// The dash-work allowance of a swatch (a hatch's is [`MAX_HATCH_WORK`]). +const MAX_SWATCH_WORK: u64 = 2_000_000; /// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the /// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the @@ -441,14 +459,19 @@ pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec> { pattern_lines: lines.to_vec(), }; let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)]; - let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None); + // The generator stops at the swatch's own limits, so a compact pattern is never made in full + // (about a million runs) to keep a few thousand. + let mut runs = pattern_lines_within(&Drawing::default(), &h, &[square], 0.0, None, None, MAX_SWATCH_RUNS, MAX_SWATCH_WORK); runs.truncate(MAX_SWATCH_RUNS); + runs.shrink_to_fit(); runs } /// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin` /// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last. struct Family<'a> { + /// Most runs the hatch may hold in all ([`MAX_RUNS`], less for a swatch). + max_runs: usize, origin: Vec2, ang: f64, delta: Vec2, @@ -505,10 +528,15 @@ impl Family<'_> { // family draws anything at all). let draws = self.dashes.iter().any(|d| *d >= 0.0); let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws); + // Each scanline visits every boundary edge and sorts the crossings: that costs the shared + // allowance one unit per 8 edges (a 5,000-edge boundary swept by 10,000 lines uses about + // 6 million of its 50 million), whether or not any dash is made. + let edges: u64 = local.iter().map(|l| l.len() as u64).sum(); for k in k0..=k1 { - if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) { + if out.len() >= self.max_runs || *work == 0 || is_set(cancel) { return; } + *work = work.saturating_sub(edges / 8 + 1); let y = k as f64 * self.spacing; let xs = crossings(&local, y); let offset = shift_per_line * k as f64; @@ -539,7 +567,7 @@ impl Family<'_> { for d in self.dashes { guard += 1; *work = work.saturating_sub(1); - if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 { + if guard > 100_000 || out.len() >= self.max_runs || *work == 0 { break 'outer; } let len = d.abs(); diff --git a/crates/render/src/linetype_text.rs b/crates/render/src/linetype_text.rs index a012f0df0..8770eb087 100644 --- a/crates/render/src/linetype_text.rs +++ b/crates/render/src/linetype_text.rs @@ -21,11 +21,17 @@ use cadcraft_geom::Vec2; /// Upper bound on the texts drawn along one run (hostile scales; `apply` caps the dashes). const MAX_TEXTS: usize = 10_000; +/// Upper bound on the points of all the texts of one run: each placement keeps its own copy of the +/// label's outline, so a long label times [`MAX_TEXTS`] would otherwise be too much to hold. +const MAX_TEXT_POINTS: usize = 4_000_000; + /// One text element of the pattern, shaped once at the origin. struct Element { /// Distance from the start of a pattern repeat to the anchor (where the element ends). at: f64, local: Shaped, + /// Points in `local`, each placement's cost against [`MAX_TEXT_POINTS`]. + points: usize, rotation: f64, absolute: bool, offset: Vec2, @@ -50,6 +56,7 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern: return out; } let mut walk = Walk { pts, i: 0, start: 0.0 }; + let mut points_left = MAX_TEXT_POINTS; let mut base = 0.0; 'repeats: while base <= len { for el in &elements { @@ -57,6 +64,8 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern: if s > len + 1e-9 || out.len() >= MAX_TEXTS { break 'repeats; } + let Some(left) = points_left.checked_sub(el.points) else { break 'repeats }; + points_left = left; let Some((p, dir)) = walk.at(s) else { break 'repeats }; let origin = p + dir * el.offset.x + dir.perp() * el.offset.y; let rot = if el.absolute { el.rotation } else { dir.angle() + el.rotation }; @@ -93,8 +102,10 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec { }; let (local, _) = cadcraft_fonts::place(&font, text, ¶ms); let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; + let points = local.strokes.iter().map(Vec::len).sum::() + local.glyphs.iter().flatten().map(Vec::len).sum::(); out.push(Element { at, + points, local, rotation: finite(el.rotation), absolute: el.absolute, @@ -175,6 +186,20 @@ mod tests { assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty()); } + /// A long label repeated along a long run stops at the point allowance, well before MAX_TEXTS. + #[test] + fn placed_labels_stop_at_the_point_allowance() { + let d = Drawing::new_imperial(); + let mut lt = gas(); + lt.pattern[1].text = Some("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM".into()); + // 0.95 per repeat over 9,000: about 9,500 repeats, under the 50,000 the run is drawn for. + let t = texts(&d, &[Vec2::ZERO, Vec2::new(9_000.0, 0.0)], <, 1.0, 0.0); + let points: usize = + t.iter().map(|s| s.strokes.iter().map(Vec::len).sum::() + s.glyphs.iter().flatten().map(Vec::len).sum::()).sum(); + assert!(!t.is_empty() && t.len() < MAX_TEXTS, "{}", t.len()); + assert!(points <= MAX_TEXT_POINTS, "{points}"); + } + #[test] fn hostile_values_never_panic() { let d = Drawing::new_imperial(); diff --git a/crates/render/tests/it/hatch_fill.rs b/crates/render/tests/it/hatch_fill.rs index ff6195355..894eb720b 100644 --- a/crates/render/tests/it/hatch_fill.rs +++ b/crates/render/tests/it/hatch_fill.rs @@ -211,3 +211,14 @@ fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() { assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1); } } + +/// A pattern with a huge number of tiny dashes makes a swatch of a few thousand runs, stopping +/// there; and many scanlines over a many-edged boundary are charged to the hatch's work allowance. +#[test] +fn compact_patterns_stop_at_the_swatch_limits() { + let lines = vec![PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![1e-6, -1e-6] }]; + let started = std::time::Instant::now(); + let runs = cadcraft_render::pattern_swatch(&lines, 1.0); + assert!(!runs.is_empty() && runs.len() <= 4000, "{}", runs.len()); + assert!(started.elapsed() < std::time::Duration::from_secs(20)); +}