Text: per-character 3D rotation turns Z first, as a 3D layer and After Effects

With per-character 3D on, After Effects turns each character by Rx * Ry * Rz
(Z first), as it turns a 3D layer's X / Y / Z Rotation (case 931, AE 2026:
"H" at 400 px with animator X / Y / Z Rotation 40 / 30 / 20). EffectCraft
composed Rz * Ry * Rx, so the glyph faced another way: 23 of 5184 grid
points off (max 255), now 3 at the anti-aliased edge (mean 0.015/255). The
same order fix-orientation uses for layers; texts with one rotation axis
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:41:16 +02:00
co-authored by Claude Opus 5.5
parent 8ea876914d
commit 8b64074ce3
3 changed files with 41 additions and 2 deletions
+2 -2
View File
@@ -752,9 +752,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
};
// Outline relative to the pivot.
let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline;
let char_m = Mat4::rotate_z(x.rotation)
let char_m = Mat4::rotate_x(x.rotation_x)
* Mat4::rotate_y(x.rotation_y)
* Mat4::rotate_x(x.rotation_x)
* Mat4::rotate_z(x.rotation)
* Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis))
* Mat4::scale(Vec3::from(x.scale) / 100.0)
* Mat4::translate(-Vec3::from(x.anchor));
+31
View File
@@ -484,6 +484,37 @@ fn per_character_3d_projects_characters() {
assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}");
}
/// After Effects 2026 (case 931: "H", 400 px, per-character 3D, X / Y / Z Rotation 40 / 30 /
/// 20): a character turns like a 3D layer, Rx · Ry · Rz (Z first), so its matrix is the same
/// product as a layer's X / Y / Z Rotation. The render then matches AE (3 of 5184 grid points
/// off at edges; with Rz · Ry · Rx 23).
#[test]
fn per_character_rotation_turns_z_first_as_a_3d_layer() {
let (mut p, cid, comp) = setup(400, 300);
let mut l = text_layer(&mut p, &comp, doc("H", 80.0));
l.switches.three_d = true;
animator(&mut p, &mut l, &["rotation"], &["range"]);
let mut next = p.next_id;
let mut extra = build::text_anim_props(&mut Ids(&mut next), "rotation", true);
p.next_id = next;
let props: &mut PropGroup = l.props.group_mut("text/animators/#1/properties").unwrap();
props.children.retain(|c| c.match_id() != "rotation");
props.children.extend(extra.drain(..).map(Into::into));
for (id, v) in [("rotationX", 40.0), ("rotationY", 30.0), ("rotation", 20.0)] {
props.get_mut(id).unwrap().value = Value::Scalar(v);
}
l.props.sub_mut("text").unwrap().get_mut("perChar3d").unwrap().value = Value::Bool(true);
p.comp_mut(cid).unwrap().layers.push(l);
let geom = text_geom(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]).unwrap();
let g = &geom.glyphs[0];
let want = Mat4::rotate_x(40.0) * Mat4::rotate_y(30.0) * Mat4::rotate_z(20.0);
for r in 0..3 {
for c in 0..3 {
assert!((g.m.0[r][c] - want.0[r][c]).abs() < 1e-9, "row {r} col {c}: {:?} vs {:?}", g.m.0, want.0);
}
}
}
#[test]
fn keyframed_selector_animates() {
let (mut p, cid, comp) = setup(400, 200);
+8
View File
@@ -102,6 +102,14 @@ preset now animates 40 → 0 px (was 400 → 0 thousandths of an em); Lottie exp
tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`,
`modes_combine`.
## Per-character 3D rotation order
Measured on 19 October 2026 with After Effects 2026 (26.2) (case 931: "H" at 400 px, per-character
3D, animator X / Y / Z Rotation 40 / 30 / 20): a character turns as a 3D layer does, Rx · Ry · Rz
(Z first). EffectCraft composed Rz · Ry · Rx, so the glyph faced another way (23 of 5184 grid points
off, max 255); now 3 at the anti-aliased edge (mean 0.015). Regression test:
`per_character_rotation_turns_z_first_as_a_3d_layer`.
## Still unmeasured
Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames`