mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user