From 8ebacda419ff393e9e1a2425f2e8da9a6e21bf2f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 15:55:56 -0700 Subject: [PATCH 1/2] Shapes: Wiggle Paths has Spatial Phase, which slides its wiggle along the path Wiggle Paths had Size, Detail, Points, Wiggles/Second, Correlation, Temporal Phase and Random Seed, but no Spatial Phase, which After Effects lists among its properties (ADBE Vector Spatial Phase). Spatial Phase now moves each point's own displacement along its subpath: 360 degrees is once around, and a fraction of a point is interpolated between neighbors. At 0 degrees a path draws as before. A project saved without the property gets it at 0 degrees, after Temporal Phase, when it opens. Scripts can read and set every Wiggle Paths property by its match name. --- crates/engine/src/lib.rs | 7 ++++- crates/engine/src/tests.rs | 25 +++++++++++++++++ crates/path/src/ops.rs | 45 ++++++++++++++++++++++++------- crates/path/src/ops/tests.rs | 32 ++++++++++++++++++++++ crates/project/src/build.rs | 25 ++++++++++++++++- crates/render/src/shapes.rs | 1 + crates/render/src/shapes/tests.rs | 18 +++++++++++++ crates/script/src/matchnames.rs | 8 ++++++ crates/script/src/tests.rs | 38 ++++++++++++++++++++++++++ 9 files changed, 187 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 83024dd81..be605baa8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1313,7 +1313,9 @@ impl Session { /// (renamed / regrouped / added parameters, reordered popups; see /// [`effectcraft_effects::migrate`]), and a project from before /// [`effectcraft_project::SCHEMA_EFFECT_SPACE`] into effect space -/// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema. +/// ([`effectcraft_effects::migrate::to_effect_space`]). Shape path operations get the +/// properties added since ([`effectcraft_project::build::upgrade_shape_contents`]). Stamps the +/// current schema. pub fn upgrade_effects(p: &mut Project) { let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE; // Effect bounds per layer: (size, top-left in layer coordinates). @@ -1336,6 +1338,9 @@ pub fn upgrade_effects(p: &mut Project) { Some(id) => bounds.get(&(*cid, effectcraft_project::LayerId(id))).map_or([0.0; 2], |b| shift(b.1)), None => d, }; + if let Some(contents) = l.props.sub_mut("contents") { + effectcraft_project::build::upgrade_shape_contents(&mut effectcraft_project::build::Ids(&mut next), contents); + } let Some(fx) = l.props.sub_mut("effects") else { continue }; for n in &mut fx.children { let effectcraft_project::Node::Group(g) = n else { continue }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b3f0602b3..b5d1cbb5f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -280,6 +280,31 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() { let _ = s.render(cid, s.time(), Default::default()); } +/// A Wiggle Paths saved without Spatial Phase gets the property, at 0°, after Temporal Phase when +/// the project opens. +#[test] +fn opening_a_wiggle_paths_without_spatial_phase_adds_it() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let uid = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "wiggle"})).unwrap()["uid"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let mut old = effectcraft_project::Project::from_json(&s.project.to_file_json().unwrap()).unwrap(); + let l = old.comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(sh)).unwrap(); + let g = l.props.find_group_mut(uid).unwrap(); + g.children.retain(|c| c.match_id() != "spatialPhase"); + assert_eq!(g.children.len(), 7); + let next = old.next_id; + s.replace_project(old, None); + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone(); + let g = l.props.find_group(uid).unwrap(); + let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect(); + assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]); + let sp = g.get("spatialPhase").unwrap(); + assert_eq!((sp.name.as_str(), &sp.value), ("Spatial Phase", &effectcraft_keyframe::Value::Scalar(0.0))); + assert!(sp.uid >= next && s.project.next_id > sp.uid, "a fresh uid: {} (next {next})", sp.uid); +} + /// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732): /// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its /// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none. diff --git a/crates/path/src/ops.rs b/crates/path/src/ops.rs index ec6e18604..3ad94944d 100644 --- a/crates/path/src/ops.rs +++ b/crates/path/src/ops.rs @@ -533,12 +533,15 @@ pub struct WiggleParams { pub correlation: f64, /// Temporal phase in degrees (360° = one wiggle period). pub phase_deg: f64, + /// Spatial phase in degrees: slides each subpath's pattern of displacements along its points + /// (360° = once around the subpath). + pub spatial_phase_deg: f64, pub seed: f64, } impl Default for WiggleParams { fn default() -> Self { - WiggleParams { size: 10.0, detail: 10.0, smooth: true, speed: 2.0, correlation: 50.0, phase_deg: 0.0, seed: 0.0 } + WiggleParams { size: 10.0, detail: 10.0, smooth: true, speed: 2.0, correlation: 50.0, phase_deg: 0.0, spatial_phase_deg: 0.0, seed: 0.0 } } } @@ -568,9 +571,16 @@ fn noise(seed: u64, idx: u64, t: f64) -> f64 { v.clamp(-1.0, 1.0) } +/// Catmull-Rom interpolation between `p1` (f = 0) and `p2` (f = 1). +fn catmull_rom(p0: f64, p1: f64, p2: f64, p3: f64, f: f64) -> f64 { + 0.5 * (2.0 * p1 + (-p0 + p2) * f + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * f * f + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * f * f * f) +} + /// Wiggle Paths: subdivides the paths (`detail` points per segment) and displaces every point by /// smooth random noise of up to `size` pixels that evolves over `time_s` seconds at `speed` -/// wiggles per second. Deterministic for a given seed and time. +/// wiggles per second. The spatial phase moves each point's own share of the noise along the +/// subpath, and a fraction of a point is interpolated between its neighbors. Deterministic for a +/// given seed and time. pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec { if w.size == 0.0 { return paths.to_vec(); @@ -580,6 +590,7 @@ pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec let t = time_s * w.speed + w.phase_deg / 360.0; let corr = (w.correlation / 100.0).clamp(0.0, 1.0); let shared = Vec2::new(noise(seed, u64::MAX, t), noise(seed, u64::MAX - 1, t)); + let turns = if w.spatial_phase_deg.is_finite() { (w.spatial_phase_deg / 360.0).rem_euclid(1.0) } else { 0.0 }; let mut idx: u64 = 0; paths .iter() @@ -587,14 +598,28 @@ pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec let mut out = BezPath::new(); for sub in subs(p) { let s = resample(&sub, m); - let disp: Vec = s - .iter() - .map(|_| { - let own = Vec2::new(noise(seed, idx * 2, t + idx as f64 * 0.37), noise(seed, idx * 2 + 1, t + idx as f64 * 0.61)); - idx += 1; - (shared * corr + own * (1.0 - corr)) * w.size - }) - .collect(); + let n = s.len(); + let first = idx; + let own = |j: usize| { + let i = first + j as u64; + Vec2::new(noise(seed, i * 2, t + i as f64 * 0.37), noise(seed, i * 2 + 1, t + i as f64 * 0.61)) + }; + let along = |j: usize| { + if turns == 0.0 { + return own(j); + } + let u = (j as f64 + turns * n as f64).rem_euclid(n as f64); + let k = u.floor(); + let f = u - k; + let k = k as usize % n; + if f == 0.0 { + return own(k); + } + let (p0, p1, p2, p3) = (own((k + n - 1) % n), own(k), own((k + 1) % n), own((k + 2) % n)); + Vec2::new(catmull_rom(p0.x, p1.x, p2.x, p3.x, f).clamp(-1.0, 1.0), catmull_rom(p0.y, p1.y, p2.y, p3.y, f).clamp(-1.0, 1.0)) + }; + let disp: Vec = (0..n).map(|j| (shared * corr + along(j) * (1.0 - corr)) * w.size).collect(); + idx += n as u64; emit(&s, &disp, sub.closed, w.smooth, 1.0 / 3.0, &mut out); } out diff --git a/crates/path/src/ops/tests.rs b/crates/path/src/ops/tests.rs index dbdf213ba..89c009bc4 100644 --- a/crates/path/src/ops/tests.rs +++ b/crates/path/src/ops/tests.rs @@ -317,6 +317,38 @@ fn wiggle_full_correlation_translates() { assert_eq!(zero[0], s[0]); } +/// Spatial Phase moves the pattern of displacements along the path: 360° is once around, and a +/// whole number of points' worth gives each point the displacement of the point that far ahead. +#[test] +fn wiggle_spatial_phase_moves_the_pattern_along_the_path() { + let s = [sq(0.0, 0.0, 100.0)]; + let w = WiggleParams { detail: 3.0, correlation: 0.0, smooth: false, ..Default::default() }; + // Displacements are proportional to Size: twice the size less once the size. + let disp = |w: WiggleParams| -> Vec { + let a = on_curve(&wiggle(&s, &w, 0.4)[0]); + let b = on_curve(&wiggle(&s, &WiggleParams { size: w.size * 2.0, ..w }, 0.4)[0]); + a.iter().zip(&b).map(|(a, b)| *b - *a).take(16).collect() + }; + let d0 = disp(w); + let n = d0.len(); + assert_eq!(n, 16); + for k in [1, 5] { + let dk = disp(WiggleParams { spatial_phase_deg: 360.0 * k as f64 / n as f64, ..w }); + for j in 0..n { + let e = d0[(j + k) % n]; + assert!(close(dk[j].x, e.x, 1e-9) && close(dk[j].y, e.y, 1e-9), "{k} ahead, point {j}: {:?} vs {e:?}", dk[j]); + } + } + // Whole turns either way leave the path as it is. + let base = wiggle(&s, &w, 0.4); + assert_eq!(wiggle(&s, &WiggleParams { spatial_phase_deg: 360.0, ..w }, 0.4), base); + assert_eq!(wiggle(&s, &WiggleParams { spatial_phase_deg: -720.0, ..w }, 0.4), base); + // Half a point ahead, the displacements change and stay within Size. + let half = disp(WiggleParams { spatial_phase_deg: 180.0 / n as f64, ..w }); + assert!(half.iter().zip(&d0).any(|(h, d)| (*h - *d).hypot() > 1e-3)); + assert!(half.iter().all(|h| h.x.abs() <= 10.0 + 1e-9 && h.y.abs() <= 10.0 + 1e-9)); +} + // ---------------------------------------------------------------- merge / booleans fn inclusion_exclusion(a: BezPath, b: BezPath) { diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 935c03b0e..3f9e232aa 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{Gradient, ShapePath, TextDoc, Value}; use effectcraft_time::Tick; -use crate::props::{GroupKind, MaskMode, ParamUi, PropGroup, Property}; +use crate::props::{GroupKind, MaskMode, Node, ParamUi, PropGroup, Property}; use crate::{Comp, Layer, LayerId, LayerSource, LightKind, Project, Switches}; /// Id allocator borrowing the project's counter. @@ -594,6 +594,7 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option { .with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0))) .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent)) .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(wiggle_spatial_phase(ids)) .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))), "merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[ "Merge", @@ -607,6 +608,28 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option { Some(g) } +/// Wiggle Paths' Spatial Phase. +fn wiggle_spatial_phase(ids: &mut Ids) -> Property { + ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle) +} + +/// Adds the properties that shape path operations have gained since a project was saved (Wiggle +/// Paths' Spatial Phase), at their defaults, to the operations in `contents` and its nested +/// groups. Returns whether anything was added. +pub fn upgrade_shape_contents(ids: &mut Ids, contents: &mut PropGroup) -> bool { + let mut changed = false; + for n in &mut contents.children { + let Node::Group(g) = n else { continue }; + if g.match_id == "wiggle" && !g.children.iter().any(|c| c.match_id() == "spatialPhase") { + let at = g.children.iter().position(|c| c.match_id() == "phase").map_or(g.children.len(), |i| i + 1); + g.children.insert(at, wiggle_spatial_phase(ids).into()); + changed = true; + } + changed |= upgrade_shape_contents(ids, g); + } + changed +} + // ---------------------------------------------------------------- camera / light / 3D pub fn camera_options(ids: &mut Ids, zoom: f64) -> PropGroup { diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f8de5a290..7438f4a2d 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -324,6 +324,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec Date: Sun, 25 Oct 2026 20:13:34 -0700 Subject: [PATCH 2/2] Shapes: upgrading a saved project walks nested shape groups without recursion upgrade_shape_contents now visits nested groups with a work list, so a project with very deeply nested shape groups does not run out of stack while Wiggle Paths gains Spatial Phase. The new test nests a Wiggle Paths 100,000 groups deep and upgrades it on a 2 MiB stack. --- crates/project/src/build.rs | 20 ++++++++++++-------- crates/project/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 3f9e232aa..b78e3d8c1 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -615,17 +615,21 @@ fn wiggle_spatial_phase(ids: &mut Ids) -> Property { /// Adds the properties that shape path operations have gained since a project was saved (Wiggle /// Paths' Spatial Phase), at their defaults, to the operations in `contents` and its nested -/// groups. Returns whether anything was added. +/// groups. Returns whether anything was added. It walks the nested groups with a work list and +/// does not recurse. pub fn upgrade_shape_contents(ids: &mut Ids, contents: &mut PropGroup) -> bool { let mut changed = false; - for n in &mut contents.children { - let Node::Group(g) = n else { continue }; - if g.match_id == "wiggle" && !g.children.iter().any(|c| c.match_id() == "spatialPhase") { - let at = g.children.iter().position(|c| c.match_id() == "phase").map_or(g.children.len(), |i| i + 1); - g.children.insert(at, wiggle_spatial_phase(ids).into()); - changed = true; + let mut todo = vec![contents]; + while let Some(group) = todo.pop() { + for n in &mut group.children { + let Node::Group(g) = n else { continue }; + if g.match_id == "wiggle" && !g.children.iter().any(|c| c.match_id() == "spatialPhase") { + let at = g.children.iter().position(|c| c.match_id() == "phase").map_or(g.children.len(), |i| i + 1); + g.children.insert(at, wiggle_spatial_phase(ids).into()); + changed = true; + } + todo.push(g); } - changed |= upgrade_shape_contents(ids, g); } changed } diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index fe36c7724..f9ee16167 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -290,3 +290,35 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { assert!(p.comp_contains(c[0], c[n - 1])); assert!(!p.comp_contains(c[n - 1], c[0])); } + +/// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack. +#[test] +fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() { + let n = 100_000; + let mut next = 1; + let mut contents = build::shape_simple_op(&mut Ids(&mut next), "wiggle").unwrap(); + contents.children.retain(|c| c.match_id() != "spatialPhase"); + for _ in 0..n { + let uid = Ids(&mut next).alloc(); + contents = crate::PropGroup::new(uid, "group", "Group 1").with(contents); + } + let h = std::thread::Builder::new() + .stack_size(2 << 20) + .spawn(move || { + let added = build::upgrade_shape_contents(&mut Ids(&mut next), &mut contents); + (added, contents) + }) + .unwrap(); + let (added, mut g) = h.join().unwrap(); + assert!(added); + // One level at a time: dropping the whole tree would recurse as deep as it is nested. + let mut depth = 0; + while g.match_id == "group" { + let Some(crate::Node::Group(child)) = g.children.pop() else { panic!("a nested group") }; + g = child; + depth += 1; + } + assert_eq!(depth, n); + let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect(); + assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]); +}