mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
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:
co-authored by
Claude Opus 5.5
parent
8ea876914d
commit
8b64074ce3
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user