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 <codex@openai.com>
This commit is contained in:
sanyamsingh001
2026-10-10 04:51:15 +10:00
co-authored by Codex
parent 9144eef269
commit cac47626f3
2 changed files with 616 additions and 6 deletions
+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",