Skip to main content

proof_engine/anim/
skeleton.rs

1//! Skeletal animation primitives: bones, poses, skinning matrices, bone masks.
2//!
3//! Provides the structural foundation for character animation:
4//! - [`Skeleton`] — the bone hierarchy with name index
5//! - [`Pose`] — per-bone local transforms that can be blended and masked
6//! - [`BoneMask`] — per-bone weights for partial-body animation layers
7//! - [`SkinningMatrices`] — GPU-ready world-space skinning matrices
8//! - [`SkeletonBuilder`] — fluent API for constructing skeletons
9
10use std::collections::HashMap;
11use glam::{Mat4, Quat, Vec3};
12
13// ── BoneId ────────────────────────────────────────────────────────────────────
14
15/// Typed index into a [`Skeleton`]'s bone list.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub struct BoneId(pub u32);
18
19impl BoneId {
20    /// The root bone always has id 0.
21    pub const ROOT: BoneId = BoneId(0);
22
23    pub fn index(self) -> usize {
24        self.0 as usize
25    }
26}
27
28// ── Transform3D ───────────────────────────────────────────────────────────────
29
30/// Local-space transform: translation, rotation, scale.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct Transform3D {
33    pub translation: Vec3,
34    pub rotation:    Quat,
35    pub scale:       Vec3,
36}
37
38impl Transform3D {
39    pub fn identity() -> Self {
40        Self {
41            translation: Vec3::ZERO,
42            rotation:    Quat::IDENTITY,
43            scale:       Vec3::ONE,
44        }
45    }
46
47    pub fn new(translation: Vec3, rotation: Quat, scale: Vec3) -> Self {
48        Self { translation, rotation, scale }
49    }
50
51    /// Convert to a column-major 4x4 matrix.
52    pub fn to_mat4(self) -> Mat4 {
53        Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
54    }
55
56    /// Linear interpolation between two transforms.
57    pub fn lerp(self, other: Self, t: f32) -> Self {
58        Self {
59            translation: self.translation.lerp(other.translation, t),
60            rotation:    self.rotation.slerp(other.rotation, t),
61            scale:       self.scale.lerp(other.scale, t),
62        }
63    }
64
65    /// Additive blend: apply `additive` on top of `self` with `weight`.
66    pub fn add_weighted(self, additive: Self, weight: f32) -> Self {
67        let ref_identity = Transform3D::identity();
68        // Additive delta from identity
69        let delta_trans = additive.translation - ref_identity.translation;
70        let delta_scale = additive.scale - ref_identity.scale;
71        // For rotation, compose with weight-attenuated delta
72        let delta_rot = Quat::IDENTITY.slerp(additive.rotation, weight);
73        Self {
74            translation: self.translation + delta_trans * weight,
75            rotation:    (self.rotation * delta_rot).normalize(),
76            scale:       self.scale + delta_scale * weight,
77        }
78    }
79}
80
81impl Default for Transform3D {
82    fn default() -> Self { Self::identity() }
83}
84
85// ── Bone ──────────────────────────────────────────────────────────────────────
86
87/// A single bone in the skeleton hierarchy.
88#[derive(Debug, Clone)]
89pub struct Bone {
90    pub id:               BoneId,
91    pub name:             String,
92    pub parent:           Option<BoneId>,
93    /// Bind-pose local transform (rest pose).
94    pub local_bind_pose:  Transform3D,
95    /// Pre-computed inverse bind-pose matrix (model space → bone space).
96    pub inv_bind_matrix:  Mat4,
97    pub children:         Vec<BoneId>,
98}
99
100impl Bone {
101    pub fn new(id: BoneId, name: impl Into<String>, parent: Option<BoneId>, local_bind_pose: Transform3D) -> Self {
102        Self {
103            id,
104            name: name.into(),
105            parent,
106            local_bind_pose,
107            inv_bind_matrix: Mat4::IDENTITY,
108            children: Vec::new(),
109        }
110    }
111}
112
113// ── Skeleton ──────────────────────────────────────────────────────────────────
114
115/// The bone hierarchy for a character or object.
116///
117/// Bones are stored in a flat [`Vec`] sorted so that parents always appear
118/// before their children (topological order). This allows a single forward
119/// pass to compute world-space transforms.
120#[derive(Debug, Clone)]
121pub struct Skeleton {
122    pub bones:      Vec<Bone>,
123    pub name_index: HashMap<String, BoneId>,
124}
125
126impl Skeleton {
127    /// Create an empty skeleton.
128    pub fn new() -> Self {
129        Self {
130            bones:      Vec::new(),
131            name_index: HashMap::new(),
132        }
133    }
134
135    /// Number of bones.
136    pub fn len(&self) -> usize { self.bones.len() }
137    pub fn is_empty(&self) -> bool { self.bones.is_empty() }
138
139    /// Look up a bone by name.
140    pub fn bone_by_name(&self, name: &str) -> Option<&Bone> {
141        let id = self.name_index.get(name)?;
142        self.bones.get(id.index())
143    }
144
145    /// Look up a bone by id.
146    pub fn bone(&self, id: BoneId) -> Option<&Bone> {
147        self.bones.get(id.index())
148    }
149
150    /// Mutable access to a bone by id.
151    pub fn bone_mut(&mut self, id: BoneId) -> Option<&mut Bone> {
152        self.bones.get_mut(id.index())
153    }
154
155    /// Return the id of the root bone (first bone, if any).
156    pub fn root_id(&self) -> Option<BoneId> {
157        self.bones.first().map(|b| b.id)
158    }
159
160    /// Compute the world-space (model-space) bind pose matrices for all bones.
161    /// Returned in bone-index order.
162    pub fn compute_bind_world_matrices(&self) -> Vec<Mat4> {
163        let n = self.bones.len();
164        let mut world = vec![Mat4::IDENTITY; n];
165        for bone in &self.bones {
166            let local = bone.local_bind_pose.to_mat4();
167            world[bone.id.index()] = match bone.parent {
168                None         => local,
169                Some(parent) => world[parent.index()] * local,
170            };
171        }
172        world
173    }
174
175    /// Recompute all `inv_bind_matrix` fields from current bind pose.
176    pub fn recompute_inv_bind_matrices(&mut self) {
177        let world = self.compute_bind_world_matrices();
178        // The field is the INVERSE bind matrix; it used to store the bind
179        // matrix itself, so the rest pose did not skin to identity.
180        for bone in &mut self.bones {
181            bone.inv_bind_matrix = world[bone.id.index()].inverse();
182        }
183    }
184
185    /// Build the bind rest pose.
186    pub fn rest_pose(&self) -> Pose {
187        let mut pose = Pose::new(self.bones.len());
188        for bone in &self.bones {
189            pose.local_transforms[bone.id.index()] = bone.local_bind_pose;
190        }
191        pose
192    }
193
194    /// Collect all bone ids in topological order (parent before child).
195    pub fn topological_order(&self) -> Vec<BoneId> {
196        self.bones.iter().map(|b| b.id).collect()
197    }
198
199    /// Get child ids of a bone.
200    pub fn children_of(&self, id: BoneId) -> &[BoneId] {
201        self.bones.get(id.index()).map(|b| b.children.as_slice()).unwrap_or(&[])
202    }
203}
204
205impl Default for Skeleton {
206    fn default() -> Self { Self::new() }
207}
208
209// ── Pose ──────────────────────────────────────────────────────────────────────
210
211/// Per-bone local transforms representing a character pose.
212///
213/// The length of `local_transforms` matches `Skeleton::len()`.
214#[derive(Debug, Clone)]
215pub struct Pose {
216    pub local_transforms: Vec<Transform3D>,
217}
218
219impl Pose {
220    /// Create a pose for a skeleton with `bone_count` bones, initialised to identity.
221    pub fn new(bone_count: usize) -> Self {
222        Self {
223            local_transforms: vec![Transform3D::identity(); bone_count],
224        }
225    }
226
227    /// Number of bones this pose covers.
228    pub fn len(&self) -> usize { self.local_transforms.len() }
229    pub fn is_empty(&self) -> bool { self.local_transforms.is_empty() }
230
231    /// Get the local transform for bone `id`.
232    pub fn get(&self, id: BoneId) -> Option<Transform3D> {
233        self.local_transforms.get(id.index()).copied()
234    }
235
236    /// Set the local transform for bone `id`.
237    pub fn set(&mut self, id: BoneId, xform: Transform3D) {
238        if let Some(slot) = self.local_transforms.get_mut(id.index()) {
239            *slot = xform;
240        }
241    }
242
243    /// Linear blend: `self * (1 - t) + other * t`.
244    ///
245    /// Both poses must have the same number of bones.
246    pub fn blend(&self, other: &Pose, t: f32) -> Pose {
247        let len = self.local_transforms.len().min(other.local_transforms.len());
248        let mut result = Pose::new(len);
249        for i in 0..len {
250            result.local_transforms[i] = self.local_transforms[i].lerp(other.local_transforms[i], t);
251        }
252        result
253    }
254
255    /// Additive blend: apply `additive` on top of `self` with `weight`.
256    ///
257    /// The additive pose is interpreted relative to the reference (identity) pose.
258    pub fn add_pose(&self, additive: &Pose, weight: f32) -> Pose {
259        let len = self.local_transforms.len().min(additive.local_transforms.len());
260        let mut result = self.clone();
261        for i in 0..len {
262            result.local_transforms[i] = self.local_transforms[i]
263                .add_weighted(additive.local_transforms[i], weight);
264        }
265        result
266    }
267
268    /// Apply a bone mask: for each bone, blend between `self` (original)
269    /// and `other` according to the mask weight for that bone.
270    ///
271    /// Bones absent from the mask are kept from `self`.
272    pub fn apply_mask(&self, other: &Pose, mask: &BoneMask) -> Pose {
273        let len = self.local_transforms.len().min(other.local_transforms.len());
274        let mut result = self.clone();
275        for i in 0..len {
276            let w = mask.weights.get(i).copied().unwrap_or(0.0);
277            result.local_transforms[i] = self.local_transforms[i].lerp(other.local_transforms[i], w);
278        }
279        result
280    }
281
282    /// Copy only the bones selected by a mask (weight > threshold) from `other`.
283    pub fn override_with_mask(&self, other: &Pose, mask: &BoneMask, threshold: f32) -> Pose {
284        let len = self.local_transforms.len().min(other.local_transforms.len());
285        let mut result = self.clone();
286        for i in 0..len {
287            let w = mask.weights.get(i).copied().unwrap_or(0.0);
288            if w > threshold {
289                result.local_transforms[i] = other.local_transforms[i];
290            }
291        }
292        result
293    }
294}
295
296// ── BoneMask ──────────────────────────────────────────────────────────────────
297
298/// Per-bone blend weights in [0.0, 1.0] used for partial-body animation layers.
299///
300/// A weight of `1.0` means the layer fully overrides that bone; `0.0` means
301/// the bone is untouched.
302#[derive(Debug, Clone)]
303pub struct BoneMask {
304    /// Indexed by bone index (same order as [`Skeleton::bones`]).
305    pub weights: Vec<f32>,
306}
307
308impl BoneMask {
309    /// Create a mask with all weights set to `default_weight`.
310    pub fn uniform(bone_count: usize, default_weight: f32) -> Self {
311        Self { weights: vec![default_weight.clamp(0.0, 1.0); bone_count] }
312    }
313
314    /// Zero mask — no bones affected.
315    pub fn zero(bone_count: usize) -> Self {
316        Self::uniform(bone_count, 0.0)
317    }
318
319    /// Full-body mask — all bones at weight 1.0.
320    pub fn full_body(bone_count: usize) -> Self {
321        Self::uniform(bone_count, 1.0)
322    }
323
324    /// Upper-body preset for a standard humanoid skeleton.
325    ///
326    /// Sets bones named with "spine", "chest", "neck", "head", "shoulder",
327    /// "arm", "hand", "finger", "clavicle" to weight 1.0; all others to 0.0.
328    pub fn upper_body(skeleton: &Skeleton) -> Self {
329        let mut mask = Self::zero(skeleton.len());
330        let upper_keywords = [
331            "spine", "chest", "neck", "head", "shoulder",
332            "arm", "hand", "finger", "thumb", "index", "middle",
333            "ring", "pinky", "clavicle", "elbow", "wrist",
334        ];
335        for bone in &skeleton.bones {
336            let name_lower = bone.name.to_lowercase();
337            let is_upper = upper_keywords.iter().any(|kw| name_lower.contains(kw));
338            if is_upper {
339                if let Some(w) = mask.weights.get_mut(bone.id.index()) {
340                    *w = 1.0;
341                }
342            }
343        }
344        mask
345    }
346
347    /// Lower-body preset for a standard humanoid skeleton.
348    ///
349    /// Sets bones named with "hip", "pelvis", "leg", "knee", "ankle",
350    /// "foot", "toe" to weight 1.0; all others to 0.0.
351    pub fn lower_body(skeleton: &Skeleton) -> Self {
352        let mut mask = Self::zero(skeleton.len());
353        let lower_keywords = [
354            "hip", "pelvis", "leg", "thigh", "knee",
355            "shin", "calf", "ankle", "foot", "toe",
356        ];
357        for bone in &skeleton.bones {
358            let name_lower = bone.name.to_lowercase();
359            let is_lower = lower_keywords.iter().any(|kw| name_lower.contains(kw));
360            if is_lower {
361                if let Some(w) = mask.weights.get_mut(bone.id.index()) {
362                    *w = 1.0;
363                }
364            }
365        }
366        mask
367    }
368
369    /// Set the weight for a specific bone.
370    pub fn set_weight(&mut self, id: BoneId, weight: f32) {
371        if let Some(w) = self.weights.get_mut(id.index()) {
372            *w = weight.clamp(0.0, 1.0);
373        }
374    }
375
376    /// Get the weight for a specific bone.
377    pub fn get_weight(&self, id: BoneId) -> f32 {
378        self.weights.get(id.index()).copied().unwrap_or(0.0)
379    }
380
381    /// Scale all weights by a factor.
382    pub fn scale(&self, factor: f32) -> Self {
383        Self {
384            weights: self.weights.iter().map(|&w| (w * factor).clamp(0.0, 1.0)).collect(),
385        }
386    }
387
388    /// Combine two masks by taking the maximum weight per bone.
389    pub fn union(&self, other: &BoneMask) -> Self {
390        let len = self.weights.len().max(other.weights.len());
391        let mut weights = vec![0.0f32; len];
392        for i in 0..len {
393            let a = self.weights.get(i).copied().unwrap_or(0.0);
394            let b = other.weights.get(i).copied().unwrap_or(0.0);
395            weights[i] = a.max(b);
396        }
397        Self { weights }
398    }
399
400    /// Combine two masks by taking the minimum weight per bone.
401    pub fn intersection(&self, other: &BoneMask) -> Self {
402        let len = self.weights.len().min(other.weights.len());
403        let weights = (0..len)
404            .map(|i| {
405                let a = self.weights.get(i).copied().unwrap_or(0.0);
406                let b = other.weights.get(i).copied().unwrap_or(0.0);
407                a.min(b)
408            })
409            .collect();
410        Self { weights }
411    }
412
413    /// Invert all weights (1.0 - w).
414    pub fn invert(&self) -> Self {
415        Self {
416            weights: self.weights.iter().map(|&w| 1.0 - w).collect(),
417        }
418    }
419
420    /// Build a mask from an explicit list of (BoneId, weight) pairs.
421    pub fn from_pairs(bone_count: usize, pairs: &[(BoneId, f32)]) -> Self {
422        let mut mask = Self::zero(bone_count);
423        for &(id, weight) in pairs {
424            mask.set_weight(id, weight);
425        }
426        mask
427    }
428
429    /// Build a mask where only the listed bones (and their children) are active.
430    pub fn from_bone_subtree(skeleton: &Skeleton, root_bones: &[BoneId], weight: f32) -> Self {
431        let mut mask = Self::zero(skeleton.len());
432        let mut stack: Vec<BoneId> = root_bones.to_vec();
433        while let Some(id) = stack.pop() {
434            mask.set_weight(id, weight);
435            for &child in skeleton.children_of(id) {
436                stack.push(child);
437            }
438        }
439        mask
440    }
441}
442
443// ── SkinningMatrices ──────────────────────────────────────────────────────────
444
445/// GPU-ready skinning matrices computed from a pose and skeleton.
446///
447/// Each entry is `world_pose_matrix * inv_bind_matrix`, which transforms
448/// a vertex from bind-pose model space to the animated model space.
449#[derive(Debug, Clone)]
450pub struct SkinningMatrices {
451    pub matrices: Vec<Mat4>,
452}
453
454impl SkinningMatrices {
455    /// Compute skinning matrices from a pose.
456    ///
457    /// The matrices are in bone-index order and ready for upload to a GPU
458    /// uniform buffer (row-major or column-major depending on shader convention).
459    pub fn compute(skeleton: &Skeleton, pose: &Pose) -> Self {
460        let n = skeleton.len();
461        let mut world = vec![Mat4::IDENTITY; n];
462
463        // Forward pass: accumulate world transforms in topological order.
464        for bone in &skeleton.bones {
465            let idx = bone.id.index();
466            let local_xform = pose.local_transforms.get(idx)
467                .copied()
468                .unwrap_or_else(Transform3D::identity);
469            let local_mat = local_xform.to_mat4();
470            world[idx] = match bone.parent {
471                None         => local_mat,
472                Some(parent) => world[parent.index()] * local_mat,
473            };
474        }
475
476        // Skinning matrix = world_pose * inv_bind (glam uses column vectors,
477        // so inv_bind is applied to the vertex first).
478        let matrices = skeleton.bones.iter().map(|bone| {
479            world[bone.id.index()] * bone.inv_bind_matrix
480        }).collect();
481
482        Self { matrices }
483    }
484
485    /// Number of matrices (equals number of bones).
486    pub fn len(&self) -> usize { self.matrices.len() }
487    pub fn is_empty(&self) -> bool { self.matrices.is_empty() }
488
489    /// Get the skinning matrix for bone `id`.
490    pub fn get(&self, id: BoneId) -> Option<Mat4> {
491        self.matrices.get(id.index()).copied()
492    }
493
494    /// Return a flat slice of f32 values suitable for a GPU buffer.
495    /// Each Mat4 contributes 16 f32s in column-major order.
496    pub fn as_flat_slice(&self) -> Vec<f32> {
497        self.matrices.iter().flat_map(|m| m.to_cols_array()).collect()
498    }
499
500    /// Return the matrices as an array of column-major arrays.
501    pub fn as_arrays(&self) -> Vec<[f32; 16]> {
502        self.matrices.iter().map(|m| m.to_cols_array()).collect()
503    }
504}
505
506// ── SkeletonBuilder ───────────────────────────────────────────────────────────
507
508/// Fluent builder for constructing a [`Skeleton`].
509///
510/// ```rust,ignore
511/// let skeleton = SkeletonBuilder::new()
512///     .add_bone("root",     None,           Transform3D::identity())
513///     .add_bone("hip",      Some("root"),   Transform3D::new(Vec3::new(0.0, 1.0, 0.0), Quat::IDENTITY, Vec3::ONE))
514///     .add_bone("spine",    Some("hip"),    Transform3D::new(Vec3::new(0.0, 0.3, 0.0), Quat::IDENTITY, Vec3::ONE))
515///     .build();
516/// ```
517#[derive(Debug, Default)]
518pub struct SkeletonBuilder {
519    /// (name, parent_name, local_bind_pose)
520    pending: Vec<(String, Option<String>, Transform3D)>,
521}
522
523impl SkeletonBuilder {
524    pub fn new() -> Self { Self::default() }
525
526    /// Add a bone with an optional parent name.
527    ///
528    /// Bones must be added in topological order (parent before child).
529    pub fn add_bone(
530        mut self,
531        name: impl Into<String>,
532        parent: Option<&str>,
533        local_bind_pose: Transform3D,
534    ) -> Self {
535        self.pending.push((name.into(), parent.map(str::to_owned), local_bind_pose));
536        self
537    }
538
539    /// Add a simple bone using individual transform components.
540    pub fn add_bone_components(
541        self,
542        name: impl Into<String>,
543        parent: Option<&str>,
544        translation: Vec3,
545        rotation: Quat,
546        scale: Vec3,
547    ) -> Self {
548        self.add_bone(name, parent, Transform3D::new(translation, rotation, scale))
549    }
550
551    /// Consume the builder and produce a [`Skeleton`] with computed inverse bind matrices.
552    pub fn build(self) -> Skeleton {
553        let mut skeleton = Skeleton::new();
554
555        for (idx, (name, parent_name, local_bind_pose)) in self.pending.into_iter().enumerate() {
556            let id = BoneId(idx as u32);
557            let parent_id = parent_name.as_deref().and_then(|pn| skeleton.name_index.get(pn).copied());
558
559            let bone = Bone::new(id, name.clone(), parent_id, local_bind_pose);
560            skeleton.name_index.insert(name, id);
561
562            // Register this bone as a child of its parent.
563            if let Some(pid) = parent_id {
564                if let Some(parent_bone) = skeleton.bones.get_mut(pid.index()) {
565                    parent_bone.children.push(id);
566                }
567            }
568
569            skeleton.bones.push(bone);
570        }
571
572        skeleton.recompute_inv_bind_matrices();
573        skeleton
574    }
575}
576
577// ── Standard humanoid skeleton factory ────────────────────────────────────────
578
579impl Skeleton {
580    /// Build a minimal standard humanoid skeleton (22 bones).
581    pub fn standard_humanoid() -> Self {
582        SkeletonBuilder::new()
583            // Root / pelvis
584            .add_bone("root",           None,              Transform3D::identity())
585            .add_bone("pelvis",         Some("root"),      Transform3D::new(Vec3::new(0.0, 1.0, 0.0),   Quat::IDENTITY, Vec3::ONE))
586            // Spine
587            .add_bone("spine_01",       Some("pelvis"),    Transform3D::new(Vec3::new(0.0, 0.15, 0.0),  Quat::IDENTITY, Vec3::ONE))
588            .add_bone("spine_02",       Some("spine_01"),  Transform3D::new(Vec3::new(0.0, 0.15, 0.0),  Quat::IDENTITY, Vec3::ONE))
589            .add_bone("spine_03",       Some("spine_02"),  Transform3D::new(Vec3::new(0.0, 0.15, 0.0),  Quat::IDENTITY, Vec3::ONE))
590            // Neck / Head
591            .add_bone("neck",           Some("spine_03"),  Transform3D::new(Vec3::new(0.0, 0.10, 0.0),  Quat::IDENTITY, Vec3::ONE))
592            .add_bone("head",           Some("neck"),      Transform3D::new(Vec3::new(0.0, 0.10, 0.0),  Quat::IDENTITY, Vec3::ONE))
593            // Left arm
594            .add_bone("clavicle_l",     Some("spine_03"),  Transform3D::new(Vec3::new(-0.10, 0.05, 0.0), Quat::IDENTITY, Vec3::ONE))
595            .add_bone("upperarm_l",     Some("clavicle_l"),Transform3D::new(Vec3::new(-0.15, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE))
596            .add_bone("lowerarm_l",     Some("upperarm_l"),Transform3D::new(Vec3::new(-0.28, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE))
597            .add_bone("hand_l",         Some("lowerarm_l"),Transform3D::new(Vec3::new(-0.25, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE))
598            // Right arm
599            .add_bone("clavicle_r",     Some("spine_03"),  Transform3D::new(Vec3::new( 0.10, 0.05, 0.0), Quat::IDENTITY, Vec3::ONE))
600            .add_bone("upperarm_r",     Some("clavicle_r"),Transform3D::new(Vec3::new( 0.15, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE))
601            .add_bone("lowerarm_r",     Some("upperarm_r"),Transform3D::new(Vec3::new( 0.28, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE))
602            .add_bone("hand_r",         Some("lowerarm_r"),Transform3D::new(Vec3::new( 0.25, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE))
603            // Left leg
604            .add_bone("thigh_l",        Some("pelvis"),    Transform3D::new(Vec3::new(-0.10, -0.05, 0.0),Quat::IDENTITY, Vec3::ONE))
605            .add_bone("calf_l",         Some("thigh_l"),   Transform3D::new(Vec3::new(0.0, -0.42, 0.0), Quat::IDENTITY, Vec3::ONE))
606            .add_bone("foot_l",         Some("calf_l"),    Transform3D::new(Vec3::new(0.0, -0.42, 0.0), Quat::IDENTITY, Vec3::ONE))
607            .add_bone("toe_l",          Some("foot_l"),    Transform3D::new(Vec3::new(0.0, 0.0, 0.14),  Quat::IDENTITY, Vec3::ONE))
608            // Right leg
609            .add_bone("thigh_r",        Some("pelvis"),    Transform3D::new(Vec3::new( 0.10, -0.05, 0.0),Quat::IDENTITY, Vec3::ONE))
610            .add_bone("calf_r",         Some("thigh_r"),   Transform3D::new(Vec3::new(0.0, -0.42, 0.0), Quat::IDENTITY, Vec3::ONE))
611            .add_bone("foot_r",         Some("calf_r"),    Transform3D::new(Vec3::new(0.0, -0.42, 0.0), Quat::IDENTITY, Vec3::ONE))
612            .add_bone("toe_r",          Some("foot_r"),    Transform3D::new(Vec3::new(0.0, 0.0, 0.14),  Quat::IDENTITY, Vec3::ONE))
613            .build()
614    }
615}
616
617// ── Tests ─────────────────────────────────────────────────────────────────────
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    fn simple_skeleton() -> Skeleton {
624        SkeletonBuilder::new()
625            .add_bone("root",  None,           Transform3D::identity())
626            .add_bone("spine", Some("root"),   Transform3D::new(Vec3::new(0.0, 1.0, 0.0), Quat::IDENTITY, Vec3::ONE))
627            .add_bone("head",  Some("spine"),  Transform3D::new(Vec3::new(0.0, 0.5, 0.0), Quat::IDENTITY, Vec3::ONE))
628            .add_bone("arm_l", Some("spine"),  Transform3D::new(Vec3::new(-0.3, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE))
629            .add_bone("arm_r", Some("spine"),  Transform3D::new(Vec3::new( 0.3, 0.0, 0.0), Quat::IDENTITY, Vec3::ONE))
630            .build()
631    }
632
633    #[test]
634    fn test_bone_id_index() {
635        assert_eq!(BoneId(3).index(), 3);
636        assert_eq!(BoneId::ROOT.index(), 0);
637    }
638
639    #[test]
640    fn test_skeleton_builder_creates_bones() {
641        let skeleton = simple_skeleton();
642        assert_eq!(skeleton.len(), 5);
643        assert!(skeleton.bone_by_name("root").is_some());
644        assert!(skeleton.bone_by_name("spine").is_some());
645        assert!(skeleton.bone_by_name("head").is_some());
646    }
647
648    #[test]
649    fn test_skeleton_name_index() {
650        let skeleton = simple_skeleton();
651        let id = skeleton.name_index["spine"];
652        assert_eq!(id.index(), 1);
653    }
654
655    #[test]
656    fn test_skeleton_parent_child_links() {
657        let skeleton = simple_skeleton();
658        let spine = skeleton.bone_by_name("spine").unwrap();
659        assert_eq!(spine.parent, Some(BoneId(0)));
660        assert!(spine.children.contains(&BoneId(2))); // head
661    }
662
663    #[test]
664    fn test_skeleton_inv_bind_matrices_not_zero() {
665        let skeleton = simple_skeleton();
666        // The root bone's inv_bind should be identity (it has no offset from identity).
667        let root = &skeleton.bones[0];
668        // Inv of identity is identity
669        assert!((root.inv_bind_matrix - Mat4::IDENTITY).abs_diff_eq(Mat4::ZERO, 1e-5));
670        // Spine should differ
671        let spine = &skeleton.bones[1];
672        let diff = spine.inv_bind_matrix - Mat4::IDENTITY;
673        let max_elem = [diff.x_axis, diff.y_axis, diff.z_axis, diff.w_axis]
674            .iter()
675            .flat_map(|col| [col.x, col.y, col.z, col.w])
676            .map(f32::abs)
677            .fold(0.0, f32::max);
678        // Compare magnitudes: a real inverse of a +y offset has a negative
679        // translation, which the old signed max never saw.
680        assert!(max_elem > 0.01);
681        assert!(spine.inv_bind_matrix.w_axis.y < 0.0);
682    }
683
684    #[test]
685    fn test_rest_pose_matches_bind() {
686        let skeleton = simple_skeleton();
687        let pose = skeleton.rest_pose();
688        assert_eq!(pose.len(), skeleton.len());
689        for (i, bone) in skeleton.bones.iter().enumerate() {
690            assert_eq!(pose.local_transforms[i].translation, bone.local_bind_pose.translation);
691        }
692    }
693
694    #[test]
695    fn test_pose_blend_halfway() {
696        let n = 3;
697        let mut a = Pose::new(n);
698        let mut b = Pose::new(n);
699        a.local_transforms[0].translation = Vec3::ZERO;
700        b.local_transforms[0].translation = Vec3::new(2.0, 0.0, 0.0);
701        let blended = a.blend(&b, 0.5);
702        assert!((blended.local_transforms[0].translation.x - 1.0).abs() < 1e-5);
703    }
704
705    #[test]
706    fn test_pose_blend_extremes() {
707        let n = 2;
708        let mut a = Pose::new(n);
709        let mut b = Pose::new(n);
710        a.local_transforms[0].translation = Vec3::new(1.0, 0.0, 0.0);
711        b.local_transforms[0].translation = Vec3::new(3.0, 0.0, 0.0);
712        let at_zero = a.blend(&b, 0.0);
713        let at_one  = a.blend(&b, 1.0);
714        assert!((at_zero.local_transforms[0].translation.x - 1.0).abs() < 1e-5);
715        assert!((at_one.local_transforms[0].translation.x  - 3.0).abs() < 1e-5);
716    }
717
718    #[test]
719    fn test_pose_add_pose() {
720        let n = 2;
721        let mut base = Pose::new(n);
722        let mut additive = Pose::new(n);
723        base.local_transforms[0].translation = Vec3::new(1.0, 0.0, 0.0);
724        additive.local_transforms[0].translation = Vec3::new(0.5, 0.0, 0.0);
725        let result = base.add_pose(&additive, 1.0);
726        // delta = 0.5 - 0 (identity) = 0.5; applied at weight 1.0
727        assert!(result.local_transforms[0].translation.x > 1.0);
728    }
729
730    #[test]
731    fn test_bone_mask_full_body() {
732        let skeleton = simple_skeleton();
733        let mask = BoneMask::full_body(skeleton.len());
734        for &w in &mask.weights {
735            assert!((w - 1.0).abs() < 1e-6);
736        }
737    }
738
739    #[test]
740    fn test_bone_mask_zero() {
741        let skeleton = simple_skeleton();
742        let mask = BoneMask::zero(skeleton.len());
743        for &w in &mask.weights {
744            assert!(w.abs() < 1e-6);
745        }
746    }
747
748    #[test]
749    fn test_skinning_matrices_identity_pose() {
750        let skeleton = simple_skeleton();
751        let pose = skeleton.rest_pose();
752        let skinning = SkinningMatrices::compute(&skeleton, &pose);
753        assert_eq!(skinning.len(), skeleton.len());
754        // With rest pose, each skinning matrix should be near identity
755        // because world_pose * inv_bind ≈ identity when pose == bind.
756        for m in &skinning.matrices {
757            // The product should be close to identity
758            assert!(m.abs_diff_eq(Mat4::IDENTITY, 1e-4),
759                "Expected near-identity skinning matrix for rest pose, got {:?}", m);
760        }
761    }
762
763    #[test]
764    fn test_skinning_flat_slice_length() {
765        let skeleton = simple_skeleton();
766        let pose = skeleton.rest_pose();
767        let skinning = SkinningMatrices::compute(&skeleton, &pose);
768        let flat = skinning.as_flat_slice();
769        assert_eq!(flat.len(), skeleton.len() * 16);
770    }
771
772    #[test]
773    fn test_standard_humanoid_bone_count() {
774        let skeleton = Skeleton::standard_humanoid();
775        assert_eq!(skeleton.len(), 23);
776        assert!(skeleton.bone_by_name("head").is_some());
777        assert!(skeleton.bone_by_name("hand_l").is_some());
778        assert!(skeleton.bone_by_name("foot_r").is_some());
779    }
780
781    #[test]
782    fn test_upper_body_mask_has_arm_bones() {
783        let skeleton = Skeleton::standard_humanoid();
784        let mask = BoneMask::upper_body(&skeleton);
785        let upperarm_l_id = skeleton.name_index["upperarm_l"];
786        assert!((mask.get_weight(upperarm_l_id) - 1.0).abs() < 1e-6);
787    }
788
789    #[test]
790    fn test_lower_body_mask_has_leg_bones() {
791        let skeleton = Skeleton::standard_humanoid();
792        let mask = BoneMask::lower_body(&skeleton);
793        let thigh_l_id = skeleton.name_index["thigh_l"];
794        assert!((mask.get_weight(thigh_l_id) - 1.0).abs() < 1e-6);
795    }
796
797    #[test]
798    fn test_mask_subtree() {
799        let skeleton = simple_skeleton();
800        let spine_id = skeleton.name_index["spine"];
801        let mask = BoneMask::from_bone_subtree(&skeleton, &[spine_id], 1.0);
802        // spine itself and all its children should be 1.0
803        assert!((mask.get_weight(spine_id) - 1.0).abs() < 1e-6);
804        let head_id = skeleton.name_index["head"];
805        assert!((mask.get_weight(head_id) - 1.0).abs() < 1e-6);
806    }
807}