diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index a8b03461a..3f997d08e 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -262,7 +262,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). @@ -285,6 +287,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/session.rs b/crates/engine/src/tests/session.rs index b3f0602b3..b5d1cbb5f 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.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..b78e3d8c1 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,32 @@ 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. 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; + 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 +} + // ---------------------------------------------------------------- camera / light / 3D pub fn camera_options(ids: &mut Ids, zoom: f64) -> PropGroup { 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"]); +} diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index a4e4077f8..511b32f3e 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