Files
effectcraft/crates/engine/tests/it/parenting_3d_position.rs
T
719b9170dd 3D rotation order, keyed Orientation, linear-to-eased keyframes and Timewarp behave as in After Effects (#1876)
* 3D: Orientation and X/Y/Z Rotation turn Z first, keyed Orientation slerps, as After Effects

After Effects composes a 3D layer's (and a camera's) Orientation and its X/Y/Z
Rotation as Rx * Ry * Rz each, Orientation before the axis rotations; EffectCraft
used Rz * Ry * Rx. Measured on synthetic AE 2026 cases: Orientation [10,20,30]
(488) was 5.2 % of the frame off and now matches to the anti-aliased edge (0.03 %);
X/Y/Z Rotation 10/20/30 (800), Orientation + X Rotation (801) and one-node
cameras (804, 805) likewise.

Keyed Orientation moves along the shortest arc between the keys (quaternion
slerp, eased in time, angles written in [0, 360)): keys [0,0,0] -> [10,20,30]
read back [6.2643, 9.3531, 15.2190] half-way where EffectCraft gave [5,10,15];
[0,0,0] -> [60,80,40] linear and eased match AE's read-backs within 0.002 deg.

lookAt() returns AE's angles (z axis on the target, x axis level; two AE
read-backs within 1e-3 deg). The camera tracker, VR editor, Look At and orbit
commands use the new decomposition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keyframes: a linear side next to an eased side has 16.67 % influence, as After Effects

In a segment that is Bezier on one side and linear on the other, the linear
side keeps the segment's average speed, but with the 16.67 % influence After
Effects shows for linear keys in the Keyframe Velocity dialog (and reports to
scripts), not 33.3 %. Measured on an After Effects 2026 time-remap render (a
square at x = 50 + 455 * remap): out of a key at 2 s (3.5, speed 0, influence
50 %) into a linear key at 4 s (0.5), AE plays 3.290 at 2.5 s and 2.657 at
3 s; EffectCraft played 3.308 and 2.629 (8 and 13 px off), now the same
square positions to the pixel.

LINEAR_SIDE_INFLUENCE is used by evaluation (scalar, per-dimension, paths and
spatial), side_ease (Graph Editor, velocity dialog, keyIn/OutTemporalEase) and
the Lottie export, so exported easing plays as EffectCraft does. Segments
linear on both sides stay straight lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timewarp: retime the layer's source without the effects above it, as After Effects

After Effects' Timewarp reads the layer's source (with its masks) at the
retimed times and ignores the effects applied before it: a red solid with a
Gradient Ramp and then Timewarp (Speed 25) renders as the red solid in AE
2026 (EXPORTER e2 read-back). EffectCraft retimed the layer with its
effects (mean difference 127.5/255 over the comp), now identical (0, CPU and
GPU). Echo, Posterize Time and the other time effects keep reading the
effects before them, as measured before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Image sequences: a gap in the numbering holds the previous frame, as After Effects

After Effects 2026 shows the last existing file across a gap in a sequence's
numbering (case 860: files 1, 2, 4, 5 show frame 2 at frame 3; VERIFY
read-back). EffectCraft drew colour bars there. The media pool now holds the
previous file (Footage::sequence_file_held); colour bars remain only where no
file precedes the frame. Missing-frame reports on import are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: keyed Orientation uses LINEAR_SIDE_INFLUENCE for linear sides; real NaN assert

evaluate_orientation still gave a linear side (and the unreachable missing-key fallback)
1/3 influence while segment_value, side_ease and the Lottie export use
LINEAR_SIDE_INFLUENCE (1/6), so a keyed Orientation next to an eased key moved on a
different curve than every other property (CodeRabbit on #1876). New test:
orientation_linear_side_next_to_an_eased_side_matches_a_scalar (both directions).
The always-true NaN assert in keyed_orientation_slerps_as_after_effects now checks that
a non-finite key comes back as given; Property::value_at says why the match_id
"orientation" routing is safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Format keyframe orientation test (cargo fmt)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Move the image-sequence gap change out of this branch

The gap change (hold the previous file instead of colour bars) reverses
#297's documented behaviour and goes to its own PR, as requested in review.
pool.rs, sequence.rs, the footage test and docs/footage.md / docs/agents.md
are back to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-07 00:03:52 +01:00

593 lines
32 KiB
Rust

//! 3D parenting preserves Position and static poses in uniform parent spaces.
use effectcraft_engine::{
Session,
geom::{Mat4, Vec3, vec3},
project::{BitDepth, LayerId},
raster::Image,
render::{Backend, EvalCtx, NoFootage, RenderOpts, Renderer},
time::Tick,
};
use serde_json::{Value, json};
use std::sync::Arc;
fn command(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, p).unwrap()
}
fn set(s: &mut Session, layer: u64, path: &str, value: Value) {
command(s, "prop.set", json!({"layer":layer,"path":path,"value":value}));
}
fn solid(s: &mut Session, name: &str) -> u64 {
command(s, "layer.newSolid", json!({"name":name,"width":12,"height":10,"color":[0.8,0.3,0.1]}))["layer"].as_u64().unwrap()
}
fn fixture(three: bool, rotated: bool) -> (Session, u64, u64) {
let mut s = Session::default();
command(&mut s, "comp.new", json!({"name":"Synthetic 3D parent","width":64,"height":48,"duration":2,"frameRate":24}));
Arc::make_mut(&mut s.project).settings.bit_depth = BitDepth::Bpc32;
let parent = solid(&mut s, "Parent");
let child = solid(&mut s, "Child");
command(&mut s, "layer.setSwitch", json!({"layers":[parent],"switch":"video","value":false}));
if three {
command(&mut s, "layer.setSwitch", json!({"layers":[parent,child],"switch":"threeD","value":true}));
}
set(&mut s, parent, "transform/position", json!([20.0, 14.0, if three { 8.0 } else { 0.0 }]));
set(&mut s, child, "transform/position", json!([30.0, 25.0, if three { 3.0 } else { 0.0 }]));
if rotated {
set(&mut s, parent, "transform/orientation", json!([15.0, -18.0, 23.0]));
set(&mut s, parent, "transform/rotationX", json!(7.0));
set(&mut s, parent, "transform/rotationY", json!(-11.0));
set(&mut s, parent, "transform/rotation", json!(9.0));
set(&mut s, parent, "transform/scale", json!([120.0, 120.0, 120.0]));
set(&mut s, child, "transform/orientation", json!([-8.0, 12.0, -13.0]));
set(&mut s, child, "transform/rotationX", json!(4.0));
set(&mut s, child, "transform/rotationY", json!(6.0));
set(&mut s, child, "transform/rotation", json!(-5.0));
}
(s, parent, child)
}
fn world(s: &Session, child: u64, t: Tick) -> Mat4 {
let cid = s.active_comp_id().unwrap();
let c = s.project.comp(cid).unwrap();
EvalCtx::new(&s.project, cid, c, t).world_matrix(c.layer(LayerId(child)).unwrap())
}
fn points(m: Mat4) -> [Vec3; 5] {
[vec3(6.0, 5.0, 0.0), vec3(0.0, 0.0, 0.0), vec3(12.0, 0.0, 0.0), vec3(0.0, 10.0, 0.0), vec3(12.0, 10.0, 0.0)].map(|p| m.apply(p))
}
fn displacement(a: Mat4, b: Mat4) -> f64 {
points(a).into_iter().zip(points(b)).map(|(a, b)| (a - b).length()).fold(0.0, f64::max)
}
fn cpu(s: &Session, t: Tick) -> Image {
Renderer::new(&s.project, &NoFootage, RenderOpts { backend: Backend::Cpu, motion_blur: false, ..Default::default() })
.comp_frame_cpu(s.active_comp_id().unwrap(), t)
}
fn finite_visible(i: &Image) {
assert_eq!([i.width, i.height], [64, 48]);
assert!(i.data.iter().flatten().all(|v| v.is_finite()));
assert!(i.data.iter().any(|p| p[3] > 0.9 && p[0] > 0.2), "synthetic child must be visibly rendered");
}
fn diff(a: &Image, b: &Image) -> f32 {
assert_eq!([a.width, a.height], [b.width, b.height]);
a.data.iter().flatten().zip(b.data.iter().flatten()).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max)
}
fn parent(s: &mut Session, child: u64, to: Option<u64>, compensate: bool) {
command(s, "layer.setParent", json!({"layers":[child],"parent":to,"compensate":compensate}));
}
#[test]
fn parenting_2d_compensation_positive_control() {
let (mut s, p, c) = fixture(false, false);
let before = world(&s, c, Tick::ZERO);
let image = cpu(&s, Tick::ZERO);
finite_visible(&image);
let old = s.project.clone();
let undo = s.history.undo.len();
parent(&mut s, c, Some(p), true);
assert!(displacement(before, world(&s, c, Tick::ZERO)) < 1e-9);
assert!(diff(&image, &cpu(&s, Tick::ZERO)) < 1e-6);
assert_eq!(s.history.undo.len(), undo + 1);
let attached = s.project.clone();
assert!(s.undo());
assert!(Arc::ptr_eq(&old, &s.project));
assert!(s.redo());
assert!(Arc::ptr_eq(&attached, &s.project));
parent(&mut s, c, None, true);
assert!(displacement(before, world(&s, c, Tick::ZERO)) < 1e-9);
}
#[test]
fn parenting_3d_explicit_uncompensated_positive_control() {
let (mut s, p, c) = fixture(true, true);
let old = world(&s, c, Tick::ZERO);
let pm = world(&s, p, Tick::ZERO);
let old_transform = s.active_comp().unwrap().layer(LayerId(c)).unwrap().transform().unwrap().clone();
parent(&mut s, c, Some(p), false);
let after = world(&s, c, Tick::ZERO);
assert!(displacement(pm * old, after) < 1e-9);
assert!(displacement(old, after) > 1.0, "control must visibly follow its parent");
assert_eq!(&old_transform, s.active_comp().unwrap().layer(LayerId(c)).unwrap().transform().unwrap());
assert!(cpu(&s, Tick::ZERO).data.iter().flatten().all(|v| v.is_finite()));
}
#[test]
fn parenting_3d_default_parent_preserves_position_and_cpu_pixels() {
let (mut s, p, c) = fixture(true, false);
let before = world(&s, c, Tick::ZERO);
let image = cpu(&s, Tick::ZERO);
finite_visible(&image);
// Omit compensate to exercise the same public default as timeline pick-whip.
command(&mut s, "layer.setParent", json!({"layers":[c],"parent":p}));
let out = cpu(&s, Tick::ZERO);
assert!(out.data.iter().flatten().all(|v| v.is_finite()));
let motion = displacement(before, world(&s, c, Tick::ZERO));
let pixels = diff(&image, &out);
eprintln!("DEFAULT_PARENT motion={motion} pixels={pixels}");
assert!(motion < 1e-9 && pixels < 1e-6, "3D default parenting must preserve world position and rendered pixels");
}
#[test]
fn parenting_3d_rotated_uniform_parent_preserves_pose() {
let (mut s, p, c) = fixture(true, true);
let before = world(&s, c, Tick::ZERO);
let image = cpu(&s, Tick::ZERO);
finite_visible(&image);
parent(&mut s, c, Some(p), true);
let motion = displacement(before, world(&s, c, Tick::ZERO));
let out = cpu(&s, Tick::ZERO);
let pixels = diff(&image, &out);
eprintln!("ROTATED_PARENT motion={motion} pixels={pixels}");
assert!(motion < 1e-8 && pixels < 2e-5, "3D rotated uniform parent must preserve child pose");
}
#[test]
fn parenting_3d_unparent_preserves_position() {
let (mut s, p, c) = fixture(true, false);
parent(&mut s, c, Some(p), false);
let before = world(&s, c, Tick::ZERO);
let image = cpu(&s, Tick::ZERO);
finite_visible(&image);
parent(&mut s, c, None, true);
let motion = displacement(before, world(&s, c, Tick::ZERO));
let pixels = diff(&image, &cpu(&s, Tick::ZERO));
eprintln!("UNPARENT motion={motion} pixels={pixels}");
assert!(motion < 1e-9 && pixels < 1e-6, "3D unparenting must preserve world position");
}
#[test]
fn parenting_3d_animated_position_preserves_authored_keys() {
let (mut s, p, c) = fixture(true, false);
for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value}));
}
let times = [0.0, 0.25, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| world(&s, c, t));
let undo = s.history.undo.len();
let old = s.project.clone();
parent(&mut s, c, Some(p), true);
let motion = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max);
eprintln!("ANIMATED_PARENT motion={motion}");
assert!(motion < 1e-8, "3D parenting must re-express ordinary Position keys");
assert_eq!(s.history.undo.len(), undo + 1);
let attached = s.project.clone();
assert!(s.undo());
assert!(Arc::ptr_eq(&old, &s.project));
assert!(s.redo());
assert!(Arc::ptr_eq(&attached, &s.project));
}
fn anchor_motion(a: Mat4, b: Mat4) -> f64 {
(a.apply(vec3(6.0, 5.0, 0.0)) - b.apply(vec3(6.0, 5.0, 0.0))).length()
}
#[test]
fn parenting_3d_rotated_parent_preserves_anchor() {
let (mut s, p, c) = fixture(true, true);
let before = world(&s, c, Tick::ZERO);
let image = cpu(&s, Tick::ZERO);
finite_visible(&image);
parent(&mut s, c, Some(p), true);
let after = world(&s, c, Tick::ZERO);
let anchor = anchor_motion(before, after);
eprintln!("ROTATED_ANCHOR anchor={anchor} corners={} pixels={}", displacement(before, after), diff(&image, &cpu(&s, Tick::ZERO)));
assert!(anchor < 1e-8, "3D rotated parenting must retain the world-space anchor");
parent(&mut s, c, None, true);
assert!(anchor_motion(before, world(&s, c, Tick::ZERO)) < 1e-8, "3D rotated unparent must retain the world-space anchor");
}
#[test]
fn parenting_3d_separated_position_preserves_each_axis_and_undo() {
let (mut s, p, c) = fixture(true, false);
command(&mut s, "prop.separateDimensions", json!({"layer":c,"value":true}));
for (name, keys) in [
("positionX", vec![(0.0, 27.0), (1.0, 35.0)]),
("positionY", vec![(0.0, 22.0), (0.5, 27.0), (1.0, 29.0)]),
("positionZ", vec![(0.0, 2.0), (0.25, 3.0), (1.0, 5.0)]),
] {
for (time, value) in keys {
command(&mut s, "prop.addKey", json!({"layer":c,"path":format!("transform/{name}"),"time":time,"value":value}));
}
}
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| world(&s, c, t));
let old = s.project.clone();
let undo = s.history.undo.len();
parent(&mut s, c, Some(p), true);
let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max);
eprintln!("SEPARATED_PARENT motion={error}");
assert!(error < 1e-8, "3D parenting must preserve separated Position animation");
for name in ["positionX", "positionY", "positionZ"] {
let a = &old.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().keys;
let b = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().keys;
assert_eq!(a.iter().map(|k| k.time).collect::<Vec<_>>(), b.iter().map(|k| k.time).collect::<Vec<_>>(), "parent compensation does not retime axes");
}
assert_eq!(s.history.undo.len(), undo + 1);
let attached = s.project.clone();
assert!(s.undo());
assert!(Arc::ptr_eq(&old, &s.project));
assert!(s.redo());
assert!(Arc::ptr_eq(&attached, &s.project));
parent(&mut s, c, None, true);
for (t, b) in times.into_iter().zip(before) {
assert!(displacement(b, world(&s, c, t)) < 1e-8);
}
}
#[test]
fn parenting_3d_position_spatial_tangents_follow_relative_parent_space() {
let (mut s, p, c) = fixture(true, true);
for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value}));
}
// Generated manual spatial-curve data; temporal interpolation remains linear.
let cid = s.active_comp_id().unwrap();
let pr = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/position").unwrap();
for key in &mut pr.keys {
key.spatial_auto = false;
}
pr.keys[0].spatial_out = [3.0, 5.0, -1.0];
pr.keys[1].spatial_in = [-4.0, 2.0, 1.0];
let tangents = pr.keys.iter().map(|k| (k.spatial_in, k.spatial_out)).collect::<Vec<_>>();
let relative = world(&s, p, Tick::ZERO).inverse().unwrap();
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| world(&s, c, t));
parent(&mut s, c, Some(p), true);
let error = times.into_iter().zip(before).map(|(t, b)| anchor_motion(b, world(&s, c, t))).fold(0.0, f64::max);
let keys = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/position").unwrap().keys;
let tangent_error = keys
.iter()
.zip(tangents)
.flat_map(|(key, (tin, tout))| [(key.spatial_in, tin), (key.spatial_out, tout)])
.map(|(after, before)| (Vec3::from(after) - relative.apply_vec(Vec3::from(before))).length())
.fold(0.0, f64::max);
eprintln!("SPATIAL_PARENT anchor={error} tangent={tangent_error}");
assert!(error < 1e-8 && tangent_error < 1e-8, "3D Position spatial tangents must remain relative vectors");
}
#[test]
fn parenting_3d_parent_source_pixel_aspect_is_not_inherited() {
let (mut s, _, c) = fixture(true, false);
let p = command(&mut s, "layer.newSolid", json!({"name":"Wide pixels parent","width":12,"height":10,"color":[0.1,0.2,0.3],"pixelAspect":2.0}))["layer"]
.as_u64()
.unwrap();
command(&mut s, "layer.setSwitch", json!({"layers":[p],"switch":"threeD","value":true}));
command(&mut s, "layer.setSwitch", json!({"layers":[p],"switch":"video","value":false}));
set(&mut s, p, "transform/position", json!([20.0, 14.0, 8.0]));
let before = world(&s, c, Tick::ZERO);
let image = cpu(&s, Tick::ZERO);
finite_visible(&image);
parent(&mut s, c, Some(p), true);
let error = displacement(before, world(&s, c, Tick::ZERO));
let pixels = diff(&image, &cpu(&s, Tick::ZERO));
eprintln!("PAR_PARENT motion={error} pixels={pixels}");
assert!(error < 1e-8 && pixels < 1e-6, "3D children must not inherit parent source pixel aspect");
}
fn rotation_properties(s: &Session, c: u64) -> Vec<effectcraft_engine::project::Property> {
["rotationX", "rotationY", "rotation"]
.map(|name| s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().clone())
.into()
}
fn axis_animation(s: &mut Session, c: u64) {
for (name, values) in [("rotationX", [-21.0, 35.0]), ("rotationY", [9.0, -30.0]), ("rotation", [12.0, 28.0])] {
for (time, value) in [(0.0, values[0]), (1.0, values[1])] {
command(s, "prop.addKey", json!({"layer":c,"path":format!("transform/{name}"),"time":time,"value":value}));
}
}
}
#[test]
fn parenting_static_orientation_preserves_nonuniform_child_scale_and_axis_rotation_keys() {
let (mut s, p, c) = fixture(true, true);
set(&mut s, c, "transform/scale", json!([83.0, 127.0, 61.0]));
axis_animation(&mut s, c);
let rotations = rotation_properties(&s, c);
let old = s.project.clone();
let undo = s.history.undo.len();
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| world(&s, c, t));
let images = times.map(|t| cpu(&s, t));
for i in &images {
finite_visible(i);
}
parent(&mut s, c, Some(p), true);
let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max);
let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max);
eprintln!("STATIC_AXIS_POSE corners={error} pixels={pixels}");
assert!(error < 1e-8 && pixels < 2e-5, "static Orientation must preserve pose with nonuniform child Scale and authored axis Rotation animation");
assert_eq!(rotation_properties(&s, c), rotations, "axis Rotation properties are byte-for-byte authored");
assert_eq!(s.history.undo.len(), undo + 1);
let attached = s.project.clone();
assert!(s.undo());
assert!(Arc::ptr_eq(&s.project, &old));
assert!(s.redo());
assert!(Arc::ptr_eq(&s.project, &attached));
parent(&mut s, c, None, true);
for (t, b) in times.into_iter().zip(before) {
assert!(displacement(b, world(&s, c, t)) < 1e-8);
}
assert_eq!(rotation_properties(&s, c), rotations);
}
#[test]
fn parenting_uniform_parent_preserves_animated_eased_scale() {
use effectcraft_engine::keyframe::{Ease, Interp};
let (mut s, p, c) = fixture(true, true);
for (time, value) in [(0.0, json!([80.0, 120.0, 95.0])), (1.0, json!([135.0, 75.0, 150.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/scale","time":time,"value":value}));
}
let cid = s.active_comp_id().unwrap();
let scale = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/scale").unwrap();
for key in &mut scale.keys {
key.in_interp = Interp::Bezier;
key.out_interp = Interp::Bezier;
key.in_ease = vec![Ease { speed: 17.0, influence: 0.3 }, Ease { speed: -13.0, influence: 0.6 }, Ease { speed: 7.0, influence: 0.4 }];
key.out_ease = vec![Ease { speed: 11.0, influence: 0.4 }, Ease { speed: -19.0, influence: 0.2 }, Ease { speed: 13.0, influence: 0.5 }];
}
let old_scale = scale.clone();
let rotations = rotation_properties(&s, c);
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| world(&s, c, t));
let images = times.map(|t| cpu(&s, t));
for image in &images {
finite_visible(image);
}
parent(&mut s, c, Some(p), true);
let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max);
let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max);
eprintln!("EASED_SCALE_POSE corners={error} pixels={pixels}");
assert!(error < 1e-8 && pixels < 2e-5, "uniform parent compensation must preserve animated eased Scale poses");
assert_eq!(rotation_properties(&s, c), rotations);
let scale = s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/scale").unwrap();
assert_eq!(scale.keys.iter().map(|k| k.time).collect::<Vec<_>>(), old_scale.keys.iter().map(|k| k.time).collect::<Vec<_>>());
for (old, new) in old_scale.keys.iter().zip(&scale.keys) {
for (old, new) in old.in_ease.iter().chain(&old.out_ease).zip(new.in_ease.iter().chain(&new.out_ease)) {
assert!((new.speed - old.speed / 1.2).abs() < 1e-9);
assert_eq!(new.influence, old.influence);
}
}
}
#[test]
fn parenting_static_orientation_handles_signed_uniform_scale_and_gimbal_order() {
let mut max_corners = 0.0f64;
let mut max_pixels = 0.0f32;
for factor in [120.0, -120.0] {
for orientation in [[23.0, 90.0, -37.0], [-41.0, -90.0, 16.0]] {
let (mut s, p, c) = fixture(true, true);
set(&mut s, p, "transform/orientation", json!([0.0, 0.0, 0.0]));
set(&mut s, p, "transform/rotationX", json!(0.0));
set(&mut s, p, "transform/rotationY", json!(0.0));
set(&mut s, p, "transform/rotation", json!(42.0));
set(&mut s, p, "transform/scale", json!([factor, factor, factor]));
set(&mut s, c, "transform/orientation", json!(orientation));
set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0]));
let before = world(&s, c, Tick::ZERO);
let image = cpu(&s, Tick::ZERO);
assert!(image.data.iter().flatten().all(|v| v.is_finite()));
assert!(image.data.iter().any(|p| p[3] > 0.05), "gimbal control must render visible pixels");
let rotations = rotation_properties(&s, c);
parent(&mut s, c, Some(p), true);
let error = displacement(before, world(&s, c, Tick::ZERO));
let pixels = diff(&image, &cpu(&s, Tick::ZERO));
max_corners = max_corners.max(error);
max_pixels = max_pixels.max(pixels);
assert_eq!(rotation_properties(&s, c), rotations);
}
}
eprintln!("SIGNED_GIMBAL_POSE corners={max_corners} pixels={max_pixels}");
assert!(max_corners < 1e-8 && max_pixels < 2e-5, "static uniform pose must use the existing Euler order including signed scale and gimbal angles");
}
/// A static Orientation just off gimbal lock (Y within 1e-6 degrees of ±90) keeps its world pose
/// under a uniform parent. Recovering Euler angles from the matrix must not lose precision there.
#[test]
fn parenting_static_orientation_is_exact_just_off_gimbal_lock() {
let mut max_corners = 0.0f64;
for factor in [120.0, -120.0] {
for y in [90.0 - 1e-6, -90.0 + 1e-6] {
let (mut s, p, c) = fixture(true, true);
set(&mut s, p, "transform/orientation", json!([0.0, 0.0, 0.0]));
set(&mut s, p, "transform/rotationX", json!(0.0));
set(&mut s, p, "transform/rotationY", json!(0.0));
set(&mut s, p, "transform/rotation", json!(42.0));
set(&mut s, p, "transform/scale", json!([factor, factor, factor]));
set(&mut s, c, "transform/orientation", json!([23.0, y, -37.0]));
set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0]));
let before = world(&s, c, Tick::ZERO);
parent(&mut s, c, Some(p), true);
max_corners = max_corners.max(displacement(before, world(&s, c, Tick::ZERO)));
}
}
assert!(max_corners < 1e-9, "a static Orientation near gimbal lock must keep its world pose: corners {max_corners}");
}
#[test]
fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() {
for animated in [true, false] {
let (mut s, p, c) = fixture(true, true);
// A non-uniform parent has no uniform pose to re-express, animated or not.
set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0]));
if animated {
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value}));
}
}
let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap();
let orientation = layer.props.prop("transform/orientation").unwrap().clone();
let scale = layer.props.prop("transform/scale").unwrap().clone();
let before = world(&s, c, Tick::ZERO);
parent(&mut s, c, Some(p), true);
let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap();
assert_eq!(layer.props.prop("transform/orientation").unwrap(), &orientation, "unsupported animated Euler/shear receives no blind Orientation rewrite");
assert_eq!(layer.props.prop("transform/scale").unwrap(), &scale, "unsupported animated Euler/shear receives no blind Scale rewrite");
assert!(anchor_motion(before, world(&s, c, Tick::ZERO)) < 1e-8, "boundary retains already-proved Position behavior");
assert!(cpu(&s, Tick::ZERO).data.iter().flatten().all(|v| v.is_finite()));
}
}
/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose
/// and pixels stay put at every time along the quaternion arcs between keys.
#[test]
fn parenting_uniform_parent_re_expresses_keyed_orientation() {
for factor in [120.0, -120.0] {
let (mut s, p, c) = fixture(true, false);
set(&mut s, p, "transform/rotation", json!(42.0));
set(&mut s, p, "transform/scale", json!([factor, factor, factor]));
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value}));
}
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| world(&s, c, t));
let images = times.map(|t| cpu(&s, t));
parent(&mut s, c, Some(p), true);
let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max);
let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max);
assert!(error < 1e-8 && pixels < 2e-5, "keyed Orientation under a uniform parent (factor {factor}): corners {error}, pixels {pixels}");
}
}
/// Position's temporal speed is measured along its spatial path, in parent-space units.
#[test]
fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() {
use effectcraft_engine::keyframe::{Ease, Interp};
let mut maximum_error = 0.0f64;
for factor in [100.0, 200.0] {
let (mut s, p, c) = fixture(true, false);
set(&mut s, p, "transform/scale", json!([factor, factor, factor]));
for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value}));
}
let cid = s.active_comp_id().unwrap();
let position = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/position").unwrap();
for key in &mut position.keys {
key.in_interp = Interp::Bezier;
key.out_interp = Interp::Bezier;
key.in_ease = vec![Ease { speed: 10.0, influence: 1.0 / 3.0 }];
key.out_ease = vec![Ease { speed: 10.0, influence: 1.0 / 3.0 }];
key.auto_bezier = false;
key.spatial_auto = false;
key.spatial_in = [0.0; 3];
key.spatial_out = [0.0; 3];
}
let times = [0.0, 0.25, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| world(&s, c, t));
assert!(anchor_motion(before[0], before[1]) > 1.0, "the eased Position fixture must actually move at quarter time");
let old = s.project.clone();
let undo = s.history.undo.len();
// Omit compensate to exercise the public default without depending on the schema-only fix.
s.execute_checked("layer.setParent", json!({"layers":[c],"parent":p})).unwrap();
let after = times.map(|t| world(&s, c, t));
assert!(after.iter().all(|m| m.0.iter().flatten().all(|v| v.is_finite())));
for i in [0, 3] {
assert!(anchor_motion(before[i], after[i]) < 1e-8, "parent compensation must preserve the authored Position endpoints");
}
for i in [1, 2] {
let error = anchor_motion(before[i], after[i]);
maximum_error = maximum_error.max(error);
eprintln!("EASED_POSITION_PARENT factor={factor} time={} anchor_error={error}", times[i].seconds());
}
assert_eq!(s.history.undo.len(), undo + 1, "parent compensation is one undo action");
let attached = s.project.clone();
assert!(s.undo());
assert!(Arc::ptr_eq(&old, &s.project), "Undo restores the exact authored project");
for (t, expected) in times.into_iter().zip(before) {
assert!(anchor_motion(expected, world(&s, c, t)) < 1e-8, "Undo restores all sampled world anchors");
}
assert!(s.redo());
assert!(Arc::ptr_eq(&attached, &s.project), "Redo restores the exact compensated project");
for (t, expected) in times.into_iter().zip(after) {
assert!(anchor_motion(expected, world(&s, c, t)) < 1e-8, "Redo retains the same compensated samples");
}
}
assert!(maximum_error < 1e-8, "3D uniform parent compensation must preserve eased Position at quarter and three-quarter times");
}
/// Parenting a two-node camera to a default null keeps its world frame (eye, forward, down):
/// Position and Point of Interest are re-expressed in the null's space (#1010, #1020).
#[test]
fn parenting_camera_to_default_null_preserves_framing() {
use effectcraft_engine::render::three_d::camera::layer_frame;
let mut s = Session::default();
command(&mut s, "comp.new", json!({"name":"Camera","width":640,"height":360,"duration":2,"frameRate":24}));
let cam = command(&mut s, "layer.newCamera", json!({"name":"Camera 1"}))["layer"].as_u64().unwrap();
let null = command(&mut s, "layer.newNull", json!({"name":"Null 1"}))["layer"].as_u64().unwrap();
let frame = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let c = s.project.comp(cid).unwrap();
let ctx = EvalCtx::new(&s.project, cid, c, Tick::ZERO);
layer_frame(&ctx, c.layer(LayerId(cam)).unwrap())
};
let authored = |s: &Session, path: &str| {
let cid = s.active_comp_id().unwrap();
s.project.comp(cid).unwrap().layer(LayerId(cam)).unwrap().props.prop(path).unwrap().value.as_vec3()
};
let (eye0, fwd0, down0) = frame(&s);
let (pos0, poi0) = (authored(&s, "transform/position"), authored(&s, "transform/poi"));
let frame_error = |(a0, b0, c0): (Vec3, Vec3, Vec3), (a1, b1, c1): (Vec3, Vec3, Vec3)| {
[(a0 - a1).length(), (b0 - b1).length(), (c0 - c1).length()].into_iter().fold(0.0, f64::max)
};
let undo = s.history.undo.len();
parent(&mut s, cam, Some(null), true);
assert!(frame_error(frame(&s), (eye0, fwd0, down0)) < 1e-9, "the camera frame must not move");
// Compensation rewrote the authored values, not just the parent pointer.
assert!((authored(&s, "transform/position")[0] - pos0[0]).abs() > 1.0, "position is re-expressed in the null's space");
assert!((authored(&s, "transform/poi")[0] - poi0[0]).abs() > 1.0, "point of interest follows it");
assert_eq!(s.history.undo.len(), undo + 1, "parent compensation is one undo action");
// Unparenting restores the authored transform and the same frame.
parent(&mut s, cam, None, true);
assert!(frame_error(frame(&s), (eye0, fwd0, down0)) < 1e-9);
assert_eq!(authored(&s, "transform/position"), pos0, "unparenting restores position");
assert_eq!(authored(&s, "transform/poi"), poi0, "unparenting restores point of interest");
}
/// A constant parent rotation preserves the full eased quaternion path, including mixed axes.
#[test]
fn parenting_mixed_axis_eased_orientation_preserves_arcs_and_key_metadata() {
use effectcraft_engine::keyframe::{Ease, Interp};
for factor in [120.0, -120.0] {
let (mut s, p, c) = fixture(true, true);
set(&mut s, p, "transform/scale", json!([factor, factor, factor]));
for (time, value) in [(0.0, json!([-35.0, 48.0, 150.0])), (0.7, json!([62.0, -27.0, -145.0])), (1.6, json!([-12.0, 58.0, 70.0]))] {
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value}));
}
let cid = s.active_comp_id().unwrap();
let orientation = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/orientation").unwrap();
for key in &mut orientation.keys {
key.in_interp = Interp::Bezier;
key.out_interp = Interp::Bezier;
key.in_ease = vec![Ease { speed: 17.0, influence: 0.3 }];
key.out_ease = vec![Ease { speed: 11.0, influence: 0.4 }];
}
orientation.keys[1].auto_bezier = true;
let original = orientation.keys.clone();
let times: Vec<_> = (0..=32).map(|i| Tick::from_seconds_f64(i as f64 * 0.05)).collect();
let before: Vec<_> = times.iter().map(|&t| world(&s, c, t)).collect();
let old_project = s.project.clone();
parent(&mut s, c, Some(p), true);
let converted = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/orientation").unwrap().keys;
assert_eq!(converted.len(), original.len());
for (new, old) in converted.iter().zip(&original) {
let mut metadata = new.clone();
metadata.value = old.value.clone();
assert_eq!(&metadata, old, "all authored key metadata must survive");
}
// Rotation matrices represent both q and -q identically, so compare transformed geometry
// rather than Euler representatives or quaternion signs.
for (&t, &matrix) in times.iter().zip(&before) {
assert!(displacement(matrix, world(&s, c, t)) < 1e-8, "pose changed at {t:?}, factor {factor}");
}
parent(&mut s, c, None, true);
for (&t, &matrix) in times.iter().zip(&before) {
assert!(displacement(matrix, world(&s, c, t)) < 1e-8, "unparent changed pose at {t:?}");
}
assert!(s.undo());
assert!(s.undo());
assert!(Arc::ptr_eq(&old_project, &s.project));
}
}