Skip to main content

concinnity_core/gfx/
transform.rs

1//! The engine's transform convention, in one place: the column-major 4x4 layout
2//! every renderer uniform is written in, the multiply and the two inverses over
3//! it, the `T * R(YXZ) * S` composition joints and props both build their matrix
4//! through, and the quaternion conversions that let a rotation be interpolated
5//! along the shorter arc rather than component-wise through its Euler angles.
6
7use crate::math::{acos, sin, sin_cos, sqrt};
8
9/// Column-major 4x4 matrix, `m[col][row]`: the layout shared by every renderer
10/// uniform in this codebase.
11pub type Mat4 = [[f32; 4]; 4];
12
13/// Column-major 4x4 identity.
14pub const IDENTITY: Mat4 = [
15    [1.0, 0.0, 0.0, 0.0],
16    [0.0, 1.0, 0.0, 0.0],
17    [0.0, 0.0, 1.0, 0.0],
18    [0.0, 0.0, 0.0, 1.0],
19];
20
21/// Column-major 4x4 multiply: `a * b`.
22pub fn mat4_mul(a: Mat4, b: Mat4) -> Mat4 {
23    let mut out = [[0.0f32; 4]; 4];
24    for col in 0..4 {
25        for row in 0..4 {
26            for k in 0..4 {
27                out[col][row] += a[k][row] * b[col][k];
28            }
29        }
30    }
31    out
32}
33
34/// Inverse of an affine matrix whose bottom row is `[0, 0, 0, 1]`. The upper
35/// 3x3 is inverted via the adjugate; the translation is mapped through it.
36/// A near-singular upper 3x3 (degenerate scale) falls back to identity rather
37/// than producing NaNs.
38pub fn mat4_affine_inverse(m: Mat4) -> Mat4 {
39    // Upper-left 3x3, addressed as a[col][row].
40    let a = m;
41    let det = a[0][0] * (a[1][1] * a[2][2] - a[2][1] * a[1][2])
42        - a[1][0] * (a[0][1] * a[2][2] - a[2][1] * a[0][2])
43        + a[2][0] * (a[0][1] * a[1][2] - a[1][1] * a[0][2]);
44    if det.abs() < 1e-12 {
45        return IDENTITY;
46    }
47    let inv_det = 1.0 / det;
48    // Inverse 3x3 (cofactor transpose * 1/det), again as inv[col][row].
49    let mut inv = [[0.0f32; 4]; 4];
50    inv[0][0] = (a[1][1] * a[2][2] - a[2][1] * a[1][2]) * inv_det;
51    inv[1][0] = -(a[1][0] * a[2][2] - a[2][0] * a[1][2]) * inv_det;
52    inv[2][0] = (a[1][0] * a[2][1] - a[2][0] * a[1][1]) * inv_det;
53    inv[0][1] = -(a[0][1] * a[2][2] - a[2][1] * a[0][2]) * inv_det;
54    inv[1][1] = (a[0][0] * a[2][2] - a[2][0] * a[0][2]) * inv_det;
55    inv[2][1] = -(a[0][0] * a[2][1] - a[2][0] * a[0][1]) * inv_det;
56    inv[0][2] = (a[0][1] * a[1][2] - a[1][1] * a[0][2]) * inv_det;
57    inv[1][2] = -(a[0][0] * a[1][2] - a[1][0] * a[0][2]) * inv_det;
58    inv[2][2] = (a[0][0] * a[1][1] - a[1][0] * a[0][1]) * inv_det;
59    // Inverse translation: -inv3x3 * t.
60    let t = [m[3][0], m[3][1], m[3][2]];
61    inv[3][0] = -(inv[0][0] * t[0] + inv[1][0] * t[1] + inv[2][0] * t[2]);
62    inv[3][1] = -(inv[0][1] * t[0] + inv[1][1] * t[1] + inv[2][1] * t[2]);
63    inv[3][2] = -(inv[0][2] * t[0] + inv[1][2] * t[1] + inv[2][2] * t[2]);
64    inv[3][3] = 1.0;
65    inv
66}
67
68/// Inverse of a general 4x4 matrix, by cofactor expansion. Returns
69/// [`IDENTITY`] when the determinant is singular or non-finite, so a degenerate
70/// input yields a usable matrix instead of spreading NaNs through the pass that
71/// asked. Unlike [`mat4_affine_inverse`] this handles a projection's bottom row,
72/// which is what the screen-space passes need to invert a view-projection and
73/// rebuild world position from depth.
74pub fn mat4_inverse(m: Mat4) -> Mat4 {
75    // Named row-major (aRC = row R, column C) for the standard cofactor layout,
76    // read out of the column-major input and re-emitted column-major below.
77    let a00 = m[0][0];
78    let a01 = m[1][0];
79    let a02 = m[2][0];
80    let a03 = m[3][0];
81    let a10 = m[0][1];
82    let a11 = m[1][1];
83    let a12 = m[2][1];
84    let a13 = m[3][1];
85    let a20 = m[0][2];
86    let a21 = m[1][2];
87    let a22 = m[2][2];
88    let a23 = m[3][2];
89    let a30 = m[0][3];
90    let a31 = m[1][3];
91    let a32 = m[2][3];
92    let a33 = m[3][3];
93
94    let b00 = a00 * a11 - a01 * a10;
95    let b01 = a00 * a12 - a02 * a10;
96    let b02 = a00 * a13 - a03 * a10;
97    let b03 = a01 * a12 - a02 * a11;
98    let b04 = a01 * a13 - a03 * a11;
99    let b05 = a02 * a13 - a03 * a12;
100    let b06 = a20 * a31 - a21 * a30;
101    let b07 = a20 * a32 - a22 * a30;
102    let b08 = a20 * a33 - a23 * a30;
103    let b09 = a21 * a32 - a22 * a31;
104    let b10 = a21 * a33 - a23 * a31;
105    let b11 = a22 * a33 - a23 * a32;
106
107    let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
108    if det.abs() < 1e-20 || !det.is_finite() {
109        return IDENTITY;
110    }
111    let inv_det = 1.0 / det;
112
113    let i00 = (a11 * b11 - a12 * b10 + a13 * b09) * inv_det;
114    let i01 = (-a01 * b11 + a02 * b10 - a03 * b09) * inv_det;
115    let i02 = (a31 * b05 - a32 * b04 + a33 * b03) * inv_det;
116    let i03 = (-a21 * b05 + a22 * b04 - a23 * b03) * inv_det;
117    let i10 = (-a10 * b11 + a12 * b08 - a13 * b07) * inv_det;
118    let i11 = (a00 * b11 - a02 * b08 + a03 * b07) * inv_det;
119    let i12 = (-a30 * b05 + a32 * b02 - a33 * b01) * inv_det;
120    let i13 = (a20 * b05 - a22 * b02 + a23 * b01) * inv_det;
121    let i20 = (a10 * b10 - a11 * b08 + a13 * b06) * inv_det;
122    let i21 = (-a00 * b10 + a01 * b08 - a03 * b06) * inv_det;
123    let i22 = (a30 * b04 - a31 * b02 + a33 * b00) * inv_det;
124    let i23 = (-a20 * b04 + a21 * b02 - a23 * b00) * inv_det;
125    let i30 = (-a10 * b09 + a11 * b07 - a12 * b06) * inv_det;
126    let i31 = (a00 * b09 - a01 * b07 + a02 * b06) * inv_det;
127    let i32 = (-a30 * b03 + a31 * b01 - a32 * b00) * inv_det;
128    let i33 = (a20 * b03 - a21 * b01 + a22 * b00) * inv_det;
129
130    [
131        [i00, i10, i20, i30],
132        [i01, i11, i21, i31],
133        [i02, i12, i22, i32],
134        [i03, i13, i23, i33],
135    ]
136}
137
138/// Column-major 3x3 rotation matrix, `m[col][row]`.
139pub type Mat3 = [[f32; 3]; 3];
140
141pub(crate) use crate::math::Quat;
142
143// Column-major 3x3 rotation matrix from YXZ Euler degrees. Identical trig to
144// [`JointPose::to_matrix`](crate::gfx::skeleton::JointPose::to_matrix),
145// without the scale or translation.
146pub(crate) fn rotation_mat3(rotation_deg: [f32; 3]) -> Mat3 {
147    let [pitch, yaw, roll] = rotation_deg;
148    let (sp, cp) = sin_cos(pitch.to_radians());
149    let (syw, cyw) = sin_cos(yaw.to_radians());
150    let (sr, cr) = sin_cos(roll.to_radians());
151    [
152        [cyw * cr + syw * sp * sr, cp * sr, -syw * cr + cyw * sp * sr],
153        [-cyw * sr + syw * sp * cr, cp * cr, syw * sr + cyw * sp * cr],
154        [syw * cp, -sp, cyw * cp],
155    ]
156}
157
158/// Compose a column-major `T * R * S` affine matrix from a rotation 3x3,
159/// per-axis scale, and translation.
160pub fn compose(r: Mat3, scale: [f32; 3], t: [f32; 3]) -> Mat4 {
161    let [sx, sy, sz] = scale;
162    [
163        [r[0][0] * sx, r[0][1] * sx, r[0][2] * sx, 0.0],
164        [r[1][0] * sy, r[1][1] * sy, r[1][2] * sy, 0.0],
165        [r[2][0] * sz, r[2][1] * sz, r[2][2] * sz, 0.0],
166        [t[0], t[1], t[2], 1.0],
167    ]
168}
169
170/// Column-major `T * R(YXZ) * S` model matrix. The single home of the engine's
171/// transform convention: joints, props, and runtime transforms all build their
172/// matrix here so they compose consistently.
173pub fn trs_matrix(position: [f32; 3], rotation_deg: [f32; 3], scale: [f32; 3]) -> Mat4 {
174    compose(rotation_mat3(rotation_deg), scale, position)
175}
176
177// Quaternion of a column-major rotation 3x3 (Shepperd's method: picks the
178// largest-magnitude component to keep the division well-conditioned).
179pub(crate) fn quat_from_mat3(m: Mat3) -> Quat {
180    let (m00, m11, m22) = (m[0][0], m[1][1], m[2][2]);
181    let trace = m00 + m11 + m22;
182    if trace > 0.0 {
183        let s = sqrt(trace + 1.0) * 2.0;
184        [
185            (m[1][2] - m[2][1]) / s,
186            (m[2][0] - m[0][2]) / s,
187            (m[0][1] - m[1][0]) / s,
188            0.25 * s,
189        ]
190    } else if m00 > m11 && m00 > m22 {
191        let s = sqrt(1.0 + m00 - m11 - m22) * 2.0;
192        [
193            0.25 * s,
194            (m[1][0] + m[0][1]) / s,
195            (m[2][0] + m[0][2]) / s,
196            (m[1][2] - m[2][1]) / s,
197        ]
198    } else if m11 > m22 {
199        let s = sqrt(1.0 + m11 - m00 - m22) * 2.0;
200        [
201            (m[1][0] + m[0][1]) / s,
202            0.25 * s,
203            (m[2][1] + m[1][2]) / s,
204            (m[2][0] - m[0][2]) / s,
205        ]
206    } else {
207        let s = sqrt(1.0 + m22 - m00 - m11) * 2.0;
208        [
209            (m[2][0] + m[0][2]) / s,
210            (m[2][1] + m[1][2]) / s,
211            0.25 * s,
212            (m[0][1] - m[1][0]) / s,
213        ]
214    }
215}
216
217// Column-major rotation 3x3 of a unit quaternion.
218pub(crate) fn quat_to_mat3(q: Quat) -> Mat3 {
219    let [x, y, z, w] = q;
220    [
221        [
222            1.0 - 2.0 * (y * y + z * z),
223            2.0 * (x * y + w * z),
224            2.0 * (x * z - w * y),
225        ],
226        [
227            2.0 * (x * y - w * z),
228            1.0 - 2.0 * (x * x + z * z),
229            2.0 * (y * z + w * x),
230        ],
231        [
232            2.0 * (x * z + w * y),
233            2.0 * (y * z - w * x),
234            1.0 - 2.0 * (x * x + y * y),
235        ],
236    ]
237}
238
239// Scale a quaternion to unit length, falling back to identity when it is too
240// short to normalise.
241
242// Spherical linear interpolation between two unit quaternions. Negates `b`
243// when the pair points to opposite hemispheres so the interpolation always
244// takes the shorter arc, and falls back to a normalised lerp when the two
245// rotations are nearly parallel (the slerp denominator approaches zero there
246// and nlerp is visually identical at that angle). `f` is clamped to `[0, 1]`.
247pub(crate) fn quat_slerp(a: Quat, mut b: Quat, f: f32) -> Quat {
248    let f = f.clamp(0.0, 1.0);
249    let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
250    if dot < 0.0 {
251        b = [-b[0], -b[1], -b[2], -b[3]];
252        dot = -dot;
253    }
254    if dot > 0.9995 {
255        return crate::math::quat_normalize([
256            a[0] + (b[0] - a[0]) * f,
257            a[1] + (b[1] - a[1]) * f,
258            a[2] + (b[2] - a[2]) * f,
259            a[3] + (b[3] - a[3]) * f,
260        ]);
261    }
262    let theta_0 = acos(dot.clamp(-1.0, 1.0));
263    let sin_0 = sin(theta_0);
264    let s_a = sin((1.0 - f) * theta_0) / sin_0;
265    let s_b = sin(f * theta_0) / sin_0;
266    [
267        a[0] * s_a + b[0] * s_b,
268        a[1] * s_a + b[1] * s_b,
269        a[2] * s_a + b[2] * s_b,
270        a[3] * s_a + b[3] * s_b,
271    ]
272}
273
274/// Decompose a column-major affine matrix into translation, a unit rotation
275/// quaternion, and per-axis scale: the inverse of [`compose`] for a
276/// positive-scale `T * R * S` matrix. Scale is recovered as the length of each
277/// rotation column; a zero-length column yields a zero scale axis and the
278/// rotation falls back to identity for that axis.
279pub fn decompose(m: Mat4) -> ([f32; 3], Quat, [f32; 3]) {
280    let t = [m[3][0], m[3][1], m[3][2]];
281    let col_len = |c: usize| sqrt(m[c][0] * m[c][0] + m[c][1] * m[c][1] + m[c][2] * m[c][2]);
282    let scale = [col_len(0), col_len(1), col_len(2)];
283    let norm = |c: usize| {
284        let s = scale[c];
285        if s < 1e-12 {
286            [0.0, 0.0, 0.0]
287        } else {
288            [m[c][0] / s, m[c][1] / s, m[c][2] / s]
289        }
290    };
291    let r: Mat3 = [norm(0), norm(1), norm(2)];
292    (t, crate::math::quat_normalize(quat_from_mat3(r)), scale)
293}
294
295/// Interpolate two affine matrices in TRS space by weight `f`, clamped to
296/// `[0, 1]`. Translation and scale blend linearly while rotation is
297/// quaternion-slerped along the shorter arc, matching what a clip does between
298/// keyframes, so a blended pose is continuous with a clip's own sampling.
299pub fn blend_matrices(a: Mat4, b: Mat4, f: f32) -> Mat4 {
300    let f = f.clamp(0.0, 1.0);
301    let (ta, qa, sa) = decompose(a);
302    let (tb, qb, sb) = decompose(b);
303    let mix = |x: [f32; 3], y: [f32; 3]| {
304        [
305            x[0] + (y[0] - x[0]) * f,
306            x[1] + (y[1] - x[1]) * f,
307            x[2] + (y[2] - x[2]) * f,
308        ]
309    };
310    compose(
311        quat_to_mat3(quat_slerp(qa, qb, f)),
312        mix(sa, sb),
313        mix(ta, tb),
314    )
315}
316
317/// YXZ Euler angles in degrees recovered from a unit rotation quaternion: the
318/// inverse of `rotation_mat3` composed with `quat_to_mat3`. glTF stores node
319/// rotations as quaternions; the glTF importer converts them to the Euler
320/// [`JointPose`](crate::gfx::skeleton::JointPose) representation this engine's
321/// joints use. The conversion is matrix-exact for non-degenerate rotations; at
322/// gimbal lock (pitch ±90°) it folds the rotation onto the yaw axis with zero
323/// roll.
324pub fn euler_yxz_from_quat(q: Quat) -> [f32; 3] {
325    crate::math::euler_yxz_deg_from_quat(q)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn approx(a: f32, b: f32) -> bool {
333        (a - b).abs() < 1e-4
334    }
335
336    #[test]
337    fn affine_inverse_round_trips() {
338        let m = trs_matrix([3.0, -2.0, 5.0], [0.0, 30.0, 0.0], [2.0, 2.0, 2.0]);
339        let id = mat4_mul(m, mat4_affine_inverse(m));
340        for col in 0..4 {
341            for row in 0..4 {
342                assert!(approx(id[col][row], IDENTITY[col][row]));
343            }
344        }
345    }
346
347    #[test]
348    fn affine_inverse_of_a_degenerate_matrix_falls_back_to_identity() {
349        // A zero scale axis leaves the upper 3x3 singular; the fallback keeps
350        // NaNs out of the joint matrices rather than propagating them.
351        let m = trs_matrix([1.0, 2.0, 3.0], [10.0, 20.0, 30.0], [1.0, 0.0, 1.0]);
352        assert_eq!(mat4_affine_inverse(m), IDENTITY);
353    }
354
355    #[test]
356    fn general_inverse_round_trips_a_projection_and_a_view() {
357        // The screen-space passes invert a view-projection, whose bottom row is
358        // not [0, 0, 0, 1], so mat4_affine_inverse cannot serve them.
359        let proj = crate::gfx::projection::perspective_rh(75.0f32.to_radians(), 1.6, 0.1, 500.0);
360        let view: Mat4 = [
361            [0.92388, 0.0, -0.38268, 0.0],
362            [0.0, 1.0, 0.0, 0.0],
363            [0.38268, 0.0, 0.92388, 0.0],
364            [-1.5, -0.7, 4.0, 1.0],
365        ];
366        for m in [proj, view, mat4_mul(proj, view)] {
367            for id in [mat4_mul(m, mat4_inverse(m)), mat4_mul(mat4_inverse(m), m)] {
368                for col in 0..4 {
369                    for row in 0..4 {
370                        assert!(
371                            (id[col][row] - IDENTITY[col][row]).abs() < 1e-3,
372                            "[{col}][{row}]: {}",
373                            id[col][row]
374                        );
375                    }
376                }
377            }
378        }
379    }
380
381    // The one place the four copies this replaced disagreed: the planar-reflection
382    // copy guarded on determinant magnitude alone, which a NaN slips straight
383    // through. Everything else about them was character-identical.
384    #[test]
385    fn general_inverse_falls_back_on_a_singular_or_non_finite_matrix() {
386        // A zero column is singular; the fallback keeps the inverted VP usable
387        // rather than filling a screen-space pass's uniforms with NaNs.
388        let mut singular = IDENTITY;
389        singular[1] = [0.0; 4];
390        assert_eq!(mat4_inverse(singular), IDENTITY);
391        // A non-finite entry makes the determinant NaN, which no magnitude test
392        // catches on its own: `NaN.abs() < 1e-20` is false.
393        let mut nan = IDENTITY;
394        nan[0][0] = f32::NAN;
395        assert_eq!(mat4_inverse(nan), IDENTITY);
396        let mut inf = IDENTITY;
397        inf[2][2] = f32::INFINITY;
398        assert_eq!(mat4_inverse(inf), IDENTITY);
399    }
400
401    #[test]
402    fn quat_mat3_round_trips() {
403        // A rotation 3x3 -> quaternion -> rotation 3x3 must reproduce itself,
404        // across the diagonal-dominant and trace-positive branches of
405        // Shepperd's method. This is what makes blend_matrices' endpoints exact.
406        for e in [
407            [0.0, 0.0, 0.0],
408            [30.0, 50.0, 20.0],
409            [-80.0, 140.0, -25.0],
410            [90.0, 0.0, 0.0],
411            [0.0, 180.0, 0.0],
412        ] {
413            let r = rotation_mat3(e);
414            let r2 = quat_to_mat3(quat_from_mat3(r));
415            for c in 0..3 {
416                for row in 0..3 {
417                    assert!(
418                        approx(r[c][row], r2[c][row]),
419                        "e={:?} [{}][{}]: {} vs {}",
420                        e,
421                        c,
422                        row,
423                        r[c][row],
424                        r2[c][row]
425                    );
426                }
427            }
428        }
429    }
430
431    #[test]
432    fn quat_normalize_falls_back_for_a_zero_quaternion() {
433        assert_eq!(crate::math::quat_normalize([0.0; 4]), [0.0, 0.0, 0.0, 1.0]);
434        let n = crate::math::quat_normalize([0.0, 0.0, 0.0, 4.0]);
435        assert!(approx(n[3], 1.0));
436    }
437
438    #[test]
439    fn slerp_midpoint_splits_the_arc_equally() {
440        // The defining property of slerp: the f=0.5 quaternion is equidistant
441        // (equal rotation angle) from both endpoints. A component-wise Euler
442        // lerp does not satisfy this for a multi-axis rotation difference.
443        let qa = quat_from_mat3(rotation_mat3([10.0, 20.0, 30.0]));
444        let qb = quat_from_mat3(rotation_mat3([70.0, -40.0, 80.0]));
445        let qm = quat_slerp(qa, qb, 0.5);
446        let angle = |x: Quat, y: Quat| {
447            let d = (x[0] * y[0] + x[1] * y[1] + x[2] * y[2] + x[3] * y[3])
448                .abs()
449                .min(1.0);
450            2.0 * acos(d)
451        };
452        assert!(
453            approx(angle(qa, qm), angle(qm, qb)),
454            "arcs {} vs {}",
455            angle(qa, qm),
456            angle(qm, qb)
457        );
458    }
459
460    #[test]
461    fn decompose_round_trips_a_composed_matrix() {
462        // decompose must invert compose for a positive-scale TRS matrix, so a
463        // blend interpolates the same transform a clip sampled.
464        let m = trs_matrix([3.0, -2.0, 5.0], [25.0, -60.0, 40.0], [1.5, 0.5, 2.0]);
465        let (t, q, s) = decompose(m);
466        let rebuilt = compose(quat_to_mat3(q), s, t);
467        for c in 0..4 {
468            for row in 0..4 {
469                assert!(
470                    approx(rebuilt[c][row], m[c][row]),
471                    "[{}][{}]: {} vs {}",
472                    c,
473                    row,
474                    rebuilt[c][row],
475                    m[c][row]
476                );
477            }
478        }
479    }
480
481    #[test]
482    fn blend_matrices_endpoints_are_exact_and_the_middle_slerps() {
483        let a = trs_matrix([1.0, 2.0, 3.0], [10.0, 20.0, 30.0], [1.0, 1.5, 2.0]);
484        let b = trs_matrix([-4.0, 0.0, 5.0], [70.0, -40.0, 15.0], [2.0, 1.0, 0.5]);
485        for (f, want) in [(0.0, a), (1.0, b)] {
486            let got = blend_matrices(a, b, f);
487            for c in 0..4 {
488                for row in 0..4 {
489                    assert!(approx(got[c][row], want[c][row]), "f={f} [{c}][{row}]");
490                }
491            }
492        }
493        // The midpoint's rotation is the slerped one, not a matrix lerp: its
494        // basis stays orthonormal, which an element-wise average would not.
495        let mid = blend_matrices(a, b, 0.5);
496        let (_, _, scale) = decompose(mid);
497        assert!(approx(scale[0], 1.5) && approx(scale[1], 1.25) && approx(scale[2], 1.25));
498        assert!(approx(mid[3][0], -1.5) && approx(mid[3][1], 1.0) && approx(mid[3][2], 4.0));
499        // f outside [0, 1] clamps rather than extrapolating.
500        assert_eq!(blend_matrices(a, b, -1.0), blend_matrices(a, b, 0.0));
501        assert_eq!(blend_matrices(a, b, 2.0), blend_matrices(a, b, 1.0));
502    }
503
504    #[test]
505    fn euler_from_quat_round_trips_through_the_rotation_matrix() {
506        // quat -> YXZ Euler must reproduce the original rotation matrix, so
507        // the glTF importer's quaternion node rotations land losslessly in the
508        // Euler JointPose representation. Checked across multi-axis rotations.
509        for e in [
510            [0.0, 0.0, 0.0],
511            [25.0, -60.0, 40.0],
512            [-80.0, 140.0, -25.0],
513            [10.0, 200.0, -170.0],
514        ] {
515            let r = rotation_mat3(e);
516            let q = quat_from_mat3(r);
517            let e2 = euler_yxz_from_quat(q);
518            let r2 = rotation_mat3(e2);
519            for c in 0..3 {
520                for row in 0..3 {
521                    assert!(
522                        approx(r[c][row], r2[c][row]),
523                        "e={:?} [{}][{}]: {} vs {}",
524                        e,
525                        c,
526                        row,
527                        r[c][row],
528                        r2[c][row]
529                    );
530                }
531            }
532        }
533    }
534
535    #[test]
536    fn euler_from_quat_handles_gimbal_lock() {
537        // At pitch ±90° the conversion must stay finite and reproduce the
538        // rotation matrix (with roll folded onto yaw).
539        for e in [[90.0, 35.0, 0.0], [-90.0, -110.0, 0.0]] {
540            let r = rotation_mat3(e);
541            let e2 = euler_yxz_from_quat(quat_from_mat3(r));
542            assert!(e2.iter().all(|v| v.is_finite()), "non-finite for {:?}", e);
543            let r2 = rotation_mat3(e2);
544            for c in 0..3 {
545                for row in 0..3 {
546                    assert!(approx(r[c][row], r2[c][row]), "e={:?} [{}][{}]", e, c, row);
547                }
548            }
549        }
550    }
551
552    // A collapsed axis has no direction to recover a rotation from, so it
553    // decomposes to a zero scale and contributes nothing to the basis rather
554    // than dividing by its own zero length.
555    #[test]
556    fn decomposing_a_collapsed_axis_yields_a_zero_scale() {
557        let mut m = IDENTITY;
558        m[1] = [0.0, 0.0, 0.0, 0.0];
559        m[3] = [1.0, 2.0, 3.0, 1.0];
560
561        let (translation, rotation, scale) = decompose(m);
562        assert_eq!(translation, [1.0, 2.0, 3.0]);
563        assert_eq!(scale[1], 0.0, "the collapsed axis has no length");
564        assert_eq!((scale[0], scale[2]), (1.0, 1.0), "the others are intact");
565        assert!(
566            rotation.iter().all(|v| v.is_finite()),
567            "{rotation:?} is not a usable rotation"
568        );
569    }
570}