mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge pull request #2450 from dotCooCoo/feat/wiggle-paths-spatial-phase
Shapes: Wiggle Paths has Spatial Phase, which slides its wiggle along the path
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
|
||||
+35
-10
@@ -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<BezPath> {
|
||||
if w.size == 0.0 {
|
||||
return paths.to_vec();
|
||||
@@ -580,6 +590,7 @@ pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath>
|
||||
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<BezPath>
|
||||
let mut out = BezPath::new();
|
||||
for sub in subs(p) {
|
||||
let s = resample(&sub, m);
|
||||
let disp: Vec<Vec2> = 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<Vec2> = (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
|
||||
|
||||
@@ -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<Vec2> {
|
||||
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) {
|
||||
|
||||
@@ -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<PropGroup> {
|
||||
.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<PropGroup> {
|
||||
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 {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -324,6 +324,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
speed: ctx.f(layer, g, "speed", 2.0),
|
||||
correlation: ctx.f(layer, g, "correlation", 50.0),
|
||||
phase_deg: ctx.f(layer, g, "phase", 0.0),
|
||||
spatial_phase_deg: ctx.f(layer, g, "spatialPhase", 0.0),
|
||||
seed: ctx.f(layer, g, "seed", 0.0),
|
||||
};
|
||||
let t = layer.layer_time(ctx.time).seconds();
|
||||
|
||||
@@ -258,6 +258,24 @@ fn round_corners_clip_corner_pixels() {
|
||||
assert!((coverage(&img) - std::f32::consts::PI * 400.0).abs() < 15.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
/// Wiggle Paths' Spatial Phase changes the drawn shape; a whole turn draws it as at 0°.
|
||||
#[test]
|
||||
fn wiggle_paths_spatial_phase_renders() {
|
||||
let render = |phase: f64| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_ellipse(ids, [60.0, 60.0], [0.0, 0.0]),
|
||||
op(ids, "wiggle", &[("size", Value::Scalar(8.0)), ("correlation", Value::Scalar(0.0)), ("spatialPhase", Value::Scalar(phase))]),
|
||||
build::shape_fill(ids, RED),
|
||||
]
|
||||
});
|
||||
render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0).data
|
||||
};
|
||||
let zero = render(0.0);
|
||||
assert!(render(90.0) != zero, "Spatial Phase 90° draws the shape of 0°");
|
||||
assert!(render(360.0) == zero, "Spatial Phase 360° draws another shape than 0°");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_paths_vary_over_time_deterministically() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
|
||||
@@ -209,6 +209,14 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[
|
||||
("twist", "angle", "ADBE Vector Twist Angle"),
|
||||
("twist", "center", "ADBE Vector Twist Center"),
|
||||
("merge", "mode", "ADBE Vector Merge Type"),
|
||||
("wiggle", "size", "ADBE Vector Roughen Size"),
|
||||
("wiggle", "detail", "ADBE Vector Roughen Detail"),
|
||||
("wiggle", "points", "ADBE Vector Roughen Points"),
|
||||
("wiggle", "speed", "ADBE Vector Temporal Freq"),
|
||||
("wiggle", "correlation", "ADBE Vector Correlation"),
|
||||
("wiggle", "phase", "ADBE Vector Temporal Phase"),
|
||||
("wiggle", "spatialPhase", "ADBE Vector Spatial Phase"),
|
||||
("wiggle", "seed", "ADBE Vector Random Seed"),
|
||||
];
|
||||
|
||||
/// Effects: our effect id → AE match name (the documented ones; the rest keep our id).
|
||||
|
||||
@@ -270,6 +270,44 @@ fn an_undefined_name_mentioned_earlier_reports_its_use_in_the_reading_function()
|
||||
assert_eq!(e.line, Some(6), "{e:?}");
|
||||
}
|
||||
|
||||
/// Scripts reach Wiggle Paths' properties, Spatial Phase included, by their match names.
|
||||
#[test]
|
||||
fn wiggle_paths_properties_by_match_name() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("W", 200, 200, 1, 2, 30);
|
||||
var sh = c.layers.addShape();
|
||||
var w = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Filter - Roughen");
|
||||
w.property("ADBE Vector Spatial Phase").setValue(45);
|
||||
var names = [];
|
||||
for (var i = 1; i <= w.numProperties; i++) names.push(w.property(i).matchName);
|
||||
[w.matchName, w.property("ADBE Vector Spatial Phase").name, names]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.result,
|
||||
json!([
|
||||
"ADBE Vector Filter - Roughen",
|
||||
"Spatial Phase",
|
||||
[
|
||||
"ADBE Vector Roughen Size",
|
||||
"ADBE Vector Roughen Detail",
|
||||
"ADBE Vector Roughen Points",
|
||||
"ADBE Vector Temporal Freq",
|
||||
"ADBE Vector Correlation",
|
||||
"ADBE Vector Temporal Phase",
|
||||
"ADBE Vector Spatial Phase",
|
||||
"ADBE Vector Random Seed"
|
||||
]
|
||||
])
|
||||
);
|
||||
let (_, c) = comp_named(&s, "W");
|
||||
let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
|
||||
assert_eq!(sh.props.prop("contents/wiggle/spatialPhase").unwrap().value, KV::Scalar(45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_names_and_lookups() {
|
||||
let mut s = session();
|
||||
|
||||
Reference in New Issue
Block a user