From 34e6973b505a87719d853ac669d5216025f46dfe Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 03:11:07 +0000 Subject: [PATCH] effects: count paint strings in 3D copies, charge dash restarts, cap trap pairs --- crates/effects/src/revolve.rs | 22 +++++++++++++++++----- crates/effects/src/solids.rs | 5 +++++ crates/effects/src/stroke/dash.rs | 8 ++++++++ crates/effects/src/stroke/tests_fit.rs | 14 ++++++++++++++ crates/effects/src/trap.rs | 23 ++++++++++++++++++----- 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index b2528818b..dfd85fa16 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -17,17 +17,29 @@ pub const REVOLVE: &str = "threeD.revolve"; /// thousands of faces took gigabytes; past this the source path is drawn. const MAX_PAINT_COPIES: usize = 256 << 20; -/// About the bytes one copy of `paint` takes. -fn paint_bytes(paint: &Paint) -> usize { +/// About the bytes one copy of `paint` takes, with the strings it owns (swatch and pattern names). +pub(crate) fn paint_bytes(paint: &Paint) -> usize { use std::mem::size_of; use vectorcraft_doc::color::{FreeformPoint, GradientStop}; let own = size_of::(); - let Paint::Gradient(g) = paint else { return own }; + let name = |s: &Option| s.as_ref().map_or(0, String::len); + let g = match paint { + Paint::Solid { swatch, .. } => return own + name(swatch), + Paint::Pattern { pattern, .. } => return own + pattern.len(), + Paint::Gradient(g) => g, + _ => return own, + }; let freeform = g .freeform .as_ref() .map_or(0, |f| f.points.len() * size_of::() + f.lines.iter().map(|l| l.len() * size_of::()).sum::()); - own + size_of::() + g.gradient.stops.len() * size_of::() + freeform + let stops: usize = g.gradient.stops.iter().map(|s| size_of::() + name(&s.swatch)).sum(); + own + size_of::() + name(&g.swatch) + stops + freeform +} + +/// Whether `copies` copies of `paint` stay within [`MAX_PAINT_COPIES`]. +pub(crate) fn paint_copies_fit(copies: usize, paint: &Paint) -> bool { + copies.saturating_mul(paint_bytes(paint)) <= MAX_PAINT_COPIES } fn visible(fx: &[Effect]) -> bool { @@ -112,7 +124,7 @@ fn surface( let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; // A face's fill, its shadow and their seam strokes: up to four copies of the paint. - if faces.len().saturating_mul(4).saturating_mul(paint_bytes(&paint)) > MAX_PAINT_COPIES { + if !paint_copies_fit(faces.len().saturating_mul(4), &paint) { return None; } // Visibility is an expansion option, never a change to live geometry or export baking. diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 1165340bc..34649a4c3 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -260,6 +260,10 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App let (paint, opacity, blend) = paint_of(item); let (area, rule) = region(path, rule, item); let f = facets(ROTATE, &area, rule, b, &p).ok()?; + // Each facet and its seam stroke copy the paint; past the allowance the source path is drawn. + if !crate::revolve::paint_copies_fit(f.len().saturating_mul(2), &paint) { + return None; + } out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored))); } return Some(out); @@ -288,6 +292,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App for ((item, _, _), f) in parts.iter().zip(built) { let item: &AppearanceItem = item; match f { + Ok(f) if !crate::revolve::paint_copies_fit(f.len().saturating_mul(2), &paint_of(item).0) => return None, Ok(f) => solids.push((item, f)), // A stroke's outline past the limits is left out; the fill's solid still draws. Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index 689c88dae..dd52063d9 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -14,6 +14,10 @@ use super::{ARCLEN_ACCURACY, kink, push_seg, segments, subpaths, tangent}; /// More dashes than this (a tiny pattern on a long path) draws the line solid instead. const MAX_DASHES: f64 = 200_000.0; +/// Pattern entries walked in all (every subpath and every corner restart starts the pattern over, and +/// zero-length entries cost no path length, so a long pattern is walked in full even on a short +/// run); past this the line is drawn solid. About 100x what real art needs. +const MAX_STEPS: f64 = 20_000_000.0; /// A pattern whose longest entry is shorter than this can't advance along the path (each entry /// ends within the generator's tolerance of where it starts), so it draws the line solid. const MIN_ENTRY: f64 = 1e-6; @@ -72,6 +76,10 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option { if plans.iter().map(|p| p.periods).sum::() * pat.len() as f64 > MAX_DASHES { return None; } + let restarts = |p: &Plan| p.restart.iter().filter(|r| **r).count() as f64; + if plans.iter().map(|p| (p.periods.ceil().max(1.0) + restarts(p)) * pat.len() as f64).sum::() > MAX_STEPS { + return None; + } // Where an exact pattern starts: `offset` into it. let mut off = d.offset.rem_euclid(period); let mut ix0 = 0; diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index 91933087d..b427fdbce 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -222,3 +222,17 @@ fn smooth_points_of_curves_stay_smooth_with_any_join() { assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}"); } } + +#[test] +fn a_long_pattern_restarted_on_many_short_subpaths_draws_solid() { + // Each subpath walks the zero-length entries in full although it is far shorter than a period. + let mut pattern = vec![0.0; 2000]; + pattern[0] = 1.0; + let mut b = BezPath::new(); + for i in 0..20_000 { + b.move_to((i as f64 * 2.0, 0.0)); + b.line_to((i as f64 * 2.0 + 0.01, 0.0)); + } + let d = Dash { pattern, offset: 0.0, align_corners: false }; + assert!(dash(&b, &d).is_none()); +} diff --git a/crates/effects/src/trap.rs b/crates/effects/src/trap.rs index f60232bed..5418038c3 100644 --- a/crates/effects/src/trap.rs +++ b/crates/effects/src/trap.rs @@ -218,7 +218,9 @@ fn visible(sources: &[TrapSource]) -> Vec { /// The pairs `(i, j)`, `i < j`, in ascending order, of `rects` (`None`: left out) that come within /// `reach_x` of each other across x and `reach_y` across y. A sweep over the rects sorted by their /// left edge, so shapes spread over the page cost about one check each rather than one per pair. -fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Vec<(usize, usize)> { +/// `None` when there are more than [`MAX_NEAR_PAIRS`] (many shapes all within reach of each other +/// make the pair list grow with the square of their count). +fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Option> { let mut order: Vec<(usize, Rect)> = rects.iter().enumerate().filter_map(|(i, r)| r.map(|r| (i, r))).collect(); order.sort_unstable_by(|a, b| a.1.x0.total_cmp(&b.1.x0).then(a.0.cmp(&b.0))); let mut pairs = vec![]; @@ -226,14 +228,21 @@ fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Vec<(usize, // Later rects start at or right of `a`, so only how far right they start can rule them out. for &(j, b) in order[k + 1..].iter().take_while(|(_, b)| b.x0 <= a.x1 + reach_x) { if b.y0 <= a.y1 + reach_y && a.y0 <= b.y1 + reach_y { + if pairs.len() >= MAX_NEAR_PAIRS { + return None; + } pairs.push((i.min(j), i.max(j))); } } } pairs.sort_unstable(); - pairs + Some(pairs) } +/// Most candidate pairs one trap evaluation keeps; past it no traps are made (the art draws as is). +/// Each pair costs a boolean trap band, so real art stays far below this. +const MAX_NEAR_PAIRS: usize = 1 << 20; + /// The traps between `sources` (back to front) with options `o`: for each two sources of /// different solid colours whose visible parts meet, the strip of the darker one's visible part /// next to the lighter one's (the other way round with [`TrapOptions::reverse`]), painted per @@ -257,7 +266,8 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { let extent = rects.iter().flatten().flat_map(|b| [b.x0, b.y0, b.x1, b.y1]).fold(0.0_f64, |m, v| m.max(v.abs())); let hair = 1e-9 * (1.0 + extent + w * r.max(1.0)); let mut out = vec![]; - for (i, j) in near_pairs(&rects, w + hair, w * r + hair) { + let Some(pairs) = near_pairs(&rects, w + hair, w * r + hair) else { return vec![] }; + for (i, j) in pairs { let (Some(ci), Some(cj)) = (colors[i], colors[j]) else { continue }; if vis[i].is_empty() || vis[j].is_empty() || same_color(&sources[i].fill, &sources[j].fill, ci, cj) { continue; @@ -431,10 +441,13 @@ mod tests { fn only_shapes_within_reach_are_paired() { let s = many_apart_and_one_abutting(); let rects: Vec> = s.iter().map(|t| t.path.bounds()).collect(); - assert_eq!(near_pairs(&rects, 1.0, 1.0), vec![(0, 2000)], "the squares 10 pt apart are never paired"); + assert_eq!(near_pairs(&rects, 1.0, 1.0), Some(vec![(0, 2000)]), "the squares 10 pt apart are never paired"); // Reaching 10 pt, neighbours in a row pair, in ascending order; left-out rects never do. let row: Vec> = [3, 1, 0, 2].iter().map(|&k| s[k].path.bounds()).chain([None]).collect(); - assert_eq!(near_pairs(&row, 10.0, 10.0), vec![(0, 3), (1, 2), (1, 3)]); + assert_eq!(near_pairs(&row, 10.0, 10.0), Some(vec![(0, 3), (1, 2), (1, 3)])); + // Past the allowance the pair list stops growing instead of being built. + let packed = vec![Some(Rect::new(0.0, 0.0, 1.0, 1.0)); 1500]; + assert_eq!(near_pairs(&packed, 1.0, 1.0), None); let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() }); assert_eq!(t.len(), 1, "only the abutting pair traps"); assert_eq!((t[0].from, t[0].into), (2000, 0));