#1010: recover a static Orientation's Y angle without losing precision near gimbal lock

uniform_parent_pose took the Y angle from asin, which loses precision within about 1e-6 degrees of
+-90. The Y angle now comes from atan2 on cos(Y), as the X and Z angles already do. The gimbal
branch and the principal-range output are unchanged.

A new test parents a static Orientation with Y 1e-6 degrees off gimbal lock under a uniform
parent. Its largest corner displacement was 3.3e-8 px before this change and 1.5e-14 px after.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Kristian Myram 2026-10-12 18:37:05 +02:00
parent 470ea7a3af
commit be99fecfdc
2 changed files with 27 additions and 3 deletions

View File

@ -743,9 +743,10 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
return Some((orientation, factor));
}
let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0;
let sy = (-r[2][0]).clamp(-1.0, 1.0);
let y = sy.asin();
let (x, z) = if (1.0 - sy * sy).sqrt() > 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) };
// 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))
}

View File

@ -383,6 +383,29 @@ fn parenting_static_orientation_handles_signed_uniform_scale_and_gimbal_order()
assert!(max_corners < 1e-8 && max_pixels < 2e-5, "static uniform pose must use the existing Euler order including signed scale and gimbal angles");
}
/// A static Orientation just off gimbal lock (Y within 1e-6 degrees of ±90) keeps its world pose
/// under a uniform parent. Recovering Euler angles from the matrix must not lose precision there.
#[test]
fn parenting_static_orientation_is_exact_just_off_gimbal_lock() {
let mut max_corners = 0.0f64;
for factor in [120.0, -120.0] {
for y in [90.0 - 1e-6, -90.0 + 1e-6] {
let (mut s, p, c) = fixture(true, true);
set(&mut s, p, "transform/orientation", json!([0.0, 0.0, 0.0]));
set(&mut s, p, "transform/rotationX", json!(0.0));
set(&mut s, p, "transform/rotationY", json!(0.0));
set(&mut s, p, "transform/rotation", json!(42.0));
set(&mut s, p, "transform/scale", json!([factor, factor, factor]));
set(&mut s, c, "transform/orientation", json!([23.0, y, -37.0]));
set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0]));
let before = world(&s, c, Tick::ZERO);
parent(&mut s, c, Some(p), true);
max_corners = max_corners.max(displacement(before, world(&s, c, Tick::ZERO)));
}
}
assert!(max_corners < 1e-9, "a static Orientation near gimbal lock must keep its world pose: corners {max_corners}");
}
#[test]
fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() {
for animated in [true, false] {