diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index dfd85fa16..53d3feb07 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -29,17 +29,26 @@ pub(crate) fn paint_bytes(paint: &Paint) -> usize { 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::()); + let freeform = g.freeform.as_ref().map_or(0, |f| { + // Each line is a Vec: its header too, not only its indices. + f.points.len() * size_of::() + + f.lines.len() * size_of::>() + + f.lines.iter().map(|l| l.len() * size_of::()).sum::() + }); 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 + paint_copies_add(&mut 0, copies, paint) +} + +/// Add `copies` copies of `paint` to the running byte total `used`; whether it still fits within +/// [`MAX_PAINT_COPIES`] (for several items whose copies count together). +pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) -> bool { + *used = used.saturating_add(copies.saturating_mul(paint_bytes(paint))); + *used <= MAX_PAINT_COPIES } fn visible(fx: &[Effect]) -> bool { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 34649a4c3..193f47c91 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -256,12 +256,14 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App if fx.id == ROTATE { // Every part turns about the centre of the object's own bounds, so they stay together. let mut out = vec![]; + let mut paint_bytes = 0usize; for item in items { 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) { + // Each facet and its seam stroke copy the paint; past the allowance (all items together) + // the source path is drawn. + if !crate::revolve::paint_copies_add(&mut paint_bytes, f.len().saturating_mul(2), &paint) { return None; } out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored))); @@ -289,10 +291,11 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect() }; let mut solids = vec![]; + let mut paint_bytes = 0usize; 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) if !crate::revolve::paint_copies_add(&mut paint_bytes, 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 dd52063d9..657711de8 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -89,8 +89,13 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option { } let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 }; let mut out = Dashed::default(); + // The estimates above trust the fitted scales; this counts the pattern entries actually walked, + // so entries that fit down to nothing can't keep a subpath looping. + let mut budget = MAX_STEPS as usize; for plan in &plans { - dash_subpath(&mut out, plan, &pat, phase); + if !dash_subpath(&mut out, plan, &pat, phase, &mut budget) { + return None; + } } Some(out) } @@ -183,7 +188,8 @@ impl Piece { } } -fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) { +/// Dash one subpath; `false` when `budget` (pattern entries left to walk, shared by all subpaths) runs out. +fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, budget: &mut usize) -> bool { let first_dot = out.dots.len(); let total: f64 = plan.lens.iter().sum(); // Fraction of the subpath's length at `x` along the plan. @@ -235,6 +241,8 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) { push_dot(out, first_dot, Dot { at: seg.eval(t), dir: tangent(seg, t), t: frac(acc + end) }); } } + let Some(rest) = budget.checked_sub(1) else { return false }; + *budget = rest; cuts += 1; s = end; ph.ix = (ph.ix + 1) % pat.len(); @@ -263,7 +271,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) { write(&mut out.path, &d.segs); out.path.close_path(); out.spans.push((frac(0.0), frac(0.0) + 1.0)); - return; + return true; } if starts_on && ends_on && dashes.len() > 1 { // The dash running through the start point is one dash. @@ -281,6 +289,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) { let t0 = frac(d.start); out.spans.push((t0, if total > 0.0 { t0 + (d.end - d.start) / total } else { t0 })); } + true } /// Add `dot` unless the subpath's last dot (from index `first`) is already there: a run ending on diff --git a/crates/effects/src/trap.rs b/crates/effects/src/trap.rs index 5418038c3..98287a3b7 100644 --- a/crates/effects/src/trap.rs +++ b/crates/effects/src/trap.rs @@ -241,20 +241,27 @@ fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Option Vec { + try_traps(sources, o).unwrap_or_default() +} + +/// [`traps`], but `None` when there are more than [`MAX_NEAR_PAIRS`] candidate pairs, as opposed to +/// `Some` of an empty list when no edges meet. +pub fn try_traps(sources: &[TrapSource], o: &TrapOptions) -> Option> { let colors: Vec> = sources.iter().map(|s| trap_color(&s.fill)).collect(); if colors.iter().flatten().count() < 2 { - return vec![]; + return Some(vec![]); } let (w, r) = (o.thickness, o.height_width); if !(w.is_finite() && w > 0.0 && r.is_finite() && r > 0.0) { - return vec![]; // `trap_band` cuts nothing with these. + return Some(vec![]); // `trap_band` cuts nothing with these. } let vis = visible(sources); // Only pairs whose visible parts come within the trap's reach can trap (`trap_band` rejects @@ -266,7 +273,7 @@ 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![]; - let Some(pairs) = near_pairs(&rects, w + hair, w * r + hair) else { return vec![] }; + let pairs = near_pairs(&rects, w + hair, w * r + hair)?; 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) { @@ -286,7 +293,7 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { let Some(color) = colors[from] else { continue }; out.push(TrapPiece { path: band, paint: trap_paint(&sources[from].fill, color, o), from, into }); } - out + Some(out) } /// A trap's appearance: one overprinting fill of `paint`, no stroke. @@ -393,6 +400,7 @@ mod tests { fn no_trap_without_adjoining_edges_of_different_colours() { let apart = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), yellow()), src(Rect::new(30.0, 0.0, 40.0, 10.0), blue())]; assert!(traps(&apart, &TrapOptions::default()).is_empty()); + assert_eq!(try_traps(&apart, &TrapOptions::default()).map(|t| t.len()), Some(0), "no edges meet: empty, not over the cap"); let same = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), blue()), src(Rect::new(10.0, 0.0, 20.0, 10.0), blue())]; assert!(traps(&same, &TrapOptions::default()).is_empty()); let mut unfilled = abutting(); diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index e1fecc6da..04887e291 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -683,7 +683,7 @@ fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result { /// Pathfinder panel › Trap ([`vectorcraft_render::effects::trap`]): the traps between the selected /// objects' leaf shapes, as one group of overprinting paths above the topmost selected object. fn trap(s: &mut Session, p: &Value) -> Result { - use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps}; + use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, try_traps}; const ID: &str = "object.pathfinder.trap"; let mut q = p.clone(); if let Some(t) = len_param(p, "thickness") { @@ -721,7 +721,12 @@ fn trap(s: &mut Session, p: &Value) -> Result { if sources.iter().filter(|s| s.fill.color().is_some()).count() < 2 { return Err(EngineError::Other("Trap: select two or more objects filled with solid colours".into())); } - let made = traps(&sources, &options); + let Some(made) = try_traps(&sources, &options) else { + return Err(EngineError::Other(format!( + "Trap: more than {} pairs of objects lie close enough to trap; trap fewer objects at a time", + vectorcraft_render::effects::trap::MAX_NEAR_PAIRS + ))); + }; if made.is_empty() { return Err(EngineError::Other("Trap: no edges of different colours meet among the selected objects".into())); }