From 674a485145141278fb43662c55ffe68845c65da9 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Fri, 23 Oct 2026 03:54:45 +0400 Subject: [PATCH] Brushes: one output budget across tracks, tiles and copies (#3594) Pattern tiling and scatter cap each factor on its own: 20,000 tiles a run, MAX_SUB subdivisions a segment, 20,000 copies a track. Those caps are per track, and a path has as many tracks as it has subpaths, so they multiply rather than bound. The audit called the end-to-end impact unverified, so it was measured. A pattern brush on parallel lines, each long enough to want more tiles than a run allows: 1 subpath 20,000 nodes 320,000 anchors 36 ms 4 subpaths 80,000 nodes 1,280,000 anchors 142 ms 16 subpaths 320,000 nodes 5,120,000 anchors 571 ms Exactly 20,000 nodes a subpath, linear with no ceiling. Scatter is the same shape. MAX_ANCHORS bounds the total for one brushed stroke, threaded through both. It is 2,000,000, the figure the stacked-effects budget already uses (effects::distort::MAX_COPY_ANCHORS, #3578), and bounded the same way: per stroke rather than per document. One track at its own cap is well inside it, so the budget bites only where the caps were multiplying; sixteen subpaths now stop at the budget in 113 ms. A piece is taken whole or not at all, so art is never half-drawn. The walk that counts an art tile's anchors is iterative, and a tree it cannot finish within MAX_WALK nodes is charged the whole budget rather than undercounted, so nesting buys no room. The per-factor caps are untouched: the issue asks for a total, and changing both at once would hide which one did the bounding. --- crates/brush/src/tests.rs | 78 +++++++++++++++++++++++++++++ crates/brush/src/warp.rs | 103 ++++++++++++++++++++++++++++++++++---- docs/gaps.md | 8 +++ 3 files changed, 178 insertions(+), 11 deletions(-) diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 1a7dc61ff..8c04e102b 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -687,3 +687,81 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]); } } + +/// Anchors a brushed stroke emitted, which is what the output budget counts. +fn anchors(nodes: &[Node]) -> usize { + nodes + .iter() + .map(|n| match &n.kind { + NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::(), + _ => 0, + }) + .sum() +} + +/// A path of `subpaths` long parallel lines, each long enough to want more tiles than the +/// per-run cap allows. +fn many_long_subpaths(subpaths: usize) -> BezPath { + let mut bp = BezPath::new(); + for i in 0..subpaths { + let y = i as f64 * 50.0; + bp.move_to((0.0, y)); + bp.line_to((300_000.0, y)); + } + bp +} + +/// #3594: the per-factor caps are per track, and a path has as many tracks as it has subpaths, +/// so they multiply instead of bounding. Measured before the budget: 20,000 nodes and 320,000 +/// anchors for one subpath, and exactly N times that for N, so sixteen gave 5,120,000. +#[test] +fn pattern_output_is_capped_across_subpaths_not_only_per_run() { + let brush = pattern_brush(PatternFit::Stretch, false); + let one = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(1), &stroke(1.0)); + assert_eq!(one.len(), 20_000, "one track still fills its own per-run cap"); + let a1 = anchors(&one); + assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}"); + + // Sixteen tracks would be sixteen times that without a total budget. + // Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the + // budget must not bite before the per-factor caps have actually multiplied past it. + let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0)); + assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece"); + assert_eq!(anchors(&four), 4 * a1); + + // Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps. + let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0)); + let an = anchors(&many); + assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}"); + assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}"); + assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len()); +} + +/// The same for scatter, whose per-track guard is also 20,000 copies. +#[test] +fn scatter_output_is_capped_across_subpaths() { + let b = Brush { + name: "s".into(), + kind: BrushKind::Scatter(Scatter { art: rect_art(4.0, 4.0), colorization: Colorization::None, ..Default::default() }), + }; + let few = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(1), &stroke(1.0)); + let per_track = anchors(&few); + assert!(per_track > 0, "one track draws something"); + // Enough tracks that the per-track guards alone would pass the budget. + let tracks = 2_000_000 / per_track + 4; + let many = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(tracks), &stroke(1.0)); + let an = anchors(&many); + assert!(an <= 2_000_000, "{tracks} tracks must not exceed the budget: {an}"); + assert!(an > per_track, "but more is drawn than one track's worth: {an}"); + assert!(many.len() < tracks * few.len().max(1), "fewer copies than the per-track guards alone would allow"); +} + +/// A short, ordinary stroke is untouched by the budget: it is the absurd ones that are cut. +#[test] +fn an_ordinary_stroke_is_unaffected_by_the_budget() { + let brush = pattern_brush(PatternFit::Stretch, false); + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &sq, &stroke(1.0)); + assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget"); + assert!(anchors(&out) < 2_000_000); +} diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 35031741e..9d61216a1 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -16,6 +16,21 @@ use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Va const MAX_SEG: f64 = 1.5; /// Hard cap on subdivisions per art segment. const MAX_SUB: usize = 4000; +/// Anchors one brushed stroke may emit in total, across every track, tile and copy. +/// +/// Each factor is capped on its own (20,000 tiles a run, [`MAX_SUB`] subdivisions a segment, +/// 20,000 copies a track), but those caps are per track and a path has as many tracks as it has +/// subpaths, so they multiply rather than bound: one track at its cap measures 20,000 nodes and +/// 320,000 anchors, and sixteen subpaths measure exactly sixteen times that (#3594). This is the +/// cap on the product. The per-factor caps still shape each one. +/// +/// The same figure as the stacked-effects budget (`effects::distort::MAX_COPY_ANCHORS`, #3578), +/// and bounded the same way: per brushed stroke rather than per document, so many strokes that +/// each reach it still add up. Six times what one track at its own cap draws, so it bites only +/// where the caps were multiplying. +const MAX_ANCHORS: usize = 2_000_000; +/// Nodes [`anchors_of`] walks into before it gives up and charges the whole budget. +const MAX_WALK: usize = 10_000; /// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and /// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by @@ -235,8 +250,11 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option< let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3)); let mut rng = Rng::new(seed_of(bp, name)); let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes(); - let mut out = vec![]; + let mut out = Budget::default(); for t in tracks(bp, 0.05) { + if out.full() { + break; + } let l = t.len(); // The pen pressure at arc length `at` (half way without a recorded one). let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 })); @@ -270,10 +288,61 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option< let xf = Affine::translate(place.to_vec2()) * Affine::rotate(base - rot) * Affine::scale(size) * Affine::translate(-b.center().to_vec2()); let mut n = sc.art.clone(); n.transform(xf, true); - out.push(n); + out.take(Some(n)); + if out.full() { + break; + } } } - out + out.out +} + +/// Anchors `n` and its children hold. Walked with a stack rather than by recursion, and a tree +/// deeper or wider than [`MAX_WALK`] nodes is charged the whole budget rather than undercounted, +/// so an art tile cannot buy room by nesting. +fn anchors_of(n: &Node) -> usize { + let (mut total, mut seen) = (0usize, 0usize); + let mut stack = vec![n]; + while let Some(n) = stack.pop() { + seen += 1; + if seen > MAX_WALK { + return MAX_ANCHORS; + } + if let NodeKind::Path { path, .. } = &n.kind { + total = total.saturating_add(path.subpaths.iter().map(|s| s.anchors.len()).sum::()); + } + if let Some(c) = n.children() { + stack.extend(c.iter().map(Arc::as_ref)); + } + } + total +} + +/// Brush output collected under [`MAX_ANCHORS`]. +/// +/// A piece is taken whole or not at all, so the art a brush places is never half-drawn; the +/// total is therefore the budget plus at most one piece. +#[derive(Default)] +struct Budget { + out: Vec, + anchors: usize, +} + +impl Budget { + /// Take a piece if there is room. `None` (art that could not be placed) costs nothing. + fn take(&mut self, n: Option) { + let Some(n) = n else { return }; + if self.full() { + return; + } + self.anchors = self.anchors.saturating_add(anchors_of(&n)); + self.out.push(n); + } + + /// Whether the budget is spent, so the caller can stop walking the path. + fn full(&self) -> bool { + self.anchors >= MAX_ANCHORS + } } /// A tile placed on [s0, s1] of the track (stretched along, `cross` across). @@ -291,8 +360,11 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let end = pb.end.as_ref().and_then(orient); let tw = side.1.width() * k; let gap = tw * pb.spacing.max(0.0) / 100.0; - let mut out = vec![]; + let mut out = Budget::default(); for t in tracks(bp, 0.05) { + if out.full() { + break; + } let l = t.len(); if l <= 1e-9 { continue; @@ -314,7 +386,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec * Affine::translate(-bb.center().to_vec2()); let mut n = art.clone(); n.transform(xf, true); - out.push(n); + out.take(Some(n)); } // Runs between corners (closed tracks wrap past the end). let mut bounds: Vec = corners.iter().map(|c| c.1).collect(); @@ -346,7 +418,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec { let w = st.1.width() * k; if b - a > w { - out.extend(tile_on(&t, st, a, a + w, k)); + out.take(tile_on(&t, st, a, a + w, k)); a += w; } } @@ -356,7 +428,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec { let w = en.1.width() * k; if b - a > w { - out.extend(tile_on(&t, en, b - w, b, k)); + out.take(tile_on(&t, en, b - w, b, k)); b -= w; } } @@ -372,7 +444,10 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let (w, g) = (tw * f, gap * f); for i in 0..n.min(20000) { let s0 = a + i as f64 * (w + g); - out.extend(tile_on(&t, &side, s0, s0 + w, k)); + out.take(tile_on(&t, &side, s0, s0 + w, k)); + if out.full() { + break; + } } } PatternFit::AddSpace => { @@ -381,7 +456,10 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let lead = if n == 1 { (avail - tw) / 2.0 } else { 0.0 }; for i in 0..n.min(20000) { let s0 = a + lead + i as f64 * (tw + g.max(0.0)); - out.extend(tile_on(&t, &side, s0, s0 + tw, k)); + out.take(tile_on(&t, &side, s0, s0 + tw, k)); + if out.full() { + break; + } } } PatternFit::Approximate => { @@ -390,11 +468,14 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let lead = (avail - used) / 2.0; for i in 0..n.min(20000) { let s0 = a + lead + i as f64 * unit; - out.extend(tile_on(&t, &side, s0, s0 + tw, k)); + out.take(tile_on(&t, &side, s0, s0 + tw, k)); + if out.full() { + break; + } } } } } } - out + out.out } diff --git a/docs/gaps.md b/docs/gaps.md index 7cc5c1cb9..7c2c6763f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -199,6 +199,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall again with fewer points. Three Roughens of a rectangle in a 25 KB file needed tens of gigabytes of geometry, and two million anchors of them 141 million lines (7 GB, past vello's 2²⁷ lines a path); the file now opens in 0.4 s and about 450 MB (#3578). The budget is per path, not per document: many objects that each reach it still add up. +- **Done (2026-10-23):** a brushed stroke makes at most 2 million anchors in total, the same figure as the + stacked-effects budget above. Pattern tiling and scatter capped each factor on its own (20,000 tiles a run, 4,000 + subdivisions a segment, 20,000 copies a track), but those caps are per track and a path has as many tracks as it + has subpaths, so they multiplied instead of bounding: one track at its cap measures 20,000 pieces and 320,000 + anchors, and sixteen subpaths measured exactly sixteen times that, 5.1 million anchors in 0.57 s. A piece is now + taken whole or not at all, and the walk that counts an art tile's anchors charges the whole budget for a tree it + cannot finish, so nesting buys no room (#3594). The budget is per brushed stroke, not per document: many strokes + that each reach it still add up, and a brush inside a symbol is bounded per stroke rather than per instantiation. - **Missing:** a corpus of real-world SVG/PDF/EPS files; more Affinity versions and platforms (51 pinned files now, including 28 original Affinity 3.2.3 feature probes; large interacting documents and complex typography missing); Windows, Linux and browser QA by hand; packaging polish (Homebrew cask #588, Flathub #554, update