diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index b798493b1..c448882e0 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -739,7 +739,7 @@ impl ParentFix { } /// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal -/// rotation to static Orientation using the existing Rz * Ry * Rx order. +/// rotation to static Orientation (in [`effectcraft_geom::Mat4::orientation`] 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()) { @@ -766,12 +766,8 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti 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; - // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. - let cy = r[0][0].hypot(r[1][0]); - let y = (-r[2][0]).atan2(cy); - let (x, z) = if cy > 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)) + let o = (rotation * Mat4::orientation(Vec3::from(orientation))).orientation_angles(); + Some(([o.x, o.y, o.z], factor)) } /// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position, diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index e143690d3..69eeffb08 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -480,7 +480,7 @@ fn camera_tool(s: &mut Session, p: &Value, op: ToolOp, cmd: &str, label: &str) - let poi = if two { Vec3::from(get_at(l, "transform/poi", t).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, 0.0])) } else { - let fwd = (Mat4::orientation(Vec3::from(o)) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)).apply_vec(vec3(0.0, 0.0, 1.0)); + let fwd = (Mat4::orientation(Vec3::from(o)) * Mat4::rotation_xyz(rx, ry, 0.0)).apply_vec(vec3(0.0, 0.0, 1.0)); pos + fwd * zoom }; let mut rig = Rig { eye: pos, poi, zoom, ortho: false }; diff --git a/crates/engine/src/commands/vr_editor.rs b/crates/engine/src/commands/vr_editor.rs index e62281128..12ebcda5b 100644 --- a/crates/engine/src/commands/vr_editor.rs +++ b/crates/engine/src/commands/vr_editor.rs @@ -79,12 +79,10 @@ fn norm(a: f64) -> f64 { (r * 1e6).round() / 1e6 } -/// Orientation angles (X, Y, Z degrees, applied X then Y then Z) of a rotation matrix. +/// Orientation angles (X, Y, Z degrees, [`Mat4::orientation`] order) of a rotation matrix. pub fn euler(m: Mat4) -> [f64; 3] { - let m = m.0; - let y = (-m[2][0]).clamp(-1.0, 1.0).asin(); - let (x, z) = if y.cos().abs() > 1e-6 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) }; - [norm(x.to_degrees()), norm(y.to_degrees()), norm(z.to_degrees())] + let o = m.orientation_angles(); + [norm(o.x), norm(o.y), norm(o.z)] } /// The current view orientation of an environment (from its Front camera). diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 4da7bc63b..43d5ab230 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -166,12 +166,28 @@ function normalize(a) { return l === 0 ? a.map(function () { return 0; }) : div(a, l); } +// Orientation that points the layer's z axis at `at` with its x axis kept level, in the +// Orientation order (Rx · Ry · Rz) and wrapped to [0, 360), as After Effects returns it. function lookAt(from, at) { var d = normalize(sub(__v(at), __v(from))); var x = d[0], y = d[1], z = d[2] || 0; - var rx = -Math.asin(Math.max(-1, Math.min(1, y))) * 180 / Math.PI; - var ry = Math.atan2(x, z) * 180 / Math.PI; - return [rx, ry, 0]; + var p = Math.asin(Math.max(-1, Math.min(1, -y))); + var w = Math.atan2(x, z); + var cw = Math.cos(w), sw = Math.sin(w), cp = Math.cos(p), sp = Math.sin(p); + // The rotation Ry(w) · Rx(p), written as Orientation angles. + var oy = Math.asin(Math.max(-1, Math.min(1, sw * cp))); + var ox, oz; + if (Math.sqrt(cw * cw + sw * sw * sp * sp) > 1e-9) { + ox = Math.atan2(sp, cw * cp); + oz = Math.atan2(-sw * sp, cw); + } else { + ox = Math.atan2(cw * sp, cp); + oz = 0; + } + return [ox, oy, oz].map(function (r) { + var a = ((r * 180 / Math.PI) % 360 + 360) % 360; + return a >= 360 - 1e-9 || Math.abs(a) < 1e-9 ? 0 : a; + }); } function degreesToRadians(d) { return d * Math.PI / 180; } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 18b725084..a676ec835 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -276,6 +276,12 @@ fn angles_and_look_at() { assert_close!(f.num("degreesToRadians(90)", 0.0), std::f64::consts::FRAC_PI_2); let o = f.ev(f.a, "transform/orientation", "lookAt([0, 0, 0], [100, 0, 100])", 0.0).unwrap().as_vec3(); assert!(close(o[1], 45.0) && close(o[0], 0.0), "{o:?}"); + // After Effects 2026 read-backs of lookAt(position, …) on a layer at [960, 540, 0]. + for (at, want) in [([1500.0, 200.0, 300.0], [48.5763, 49.9802, 319.0442]), ([500.0, 900.0, -400.0], [221.9872, 319.4766, 210.3184])] { + let e = format!("lookAt([960, 540, 0], [{}, {}, {}])", at[0], at[1], at[2]); + let o = f.ev(f.a, "transform/orientation", &e, 0.0).unwrap().as_vec3(); + assert!((0..3).all(|i| (o[i] - want[i]).abs() < 1e-3), "{e}: {o:?}"); + } } // ---- interpolation ---------------------------------------------------------------------------- diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index a03c9d46c..f858c6f42 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -3,7 +3,8 @@ //! //! Conventions follow After Effects' public documentation: y grows downwards, angles are degrees //! clockwise on screen, 3D uses a left-handed system with +z pointing into the screen, and a 3D -//! layer's rotation is Orientation (X, Y, Z) followed by X Rotation, Y Rotation, Z Rotation. +//! layer's rotation is Orientation then X/Y/Z Rotation, each composed as `Rx · Ry · Rz` (Z turns +//! the vector first; measured in After Effects). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -318,17 +319,32 @@ impl Mat4 { let (s, c) = deg.to_radians().sin_cos(); Mat4([[c, -s, 0.0, 0.0], [s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } - /// Orientation (degrees, X then Y then Z applied to the vector in that order). + /// Orientation (degrees): `Rx(x) · Ry(y) · Rz(z)`, i.e. Z is applied to the vector first, + /// then Y, then X. Measured in After Effects 2026 (Orientation [10,20,30] and X/Y/Z Rotation + /// 10/20/30 on a 3D solid match this order to the anti-aliased edge; the reverse order is + /// 5 % of the frame off). pub fn orientation(o: Vec3) -> Mat4 { - Mat4::rotate_z(o.z) * Mat4::rotate_y(o.y) * Mat4::rotate_x(o.x) + Mat4::rotate_x(o.x) * Mat4::rotate_y(o.y) * Mat4::rotate_z(o.z) } - /// The 3D layer transform: layer space → parent space. + /// X, Y and Z Rotation of a 3D layer (degrees), composed like [`Mat4::orientation`]. + pub fn rotation_xyz(x: f64, y: f64, z: f64) -> Mat4 { + Mat4::rotate_x(x) * Mat4::rotate_y(y) * Mat4::rotate_z(z) + } + /// The Orientation angles (degrees) of this matrix's rotation part: the inverse of + /// [`Mat4::orientation`] (Y in [-90, 90], X and Z in (-180, 180]; Z = 0 at gimbal lock). + pub fn orientation_angles(&self) -> Vec3 { + let m = &self.0; + let sy = m[0][2].clamp(-1.0, 1.0); + let cy = m[0][0].hypot(m[0][1]); + let y = sy.atan2(cy); + let (x, z) = if cy > 1e-9 { ((-m[1][2]).atan2(m[2][2]), (-m[0][1]).atan2(m[0][0])) } else { (m[2][1].atan2(m[1][1]), 0.0) }; + vec3(x.to_degrees(), y.to_degrees(), z.to_degrees()) + } + /// The 3D layer transform: layer space → parent space (Orientation, then X/Y/Z Rotation). pub fn layer_3d(anchor: Vec3, position: Vec3, scale_pct: Vec3, orientation: Vec3, rotation: Vec3) -> Mat4 { Mat4::translate(position) * Mat4::orientation(orientation) - * Mat4::rotate_z(rotation.z) - * Mat4::rotate_y(rotation.y) - * Mat4::rotate_x(rotation.x) + * Mat4::rotation_xyz(rotation.x, rotation.y, rotation.z) * Mat4::scale(scale_pct / 100.0) * Mat4::translate(-anchor) } @@ -442,6 +458,76 @@ pub fn look_at(pos: Vec3, poi: Vec3, extra: Mat4) -> Mat4 { extra * rot * Mat4::translate(-pos) } +/// Unit quaternion `[w, x, y, z]` of [`Mat4::orientation`]`(o)`. +fn orientation_quat(o: Vec3) -> [f64; 4] { + let half = |d: f64| (d.to_radians() * 0.5).sin_cos(); + let (sx, cx) = half(o.x); + let (sy, cy) = half(o.y); + let (sz, cz) = half(o.z); + // qx · qy · qz (Hamilton product), matching Rx · Ry · Rz. + let (w, x, y, z) = (cx * cy, sx * cy, cx * sy, sx * sy); + [w * cz - z * sz, x * cz + y * sz, y * cz - x * sz, z * cz + w * sz] +} + +fn quat_matrix(q: [f64; 4]) -> Mat4 { + let [w, x, y, z] = q; + Mat4([ + [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w), 0.0], + [2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w), 0.0], + [2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y), 0.0], + [0.0, 0.0, 0.0, 1.0], + ]) +} + +/// Angle (degrees, 0..=180) of the shortest rotation between two Orientations. +pub fn orientation_arc_deg(a: Vec3, b: Vec3) -> f64 { + let (p, q) = (orientation_quat(a), orientation_quat(b)); + let d = (p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]).abs().clamp(0.0, 1.0); + let arc = 2.0 * d.acos().to_degrees(); + if arc.is_finite() { arc } else { 0.0 } +} + +/// Orientation between two Orientations at fraction `t`, as After Effects interpolates keyed +/// Orientation: spherical linear interpolation of the two rotations along the shortest arc, +/// written as angles in [0, 360) (measured in After Effects 2026: keys [0,0,0] → [10,20,30] read +/// back [6.2643, 9.3531, 15.2190] half-way, [0,0,0] → [60,80,40] [30.6186, 35.7617, 28.4317], +/// [0,0,0] → [0,0,270] [0, 0, 315], [0,0,0] → [0,0,720] [0, 0, 0]). +pub fn orientation_slerp(a: Vec3, b: Vec3, t: f64) -> Vec3 { + let blend = a + (b - a) * t; + if !t.is_finite() || !blend.x.is_finite() || !blend.y.is_finite() || !blend.z.is_finite() { + return a; + } + let p = orientation_quat(a); + let mut q = orientation_quat(b); + let mut d = p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]; + if d < 0.0 { + q = q.map(|c| -c); + d = -d; + } + let d = d.clamp(-1.0, 1.0); + let (ka, kb) = if d > 1.0 - 1e-12 { + (1.0 - t, t) + } else { + let om = d.acos(); + let s = om.sin(); + (((1.0 - t) * om).sin() / s, (t * om).sin() / s) + }; + let mut r = [0.0; 4]; + for (i, c) in r.iter_mut().enumerate() { + *c = ka * p[i] + kb * q[i]; + } + let n = r.iter().map(|c| c * c).sum::().sqrt(); + if !(n > 1e-12 && n.is_finite()) { + return a; + } + let e = quat_matrix(r.map(|c| c / n)).orientation_angles(); + let wrap = |v: f64| { + let w = v.rem_euclid(360.0); + if w >= 360.0 - 1e-9 || w.abs() < 1e-9 { 0.0 } else { w } + }; + vec3(wrap(e.x), wrap(e.y), wrap(e.z)) +} + #[cfg(test)] mod tests { use super::*; @@ -483,6 +569,65 @@ mod tests { assert!((i.apply(m.apply(p)) - p).length() < 1e-9); } + fn near3(a: Vec3, b: [f64; 3], tol: f64) -> bool { + (a.x - b[0]).abs() < tol && (a.y - b[1]).abs() < tol && (a.z - b[2]).abs() < tol + } + + #[test] + fn orientation_applies_z_then_y_then_x() { + // Rx · Ry · Rz: the vector is turned about Z first. + let o = Mat4::orientation(vec3(90.0, 0.0, 90.0)); + let p = o.apply_vec(vec3(1.0, 0.0, 0.0)); + // Rz(90): +x → +y; Rx(90): +y → +z. + assert!((p - vec3(0.0, 0.0, 1.0)).length() < 1e-12, "{p:?}"); + let r = Mat4::rotation_xyz(10.0, 20.0, 30.0); + assert_eq!(r, Mat4::orientation(vec3(10.0, 20.0, 30.0))); + } + + #[test] + fn orientation_angles_invert_orientation() { + for o in [[10.0, 20.0, 30.0], [-45.0, 80.0, 170.0], [120.0, -30.0, -90.0], [0.0, 90.0, 0.0], [30.0, -90.0, 0.0]] { + let m = Mat4::orientation(Vec3::from(o)); + let back = Mat4::orientation(m.orientation_angles()); + for r in 0..3 { + for c in 0..3 { + assert!((m.0[r][c] - back.0[r][c]).abs() < 1e-9, "{o:?}"); + } + } + } + } + + #[test] + fn keyed_orientation_slerps_as_after_effects() { + // After Effects 2026 read-backs of linear Orientation keys (cases 703 and 802). + let a = vec3(0.0, 0.0, 0.0); + let b = vec3(10.0, 20.0, 30.0); + assert!(near3(orientation_slerp(a, b, 0.25), [3.4341, 4.4691, 7.7129], 1e-3)); + assert!(near3(orientation_slerp(a, b, 0.5), [6.2643, 9.3531, 15.2190], 1e-3)); + assert!(near3(orientation_slerp(a, b, 0.75), [8.4650, 14.5598, 22.6095], 1e-3)); + let b = vec3(60.0, 80.0, 40.0); + assert!(near3(orientation_slerp(a, b, 0.25), [16.9201, 16.0688, 16.0629], 1e-3)); + assert!(near3(orientation_slerp(a, b, 0.5), [30.6186, 35.7617, 28.4317], 1e-3)); + // The shortest way round, written in [0, 360). + assert!(near3(orientation_slerp(a, vec3(0.0, 0.0, 270.0), 0.5), [0.0, 0.0, 315.0], 1e-9)); + assert!(near3(orientation_slerp(a, vec3(0.0, 0.0, 270.0), 0.25), [0.0, 0.0, 337.5], 1e-9)); + assert!(near3(orientation_slerp(a, vec3(350.0, 0.0, 0.0), 0.5), [355.0, 0.0, 0.0], 1e-9)); + assert!(near3(orientation_slerp(a, vec3(0.0, 0.0, 720.0), 0.5), [0.0, 0.0, 0.0], 1e-9)); + // The ends are the key rotations. + let k = vec3(200.0, 10.0, 5.0); + let end = Mat4::orientation(orientation_slerp(vec3(0.0, 0.0, 350.0), k, 1.0)); + let want = Mat4::orientation(k); + assert!((0..3).all(|r| (0..3).all(|c| (end.0[r][c] - want.0[r][c]).abs() < 1e-9))); + // Equal rotations and hostile input do not blow up. + let whole = Mat4::orientation(orientation_slerp(a, vec3(0.0, 0.0, 360.0), 0.5)).apply_vec(vec3(1.0, 0.0, 0.0)); + assert!((whole - vec3(1.0, 0.0, 0.0)).length() < 1e-9 && orientation_arc_deg(a, vec3(0.0, 0.0, 360.0)) < 1e-6); + let nan = orientation_slerp(vec3(f64::NAN, 0.0, 0.0), b, 0.5); + assert!(nan.x.is_nan() || nan.x.is_finite()); + assert!(orientation_slerp(a, b, f64::INFINITY).x.is_finite()); + assert!((orientation_arc_deg(a, vec3(0.0, 0.0, 90.0)) - 90.0).abs() < 1e-9); + assert!((orientation_arc_deg(a, vec3(0.0, 0.0, 270.0)) - 90.0).abs() < 1e-9); + } + #[test] fn default_camera_sees_z0_plane_unscaled() { let (w, h) = (1920.0, 1080.0); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index e0ee137ba..4bba405b1 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -516,6 +516,61 @@ pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option { Some(segment_value(keys, i, t, spatial)) } +/// Evaluate keyed 3D Orientation at `t` as After Effects does: the temporal ease gives the +/// progress through each segment, and the rotation moves along the shortest arc between the two +/// keys ([`effectcraft_geom::orientation_slerp`]) instead of blending each angle. Speeds are +/// degrees per second along that arc. Values other than 3D vectors evaluate as [`evaluate`]. +pub fn evaluate_orientation(keys: &[Keyframe], t: Tick) -> Option { + let first = keys.first()?; + if keys.len() == 1 || t <= first.time { + return Some(first.value.clone()); + } + let last = keys.last()?; + if t >= last.time { + return Some(last.value.clone()); + } + let i = keys.partition_point(|k| k.time <= t).checked_sub(1)?; + let (a, b) = (keys.get(i)?, keys.get(i + 1)?); + let (Value::Vec3(va), Value::Vec3(vb)) = (&a.value, &b.value) else { return Some(segment_value(keys, i, t, false)) }; + if a.out_interp == Interp::Hold { + return Some(a.value.clone()); + } + let (t0, t1) = (secs(a.time), secs(b.time)); + let dur = (t1 - t0).max(1e-12); + let x = ((secs(t) - t0) / dur).clamp(0.0, 1.0); + // On a key: its own value, as stored. + if x <= 0.0 { + return Some(a.value.clone()); + } + let arc = effectcraft_geom::orientation_arc_deg((*va).into(), (*vb).into()); + let lin_out = a.out_interp == Interp::Linear; + let lin_in = b.in_interp != Interp::Bezier; + let f = if lin_out && lin_in || arc <= 1e-9 { + x + } else { + // Auto-Bezier keys take the arc speed between their neighbours. + let side = |j: usize, out: bool| -> Ease { + let Some(k) = keys.get(j) else { return Ease { speed: arc / dur, influence: 1.0 / 3.0 } }; + let linear = if out { lin_out } else { lin_in }; + if linear { + return Ease { speed: arc / dur, influence: 1.0 / 3.0 }; + } + if k.auto_bezier && j > 0 && j + 1 < keys.len() { + let span = (secs(keys[j + 1].time) - secs(keys[j - 1].time)).max(1e-12); + let (Value::Vec3(p), Value::Vec3(n)) = (&keys[j - 1].value, &keys[j + 1].value) else { return Ease { speed: arc / dur, influence: 1.0 / 6.0 } }; + return Ease { speed: effectcraft_geom::orientation_arc_deg((*p).into(), (*n).into()) / span, influence: 1.0 / 6.0 }; + } + let list = if out { &k.out_ease } else { &k.in_ease }; + list.first().copied().unwrap_or_default() + }; + let (eo, ei) = (side(i, true), side(i + 1, false)); + ease_progress(eo.speed * dur / arc, eo.influence, ei.speed * dur / arc, ei.influence, x) + }; + let f = if f.is_finite() { f } else { x }; + let o = effectcraft_geom::orientation_slerp((*va).into(), (*vb).into(), f); + Some(Value::Vec3([o.x, o.y, o.z])) +} + /// Value inside segment `i` → `i+1` at time `t`. pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value { let a = &keys[i]; @@ -737,6 +792,44 @@ mod tests { } } + /// Keyed Orientation moves along the shortest arc, eased in time, as After Effects 2026 reads + /// it back (cases 802 and 803: keys [0,0,0] at 0 s and [60,80,40] at 2 s). + #[test] + fn keyed_orientation_matches_after_effects() { + let close = |v: Value, e: [f64; 3]| { + let v = v.as_vec3(); + assert!((0..3).all(|i| (v[i] - e[i]).abs() < 2e-3), "{v:?} vs {e:?}"); + }; + let lin = vec![Keyframe::new(s(0.0), Value::Vec3([0.0; 3])), Keyframe::new(s(2.0), Value::Vec3([60.0, 80.0, 40.0]))]; + close(evaluate_orientation(&lin, s(0.5)).unwrap(), [16.9201, 16.0688, 16.0629]); + close(evaluate_orientation(&lin, s(1.0)).unwrap(), [30.6186, 35.7617, 28.4317]); + close(evaluate_orientation(&lin, s(2.0)).unwrap(), [60.0, 80.0, 40.0]); + // A middle key reads back as stored (not as another angle triple of the same rotation). + let three = vec![ + Keyframe::new(s(0.0), Value::Vec3([0.0; 3])), + Keyframe::new(s(1.0), Value::Vec3([0.0, 120.0, 0.0])), + Keyframe::new(s(2.0), Value::Vec3([0.0; 3])), + ]; + assert_eq!(evaluate_orientation(&three, s(1.0)), Some(Value::Vec3([0.0, 120.0, 0.0]))); + // Easy ease (AE's preset: speed 0, influence 33 %) on both keys. + let ease = |k: Keyframe| { + let mut k = k.eased(); + k.in_ease = vec![Ease { speed: 0.0, influence: 0.33 }]; + k.out_ease = vec![Ease { speed: 0.0, influence: 0.33 }]; + k + }; + let eased: Vec = lin.into_iter().map(ease).collect(); + close(evaluate_orientation(&eased, s(0.5)).unwrap(), [11.0974, 9.5750, 10.5916]); + close(evaluate_orientation(&eased, s(1.0)).unwrap(), [30.6186, 35.7617, 28.4317]); + close(evaluate_orientation(&eased, s(1.5)).unwrap(), [47.6915, 65.7590, 40.3338]); + // Hold keys and other value types. + let held = vec![Keyframe::new(s(0.0), Value::Vec3([1.0, 2.0, 3.0])).hold(), Keyframe::new(s(1.0), Value::Vec3([0.0; 3]))]; + close(evaluate_orientation(&held, s(0.5)).unwrap(), [1.0, 2.0, 3.0]); + let scalar = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(10.0))]; + assert_eq!(evaluate_orientation(&scalar, s(0.5)), Some(Value::Scalar(5.0))); + assert_eq!(evaluate_orientation(&[], s(0.5)), None); + } + /// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns. #[test] fn gradients_coerce_from_plain_json() { diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index 6212a4b59..c53455f5b 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -127,6 +127,10 @@ impl Property { if self.keys.is_empty() { return self.value.clone(); } + // 3D Orientation turns along the shortest arc between its keys, as in After Effects. + if self.match_id == "orientation" && matches!(self.value, Value::Vec3(_)) { + return effectcraft_keyframe::evaluate_orientation(&self.keys, t).unwrap_or_else(|| self.value.clone()); + } effectcraft_keyframe::evaluate(&self.keys, t, self.spatial).unwrap_or_else(|| self.value.clone()) } /// Set the value at `t`: adds/replaces a key when animated, else sets the static value. diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 8a193b87a..2f59a965c 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -179,13 +179,7 @@ impl<'a> EvalCtx<'a> { // Auto-orient (along path / towards camera) replaces Orientation. let orient = crate::three_d::camera::auto_orient_3d(self, layer, p) .unwrap_or_else(|| Mat4::orientation(Vec3::from(self.v3(layer, tr, "orientation", [0.0; 3])))); - Mat4::translate(p) - * orient - * Mat4::rotate_z(rz) - * Mat4::rotate_y(ry) - * Mat4::rotate_x(rx) - * Mat4::scale(Vec3::from(scale) / 100.0) - * Mat4::translate(-Vec3::from(anchor)) + Mat4::translate(p) * orient * Mat4::rotation_xyz(rx, ry, rz) * Mat4::scale(Vec3::from(scale) / 100.0) * Mat4::translate(-Vec3::from(anchor)) } /// Layer space → comp (world) space, including parents. diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 5e1f983b2..13b948fe3 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -116,12 +116,15 @@ pub fn basis_view(eye: Vec3, forward: Vec3, down_hint: Vec3) -> Mat4 { Mat4([[r.x, r.y, r.z, 0.0], [d.x, d.y, d.z, 0.0], [f.x, f.y, f.z, 0.0], [0.0, 0.0, 0.0, 1.0]]) * Mat4::translate(-eye) } -/// Orientation angles (degrees, AE order X→Y→Z with Z = 0) that point +z along `dir`. +/// Orientation angles (degrees, [`Mat4::orientation`]'s order) that point +z along `dir` with +/// the layer's x axis kept level (no roll): the pitch about x, then the turn about the world's +/// vertical axis, written as Orientation. pub fn orientation_for(dir: Vec3) -> [f64; 3] { let d = dir.normalize(); - let x = (-d.y).clamp(-1.0, 1.0).asin().to_degrees(); - let y = d.x.atan2(d.z).to_degrees(); - [norm360(x), norm360(y), 0.0] + let pitch = (-d.y).clamp(-1.0, 1.0).asin().to_degrees(); + let yaw = d.x.atan2(d.z).to_degrees(); + let o = (Mat4::rotate_y(yaw) * Mat4::rotate_x(pitch)).orientation_angles(); + [norm360(o.x), norm360(o.y), norm360(o.z)] } fn norm360(a: f64) -> f64 { @@ -176,7 +179,7 @@ pub fn layer_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 { let rx = ctx.f(layer, tr, "rotationX", 0.0); let ry = ctx.f(layer, tr, "rotationY", 0.0); let rz = ctx.f(layer, tr, "rotation", 0.0); - Mat4::orientation(Vec3::from(o)) * Mat4::rotate_z(rz) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx) + Mat4::orientation(Vec3::from(o)) * Mat4::rotation_xyz(rx, ry, rz) } /// World matrix of a layer's parent chain (identity when unparented). diff --git a/crates/render/src/three_d/tests_orient.rs b/crates/render/src/three_d/tests_orient.rs index 06f2599dc..c6be0ab30 100644 --- a/crates/render/src/three_d/tests_orient.rs +++ b/crates/render/src/three_d/tests_orient.rs @@ -151,3 +151,264 @@ fn layer_cache_is_independent_of_camera_lights_and_view() { assert_ne!(third, second); assert!(cache.stats().hits > 0, "the cache was actually used"); } + +/// A 1920 × 1080 comp with a full-frame 3D solid whose transform `set` changes (and an optional +/// one-node camera at the default camera's place), as the After Effects reference cases. +fn ae_frame(set: impl Fn(&mut Layer), camera: Option<([f64; 3], [f64; 3])>) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(1920, 1080, FrameRate::FPS_25, Tick::from_seconds_f64(5.0)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let sid = p.add_item("L", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 1920, height: 1080, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "L", LayerSource::Solid { item: sid }, (1920, 1080), None); + l.switches.three_d = true; + set(&mut l); + let mut layers = vec![]; + if let Some((orientation, rotation)) = camera { + let mut cam = build::layer(&mut p, &comp, "K", LayerSource::Camera, (1920, 1080), None); + cam.auto_orient = AutoOrient::Off; + cam.props.prop_mut("transform/position").unwrap().value = Value::Vec3([960.0, 540.0, -1920.0 * 50.0 / 36.0]); + cam.props.prop_mut("transform/orientation").unwrap().value = Value::Vec3(orientation); + for (m, v) in [("transform/rotationX", rotation[0]), ("transform/rotationY", rotation[1]), ("transform/rotation", rotation[2])] { + cam.props.prop_mut(m).unwrap().value = Value::Scalar(v); + } + cam.props.prop_mut("cameraOptions/zoom").unwrap().value = Value::Scalar(1920.0 * 50.0 / 36.0); + layers.push(cam); + } + layers.push(l); + p.comp_mut(cid).unwrap().layers = layers; + (p, cid) +} + +/// Whether comp pixel (x, y) shows the solid, from its layer → comp mapping. +fn covers(p: &Project, cid: ItemId, secs: f64, x: u32, y: u32) -> bool { + let c = p.comp(cid).unwrap(); + let ctx = EvalCtx::new(p, cid, c, Tick::from_seconds_f64(secs)); + let l = c.layers.last().unwrap(); + let inv = ctx.layer_to_comp(l).0.inverse().unwrap(); + let u = inv.apply(effectcraft_geom::vec2(x as f64 + 0.5, y as f64 + 0.5)); + (0.0..=1920.0).contains(&u.x) && (0.0..=1080.0).contains(&u.y) +} + +fn check_points(p: &Project, cid: ItemId, secs: f64, case: &str, pts: &[(u32, u32, bool)]) { + for &(x, y, red) in pts { + assert_eq!(covers(p, cid, secs, x, y), red, "case {case}: pixel ({x}, {y}) should be {}", if red { "red" } else { "black" }); + } +} + +fn set3(l: &mut Layer, m: &str, v: [f64; 3]) { + l.props.prop_mut(m).unwrap().value = Value::Vec3(v); +} + +fn set1(l: &mut Layer, m: &str, v: f64) { + l.props.prop_mut(m).unwrap().value = Value::Scalar(v); +} + +/// Orientation and X/Y/Z Rotation compose as `Rx · Ry · Rz` (Z turns the layer first), and +/// Orientation comes before the axis rotations, in layers and cameras alike. The points are +/// sampled from After Effects 2026 renders of the synthetic cases (each at least 3 px from an +/// edge; red = the solid covers the pixel), a third of them where the old Z · Y · X order differed. +#[test] +fn rotation_order_matches_after_effects() { + // 488: Orientation [10, 20, 30]. + let (p, cid) = ae_frame(|l| set3(l, "transform/orientation", [10.0, 20.0, 30.0]), None); + check_points( + &p, + cid, + 1.0, + "488", + &[ + (328, 687, false), + (416, 764, false), + (209, 237, true), + (548, 861, false), + (1899, 425, false), + (87, 462, true), + (162, 581, false), + (662, 948, false), + (682, 849, true), + (618, 413, true), + (1380, 181, true), + (605, 378, true), + (1830, 518, true), + (559, 197, true), + (1760, 843, true), + (550, 823, true), + (572, 952, false), + (272, 963, false), + (268, 761, false), + (628, 1073, false), + (218, 721, false), + (256, 807, false), + (225, 881, false), + (167, 637, false), + ], + ); + // 800: X / Y / Z Rotation 10 / 20 / 30. + let (p, cid) = ae_frame( + |l| { + set1(l, "transform/rotationX", 10.0); + set1(l, "transform/rotationY", 20.0); + set1(l, "transform/rotation", 30.0); + }, + None, + ); + check_points( + &p, + cid, + 1.0, + "800", + &[ + (104, 450, true), + (755, 1002, false), + (1407, 149, false), + (271, 66, true), + (288, 54, true), + (113, 393, true), + (204, 174, true), + (1790, 360, false), + (1414, 324, true), + (682, 343, true), + (1147, 153, true), + (345, 446, true), + (1516, 542, true), + (666, 25, true), + (1327, 1065, true), + (778, 267, true), + (1588, 5, false), + (1706, 147, false), + (357, 978, false), + (97, 689, false), + (1483, 79, false), + (433, 848, false), + (255, 802, false), + (636, 1057, false), + ], + ); + // 801: Orientation [0, 30, 0] with X Rotation 40 (Orientation first). + let (p, cid) = ae_frame( + |l| { + set3(l, "transform/orientation", [0.0, 30.0, 0.0]); + set1(l, "transform/rotationX", 40.0); + }, + None, + ); + check_points( + &p, + cid, + 1.0, + "801", + &[ + (1356, 35, true), + (1762, 19, true), + (190, 227, true), + (108, 182, true), + (502, 832, true), + (1539, 203, true), + (722, 812, true), + (1872, 76, true), + (1166, 165, true), + (1640, 380, true), + (629, 619, true), + (1796, 574, true), + (1767, 311, true), + (1432, 421, true), + (1819, 425, true), + (283, 252, true), + (563, 1062, false), + (428, 963, false), + (77, 417, false), + (47, 833, false), + (995, 933, false), + (159, 839, false), + (827, 947, false), + (564, 908, false), + ], + ); + // 804: a one-node camera with Orientation [4, 6, 30]; 805: the same as X / Y / Z Rotation. + for (case, cam) in [("804", ([4.0, 6.0, 30.0], [0.0; 3])), ("805", ([0.0; 3], [4.0, 6.0, 30.0]))] { + let (p, cid) = ae_frame(|_| {}, Some(cam)); + check_points( + &p, + cid, + 1.0, + case, + &[ + (204, 410, false), + (1077, 38, false), + (60, 492, false), + (1732, 752, true), + (265, 475, false), + (1743, 821, true), + (1417, 1043, true), + (1559, 944, true), + (1280, 310, true), + (702, 984, true), + (1072, 893, true), + (370, 1042, true), + (1353, 570, true), + (1178, 136, true), + (1201, 289, true), + (1241, 605, true), + (75, 337, false), + (71, 138, false), + (352, 24, false), + (1764, 109, false), + (186, 338, false), + (1835, 668, false), + (211, 269, false), + (157, 143, false), + ], + ); + } +} + +/// Keyed Orientation turns along the shortest arc between the keys (case 802: [0, 0, 0] at 0 s +/// to [60, 80, 40] at 2 s, linear; points from the After Effects render at 1 s, a third of them +/// where blending each angle on its own differed). +#[test] +fn keyed_orientation_renders_as_after_effects() { + let (p, cid) = ae_frame( + |l| { + l.props.prop_mut("transform/orientation").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0; 3])), Keyframe::new(Tick::from_seconds_f64(2.0), Value::Vec3([60.0, 80.0, 40.0]))]; + }, + None, + ); + check_points( + &p, + cid, + 1.0, + "802", + &[ + (1880, 472, false), + (1598, 191, false), + (248, 285, true), + (1157, 51, false), + (1628, 129, false), + (357, 37, true), + (1865, 891, true), + (1915, 946, true), + (1728, 1021, true), + (1178, 665, true), + (1303, 631, true), + (1339, 954, true), + (910, 891, true), + (1257, 1002, true), + (1349, 890, true), + (1388, 551, true), + (356, 500, false), + (490, 1058, false), + (524, 829, false), + (73, 29, false), + (393, 769, false), + (1867, 124, false), + (42, 733, false), + (648, 1007, false), + ], + ); + // The value After Effects reads back half-way. + let c = p.comp(cid).unwrap(); + let v = c.layers[0].props.prop("transform/orientation").unwrap().value_at(Tick::from_seconds_f64(1.0)).as_vec3(); + let want = [30.6186, 35.7617, 28.4317]; + assert!((0..3).all(|i| (v[i] - want[i]).abs() < 1e-3), "{v:?}"); +} diff --git a/crates/track/src/camtrack/linalg.rs b/crates/track/src/camtrack/linalg.rs index 2efc14556..c0b25c0c2 100644 --- a/crates/track/src/camtrack/linalg.rs +++ b/crates/track/src/camtrack/linalg.rs @@ -404,16 +404,16 @@ pub fn umeyama(src: &[V3], dst: &[V3]) -> Option { Some(Similarity { s, r, t }) } -/// Euler angles (degrees) `[x, y, z]` with `m = Rz(z) Ry(y) Rx(x)` (After Effects' Orientation -/// order: X, then Y, then Z applied to the vector). +/// Euler angles (degrees) `[x, y, z]` with `m = Rx(x) Ry(y) Rz(z)`: After Effects' Orientation +/// order (Z is applied to the vector first, then Y, then X; see `effectcraft_geom::Mat4::orientation`). pub fn euler_xyz(m: &M3) -> V3 { - let sy = (-m[2][0]).clamp(-1.0, 1.0); + let sy = m[0][2].clamp(-1.0, 1.0); let y = sy.asin(); - let (x, z) = if sy.abs() < 0.999_999 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) }; + let (x, z) = if sy.abs() < 0.999_999 { ((-m[1][2]).atan2(m[2][2]), (-m[0][1]).atan2(m[0][0])) } else { (m[2][1].atan2(m[1][1]), 0.0) }; [x.to_degrees(), y.to_degrees(), z.to_degrees()] } -/// `Rz(z) Ry(y) Rx(x)` from degrees. +/// `Rx(x) Ry(y) Rz(z)` from degrees. pub fn from_euler_xyz(e: V3) -> M3 { let (sx, cx) = e[0].to_radians().sin_cos(); let (sy, cy) = e[1].to_radians().sin_cos(); @@ -421,7 +421,7 @@ pub fn from_euler_xyz(e: V3) -> M3 { let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]]; let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]]; let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]]; - mm(&rz, &mm(&ry, &rx)) + mm(&rx, &mm(&ry, &rz)) } #[cfg(test)]