Shape operators: cap each expanding operator's output

Zig Zag and Wiggle Paths split every segment into up to 1001 pieces, Twist
into up to 64 and Offset Paths makes up to 1000 copies, each from the
previous operator's output, so a short stack could grow a square into
billions of points. Each now reduces its pieces or copies so its output
stays within 2^20 segments (never below one per input segment).
This commit is contained in:
Dylan M. Taylor
2026-11-02 22:15:47 -05:00
parent 7870b5916a
commit 70d234d97a
2 changed files with 38 additions and 4 deletions
+17 -4
View File
@@ -351,7 +351,7 @@ fn round_sub(sub: &Sub, r: f64, out: &mut BezPath) {
/// join. `copies` copies are made, copy *i* (0-based) offset by `amount · (i + 1 + copy_offset)`,
/// all returned in the same compound path.
pub fn offset(paths: &[BezPath], amount: f64, join: Join, miter_limit: f64, copies: f64, copy_offset: f64) -> Vec<BezPath> {
let n = copies.round().clamp(1.0, 1000.0) as usize;
let n = pieces_within_budget(paths, copies.round().clamp(1.0, 1000.0) as usize);
paths
.iter()
.map(|p| {
@@ -387,6 +387,7 @@ pub fn twist(paths: &[BezPath], angle_deg: f64, center: [f64; 2]) -> Vec<BezPath
return paths.to_vec();
}
let k = a / rmax;
let most = pieces_within_budget(paths, 64);
let rot = |v: Vec2, th: f64| {
let (s, co) = th.sin_cos();
Vec2::new(v.x * co - v.y * s, v.x * s + v.y * co)
@@ -414,7 +415,7 @@ pub fn twist(paths: &[BezPath], angle_deg: f64, center: [f64; 2]) -> Vec<BezPath
for seg in &sub.segs {
let cb = seg.to_cubic();
let len = seg.arclen(ACC);
let m = ((a.abs() * len / rmax) / 0.15 + len / 50.0).ceil().clamp(1.0, 64.0) as usize;
let m = ((a.abs() * len / rmax) / 0.15 + len / 50.0).ceil().clamp(1.0, most as f64) as usize;
for i in 0..m {
let q = cb.subsegment(i as f64 / m as f64..(i + 1) as f64 / m as f64);
let (p0, p3) = (map(q.p0), map(q.p3));
@@ -442,6 +443,18 @@ struct Sample {
len_next: f64,
}
/// Most segments an expanding operator (Zig Zag, Wiggle Paths, Twist, Offset Paths' copies)
/// makes from its input. Operators stack, each expanding the previous one's output; capping each
/// output keeps a short stack from multiplying a simple path into billions of points.
pub const MAX_OPERATOR_SEGMENTS: usize = 1 << 20;
/// `want` pieces (or copies) per input segment, reduced so the whole output stays within
/// [`MAX_OPERATOR_SEGMENTS`] (never below 1, so the input itself always passes through).
fn pieces_within_budget(paths: &[BezPath], want: usize) -> usize {
let segs: usize = paths.iter().map(|p| p.elements().len()).sum();
want.min(MAX_OPERATOR_SEGMENTS / segs.max(1)).max(1)
}
/// Split every segment into `m` pieces of equal arc length. Original vertices get the average of
/// their incoming and outgoing tangents (so a corner's normal is its bisector).
fn resample(sub: &Sub, m: usize) -> Vec<Sample> {
@@ -503,7 +516,7 @@ fn emit(samples: &[Sample], disp: &[Vec2], closed: bool, smooth: bool, handle: f
/// `smooth` makes rounded waves instead of sharp zig zags. A closed path with `n` segments ends up
/// with `n · (ridges + 1)` vertices (open: one more).
pub fn zigzag(paths: &[BezPath], size: f64, ridges: f64, smooth: bool) -> Vec<BezPath> {
let m = ridges.round().clamp(0.0, 1000.0) as usize + 1;
let m = pieces_within_budget(paths, ridges.round().clamp(0.0, 1000.0) as usize + 1);
paths
.iter()
.map(|p| {
@@ -586,7 +599,7 @@ pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath>
if w.size == 0.0 {
return paths.to_vec();
}
let m = w.detail.round().clamp(0.0, 1000.0) as usize + 1;
let m = pieces_within_budget(paths, w.detail.round().clamp(0.0, 1000.0) as usize + 1);
let seed = w.seed.round() as i64 as u64;
let t = time_s * w.speed + w.phase_deg / 360.0;
let corr = (w.correlation / 100.0).clamp(0.0, 1.0);
+21
View File
@@ -417,3 +417,24 @@ fn normalize_and_filled_area() {
let n = crate::boolean::normalize(&p, FillRule::EvenOdd);
assert!(close(n.area().abs(), 10000.0, 1e-6));
}
// ---------------------------------------------------------------- operator budget
#[test]
fn stacked_expanding_operators_stay_within_the_segment_budget() {
// Three Zig Zags at 1000 ridges would turn a square into 4 · 1001³ segments; each output
// is held to the budget instead, and the last operators still pass the geometry through.
let mut p = vec![sq(0.0, 0.0, 100.0)];
for _ in 0..3 {
p = zigzag(&p, 1.0, 1000.0, false);
let n: usize = p.iter().map(|q| q.elements().len()).sum();
assert!(n <= 2 * MAX_OPERATOR_SEGMENTS, "{n}");
}
let w = wiggle(&p, &WiggleParams { detail: 1000.0, ..Default::default() }, 0.0);
let n: usize = w.iter().map(|q| q.elements().len()).sum();
assert!(n <= 2 * MAX_OPERATOR_SEGMENTS, "{n}");
let o = offset(&[sq(0.0, 0.0, 100.0)], 1.0, Join::Miter, 4.0, 1000.0, 0.0);
assert!(!o[0].elements().is_empty());
// Within the budget nothing changes: 4 segments × 11 pieces.
assert_eq!(vertex_count(&zigzag(&[sq(0.0, 0.0, 100.0)], 1.0, 10.0, false)[0]), 44);
}