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, atan2, 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
141// Unit quaternion `(x, y, z, w)` representing a rotation.
142pub(crate) type Quat = [f32; 4];
143
144// Column-major 3x3 rotation matrix from YXZ Euler degrees. Identical trig to
145// [`JointPose::to_matrix`](crate::gfx::skeleton::JointPose::to_matrix),
146// without the scale or translation.
147pub(crate) fn rotation_mat3(rotation_deg: [f32; 3]) -> Mat3 {
148    let [pitch, yaw, roll] = rotation_deg;
149    let (sp, cp) = sin_cos(pitch.to_radians());
150    let (syw, cyw) = sin_cos(yaw.to_radians());
151    let (sr, cr) = sin_cos(roll.to_radians());
152    [
153        [cyw * cr + syw * sp * sr, cp * sr, -syw * cr + cyw * sp * sr],
154        [-cyw * sr + syw * sp * cr, cp * cr, syw * sr + cyw * sp * cr],
155        [syw * cp, -sp, cyw * cp],
156    ]
157}
158
159/// Compose a column-major `T * R * S` affine matrix from a rotation 3x3,
160/// per-axis scale, and translation.
161pub fn compose(r: Mat3, scale: [f32; 3], t: [f32; 3]) -> Mat4 {
162    let [sx, sy, sz] = scale;
163    [
164        [r[0][0] * sx, r[0][1] * sx, r[0][2] * sx, 0.0],
165        [r[1][0] * sy, r[1][1] * sy, r[1][2] * sy, 0.0],
166        [r[2][0] * sz, r[2][1] * sz, r[2][2] * sz, 0.0],
167        [t[0], t[1], t[2], 1.0],
168    ]
169}
170
171/// Column-major `T * R(YXZ) * S` model matrix. The single home of the engine's
172/// transform convention: joints, props, and runtime transforms all build their
173/// matrix here so they compose consistently.
174pub fn trs_matrix(position: [f32; 3], rotation_deg: [f32; 3], scale: [f32; 3]) -> Mat4 {
175    compose(rotation_mat3(rotation_deg), scale, position)
176}
177
178// Quaternion of a column-major rotation 3x3 (Shepperd's method: picks the
179// largest-magnitude component to keep the division well-conditioned).
180pub(crate) fn quat_from_mat3(m: Mat3) -> Quat {
181    let (m00, m11, m22) = (m[0][0], m[1][1], m[2][2]);
182    let trace = m00 + m11 + m22;
183    if trace > 0.0 {
184        let s = sqrt(trace + 1.0) * 2.0;
185        [
186            (m[1][2] - m[2][1]) / s,
187            (m[2][0] - m[0][2]) / s,
188            (m[0][1] - m[1][0]) / s,
189            0.25 * s,
190        ]
191    } else if m00 > m11 && m00 > m22 {
192        let s = sqrt(1.0 + m00 - m11 - m22) * 2.0;
193        [
194            0.25 * s,
195            (m[1][0] + m[0][1]) / s,
196            (m[2][0] + m[0][2]) / s,
197            (m[1][2] - m[2][1]) / s,
198        ]
199    } else if m11 > m22 {
200        let s = sqrt(1.0 + m11 - m00 - m22) * 2.0;
201        [
202            (m[1][0] + m[0][1]) / s,
203            0.25 * s,
204            (m[2][1] + m[1][2]) / s,
205            (m[2][0] - m[0][2]) / s,
206        ]
207    } else {
208        let s = sqrt(1.0 + m22 - m00 - m11) * 2.0;
209        [
210            (m[2][0] + m[0][2]) / s,
211            (m[2][1] + m[1][2]) / s,
212            0.25 * s,
213            (m[0][1] - m[1][0]) / s,
214        ]
215    }
216}
217
218// Column-major rotation 3x3 of a unit quaternion.
219pub(crate) fn quat_to_mat3(q: Quat) -> Mat3 {
220    let [x, y, z, w] = q;
221    [
222        [
223            1.0 - 2.0 * (y * y + z * z),
224            2.0 * (x * y + w * z),
225            2.0 * (x * z - w * y),
226        ],
227        [
228            2.0 * (x * y - w * z),
229            1.0 - 2.0 * (x * x + z * z),
230            2.0 * (y * z + w * x),
231        ],
232        [
233            2.0 * (x * z + w * y),
234            2.0 * (y * z - w * x),
235            1.0 - 2.0 * (x * x + y * y),
236        ],
237    ]
238}
239
240// Scale a quaternion to unit length, falling back to identity when it is too
241// short to normalise.
242pub(crate) fn quat_normalize(q: Quat) -> Quat {
243    let len = sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]);
244    if len < 1e-12 {
245        return [0.0, 0.0, 0.0, 1.0];
246    }
247    [q[0] / len, q[1] / len, q[2] / len, q[3] / len]
248}
249
250// Spherical linear interpolation between two unit quaternions. Negates `b`
251// when the pair points to opposite hemispheres so the interpolation always
252// takes the shorter arc, and falls back to a normalised lerp when the two
253// rotations are nearly parallel (the slerp denominator approaches zero there
254// and nlerp is visually identical at that angle). `f` is clamped to `[0, 1]`.
255pub(crate) fn quat_slerp(a: Quat, mut b: Quat, f: f32) -> Quat {
256    let f = f.clamp(0.0, 1.0);
257    let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
258    if dot < 0.0 {
259        b = [-b[0], -b[1], -b[2], -b[3]];
260        dot = -dot;
261    }
262    if dot > 0.9995 {
263        return quat_normalize([
264            a[0] + (b[0] - a[0]) * f,
265            a[1] + (b[1] - a[1]) * f,
266            a[2] + (b[2] - a[2]) * f,
267            a[3] + (b[3] - a[3]) * f,
268        ]);
269    }
270    let theta_0 = acos(dot.clamp(-1.0, 1.0));
271    let sin_0 = sin(theta_0);
272    let s_a = sin((1.0 - f) * theta_0) / sin_0;
273    let s_b = sin(f * theta_0) / sin_0;
274    [
275        a[0] * s_a + b[0] * s_b,
276        a[1] * s_a + b[1] * s_b,
277        a[2] * s_a + b[2] * s_b,
278        a[3] * s_a + b[3] * s_b,
279    ]
280}
281
282/// Decompose a column-major affine matrix into translation, a unit rotation
283/// quaternion, and per-axis scale: the inverse of [`compose`] for a
284/// positive-scale `T * R * S` matrix. Scale is recovered as the length of each
285/// rotation column; a zero-length column yields a zero scale axis and the
286/// rotation falls back to identity for that axis.
287pub fn decompose(m: Mat4) -> ([f32; 3], Quat, [f32; 3]) {
288    let t = [m[3][0], m[3][1], m[3][2]];
289    let col_len = |c: usize| sqrt(m[c][0] * m[c][0] + m[c][1] * m[c][1] + m[c][2] * m[c][2]);
290    let scale = [col_len(0), col_len(1), col_len(2)];
291    let norm = |c: usize| {
292        let s = scale[c];
293        if s < 1e-12 {
294            [0.0, 0.0, 0.0]
295        } else {
296            [m[c][0] / s, m[c][1] / s, m[c][2] / s]
297        }
298    };
299    let r: Mat3 = [norm(0), norm(1), norm(2)];
300    (t, quat_normalize(quat_from_mat3(r)), scale)
301}
302
303/// Interpolate two affine matrices in TRS space by weight `f`, clamped to
304/// `[0, 1]`. Translation and scale blend linearly while rotation is
305/// quaternion-slerped along the shorter arc, matching what a clip does between
306/// keyframes, so a blended pose is continuous with a clip's own sampling.
307pub fn blend_matrices(a: Mat4, b: Mat4, f: f32) -> Mat4 {
308    let f = f.clamp(0.0, 1.0);
309    let (ta, qa, sa) = decompose(a);
310    let (tb, qb, sb) = decompose(b);
311    let mix = |x: [f32; 3], y: [f32; 3]| {
312        [
313            x[0] + (y[0] - x[0]) * f,
314            x[1] + (y[1] - x[1]) * f,
315            x[2] + (y[2] - x[2]) * f,
316        ]
317    };
318    compose(
319        quat_to_mat3(quat_slerp(qa, qb, f)),
320        mix(sa, sb),
321        mix(ta, tb),
322    )
323}
324
325/// YXZ Euler angles in degrees recovered from a unit rotation quaternion: the
326/// inverse of `rotation_mat3` composed with `quat_to_mat3`. glTF stores node
327/// rotations as quaternions; the glTF importer converts them to the Euler
328/// [`JointPose`](crate::gfx::skeleton::JointPose) representation this engine's
329/// joints use. The conversion is matrix-exact for non-degenerate rotations; at
330/// gimbal lock (pitch ±90°) it folds the rotation onto the yaw axis with zero
331/// roll.
332pub fn euler_yxz_from_quat(q: Quat) -> [f32; 3] {
333    let m = quat_to_mat3(quat_normalize(q));
334    let sp = (-m[2][1]).clamp(-1.0, 1.0);
335    // cos(pitch) is taken straight from the matrix (the column-2 length in the
336    // XZ plane) rather than `sqrt(1 - sp*sp)`, which loses nearly all its
337    // precision to catastrophic cancellation as pitch approaches ±90°.
338    let cp = sqrt(m[2][0] * m[2][0] + m[2][2] * m[2][2]);
339    let pitch = atan2(sp, cp);
340    let (yaw, roll) = if cp > 1e-4 {
341        (atan2(m[2][0], m[2][2]), atan2(m[0][1], m[1][1]))
342    } else {
343        (atan2(sp * m[1][0], m[0][0]), 0.0)
344    };
345    [pitch.to_degrees(), yaw.to_degrees(), roll.to_degrees()]
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    fn approx(a: f32, b: f32) -> bool {
353        (a - b).abs() < 1e-4
354    }
355
356    #[test]
357    fn affine_inverse_round_trips() {
358        let m = trs_matrix([3.0, -2.0, 5.0], [0.0, 30.0, 0.0], [2.0, 2.0, 2.0]);
359        let id = mat4_mul(m, mat4_affine_inverse(m));
360        for col in 0..4 {
361            for row in 0..4 {
362                assert!(approx(id[col][row], IDENTITY[col][row]));
363            }
364        }
365    }
366
367    #[test]
368    fn affine_inverse_of_a_degenerate_matrix_falls_back_to_identity() {
369        // A zero scale axis leaves the upper 3x3 singular; the fallback keeps
370        // NaNs out of the joint matrices rather than propagating them.
371        let m = trs_matrix([1.0, 2.0, 3.0], [10.0, 20.0, 30.0], [1.0, 0.0, 1.0]);
372        assert_eq!(mat4_affine_inverse(m), IDENTITY);
373    }
374
375    #[test]
376    fn general_inverse_round_trips_a_projection_and_a_view() {
377        // The screen-space passes invert a view-projection, whose bottom row is
378        // not [0, 0, 0, 1], so mat4_affine_inverse cannot serve them.
379        let proj = crate::gfx::projection::perspective_rh(75.0f32.to_radians(), 1.6, 0.1, 500.0);
380        let view: Mat4 = [
381            [0.92388, 0.0, -0.38268, 0.0],
382            [0.0, 1.0, 0.0, 0.0],
383            [0.38268, 0.0, 0.92388, 0.0],
384            [-1.5, -0.7, 4.0, 1.0],
385        ];
386        for m in [proj, view, mat4_mul(proj, view)] {
387            for id in [mat4_mul(m, mat4_inverse(m)), mat4_mul(mat4_inverse(m), m)] {
388                for col in 0..4 {
389                    for row in 0..4 {
390                        assert!(
391                            (id[col][row] - IDENTITY[col][row]).abs() < 1e-3,
392                            "[{col}][{row}]: {}",
393                            id[col][row]
394                        );
395                    }
396                }
397            }
398        }
399    }
400
401    // The one place the four copies this replaced disagreed: the planar-reflection
402    // copy guarded on determinant magnitude alone, which a NaN slips straight
403    // through. Everything else about them was character-identical.
404    #[test]
405    fn general_inverse_falls_back_on_a_singular_or_non_finite_matrix() {
406        // A zero column is singular; the fallback keeps the inverted VP usable
407        // rather than filling a screen-space pass's uniforms with NaNs.
408        let mut singular = IDENTITY;
409        singular[1] = [0.0; 4];
410        assert_eq!(mat4_inverse(singular), IDENTITY);
411        // A non-finite entry makes the determinant NaN, which no magnitude test
412        // catches on its own: `NaN.abs() < 1e-20` is false.
413        let mut nan = IDENTITY;
414        nan[0][0] = f32::NAN;
415        assert_eq!(mat4_inverse(nan), IDENTITY);
416        let mut inf = IDENTITY;
417        inf[2][2] = f32::INFINITY;
418        assert_eq!(mat4_inverse(inf), IDENTITY);
419    }
420
421    #[test]
422    fn quat_mat3_round_trips() {
423        // A rotation 3x3 -> quaternion -> rotation 3x3 must reproduce itself,
424        // across the diagonal-dominant and trace-positive branches of
425        // Shepperd's method. This is what makes blend_matrices' endpoints exact.
426        for e in [
427            [0.0, 0.0, 0.0],
428            [30.0, 50.0, 20.0],
429            [-80.0, 140.0, -25.0],
430            [90.0, 0.0, 0.0],
431            [0.0, 180.0, 0.0],
432        ] {
433            let r = rotation_mat3(e);
434            let r2 = quat_to_mat3(quat_from_mat3(r));
435            for c in 0..3 {
436                for row in 0..3 {
437                    assert!(
438                        approx(r[c][row], r2[c][row]),
439                        "e={:?} [{}][{}]: {} vs {}",
440                        e,
441                        c,
442                        row,
443                        r[c][row],
444                        r2[c][row]
445                    );
446                }
447            }
448        }
449    }
450
451    #[test]
452    fn quat_normalize_falls_back_for_a_zero_quaternion() {
453        assert_eq!(quat_normalize([0.0; 4]), [0.0, 0.0, 0.0, 1.0]);
454        let n = quat_normalize([0.0, 0.0, 0.0, 4.0]);
455        assert!(approx(n[3], 1.0));
456    }
457
458    #[test]
459    fn slerp_midpoint_splits_the_arc_equally() {
460        // The defining property of slerp: the f=0.5 quaternion is equidistant
461        // (equal rotation angle) from both endpoints. A component-wise Euler
462        // lerp does not satisfy this for a multi-axis rotation difference.
463        let qa = quat_from_mat3(rotation_mat3([10.0, 20.0, 30.0]));
464        let qb = quat_from_mat3(rotation_mat3([70.0, -40.0, 80.0]));
465        let qm = quat_slerp(qa, qb, 0.5);
466        let angle = |x: Quat, y: Quat| {
467            let d = (x[0] * y[0] + x[1] * y[1] + x[2] * y[2] + x[3] * y[3])
468                .abs()
469                .min(1.0);
470            2.0 * acos(d)
471        };
472        assert!(
473            approx(angle(qa, qm), angle(qm, qb)),
474            "arcs {} vs {}",
475            angle(qa, qm),
476            angle(qm, qb)
477        );
478    }
479
480    #[test]
481    fn decompose_round_trips_a_composed_matrix() {
482        // decompose must invert compose for a positive-scale TRS matrix, so a
483        // blend interpolates the same transform a clip sampled.
484        let m = trs_matrix([3.0, -2.0, 5.0], [25.0, -60.0, 40.0], [1.5, 0.5, 2.0]);
485        let (t, q, s) = decompose(m);
486        let rebuilt = compose(quat_to_mat3(q), s, t);
487        for c in 0..4 {
488            for row in 0..4 {
489                assert!(
490                    approx(rebuilt[c][row], m[c][row]),
491                    "[{}][{}]: {} vs {}",
492                    c,
493                    row,
494                    rebuilt[c][row],
495                    m[c][row]
496                );
497            }
498        }
499    }
500
501    #[test]
502    fn blend_matrices_endpoints_are_exact_and_the_middle_slerps() {
503        let a = trs_matrix([1.0, 2.0, 3.0], [10.0, 20.0, 30.0], [1.0, 1.5, 2.0]);
504        let b = trs_matrix([-4.0, 0.0, 5.0], [70.0, -40.0, 15.0], [2.0, 1.0, 0.5]);
505        for (f, want) in [(0.0, a), (1.0, b)] {
506            let got = blend_matrices(a, b, f);
507            for c in 0..4 {
508                for row in 0..4 {
509                    assert!(approx(got[c][row], want[c][row]), "f={f} [{c}][{row}]");
510                }
511            }
512        }
513        // The midpoint's rotation is the slerped one, not a matrix lerp: its
514        // basis stays orthonormal, which an element-wise average would not.
515        let mid = blend_matrices(a, b, 0.5);
516        let (_, _, scale) = decompose(mid);
517        assert!(approx(scale[0], 1.5) && approx(scale[1], 1.25) && approx(scale[2], 1.25));
518        assert!(approx(mid[3][0], -1.5) && approx(mid[3][1], 1.0) && approx(mid[3][2], 4.0));
519        // f outside [0, 1] clamps rather than extrapolating.
520        assert_eq!(blend_matrices(a, b, -1.0), blend_matrices(a, b, 0.0));
521        assert_eq!(blend_matrices(a, b, 2.0), blend_matrices(a, b, 1.0));
522    }
523
524    #[test]
525    fn euler_from_quat_round_trips_through_the_rotation_matrix() {
526        // quat -> YXZ Euler must reproduce the original rotation matrix, so
527        // the glTF importer's quaternion node rotations land losslessly in the
528        // Euler JointPose representation. Checked across multi-axis rotations.
529        for e in [
530            [0.0, 0.0, 0.0],
531            [25.0, -60.0, 40.0],
532            [-80.0, 140.0, -25.0],
533            [10.0, 200.0, -170.0],
534        ] {
535            let r = rotation_mat3(e);
536            let q = quat_from_mat3(r);
537            let e2 = euler_yxz_from_quat(q);
538            let r2 = rotation_mat3(e2);
539            for c in 0..3 {
540                for row in 0..3 {
541                    assert!(
542                        approx(r[c][row], r2[c][row]),
543                        "e={:?} [{}][{}]: {} vs {}",
544                        e,
545                        c,
546                        row,
547                        r[c][row],
548                        r2[c][row]
549                    );
550                }
551            }
552        }
553    }
554
555    #[test]
556    fn euler_from_quat_handles_gimbal_lock() {
557        // At pitch ±90° the conversion must stay finite and reproduce the
558        // rotation matrix (with roll folded onto yaw).
559        for e in [[90.0, 35.0, 0.0], [-90.0, -110.0, 0.0]] {
560            let r = rotation_mat3(e);
561            let e2 = euler_yxz_from_quat(quat_from_mat3(r));
562            assert!(e2.iter().all(|v| v.is_finite()), "non-finite for {:?}", e);
563            let r2 = rotation_mat3(e2);
564            for c in 0..3 {
565                for row in 0..3 {
566                    assert!(approx(r[c][row], r2[c][row]), "e={:?} [{}][{}]", e, c, row);
567                }
568            }
569        }
570    }
571}