collective

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

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -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 alldiff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs--- a/crates/engine/src/cmd/layer.rs+++ b/crates/engine/src/cmd/layer.rs@@ -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         ),diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs--- a/crates/engine/src/cmd/paths.rs+++ b/crates/engine/src/cmd/paths.rs@@ -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).diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs--- a/crates/project/src/build.rs+++ b/crates/project/src/build.rs@@ -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",diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs--- a/crates/render/src/cache.rs+++ b/crates/render/src/cache.rs@@ -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).diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs--- a/crates/render/src/shapes.rs+++ b/crates/render/src/shapes.rs@@ -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(&copy_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, &copy_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);diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs--- a/crates/render/src/shapes/tests.rs+++ b/crates/render/src/shapes/tests.rs@@ -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| {diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs--- a/crates/script/src/matchnames.rs+++ b/crates/script/src/matchnames.rs@@ -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,     })diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs--- a/crates/script/src/tests.rs+++ b/crates/script/src/tests.rs@@ -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();diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs--- a/crates/ui-egui/src/panels/timeline.rs+++ b/crates/ui-egui/src/panels/timeline.rs@@ -3921,6 +3921,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[     ("trim", "Trim Paths"),     ("twist", "Twist"),     ("wiggle", "Wiggle Paths"),+    ("wiggler", "Wiggle Transform"),     ("zigzag", "Zig Zag"), ]; diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs--- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs+++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs@@ -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]