3D: Orientation and X/Y/Z Rotation turn Z first, keyed Orientation slerps, as After Effects

After Effects composes a 3D layer's (and a camera's) Orientation and its X/Y/Z
Rotation as Rx * Ry * Rz each, Orientation before the axis rotations; EffectCraft
used Rz * Ry * Rx. Measured on synthetic AE 2026 cases: Orientation [10,20,30]
(488) was 5.2 % of the frame off and now matches to the anti-aliased edge (0.03 %);
X/Y/Z Rotation 10/20/30 (800), Orientation + X Rotation (801) and one-node
cameras (804, 805) likewise.

Keyed Orientation moves along the shortest arc between the keys (quaternion
slerp, eased in time, angles written in [0, 360)): keys [0,0,0] -> [10,20,30]
read back [6.2643, 9.3531, 15.2190] half-way where EffectCraft gave [5,10,15];
[0,0,0] -> [60,80,40] linear and eased match AE's read-backs within 0.002 deg.

lookAt() returns AE's angles (z axis on the target, x axis level; two AE
read-backs within 1e-3 deg). The camera tracker, VR editor, Look At and orbit
commands use the new decomposition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-10-19 16:47:37 +02:00
1 parent ef74d82902
commit 4545afb9ab
12 files changed
+557 -41

No files matched your search

+3 -7
View File
@@ -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,
+1 -1
View File
@@ -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 };
+3 -5
View File
@@ -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).
+19 -3
View File
@@ -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; }
+6
View File
@@ -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 ----------------------------------------------------------------------------
+152 -7
View File
@@ -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::<f64>().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);
+93
View File
@@ -516,6 +516,61 @@ pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option<Value> {
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<Value> {
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<Keyframe> = 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() {
+4
View File
@@ -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.
+1 -7
View File
@@ -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.
+8 -5
View File
@@ -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).
+261
View File
@@ -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:?}");
}
+6 -6
View File
@@ -404,16 +404,16 @@ pub fn umeyama(src: &[V3], dst: &[V3]) -> Option<Similarity> {
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)]