mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #471 from sanyamsingh001/fix/parenting-3d-position
This commit is contained in:
2 files changed
+616
-6
No files matched your search
@@ -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
|
||||
@@ -1347,7 +1493,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");
|
||||
}
|
||||
Reference in new issue
Block a user