Merge pull request #3731 from saad-mughal435/fix/brush-total-output-budget

Brushes: one output budget across tracks, tiles and copies (#3594)
This commit is contained in:
BFlat
2026-10-23 07:12:19 +07:00
committed by GitHub
3 changed files with 178 additions and 11 deletions
+78
View File
@@ -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::<usize>(),
_ => 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);
}
+92 -11
View File
@@ -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::<usize>());
}
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<Node>,
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<Node>) {
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<Node>
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<Node>
* 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<f64> = corners.iter().map(|c| c.1).collect();
@@ -346,7 +418,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
{
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<Node>
{
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<Node>
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<Node>
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<Node>
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
}
+8
View File
@@ -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