Merge pull request #471 from sanyamsingh001/fix/parenting-3d-position

This commit is contained in:
Claude
2026-10-10 12:49:17 +00:00
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
@@ -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",