Merge pull request #2355 from dotCooCoo/feat/shape-wiggle-transform

Shapes: add the Wiggle Transform path operation, which wiggles paths and each Repeater copy
This commit is contained in:
BFlat
2026-10-30 07:04:50 +07:00
committed by GitHub
11 changed files with 350 additions and 6 deletions
+5
View File
@@ -120,6 +120,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain
that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it
starts off, so existing shapes behave as before (#2489).
- 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
+1 -1
View File
@@ -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
),
+1 -1
View File
@@ -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).
+16
View File
@@ -596,6 +596,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
.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))),
"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",
+1 -1
View File
@@ -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).
+80 -3
View File
@@ -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,75 @@ 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.
fn wiggle_transform(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, arena: &mut [BezPath], live: &[usize], copy_of: &HashMap<usize, usize>, draws: &mut [Draw]) {
let Some(tr) = g.sub("transform") else { return };
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;
@@ -330,6 +398,7 @@ 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" => wiggle_transform(ctx, layer, g, 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();
@@ -342,7 +411,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);
@@ -379,12 +448,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);
+196
View File
@@ -310,6 +310,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| {
+8
View File
@@ -185,6 +185,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"),
];
@@ -246,6 +247,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"),
("wiggle", "size", "ADBE Vector Roughen Size"),
("wiggle", "detail", "ADBE Vector Roughen Detail"),
@@ -439,6 +446,7 @@ pub fn shape_kind(name: &str) -> Option<&'static str> {
"twist" => "twist",
"zigzag" => "zigzag",
"wigglepaths" => "wiggle",
"wiggletransform" => "wiggler",
"mergepaths" => "merge",
_ => return None,
})
+25
View File
@@ -371,6 +371,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();
+1
View File
@@ -5131,6 +5131,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
("trim", "Trim Paths"),
("twist", "Twist"),
("wiggle", "Wiggle Paths"),
("wiggler", "Wiggle Transform"),
("zigzag", "Zig Zag"),
];
@@ -76,6 +76,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]