fix(engine): preserve supported 3D parenting compensation (#348)

Convert Position values and spatial tangents into the new parent space.
Preserve supported static Orientation and Scale in constant uniform
parent spaces, and convert spatial Position ease speeds for uniform
transforms. Retain the compensation and Undo/Redo regressions.

Refs #348.

Co-authored-by: Codex <[email protected]>
This commit is contained in:
sanyamsingh001andCodex committed 2026-10-10 04:51:15 +10:00
1 parent 9144eef269
commit cac47626f3
2 files changed
+616 -6

No files matched your search

+152 -6
View File
@@ -542,7 +542,7 @@ fn track_matte(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// How a 2D layer's transform changes so it stays where it is when its parent changes.
/// Re-express a layer in its new parent space without moving its anchor.
pub(crate) struct ParentFix {
pub layer: LayerId,
/// Maps Position from the old parent's space into the new one's.
@@ -551,12 +551,80 @@ pub(crate) struct ParentFix {
rotation: f64,
/// Multiply Scale.
scale: [f64; 2],
/// 3D AV layers retain their world-space anchor, including Z.
position_3d: Option<effectcraft_geom::Mat4>,
/// A uniform relative parent transform can preserve static Orientation and Scale.
pose_3d: Option<([f64; 3], f64)>,
}
impl ParentFix {
/// Re-express the layer's Position, Rotation and Scale (every key) in the new parent's space.
pub fn apply(&self, l: &mut Layer) {
let Some(tr) = l.props.sub_mut("transform") else { return };
if let Some(m) = self.position_3d {
// Spatial Position speed measures distance along its path. Its parent-space
// unit changes even when the child's Orientation cannot be re-expressed.
let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs());
let map_position = |v: &KV| {
let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3()));
KV::Vec3([q.x, q.y, q.z])
};
// Read all separated axes before writing any of them.
let names = ["positionX", "positionY", "positionZ"];
if tr.get("positionX").is_some() {
let axes = names.map(|name| tr.get(name).cloned());
let current = axes.each_ref().map(|pr| pr.as_ref().map(|pr| pr.value.as_f64()).unwrap_or(0.0));
let q = m.apply(effectcraft_geom::Vec3::from(current));
for (i, name) in names.into_iter().enumerate() {
if let Some(pr) = tr.get_mut(name) {
pr.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0));
for key in &mut pr.keys {
let p = axes.each_ref().map(|axis| axis.as_ref().map(|axis| axis.value_at(key.time).as_f64()).unwrap_or(0.0));
let q = m.apply(effectcraft_geom::Vec3::from(p));
key.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0));
}
}
}
}
if let Some(pr) = tr.get_mut("position") {
pr.value = map_position(&pr.value);
for key in &mut pr.keys {
key.value = map_position(&key.value);
// Spatial tangents are vectors, so the parent's translation is excluded.
for tangent in [&mut key.spatial_in, &mut key.spatial_out] {
let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent));
*tangent = [v.x, v.y, v.z];
}
}
if let Some(factor) = position_speed
.filter(|_| pr.spatial)
.filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite()))
{
for key in &mut pr.keys {
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
ease.speed *= factor;
}
}
}
}
if let Some((orientation, factor)) = self.pose_3d {
if let Some(pr) = tr.get_mut("orientation") {
pr.value = KV::Vec3(orientation);
}
if let Some(pr) = tr.get_mut("scale") {
let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor));
pr.value = scaled(&pr.value);
for key in &mut pr.keys {
key.value = scaled(&key.value);
// Scale's temporal speeds use the same units as its values.
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
ease.speed *= factor;
}
}
}
}
return;
}
let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| {
pr.value = f(&pr.value);
for key in &mut pr.keys {
@@ -606,7 +674,42 @@ impl ParentFix {
}
}
/// Like the pick-whip, a parent change keeps 2D layers where they are: their transform is
/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal
/// rotation to static Orientation using the existing Rz * Ry * Rx order.
fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> {
use effectcraft_geom::{Mat4, Vec3, vec3};
if !orientation.iter().all(|v| v.is_finite()) {
return None;
}
let a = m.0;
let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])];
let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z);
if !magnitude.is_finite() || magnitude <= 0.0 {
return None;
}
let c = columns.map(|c| c / magnitude);
if c.iter().any(|c| (c.dot(*c) - 1.0).abs() > 1e-9) || c[0].dot(c[1]).abs() > 1e-9 || c[0].dot(c[2]).abs() > 1e-9 || c[1].dot(c[2]).abs() > 1e-9 {
return None;
}
let cross = vec3(c[1].y * c[2].z - c[1].z * c[2].y, c[1].z * c[2].x - c[1].x * c[2].z, c[1].x * c[2].y - c[1].y * c[2].x);
let factor = if c[0].dot(cross) < 0.0 { -magnitude } else { magnitude };
let mut rotation = Mat4::IDENTITY;
for row in 0..3 {
for col in 0..3 {
rotation.0[row][col] = a[row][col] / factor;
}
}
if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) {
return Some((orientation, factor));
}
let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0;
let sy = (-r[2][0]).clamp(-1.0, 1.0);
let y = sy.asin();
let (x, z) = if (1.0 - sy * sy).sqrt() > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) };
Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor))
}
/// A parent change retains 2D transforms and the world-space anchor of 3D AV layers. Position is
/// re-expressed in the new parent's space (`None`: the composition), evaluated at the current
/// time.
pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option<LayerId>) -> Vec<ParentFix> {
@@ -625,16 +728,59 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
let new_m = space(parent);
let Some(inv) = new_m.inverse() else { return vec![] };
let (rn, sxn, syn) = decompose(&new_m);
comp.layers
let mut fixes: Vec<_> = comp
.layers
.iter()
.filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent)
.map(|l| {
let old_m = space(l.parent);
let (ro, sxo, syo) = decompose(&old_m);
let scale = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }];
ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale }
ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale, position_3d: None, pose_3d: None }
})
.collect()
.collect();
let space_3d = |id: Option<LayerId>| {
id.and_then(|id| comp.layer(id)).map_or(effectcraft_geom::Mat4::IDENTITY, |layer| {
// Parent transforms do not inherit the parent's source pixel-aspect stretch.
let world = ectx.world_matrix(layer);
let par = ectx.par_ratio(layer);
let a = layer.transform().map(|tr| ectx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
let anchor = effectcraft_geom::vec3(a[0], a[1], if layer.is_3d() { a[2] } else { 0.0 });
world
* effectcraft_geom::Mat4::translate(anchor)
* effectcraft_geom::Mat4::scale(effectcraft_geom::vec3(1.0 / par, 1.0, 1.0))
* effectcraft_geom::Mat4::translate(-anchor)
})
};
if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && !l.is_camera() && !l.is_light() && l.parent != parent) {
let m = inv * space_3d(layer.parent);
if m.0.iter().flatten().all(|v| v.is_finite()) {
let pose_3d = layer.transform().and_then(|tr| {
let orientation = tr.get("orientation")?;
let scale = tr.get("scale")?;
// Animated Euler Orientation and auto-orient cannot be re-expressed by
// prepending a constant Euler value. Expression-driven values stay authored.
if !orientation.keys.is_empty()
|| orientation.expr.is_some()
|| scale.expr.is_some()
|| layer.auto_orient != effectcraft_project::AutoOrient::Off
{
return None;
}
let pose = uniform_parent_pose(m, orientation.value.as_vec3())?;
let factor = pose.1;
let values_finite = std::iter::once(&scale.value)
.chain(scale.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite());
(values_finite && speeds_finite).then_some(pose)
});
fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d });
}
}
}
fixes
}
/// Validate the proposed ancestor chain before any authored edit, including when compensation
@@ -1333,7 +1479,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
track_matte
),
cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null}", has_layers, set_parent),
cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null, compensate?: bool}", has_layers, set_parent),
cmd!(
"layer.timing",
"Layer Timing",
@@ -0,0 +1,464 @@
//! 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");
}
#[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);
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}));
}
} else {
set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0]));
}
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()));
}
}
/// 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");
}