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