Skip to main content

concinnity_core/gfx/
skeleton.rs

1//! The skeletal-animation vocabulary: a joint hierarchy with its bind pose, the
2//! keyframe tracks a clip animates it with, and the sampling that turns a clip
3//! time into one local matrix per joint.
4//!
5//! Rotations are stored as YXZ Euler degrees (matching `Prop.rotation_deg`).
6//! Between keyframes, translation and scale interpolate linearly while rotation
7//! is converted to a quaternion and slerped (shortest-arc, constant angular
8//! velocity), so multi-axis joint rotation follows the correct path rather than
9//! the skewed one a component-wise Euler lerp would take.
10
11use alloc::string::String;
12use alloc::vec::Vec;
13
14use crate::gfx::render_types::MAX_JOINTS;
15use crate::gfx::root_motion::RootTrack;
16use crate::gfx::transform::{
17    IDENTITY, Mat4, compose, mat4_affine_inverse, mat4_mul, quat_from_mat3, quat_slerp,
18    quat_to_mat3, rotation_mat3, trs_matrix,
19};
20use crate::math::rem_euclid;
21
22/// A joint's local transform: translation, YXZ Euler rotation in degrees, and
23/// per-axis scale. Used both for the bind pose and for animation keyframes.
24#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
25#[serde(default)]
26pub struct JointPose {
27    /// Local translation.
28    pub translation: [f32; 3],
29    /// Local YXZ Euler rotation in degrees.
30    pub rotation_deg: [f32; 3],
31    /// Per-axis local scale.
32    pub scale: [f32; 3],
33}
34
35impl Default for JointPose {
36    fn default() -> Self {
37        Self {
38            translation: [0.0, 0.0, 0.0],
39            rotation_deg: [0.0, 0.0, 0.0],
40            scale: [1.0, 1.0, 1.0],
41        }
42    }
43}
44
45impl JointPose {
46    /// Column-major local matrix `T * R(YXZ) * S`.
47    pub fn to_matrix(&self) -> Mat4 {
48        trs_matrix(self.translation, self.rotation_deg, self.scale)
49    }
50
51    /// Interpolate two poses into a column-major local matrix. Translation and
52    /// scale blend linearly; rotation is quaternion-slerped (shortest-arc,
53    /// constant angular velocity) rather than Euler-lerped, so multi-axis
54    /// joint rotation follows the correct path. `f` in `[0, 1]`.
55    ///
56    /// Slerps the poses' own Euler rotations rather than going through
57    /// [`blend_matrices`](crate::gfx::transform::blend_matrices), which would
58    /// have to recover them from the composed matrices first.
59    pub fn blend_matrix(&self, other: &JointPose, f: f32) -> Mat4 {
60        let mix = |a: [f32; 3], b: [f32; 3]| {
61            [
62                a[0] + (b[0] - a[0]) * f,
63                a[1] + (b[1] - a[1]) * f,
64                a[2] + (b[2] - a[2]) * f,
65            ]
66        };
67        let qa = quat_from_mat3(rotation_mat3(self.rotation_deg));
68        let qb = quat_from_mat3(rotation_mat3(other.rotation_deg));
69        let rotation = quat_to_mat3(quat_slerp(qa, qb, f));
70        compose(
71            rotation,
72            mix(self.scale, other.scale),
73            mix(self.translation, other.translation),
74        )
75    }
76}
77
78/// One joint in a skeleton: a parent link and a local bind transform.
79#[derive(Debug, Clone)]
80pub struct Joint {
81    /// Authored joint name (empty when the source declared none). Resolved to
82    /// an index at load time by consumers that reference joints by name
83    /// (e.g. IK chains); never compared per frame.
84    pub name: String,
85    /// Index of the parent joint, or `None` for a root. Parents must appear
86    /// before their children so a single forward pass resolves the hierarchy.
87    pub parent: Option<usize>,
88    /// Local bind-pose transform relative to the parent.
89    pub bind: JointPose,
90}
91
92/// A joint hierarchy plus the bind pose. The inverse bind matrices are
93/// precomputed once on construction.
94#[derive(Debug, Clone)]
95pub struct Skeleton {
96    joints: Vec<Joint>,
97    // Local bind matrix per joint, built once. Every pose sample starts from
98    // these, so rebuilding them per frame would re-run the Euler trig for
99    // every joint of every sampled clip.
100    bind_locals: Vec<Mat4>,
101    // World-space inverse bind matrix per joint.
102    inverse_bind: Vec<Mat4>,
103    // World-space bind position per joint. With a skinning matrix
104    // `S = world * inverse_bind`, `S * bind_position` recovers the joint's
105    // current mesh-space position without another hierarchy walk.
106    bind_positions: Vec<[f32; 3]>,
107}
108
109impl Skeleton {
110    /// Build a skeleton, resolving world bind matrices and inverting them.
111    /// Joints referencing a parent that does not precede them are treated as
112    /// roots (a forward pass cannot resolve them otherwise).
113    pub fn new(joints: Vec<Joint>) -> Self {
114        let bind_locals: Vec<Mat4> = joints.iter().map(|j| j.bind.to_matrix()).collect();
115        let mut world_bind: Vec<Mat4> = Vec::with_capacity(joints.len());
116        for (i, joint) in joints.iter().enumerate() {
117            let local = bind_locals[i];
118            let world = match joint.parent {
119                Some(p) if p < i => mat4_mul(world_bind[p], local),
120                _ => local,
121            };
122            world_bind.push(world);
123        }
124        let inverse_bind = world_bind.iter().map(|m| mat4_affine_inverse(*m)).collect();
125        let bind_positions = world_bind
126            .iter()
127            .map(|m| [m[3][0], m[3][1], m[3][2]])
128            .collect();
129        Self {
130            joints,
131            bind_locals,
132            inverse_bind,
133            bind_positions,
134        }
135    }
136
137    /// Number of joints.
138    pub fn len(&self) -> usize {
139        self.joints.len()
140    }
141
142    /// Whether the skeleton has no joints.
143    pub fn is_empty(&self) -> bool {
144        self.joints.is_empty()
145    }
146
147    /// The joints, in index order.
148    pub fn joints(&self) -> &[Joint] {
149        &self.joints
150    }
151
152    /// Index of the joint with the given authored name, or `None`. Load-time
153    /// lookup for by-name joint references (IK chains); linear scan is fine.
154    pub fn joint_index(&self, name: &str) -> Option<usize> {
155        (!name.is_empty()).then(|| self.joints.iter().position(|j| j.name == name))?
156    }
157
158    /// World-space bind position of one joint.
159    pub fn bind_position(&self, joint: usize) -> [f32; 3] {
160        self.bind_positions.get(joint).copied().unwrap_or([0.0; 3])
161    }
162
163    /// Compose `local_poses` (one local matrix per joint) into mesh-space
164    /// joint matrices with a single forward pass over the hierarchy, written
165    /// into `out` (cleared first, so its capacity is reused). `local_poses`
166    /// shorter than the skeleton has its missing tail filled from the bind
167    /// pose.
168    pub fn world_matrices_into(&self, local_poses: &[Mat4], out: &mut Vec<Mat4>) {
169        out.clear();
170        out.reserve(self.joints.len());
171        for (i, joint) in self.joints.iter().enumerate() {
172            let local = local_poses.get(i).copied().unwrap_or(self.bind_locals[i]);
173            let world_mat = match joint.parent {
174                Some(p) if p < i => mat4_mul(out[p], local),
175                _ => local,
176            };
177            out.push(world_mat);
178        }
179    }
180
181    /// Compose `local_poses` into world-space joint matrices, then multiply
182    /// by the inverse bind matrices to produce the skinning matrices the
183    /// vertex shader applies, written into `out` (cleared first, so its
184    /// capacity is reused). `local_poses` must not alias `out`.
185    ///
186    /// The result is capped at `MAX_JOINTS` entries (the GPU joint buffer is
187    /// fixed-size) and is always at least one matrix so the buffer is never
188    /// empty.
189    pub fn skinning_matrices_into(&self, local_poses: &[Mat4], out: &mut Vec<Mat4>) {
190        self.world_matrices_into(local_poses, out);
191        let n = out.len().min(self.inverse_bind.len()).min(MAX_JOINTS);
192        for (i, ib) in self.inverse_bind[..n].iter().enumerate() {
193            out[i] = mat4_mul(out[i], *ib);
194        }
195        out.truncate(n);
196        if out.is_empty() {
197            out.push(IDENTITY);
198        }
199    }
200
201    /// Skinning matrices for the rest (bind) pose: every joint's local
202    /// transform is its bind transform, so every skinning matrix is identity.
203    /// Used to seed a `SkeletonPose` before the first animation tick.
204    pub fn bind_skinning_matrices(&self) -> Vec<Mat4> {
205        let mut out = Vec::new();
206        self.skinning_matrices_into(&self.bind_locals, &mut out);
207        out
208    }
209
210    /// The local bind matrix of every joint, in joint order. A pose sample
211    /// seeds its output with these before applying the clip's tracks.
212    pub fn bind_locals(&self) -> &[Mat4] {
213        &self.bind_locals
214    }
215}
216
217/// A single keyframe: a joint pose sampled at a point in time.
218#[derive(Debug, Clone, Copy)]
219pub struct Keyframe {
220    /// Seconds from the clip start.
221    pub time: f32,
222    /// The joint's local pose at `time`.
223    pub pose: JointPose,
224}
225
226/// An animation channel for one joint: a time-ordered list of keyframes.
227#[derive(Debug, Clone)]
228pub struct JointTrack {
229    /// Index of the joint this track drives.
230    pub joint: usize,
231    /// Keyframes, in ascending time order.
232    pub keys: Vec<Keyframe>,
233}
234
235impl JointTrack {
236    // Sample this track at time `t` (seconds), returning the joint's local
237    // matrix. Times outside the keyframe range clamp to the nearest end key;
238    // between keys translation/scale lerp and rotation slerps.
239    fn sample(&self, t: f32) -> Mat4 {
240        match self.keys.as_slice() {
241            [] => IDENTITY,
242            [only] => only.pose.to_matrix(),
243            keys => {
244                if t <= keys[0].time {
245                    return keys[0].pose.to_matrix();
246                }
247                let last = keys[keys.len() - 1];
248                if t >= last.time {
249                    return last.pose.to_matrix();
250                }
251                // Keys are time-ordered; imported clips are baked at the
252                // sample rate, so tracks can carry dozens of keys.
253                let i = keys.partition_point(|k| k.time < t);
254                let (a, b) = (keys[i - 1], keys[i]);
255                let span = (b.time - a.time).max(1e-6);
256                let f = (t - a.time) / span;
257                a.pose.blend_matrix(&b.pose, f)
258            }
259        }
260    }
261}
262
263/// One animation clip: a fixed-length set of per-joint keyframe tracks.
264#[derive(Debug, Clone)]
265pub struct AnimationClip {
266    /// Total clip length in seconds.
267    pub duration: f32,
268    /// When true, sampling past `duration` wraps; otherwise it holds the end.
269    pub looping: bool,
270    /// One track per animated joint.
271    pub tracks: Vec<JointTrack>,
272    /// Morph-target weight keys in time order: (time, one weight per target).
273    /// Empty for clips that animate no morph targets.
274    pub morph_keys: Vec<(f32, Vec<f32>)>,
275    /// The character-displacement curve stripped from the root joint at build
276    /// time, when the clip opted into root motion. The pose tracks above keep
277    /// the root anchored; the runtime turns this curve's frame delta into
278    /// character movement instead.
279    pub root: Option<RootTrack>,
280}
281
282impl AnimationClip {
283    /// Sample the clip at time `t` against `skeleton`, writing one local
284    /// matrix per joint into `out` (cleared first, so its capacity is
285    /// reused). Joints with no track keep their bind transform.
286    pub fn sample_into(&self, t: f32, skeleton: &Skeleton, out: &mut Vec<Mat4>) {
287        self.sample_looped_into(t, self.looping, skeleton, out)
288    }
289
290    /// `sample_into` with the loop mode supplied by the caller instead of the
291    /// clip's own flag. Lets a graph state override looping without cloning
292    /// the clip.
293    pub fn sample_looped_into(
294        &self,
295        t: f32,
296        looping: bool,
297        skeleton: &Skeleton,
298        out: &mut Vec<Mat4>,
299    ) {
300        let local_t = self.clip_time(t, looping);
301        out.clear();
302        out.extend_from_slice(skeleton.bind_locals());
303        for track in &self.tracks {
304            if track.joint < out.len() {
305                out[track.joint] = track.sample(local_t);
306            }
307        }
308    }
309
310    /// Sample the morph-weight track at time `t` into `out` (cleared first,
311    /// so its capacity is reused), lerping between the surrounding keys with
312    /// the same wrap/clamp semantics as pose sampling. `out` is left empty
313    /// when the clip has no morph keys.
314    pub fn sample_morph_weights_into(&self, t: f32, looping: bool, out: &mut Vec<f32>) {
315        out.clear();
316        if self.morph_keys.is_empty() {
317            return;
318        }
319        let local_t = self.clip_time(t, looping);
320        let first = &self.morph_keys[0];
321        if local_t <= first.0 {
322            out.extend_from_slice(&first.1);
323            return;
324        }
325        for pair in self.morph_keys.windows(2) {
326            if local_t <= pair[1].0 {
327                let span = (pair[1].0 - pair[0].0).max(1e-6);
328                let f = (local_t - pair[0].0) / span;
329                let n = pair[0].1.len().max(pair[1].1.len());
330                out.extend((0..n).map(|i| {
331                    let a = pair[0].1.get(i).copied().unwrap_or(0.0);
332                    let b = pair[1].1.get(i).copied().unwrap_or(0.0);
333                    a + (b - a) * f
334                }));
335                return;
336            }
337        }
338        out.extend_from_slice(&self.morph_keys[self.morph_keys.len() - 1].1);
339    }
340
341    // Clip-local time for a wall-clock `t`: wrapped when looping, otherwise
342    // clamped into the clip's range.
343    fn clip_time(&self, t: f32, looping: bool) -> f32 {
344        if looping && self.duration > 1e-6 {
345            rem_euclid(t, self.duration)
346        } else {
347            t.clamp(0.0, self.duration)
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::gfx::transform::blend_matrices;
356    use crate::math::atan2;
357    use alloc::vec;
358
359    fn approx(a: f32, b: f32) -> bool {
360        (a - b).abs() < 1e-4
361    }
362
363    // A two-joint vertical chain: root at origin, child one unit up in y.
364    fn chain() -> Skeleton {
365        Skeleton::new(vec![
366            Joint {
367                name: String::new(),
368                parent: None,
369                bind: JointPose::default(),
370            },
371            Joint {
372                name: String::new(),
373                parent: Some(0),
374                bind: JointPose {
375                    translation: [0.0, 1.0, 0.0],
376                    ..JointPose::default()
377                },
378            },
379        ])
380    }
381
382    #[test]
383    fn morph_weight_sampling_lerps_clamps_and_loops() {
384        let clip = AnimationClip {
385            duration: 1.0,
386            looping: false,
387            tracks: Vec::new(),
388            morph_keys: vec![(0.0, vec![0.0, 1.0]), (1.0, vec![1.0, 0.0])],
389            root: None,
390        };
391        let morph = |t: f32, looping: bool| {
392            let mut out = Vec::new();
393            clip.sample_morph_weights_into(t, looping, &mut out);
394            out
395        };
396        assert!(morph(-1.0, false)[0].abs() < 1e-6);
397        let mid = morph(0.5, false);
398        assert!(approx(mid[0], 0.5) && approx(mid[1], 0.5));
399        assert!(approx(morph(5.0, false)[0], 1.0), "clamps past the end");
400        // Looping wraps: t = 1.25 samples like t = 0.25.
401        let wrapped = morph(1.25, true);
402        assert!(approx(wrapped[0], 0.25));
403
404        let empty = AnimationClip {
405            duration: 1.0,
406            looping: true,
407            tracks: Vec::new(),
408            morph_keys: Vec::new(),
409            root: None,
410        };
411        let mut out = vec![9.0];
412        empty.sample_morph_weights_into(0.5, true, &mut out);
413        assert!(out.is_empty(), "no morph keys clears the output");
414    }
415
416    #[test]
417    fn bind_pose_skinning_matrices_are_identity() {
418        let sk = chain();
419        for m in sk.bind_skinning_matrices() {
420            for col in 0..4 {
421                for row in 0..4 {
422                    assert!(approx(m[col][row], IDENTITY[col][row]));
423                }
424            }
425        }
426    }
427
428    #[test]
429    fn rotating_child_joint_moves_a_bound_point() {
430        // Rotate the child joint 90 deg yaw. A point at the child's origin in
431        // bind space (0,1,0) should be carried by the child's skinning matrix
432        // but the joint origin itself is the rotation pivot, so it stays put.
433        // A point offset +x from the child should swing to -z.
434        let sk = chain();
435        let mut locals: Vec<Mat4> = sk.joints().iter().map(|j| j.bind.to_matrix()).collect();
436        locals[1] = JointPose {
437            translation: [0.0, 1.0, 0.0],
438            rotation_deg: [0.0, 90.0, 0.0],
439            scale: [1.0, 1.0, 1.0],
440        }
441        .to_matrix();
442        let mut skin = Vec::new();
443        sk.skinning_matrices_into(&locals, &mut skin);
444        // Bind-space point one unit +x of the child joint origin: (1, 1, 0).
445        let p = [1.0f32, 1.0, 0.0, 1.0];
446        let m = skin[1];
447        let out = [
448            m[0][0] * p[0] + m[1][0] * p[1] + m[2][0] * p[2] + m[3][0] * p[3],
449            m[0][1] * p[0] + m[1][1] * p[1] + m[2][1] * p[2] + m[3][1] * p[3],
450            m[0][2] * p[0] + m[1][2] * p[1] + m[2][2] * p[2] + m[3][2] * p[3],
451        ];
452        // +x swings to -z under a +90 deg yaw; y unchanged.
453        assert!(approx(out[0], 0.0), "x was {}", out[0]);
454        assert!(approx(out[1], 1.0), "y was {}", out[1]);
455        assert!(approx(out[2], -1.0), "z was {}", out[2]);
456    }
457
458    #[test]
459    fn clip_sampling_interpolates_between_keys() {
460        let sk = chain();
461        let clip = AnimationClip {
462            root: None,
463            duration: 2.0,
464            looping: true,
465            tracks: vec![JointTrack {
466                joint: 1,
467                keys: vec![
468                    Keyframe {
469                        time: 0.0,
470                        pose: JointPose {
471                            translation: [0.0, 1.0, 0.0],
472                            ..JointPose::default()
473                        },
474                    },
475                    Keyframe {
476                        time: 2.0,
477                        pose: JointPose {
478                            translation: [0.0, 1.0, 0.0],
479                            rotation_deg: [0.0, 90.0, 0.0],
480                            ..JointPose::default()
481                        },
482                    },
483                ],
484            }],
485            morph_keys: Vec::new(),
486        };
487        // Halfway through: yaw should be 45 deg.
488        let mut locals = Vec::new();
489        clip.sample_into(1.0, &sk, &mut locals);
490        // Recover yaw: for a pure yaw the first column is (cos, 0, -sin).
491        let yaw = atan2(-locals[1][0][2], locals[1][0][0]).to_degrees();
492        assert!(approx(yaw, 45.0), "yaw was {}", yaw);
493    }
494
495    #[test]
496    fn many_key_track_samples_the_containing_segment() {
497        // A densely baked track (like importer output): keys every 0.1s with
498        // translation.x following the key time, so any sample time recovers
499        // itself. Covers end clamps, exact key hits, and mid-segment lerps.
500        let keys: Vec<Keyframe> = (0..=20)
501            .map(|i| {
502                let time = i as f32 * 0.1;
503                Keyframe {
504                    time,
505                    pose: JointPose {
506                        translation: [time, 0.0, 0.0],
507                        ..JointPose::default()
508                    },
509                }
510            })
511            .collect();
512        let track = JointTrack { joint: 0, keys };
513        let x_at = |t: f32| track.sample(t)[3][0];
514        assert!(approx(x_at(-0.5), 0.0), "clamps at the first key");
515        assert!(approx(x_at(5.0), 2.0), "clamps at the last key");
516        assert!(approx(x_at(0.7), 0.7), "exact key hit");
517        assert!(approx(x_at(1.234), 1.234), "lerps inside a segment");
518    }
519
520    #[test]
521    fn looping_clip_wraps_past_duration() {
522        let sk = chain();
523        let clip = AnimationClip {
524            root: None,
525            duration: 2.0,
526            looping: true,
527            tracks: vec![JointTrack {
528                joint: 1,
529                keys: vec![Keyframe {
530                    time: 0.5,
531                    pose: JointPose {
532                        translation: [9.0, 1.0, 0.0],
533                        ..JointPose::default()
534                    },
535                }],
536            }],
537            morph_keys: Vec::new(),
538        };
539        // t = 2.5 wraps to 0.5: identical sample, into reused capacity.
540        let mut a = Vec::new();
541        clip.sample_into(0.5, &sk, &mut a);
542        let mut b = Vec::new();
543        clip.sample_into(2.5, &sk, &mut b);
544        assert_eq!(a[1], b[1]);
545        // Resampling into a warm buffer does not reallocate it.
546        let ptr = a.as_ptr();
547        clip.sample_into(1.5, &sk, &mut a);
548        assert_eq!(a.as_ptr(), ptr, "warm sample buffer is reused in place");
549    }
550
551    #[test]
552    fn unparented_joint_is_treated_as_root() {
553        // A joint whose parent index does not precede it must not panic and
554        // must behave as a root.
555        let sk = Skeleton::new(vec![Joint {
556            name: String::new(),
557            parent: Some(5),
558            bind: JointPose::default(),
559        }]);
560        assert_eq!(sk.len(), 1);
561        assert_eq!(sk.bind_skinning_matrices().len(), 1);
562    }
563
564    #[test]
565    fn joint_index_resolves_names_and_refuses_the_empty_one() {
566        let sk = Skeleton::new(vec![
567            Joint {
568                name: String::from("hips"),
569                parent: None,
570                bind: JointPose::default(),
571            },
572            Joint {
573                name: String::new(),
574                parent: Some(0),
575                bind: JointPose::default(),
576            },
577        ]);
578        assert_eq!(sk.joint_index("hips"), Some(0));
579        assert_eq!(sk.joint_index("missing"), None);
580        // An unnamed joint must not be reachable by the empty name.
581        assert_eq!(sk.joint_index(""), None);
582        assert!(!sk.is_empty());
583    }
584
585    #[test]
586    fn blend_matrix_endpoints_match_keyframe_poses() {
587        // At f=0 / f=1 the interpolated matrix must equal the keyframe pose's
588        // own matrix, so a clip is continuous across keyframe boundaries.
589        let a = JointPose {
590            translation: [1.0, 2.0, 3.0],
591            rotation_deg: [10.0, 20.0, 30.0],
592            scale: [1.0, 1.5, 2.0],
593        };
594        let b = JointPose {
595            translation: [-4.0, 0.0, 5.0],
596            rotation_deg: [70.0, -40.0, 15.0],
597            scale: [2.0, 1.0, 0.5],
598        };
599        let at0 = a.blend_matrix(&b, 0.0);
600        let at1 = a.blend_matrix(&b, 1.0);
601        let ma = a.to_matrix();
602        let mb = b.to_matrix();
603        for c in 0..4 {
604            for row in 0..4 {
605                assert!(approx(at0[c][row], ma[c][row]), "f=0 [{}][{}]", c, row);
606                assert!(approx(at1[c][row], mb[c][row]), "f=1 [{}][{}]", c, row);
607            }
608        }
609    }
610
611    #[test]
612    fn blend_matrix_lerps_translation_and_scale() {
613        // Translation and scale stay linearly interpolated: only rotation
614        // moved to the quaternion path.
615        let a = JointPose {
616            translation: [0.0, 0.0, 0.0],
617            rotation_deg: [0.0, 0.0, 0.0],
618            scale: [1.0, 1.0, 1.0],
619        };
620        let b = JointPose {
621            translation: [4.0, 8.0, -2.0],
622            rotation_deg: [0.0, 0.0, 0.0],
623            scale: [3.0, 3.0, 3.0],
624        };
625        let m = a.blend_matrix(&b, 0.25);
626        assert!(approx(m[3][0], 1.0));
627        assert!(approx(m[3][1], 2.0));
628        assert!(approx(m[3][2], -0.5));
629        // No rotation: the diagonal carries the lerped scale 1 + 0.25*2 = 1.5.
630        assert!(approx(m[0][0], 1.5));
631        assert!(approx(m[1][1], 1.5));
632        assert!(approx(m[2][2], 1.5));
633    }
634
635    // The pose-space blend and the matrix-space one are the same operation
636    // reached two ways, so they must not disagree where both apply.
637    #[test]
638    fn pose_blend_agrees_with_the_matrix_blend() {
639        let a = JointPose {
640            translation: [1.0, 2.0, 3.0],
641            rotation_deg: [10.0, 20.0, 30.0],
642            scale: [1.0, 1.5, 2.0],
643        };
644        let b = JointPose {
645            translation: [-4.0, 0.0, 5.0],
646            rotation_deg: [70.0, -40.0, 15.0],
647            scale: [2.0, 1.0, 0.5],
648        };
649        for f in [0.0, 0.25, 0.5, 1.0] {
650            let pose_space = a.blend_matrix(&b, f);
651            let matrix_space = blend_matrices(a.to_matrix(), b.to_matrix(), f);
652            for c in 0..4 {
653                for row in 0..4 {
654                    assert!(
655                        approx(pose_space[c][row], matrix_space[c][row]),
656                        "f={f} [{c}][{row}]"
657                    );
658                }
659            }
660        }
661    }
662}