diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index aa72ad2d..8fabb059 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -108,6 +108,10 @@ const MAX_SCALE: f64 = 100_000.0; const MAX_MOVE: f64 = 1.0e7; /// The Transform effect's copies stop before one reaches further than this (pt) from the origin. const MAX_REACH: f64 = 1.0e9; +/// Most anchors a Transform effect's copies make of a path, the original included: 1000 copies of +/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make +/// a thousand million of one square), so the limit is on the result, not on each effect's count. +pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000; /// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds /// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With @@ -115,6 +119,11 @@ const MAX_REACH: f64 = 1.0e9; /// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result /// on every redraw while each object varies its own way. pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData { + transform_within(path, b, p, seed, MAX_COPY_ANCHORS) +} + +/// [`transform`] whose copies make at most `max` anchors of the path, the original included. +pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData { let random = flag(p, "random", false); // The share (0..1) of value `k` applied. let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 }; @@ -132,9 +141,17 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData { return path.transformed(m); } let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH); + // Each copy adds the path once more, and a Transform after this one copies the lot again: + // copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705). + let each = path.anchor_count(); + let mut anchors = each; let mut out = path.clone(); let mut cur = path.clone(); for _ in 0..copies { + anchors = anchors.saturating_add(each); + if anchors > max { + break; + } cur = cur.transformed(m); if !cur.bounds().is_some_and(within) { break; diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ffafc9f8..94430875 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -206,6 +206,24 @@ fn random_transform_is_stable_per_object_and_within_the_values() { assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}"); } +/// #1705: stacked Transform effects multiply their copies; the anchors they make are bounded, +/// whatever each one's count. +#[test] +fn stacked_transform_copies_stay_within_the_anchor_budget() { + let p = json!({"copies": 1000}); + let b = square().bounds().unwrap(); + let mut path = square(); + let mut counts = vec![]; + for _ in 0..3 { + path = crate::distort::transform_within(&path, b, &p, 0, 100); + counts.push(path.anchor_count()); + } + assert_eq!(counts, [100, 100, 100], "25 squares, then no room for a copy of them"); + // Within the budget every copy is made, and the effect's own budget takes a thousand copies. + assert_eq!(crate::distort::transform_within(&square(), b, &json!({"copies": 24}), 0, 100).subpaths.len(), 25); + assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001); +} + #[test] fn transform_copies_stop_before_they_overflow() { let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());