mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-08 03:22:22 +00:00
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:
1 parent
ef74d82902
commit
4545afb9ab
12 files changed
+557
-41
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user