Browse / github.com/storytold/effectcraft / patch
Shapes: add the Wiggle Transform path operation, which wiggles paths and each Repeater copy
Shapes: add the Wiggle Transform path operation, which wiggles paths and each Repeater copy ## What A shape layer's Contents ▸ Add menu offered every After Effects path operation except Wiggle Transform (#206: "Wiggle Transform isn't implemented yet"). After Effects' Wiggle Transform "randomizes (wiggles) any combination of the position, anchor point, scale, and rotation transformations for a path", by the magnitudes set in its Transform group, and works per copy after a Repeater ([Shape attributes, paint operations, and path operations for shape layers](http://web.archive.org/web/20251209024058/https://helpx.adobe.com/after-effects/using/shape-attributes-paint-operations-path.html)
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #2355, open)
- Base
dd12ed8f9c341a6081e08c8fd840aa3bb4325537- Size
- +362 −6 in 11 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f165f1c3b0e49657d5739d29
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/layer.rs crates/engine/src/cmd/paths.rs crates/project/src/build.rs crates/render/src/cache.rs crates/render/src/shapes.rs crates/render/src/shapes/tests.rs crates/script/src/matchnames.rs crates/script/src/tests.rs crates/ui-egui/src/panels/timeline.rs crates/ui-egui/tests/it/ui_timeline_shapes.rs
@@ -117,6 +117,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or+ `layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and+ rotation of the paths above it by random amounts that change over time, up to the Transform+ values it holds. The copies of a Repeater above it wiggle independently as far as its+ Correlation allows; with the Repeater below it, they wiggle together. - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> { "Add (Shape)", [], None,- "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",+ "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", has_layers, add_shape_item ),@@ -360,7 +360,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> { const SHAPE_ITEMS: &[&str] = &[ "group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",- "wiggle", "merge", "front", "bevel", "side", "back",+ "wiggle", "wiggler", "merge", "front", "bevel", "side", "back", ]; /// Selected shape items (contents entries) of shape layers: (layer, uid).@@ -595,6 +595,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> { .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(ids.prop("seed", "Random Seed", Value::Scalar(0.0))),+ "wiggler" | "wiggleTransform" => {+ // The Transform group holds how far each transformation wiggles.+ let tr = ids+ .group("transform", "Transform")+ .with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))+ .with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))+ .with(ids.prop("scale", "Scale", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent))+ .with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));+ indexed(ids.group("wiggler", "Wiggle Transform 1"))+ .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(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))+ .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0)))+ .with(tr)+ } "merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[ "Merge", "Add",@@ -458,7 +458,7 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool { if matches!(layer.source, LayerSource::Shape) && let Some(c) = layer.props.sub("contents") {- return group_has(c, "wiggle");+ return group_has(c, "wiggle") || group_has(c, "wiggler"); } // Wiggly selectors move with time; Expression selectors evaluate per character (their // property value alone doesn't capture the result).@@ -2,8 +2,8 @@ //! with the After Effects stacking semantics: //! //! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag,-//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including-//! the paths of nested groups;+//! Wiggle Paths, Wiggle Transform, Merge Paths, Repeater) act on all paths above them in the+//! same group, including the paths of nested groups; //! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified //! by every operator in the stack* — a Trim Paths below a Stroke still trims it; //! - items higher in the list draw on top;@@ -17,7 +17,9 @@ use effectcraft_keyframe::{Gradient, Value}; use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke}; use effectcraft_project::{Layer, Node, PropGroup}; use effectcraft_raster::{Image, Mask};+use effectcraft_text::selectors::{self, Wiggly}; use rayon::prelude::*;+use std::collections::{HashMap, HashSet}; use crate::eval::EvalCtx; @@ -185,9 +187,84 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec<usize> { v } +/// Wiggle Transform: moves the live slots by a random offset of the Transform group's Anchor+/// Point, Position, Scale and Rotation (the most each one wiggles), changing over time. Its+/// Wiggles/Second, Correlation, Temporal Phase, Spatial Phase and Random Seed work as the Wiggly+/// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above+/// the operation move on their own as far as Correlation allows, and the copies of a Repeater+/// below it move as one. The four transformations of one operation move together.+#[allow(clippy::too_many_arguments)]+fn wiggle_transform(+ ctx: &EvalCtx,+ layer: &Layer,+ g: &PropGroup,+ tr: &PropGroup,+ arena: &mut [BezPath],+ live: &[usize],+ copy_of: &HashMap<usize, usize>,+ draws: &mut [Draw],+) {+ let w = Wiggly {+ max: 1.0,+ min: -1.0,+ wiggles_per_second: ctx.f(layer, g, "speed", 2.0),+ correlation: ctx.f(layer, g, "correlation", 50.0) / 100.0,+ temporal_phase: ctx.f(layer, g, "phase", 0.0),+ spatial_phase: ctx.f(layer, g, "spatialPhase", 0.0),+ lock_dimensions: false,+ seed: ctx.f(layer, g, "seed", 0.0).round() as i64 as u64,+ };+ let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);+ let pos = ctx.v2(layer, tr, "position", [0.0; 2]);+ let scale = ctx.v2(layer, tr, "scale", [0.0; 2]);+ let rot = ctx.f(layer, tr, "rotation", 0.0);+ let t = layer.layer_time(ctx.time).seconds();+ let matrix = |k: usize| {+ let (x, y) = (selectors::wiggly_value(&w, t, k, 0), selectors::wiggly_value(&w, t, k, 1));+ Mat3::translate(vec2(pos[0] * x, pos[1] * y))+ * Mat3::rotate_deg(rot * x)+ * Mat3::scale(vec2(1.0 + scale[0] * x / 100.0, 1.0 + scale[1] * y / 100.0))+ * Mat3::translate(vec2(-anchor[0] * x, -anchor[1] * y))+ };+ let mut by_copy: HashMap<usize, Mat3> = HashMap::new();+ let mut done: HashSet<usize> = HashSet::new();+ for &s in live {+ if !done.insert(s) {+ continue;+ }+ let k = copy_of.get(&s).copied().unwrap_or(0);+ let m = *by_copy.entry(k).or_insert_with(|| matrix(k));+ arena[s] = mat_path(&arena[s], &m);+ }+ // A paint above the operation whose paths all moved by one matrix moves its gradient and+ // stroke scale with them, as a group transform does. A paint over copies that moved by+ // different matrices stays one paint (its fill rule and opacity apply to all of them at once)+ // and keeps its gradient and stroke scale.+ for d in draws {+ let mut ms = d.slots.iter().map(|s| done.contains(s).then(|| by_copy.get(©_of.get(s).copied().unwrap_or(0)).copied()).flatten());+ let Some(Some(m)) = ms.next() else { continue };+ if ms.all(|x| x == Some(m)) {+ d.xf = m * d.xf;+ }+ }+}+ /// Collect the draws of a contents group and the path slots live at its end (in group space). /// `arena` holds every path slot; operators rewrite slots in place. fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>) -> (Vec<Draw>, Vec<usize>) {+ collect_in(ctx, layer, contents, arena, &mut Copies::default())+}++/// The Repeater copy each path slot belongs to, for Wiggle Transform: `of` maps a slot a Repeater+/// made to its copy's id (slots no Repeater made are copy 0). Ids are unique across the layer's+/// groups, so copies made in a nested group stay apart in the groups around it.+#[derive(Default)]+struct Copies {+ of: HashMap<usize, usize>,+ next: usize,+}++fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>, copy_ids: &mut Copies) -> (Vec<Draw>, Vec<usize>) { let mut draws: Vec<Draw> = Vec::new(); let mut live: Vec<usize> = Vec::new(); let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;@@ -329,6 +406,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B let t = layer.layer_time(ctx.time).seconds(); apply(arena, &live, |p| ops::wiggle(p, &w, t)); }+ "wiggler" => {+ let Some(tr) = g.sub("transform") else { continue };+ wiggle_transform(ctx, layer, g, tr, arena, &live, ©_ids.of, &mut draws);+ } "merge" => { let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode")); let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();@@ -341,7 +422,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B } "group" => { let Some(inner) = g.sub("contents") else { continue };- let (cd, cl) = collect(ctx, layer, inner, arena);+ let (cd, cl) = collect_in(ctx, layer, inner, arena, copy_ids); let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0)); for i in used_slots(&cl, &cd) { arena[i] = mat_path(&arena[i], &m);@@ -378,12 +459,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B // Each copy gets its own transformed slots, so operators below the repeater act on // the repeated geometry (copy order: original first). let mut copy_maps: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(n);+ // Each copy of each earlier copy (a second Repeater) gets an id of its own.+ let mut ids: HashMap<(usize, usize), usize> = HashMap::new(); for i in 0..n { let m = step(i as f64 + offset); let mut map = std::collections::HashMap::new(); for &s in &base_slots { arena.push(mat_path(&arena[s], &m)); map.insert(s, arena.len() - 1);+ let base = copy_ids.of.get(&s).copied().unwrap_or(0);+ let id = *ids.entry((i, base)).or_insert_with(|| {+ copy_ids.next += 1;+ copy_ids.next+ });+ copy_ids.of.insert(arena.len() - 1, id); } live.extend(base_live.iter().map(|s| map[s])); copy_maps.push(map);@@ -270,6 +270,202 @@ fn wiggle_paths_vary_over_time_deterministically() { assert_ne!(a.data, c.data); } +/// Alpha-weighted centroid of the pixels in columns `x0..x1`.+fn centroid(img: &Image, x0: i64, x1: i64) -> [f32; 2] {+ let (mut sx, mut sy, mut sa) = (0.0, 0.0, 0.0);+ for x in x0..x1 {+ for y in 0..img.height as i64 {+ let a = alpha(img, x, y);+ sx += a * x as f32;+ sy += a * y as f32;+ sa += a;+ }+ }+ [sx / sa, sy / sa]+}++fn wiggle_transform(ids: &mut Ids, params: &[(&str, Value)]) -> PropGroup {+ op(ids, "wiggler", params)+}++#[test]+fn wiggle_transform_moves_paths_over_time() {+ let run = |position: [f64; 2], t: f64| {+ let (p, cid) = shape_comp(|ids| {+ let w = wiggle_transform(ids, &[("transform/position", Value::Vec2(position))]);+ vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)]+ });+ render_frame(&p, cid, Tick::from_seconds_f64(t), 1.0)+ };+ // The defaults wiggle nothing: the square stays centered on pixel 99.5, 49.5.+ let rest = [99.5, 49.5];+ assert_eq!(centroid(&run([0.0, 0.0], 0.37), 0, 200), rest);+ let a = run([15.0, 15.0], 0.37);+ assert_eq!(a.data, run([15.0, 15.0], 0.37).data, "the same time gives the same frame");+ let b = run([15.0, 15.0], 1.21);+ let (ca, cb) = (centroid(&a, 0, 200), centroid(&b, 0, 200));+ assert!(ca != rest && ca != cb, "{ca:?} {cb:?}");+ for c in [ca, cb] {+ assert!((c[0] - rest[0]).abs() <= 15.01 && (c[1] - rest[1]).abs() <= 15.01, "{c:?}");+ }+ // A translation keeps the square's area.+ assert!((coverage(&a) - 400.0).abs() < 1.0, "{}", coverage(&a));+ // The layer cache keys a layer with a Wiggle Transform by time.+ let (p, cid) = shape_comp(|ids| vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), wiggle_transform(ids, &[]), build::shape_fill(ids, RED)]);+ let comp = p.comp(cid).unwrap();+ let key = |t: f64| crate::cache::layer_key(&crate::eval::EvalCtx::new(&p, cid, comp, Tick::from_seconds_f64(t)), &comp.layers[0], 1.0, false, false);+ assert_ne!(key(0.37), key(1.21));+}++#[test]+fn wiggle_transform_scales_and_rotates() {+ let run = |params: &[(&str, Value)]| {+ let (p, cid) = shape_comp(|ids| {+ let w = wiggle_transform(ids, params);+ vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)]+ });+ render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0)+ };+ let scaled = coverage(&run(&[("transform/scale", Value::Vec2([50.0, 50.0]))]));+ assert!((scaled - 1600.0).abs() > 20.0, "{scaled}");+ let rotated = run(&[("transform/rotation", Value::Scalar(45.0))]);+ assert!((coverage(&rotated) - 1600.0).abs() < 2.0, "{}", coverage(&rotated));+ assert!(alpha(&rotated, 81, 31) < 0.5 || alpha(&rotated, 119, 31) < 0.5, "a corner turned away");+}++/// A gradient above a Wiggle Transform moves with the paths it paints, as under a group transform.+#[test]+fn wiggle_transform_moves_the_gradient_with_the_path() {+ let (p, cid) = shape_comp(|ids| {+ let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };+ vec![+ build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0),+ build::shape_gradient_fill(ids, false, [-20.0, 0.0], [20.0, 0.0], g),+ wiggle_transform(ids, &[("transform/position", Value::Vec2([60.0, 0.0]))]),+ ]+ });+ let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);+ let cols: Vec<i64> = (0..200).filter(|&x| alpha(&img, x, 50) > 0.99).collect();+ let (l, r) = (cols[0], cols[cols.len() - 1]);+ assert!((l - 80).abs() > 3, "the square moved: {l}");+ let (cl, cr) = (img.get(l, 50), img.get(r, 50));+ assert!(cl[0] < 0.1 && cr[0] > 0.9, "the ramp runs edge to edge: {cl:?} {cr:?}");+}++/// A gradient that paints every copy of a Repeater moves with the copies when they wiggle alike+/// (Correlation 100).+#[test]+fn wiggle_transform_moves_a_gradient_shared_by_repeater_copies() {+ let (p, cid) = shape_comp(|ids| {+ let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };+ vec![+ build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0),+ build::shape_repeater(ids, 3.0, [60.0, 0.0]),+ build::shape_gradient_fill(ids, false, [0.0, -10.0], [0.0, 10.0], g),+ wiggle_transform(ids, &[("correlation", Value::Scalar(100.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),+ ]+ });+ let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);+ for cx in [40, 100, 160] {+ let rows: Vec<i64> = (0..100).filter(|&y| alpha(&img, cx, y) > 0.99).collect();+ let (t, b) = (rows[0], rows[rows.len() - 1]);+ assert!((t - 40).abs() > 3, "copy at {cx} moved: {t}");+ let (ct, cb) = (img.get(cx, t), img.get(cx, b));+ assert!(ct[0] < 0.1 && cb[0] > 0.9, "copy at {cx}: {ct:?} {cb:?}");+ }+}++/// A gradient over copies that wiggle on their own keeps its place: every copy shows the ramp's+/// middle at the group's origin row.+#[test]+fn wiggle_transform_keeps_a_gradient_over_copies_wiggling_apart() {+ let (p, cid) = shape_comp(|ids| {+ let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };+ vec![+ build::shape_rect(ids, [20.0, 40.0], [-60.0, 0.0], 0.0),+ build::shape_repeater(ids, 3.0, [60.0, 0.0]),+ build::shape_gradient_fill(ids, false, [0.0, -20.0], [0.0, 20.0], g),+ wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),+ ]+ });+ let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);+ for cx in [40, 100, 160] {+ let c = img.get(cx, 50);+ assert!(c[3] > 0.99 && (c[0] - 0.5).abs() < 0.05, "copy at {cx}: {c:?}");+ }+}++/// Copies wiggling on their own under one 50 % fill stay one paint: where they overlap, the fill+/// covers them once.+#[test]+fn wiggle_transform_keeps_one_fill_over_overlapping_copies() {+ let (p, cid) = shape_comp(|ids| {+ let mut fill = build::shape_fill(ids, RED);+ set(&mut fill, "opacity", Value::Scalar(50.0));+ vec![+ build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0),+ build::shape_repeater(ids, 3.0, [20.0, 0.0]),+ fill,+ wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),+ ]+ });+ let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);+ let top = img.data.iter().map(|px| px[3]).fold(0.0f32, f32::max);+ assert!((top - 0.5).abs() < 0.01, "{top}");+}++/// A long stack of Repeaters, each cleared by a zero-copy Repeater, still renders the last Repeater's copies.+#[test]+fn long_repeater_stack_with_cleared_copies_renders() {+ let (p, cid) = shape_comp(|ids| {+ let mut items = vec![];+ for _ in 0..70 {+ items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0));+ items.push(build::shape_repeater(ids, 3.0, [10.0, 0.0]));+ items.push(build::shape_repeater(ids, 0.0, [10.0, 0.0]));+ }+ items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0));+ items.push(build::shape_repeater(ids, 3.0, [30.0, 0.0]));+ items.push(wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]));+ items.push(build::shape_fill(ids, RED));+ items+ });+ let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);+ assert!((coverage(&img) - 1200.0).abs() < 2.0, "{}", coverage(&img));+}++/// Copies a Repeater makes above a Wiggle Transform wiggle on their own (Correlation 0) or as one+/// (Correlation 100); with the Wiggle Transform above the Repeater every copy wiggles the same.+#[test]+fn wiggle_transform_and_repeater_order() {+ let offsets = |wiggle_first: bool, correlation: f64| {+ let (p, cid) = shape_comp(|ids| {+ let rect = build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0);+ let rep = build::shape_repeater(ids, 3.0, [60.0, 0.0]);+ let w = wiggle_transform(ids, &[("correlation", Value::Scalar(correlation)), ("transform/position", Value::Vec2([0.0, 15.0]))]);+ let fill = build::shape_fill(ids, RED);+ if wiggle_first { vec![rect, w, rep, fill] } else { vec![rect, rep, w, fill] }+ });+ let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);+ [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0)+ };+ let own = offsets(false, 0.0);+ assert!((own[0] - own[1]).abs() > 0.5 || (own[1] - own[2]).abs() > 0.5, "copies wiggle on their own: {own:?}");+ // A Repeater inside a group above the Wiggle Transform: its copies still wiggle on their own.+ let (p, cid) = shape_comp(|ids| {+ let inner = vec![build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), build::shape_repeater(ids, 3.0, [60.0, 0.0]), build::shape_fill(ids, RED)];+ let group = build::shape_group(ids, "Repeated", inner);+ vec![group, wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])]+ });+ let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);+ let nested = [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0);+ assert!((nested[0] - nested[1]).abs() > 0.5 || (nested[1] - nested[2]).abs() > 0.5, "nested copies: {nested:?}");+ for same in [offsets(false, 100.0), offsets(true, 0.0)] {+ assert!(same[0].abs() > 0.5, "{same:?}");+ assert!((same[0] - same[1]).abs() < 0.05 && (same[1] - same[2]).abs() < 0.05, "copies wiggle as one: {same:?}");+ }+}+ #[test] fn zigzag_and_twist_render() { let (p, cid) = shape_comp(|ids| {@@ -147,6 +147,7 @@ pub const SHAPE_ITEMS: &[(&str, &str)] = &[ ("trim", "ADBE Vector Filter - Trim"), ("twist", "ADBE Vector Filter - Twist"), ("wiggle", "ADBE Vector Filter - Roughen"),+ ("wiggler", "ADBE Vector Filter - Wiggler"), ("zigzag", "ADBE Vector Filter - Zigzag"), ]; @@ -208,6 +209,12 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[ ("pucker", "amount", "ADBE Vector PuckerBloat Amount"), ("twist", "angle", "ADBE Vector Twist Angle"), ("twist", "center", "ADBE Vector Twist Center"),+ ("wiggler", "speed", "ADBE Vector Xform Temporal Freq"),+ ("wiggler", "correlation", "ADBE Vector Correlation"),+ ("wiggler", "phase", "ADBE Vector Temporal Phase"),+ ("wiggler", "spatialPhase", "ADBE Vector Spatial Phase"),+ ("wiggler", "seed", "ADBE Vector Random Seed"),+ ("wiggler", "transform", "ADBE Vector Wiggler Transform"), ("merge", "mode", "ADBE Vector Merge Type"), ]; @@ -393,6 +400,7 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { "twist" => "twist", "zigzag" => "zigzag", "wigglepaths" => "wiggle",+ "wiggletransform" => "wiggler", "mergepaths" => "merge", _ => return None, })@@ -333,6 +333,31 @@ fn match_names_and_lookups() { assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0])); } +/// Scripts add a Wiggle Transform by its match name or display name and reach its properties.+#[test]+fn wiggle_transform_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 root = sh.property("ADBE Root Vectors Group");+ var w = root.addProperty("ADBE Vector Filter - Wiggler");+ var byName = root.addProperty("Wiggle Transform");+ w.property("ADBE Vector Wiggler Transform").property("Position").setValue([20, 10]);+ [+ w.matchName, w.name, byName.matchName, w.property("ADBE Vector Xform Temporal Freq").value,+ w.property("ADBE Vector Spatial Phase").name, w.property("ADBE Vector Wiggler Transform").name+ ]+ "#,+ );+ assert_eq!(o.result, json!(["ADBE Vector Filter - Wiggler", "Wiggle Transform 1", "ADBE Vector Filter - Wiggler", 2, "Spatial Phase", "Transform"]));+ 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/wiggler/transform/position").unwrap().value, KV::Vec2([20.0, 10.0]));+}+ #[test] fn keyframes_eases_and_interpolation() { let mut s = session();@@ -3921,6 +3921,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[ ("trim", "Trim Paths"), ("twist", "Twist"), ("wiggle", "Wiggle Paths"),+ ("wiggler", "Wiggle Transform"), ("zigzag", "Zig Zag"), ]; @@ -75,6 +75,22 @@ fn shape_contents_add_menu_adds_path_operations() { assert_eq!(items, ["group", "trim"]); } +#[test]+fn shape_contents_add_menu_adds_wiggle_transform() {+ let mut s = session();+ let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap();+ let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid;+ let mut h = harness(s);+ open_layer(&mut h, l);+ click(&mut h, &format!("timeline.group.{contents}.add"));+ assert!(h.get_by_label("Zig Zag").rect().max.y <= 1000.0, "the whole menu fits in the window");+ let entry = h.get_by_label("Wiggle Transform").rect();+ click_at(&mut h, entry.center());+ let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();+ let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();+ assert_eq!(items, ["group", "wiggler"]);+}+ /// A shape group's inner Contents is elided in After Effects: opening the group lists its items /// (here the path) directly, with no "Contents" row between them. #[test]