Skip to main content

embedded_3dgfx/
skeleton.rs

1//! Skeletal animation system with subspace deformation (skinning).
2//!
3//! Provides hierarchical bone structures and linear blend skinning for
4//! deforming meshes based on skeletal transformations.
5//!
6//! # Example
7//! ```
8//! use embedded_3dgfx::skeleton::{Skeleton, Bone, SkinningData};
9//! use nalgebra::{Vector3, UnitQuaternion};
10//!
11//! let mut skeleton = Skeleton::<8>::new();
12//!
13//! // Create root bone
14//! let root = skeleton.add_bone(Bone::new("root"), None).unwrap();
15//!
16//! // Create child bone
17//! let child = skeleton.add_bone(
18//!     Bone::new("arm").with_position(Vector3::new(0.0, 1.0, 0.0)),
19//!     Some(root)
20//! ).unwrap();
21//!
22//! // Update transforms
23//! skeleton.update_transforms();
24//! ```
25
26use heapless::Vec;
27use nalgebra::{Matrix4, Point3, UnitQuaternion, Vector3};
28
29#[allow(unused_imports)]
30use nalgebra::ComplexField;
31
32/// Maximum number of bones that can influence a single vertex.
33pub const MAX_BONE_INFLUENCES: usize = 4;
34
35/// Unique identifier for a bone within a skeleton.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct BoneId(pub usize);
38
39/// A single bone in a skeleton hierarchy.
40///
41/// Each bone has a local transform relative to its parent,
42/// and a computed world transform used for skinning.
43#[derive(Debug, Clone)]
44pub struct Bone {
45    /// Bone name for debugging
46    pub name: heapless::String<32>,
47
48    /// Local position relative to parent
49    pub position: Vector3<f32>,
50
51    /// Local rotation relative to parent
52    pub rotation: UnitQuaternion<f32>,
53
54    /// Local scale
55    pub scale: Vector3<f32>,
56
57    /// Parent bone ID (None for root)
58    pub parent: Option<BoneId>,
59
60    /// Local transform matrix (position + rotation + scale)
61    pub local_transform: Matrix4<f32>,
62
63    /// World transform matrix (accumulated from root)
64    pub world_transform: Matrix4<f32>,
65
66    /// Inverse bind pose matrix (transforms from model space to bone space)
67    pub inverse_bind_pose: Matrix4<f32>,
68}
69
70impl Bone {
71    /// Create a new bone with default transform at origin.
72    pub fn new(name: &str) -> Self {
73        let mut name_str = heapless::String::new();
74        let _ = name_str.push_str(name);
75
76        Self {
77            name: name_str,
78            position: Vector3::zeros(),
79            rotation: UnitQuaternion::identity(),
80            scale: Vector3::new(1.0, 1.0, 1.0),
81            parent: None,
82            local_transform: Matrix4::identity(),
83            world_transform: Matrix4::identity(),
84            inverse_bind_pose: Matrix4::identity(),
85        }
86    }
87
88    /// Set the bone's local position.
89    pub fn with_position(mut self, position: Vector3<f32>) -> Self {
90        self.position = position;
91        self.update_local_transform();
92        self
93    }
94
95    /// Set the bone's local rotation.
96    pub fn with_rotation(mut self, rotation: UnitQuaternion<f32>) -> Self {
97        self.rotation = rotation;
98        self.update_local_transform();
99        self
100    }
101
102    /// Set the bone's local scale.
103    pub fn with_scale(mut self, scale: Vector3<f32>) -> Self {
104        self.scale = scale;
105        self.update_local_transform();
106        self
107    }
108
109    /// Update the local transform matrix from position, rotation, and scale.
110    pub fn update_local_transform(&mut self) {
111        // Build transform matrix: T * R * S
112        let translation = Matrix4::new_translation(&self.position);
113        let rotation = self.rotation.to_homogeneous();
114        let scale = Matrix4::new_nonuniform_scaling(&self.scale);
115
116        self.local_transform = translation * rotation * scale;
117    }
118
119    /// Set the position and update transform.
120    pub fn set_position(&mut self, position: Vector3<f32>) {
121        self.position = position;
122        self.update_local_transform();
123    }
124
125    /// Set the rotation and update transform.
126    pub fn set_rotation(&mut self, rotation: UnitQuaternion<f32>) {
127        self.rotation = rotation;
128        self.update_local_transform();
129    }
130}
131
132/// A hierarchical skeleton with bones.
133///
134/// The generic parameter `N` specifies the maximum number of bones.
135#[derive(Debug, Clone)]
136pub struct Skeleton<const N: usize> {
137    pub bones: Vec<Bone, N>,
138}
139
140impl<const N: usize> Skeleton<N> {
141    /// Create a new empty skeleton.
142    pub fn new() -> Self {
143        Self { bones: Vec::new() }
144    }
145
146    /// Add a bone to the skeleton.
147    ///
148    /// Returns the bone ID on success, or an error if the skeleton is full.
149    pub fn add_bone(&mut self, mut bone: Bone, parent: Option<BoneId>) -> Result<BoneId, ()> {
150        bone.parent = parent;
151        bone.update_local_transform();
152
153        let id = BoneId(self.bones.len());
154        self.bones.push(bone).map_err(|_| ())?;
155
156        Ok(id)
157    }
158
159    /// Get a bone by ID.
160    pub fn get_bone(&self, id: BoneId) -> Option<&Bone> {
161        self.bones.get(id.0)
162    }
163
164    /// Get a mutable reference to a bone by ID.
165    pub fn get_bone_mut(&mut self, id: BoneId) -> Option<&mut Bone> {
166        self.bones.get_mut(id.0)
167    }
168
169    /// Update world transforms for all bones based on hierarchy.
170    ///
171    /// Must be called after modifying any bone transforms and before skinning.
172    pub fn update_transforms(&mut self) {
173        // First pass: update local transforms
174        for bone in self.bones.iter_mut() {
175            bone.update_local_transform();
176        }
177
178        // Second pass: compute world transforms (parent-to-child order)
179        for i in 0..self.bones.len() {
180            let parent_transform = if let Some(parent_id) = self.bones[i].parent {
181                self.bones[parent_id.0].world_transform
182            } else {
183                Matrix4::identity()
184            };
185
186            self.bones[i].world_transform = parent_transform * self.bones[i].local_transform;
187        }
188    }
189
190    /// Compute inverse bind pose matrices for all bones.
191    ///
192    /// Should be called once after setting up the skeleton in its bind pose.
193    pub fn compute_inverse_bind_poses(&mut self) {
194        self.update_transforms();
195
196        for bone in self.bones.iter_mut() {
197            bone.inverse_bind_pose = bone
198                .world_transform
199                .try_inverse()
200                .unwrap_or(Matrix4::identity());
201        }
202    }
203
204    /// Get the skinning matrix for a bone (world_transform * inverse_bind_pose).
205    pub fn get_skinning_matrix(&self, bone_id: BoneId) -> Matrix4<f32> {
206        if let Some(bone) = self.get_bone(bone_id) {
207            bone.world_transform * bone.inverse_bind_pose
208        } else {
209            Matrix4::identity()
210        }
211    }
212}
213
214impl<const N: usize> Default for Skeleton<N> {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220/// Skinning data for a single vertex.
221///
222/// Stores up to MAX_BONE_INFLUENCES bone indices and their weights.
223#[derive(Debug, Clone, Copy)]
224pub struct VertexSkinning {
225    /// Bone indices (up to MAX_BONE_INFLUENCES)
226    pub bone_indices: [usize; MAX_BONE_INFLUENCES],
227
228    /// Bone weights (should sum to 1.0 for proper blending)
229    pub bone_weights: [f32; MAX_BONE_INFLUENCES],
230
231    /// Number of active bone influences (1-4)
232    pub num_influences: usize,
233}
234
235impl VertexSkinning {
236    /// Create vertex skinning with a single bone influence.
237    pub fn single_bone(bone_index: usize) -> Self {
238        Self {
239            bone_indices: [bone_index, 0, 0, 0],
240            bone_weights: [1.0, 0.0, 0.0, 0.0],
241            num_influences: 1,
242        }
243    }
244
245    /// Create vertex skinning with two bone influences.
246    pub fn two_bones(bone0: usize, weight0: f32, bone1: usize, weight1: f32) -> Self {
247        Self {
248            bone_indices: [bone0, bone1, 0, 0],
249            bone_weights: [weight0, weight1, 0.0, 0.0],
250            num_influences: 2,
251        }
252    }
253
254    /// Create vertex skinning with custom bone influences.
255    ///
256    /// Weights should sum to 1.0 for proper blending.
257    pub fn new(
258        bone_indices: [usize; MAX_BONE_INFLUENCES],
259        bone_weights: [f32; MAX_BONE_INFLUENCES],
260        num_influences: usize,
261    ) -> Self {
262        Self {
263            bone_indices,
264            bone_weights,
265            num_influences: num_influences.min(MAX_BONE_INFLUENCES),
266        }
267    }
268}
269
270impl Default for VertexSkinning {
271    fn default() -> Self {
272        Self::single_bone(0)
273    }
274}
275
276/// Skinning data for an entire mesh.
277///
278/// Associates each vertex with bone influences for deformation.
279#[derive(Debug, Clone)]
280pub struct SkinningData {
281    /// Per-vertex skinning data
282    pub vertex_skinning: heapless::Vec<VertexSkinning, 512>,
283}
284
285impl SkinningData {
286    /// Create new skinning data with capacity for vertices.
287    pub fn new() -> Self {
288        Self {
289            vertex_skinning: Vec::new(),
290        }
291    }
292
293    /// Add skinning data for a vertex.
294    pub fn add_vertex(&mut self, skinning: VertexSkinning) -> Result<(), ()> {
295        self.vertex_skinning.push(skinning).map_err(|_| ())
296    }
297}
298
299impl Default for SkinningData {
300    fn default() -> Self {
301        Self::new()
302    }
303}
304
305/// Apply skeletal subspace deformation to a set of vertices.
306///
307/// Performs linear blend skinning using the skeleton's current pose.
308///
309/// # Arguments
310/// * `skeleton` - The skeleton with current bone transforms
311/// * `skinning_data` - Per-vertex bone influences and weights
312/// * `source_vertices` - Original vertex positions in bind pose
313/// * `output_vertices` - Buffer to write deformed vertices
314///
315/// # Returns
316/// The number of vertices processed.
317pub fn apply_skinning<const N: usize>(
318    skeleton: &Skeleton<N>,
319    skinning_data: &SkinningData,
320    source_vertices: &[[f32; 3]],
321    output_vertices: &mut [[f32; 3]],
322) -> usize {
323    let count = source_vertices
324        .len()
325        .min(output_vertices.len())
326        .min(skinning_data.vertex_skinning.len());
327
328    for i in 0..count {
329        let vertex = Point3::new(
330            source_vertices[i][0],
331            source_vertices[i][1],
332            source_vertices[i][2],
333        );
334
335        let skinning = &skinning_data.vertex_skinning[i];
336        let mut deformed = Point3::new(0.0, 0.0, 0.0);
337
338        // Linear blend skinning: sum of weighted bone transforms
339        for j in 0..skinning.num_influences {
340            let bone_id = BoneId(skinning.bone_indices[j]);
341            let weight = skinning.bone_weights[j];
342
343            if weight > 0.0 {
344                let skinning_matrix = skeleton.get_skinning_matrix(bone_id);
345                let transformed = skinning_matrix.transform_point(&vertex);
346                deformed += transformed.coords * weight;
347            }
348        }
349
350        output_vertices[i] = [deformed.x, deformed.y, deformed.z];
351    }
352
353    count
354}
355
356/// Apply skeletal subspace deformation to normals.
357///
358/// Normals require special handling - they're transformed by the inverse transpose
359/// of the skinning matrix to remain perpendicular to the surface.
360///
361/// # Arguments
362/// * `skeleton` - The skeleton with current bone transforms
363/// * `skinning_data` - Per-vertex bone influences and weights
364/// * `source_normals` - Original normal vectors in bind pose
365/// * `output_normals` - Buffer to write deformed normals
366///
367/// # Returns
368/// The number of normals processed.
369pub fn apply_skinning_to_normals<const N: usize>(
370    skeleton: &Skeleton<N>,
371    skinning_data: &SkinningData,
372    source_normals: &[[f32; 3]],
373    output_normals: &mut [[f32; 3]],
374) -> usize {
375    let count = source_normals
376        .len()
377        .min(output_normals.len())
378        .min(skinning_data.vertex_skinning.len());
379
380    for i in 0..count {
381        let normal = Vector3::new(
382            source_normals[i][0],
383            source_normals[i][1],
384            source_normals[i][2],
385        );
386
387        let skinning = &skinning_data.vertex_skinning[i];
388        let mut deformed = Vector3::zeros();
389
390        for j in 0..skinning.num_influences {
391            let bone_id = BoneId(skinning.bone_indices[j]);
392            let weight = skinning.bone_weights[j];
393
394            if weight > 0.0 {
395                let skinning_matrix = skeleton.get_skinning_matrix(bone_id);
396
397                // For normals, use inverse transpose (approximated by the 3x3 rotation part)
398                let rotation_part = skinning_matrix.fixed_view::<3, 3>(0, 0);
399                let transformed = rotation_part * normal;
400                deformed += transformed * weight;
401            }
402        }
403
404        // Normalize the result
405        let normalized = deformed.normalize();
406        output_normals[i] = [normalized.x, normalized.y, normalized.z];
407    }
408
409    count
410}
411
412#[cfg(feature = "anim-blend")]
413mod anim_blend_api {
414    use super::*;
415
416    /// Local bone pose (position + rotation + scale) for clip sampling / blending.
417    #[derive(Debug, Clone, Copy)]
418    pub struct BonePose {
419        pub position: Vector3<f32>,
420        pub rotation: UnitQuaternion<f32>,
421        pub scale: Vector3<f32>,
422    }
423
424    impl BonePose {
425        pub fn identity() -> Self {
426            Self {
427                position: Vector3::zeros(),
428                rotation: UnitQuaternion::identity(),
429                scale: Vector3::new(1.0, 1.0, 1.0),
430            }
431        }
432
433        /// Spherical/linear blend between poses. Rotations use quaternion nlerp
434        /// (normalized lerp) — cheap and stable for short arcs.
435        pub fn blend(a: Self, b: Self, t: f32) -> Self {
436            let t = t.clamp(0.0, 1.0);
437            let q1 = a.rotation.into_inner();
438            let mut q2 = b.rotation.into_inner();
439            if q1.coords.dot(&q2.coords) < 0.0 {
440                q2 = -q2;
441            }
442            let q = nalgebra::Quaternion::new(
443                q1.w + (q2.w - q1.w) * t,
444                q1.i + (q2.i - q1.i) * t,
445                q1.j + (q2.j - q1.j) * t,
446                q1.k + (q2.k - q1.k) * t,
447            );
448            Self {
449                position: a.position + (b.position - a.position) * t,
450                rotation: UnitQuaternion::new_normalize(q),
451                scale: a.scale + (b.scale - a.scale) * t,
452            }
453        }
454    }
455
456    /// One keyframed skeleton pose (parallel array of per-bone poses).
457    #[derive(Debug, Clone, Copy)]
458    pub struct SkeletonKeyframe<'a> {
459        pub time: f32,
460        pub poses: &'a [BonePose],
461    }
462
463    /// Fixed-capacity clip: sorted keyframes of full-skeleton poses.
464    #[derive(Debug, Clone, Copy)]
465    pub struct AnimClip<'a> {
466        pub keyframes: &'a [SkeletonKeyframe<'a>],
467        pub looping: bool,
468    }
469
470    impl<'a> AnimClip<'a> {
471        pub fn duration(&self) -> f32 {
472            self.keyframes.last().map(|k| k.time).unwrap_or(0.0)
473        }
474
475        /// Sample pose channel `bone_index` at `time` into `out` (one BonePose).
476        pub fn sample_bone(&self, time: f32, bone_index: usize) -> Option<BonePose> {
477            if self.keyframes.is_empty() {
478                return None;
479            }
480            let duration = self.duration();
481            let t = if self.looping {
482                if duration > 0.0 { time % duration } else { 0.0 }
483            } else {
484                time.clamp(0.0, duration)
485            };
486
487            let idx = self.keyframes.partition_point(|kf| kf.time <= t);
488            let i1 = idx.saturating_sub(1);
489            let i2 = idx.min(self.keyframes.len() - 1).max(i1);
490            let k1 = &self.keyframes[i1];
491            let k2 = &self.keyframes[i2];
492            let p1 = *k1.poses.get(bone_index)?;
493            if i1 == i2 {
494                return Some(p1);
495            }
496            let p2 = *k2.poses.get(bone_index)?;
497            let alpha = if k2.time > k1.time {
498                (t - k1.time) / (k2.time - k1.time)
499            } else {
500                0.0
501            };
502            Some(BonePose::blend(p1, p2, alpha))
503        }
504    }
505
506    /// Weighted blend of up to 4 clip samples onto a skeleton's local bone transforms.
507    ///
508    /// `weights` are normalized if their sum > 0. Each entry is `(clip, time, weight)`.
509    pub fn blend_clips_onto_skeleton<const N: usize, const C: usize>(
510        skeleton: &mut Skeleton<N>,
511        layers: &[(&AnimClip<'_>, f32, f32)],
512    ) {
513        let mut accum: heapless::Vec<(BonePose, f32), 4> = heapless::Vec::new();
514        for bone_i in 0..skeleton.bones.len() {
515            accum.clear();
516            let mut wsum = 0.0f32;
517            for &(clip, time, w) in layers.iter().take(C.min(4)) {
518                if w <= 0.0 {
519                    continue;
520                }
521                if let Some(pose) = clip.sample_bone(time, bone_i) {
522                    let _ = accum.push((pose, w));
523                    wsum += w;
524                }
525            }
526            if accum.is_empty() || wsum <= 0.0 {
527                continue;
528            }
529            let mut blended = accum[0].0;
530            let mut acc_w = accum[0].1 / wsum;
531            for i in 1..accum.len() {
532                let w = accum[i].1 / wsum;
533                let t = w / (acc_w + w);
534                blended = BonePose::blend(blended, accum[i].0, t);
535                acc_w += w;
536            }
537            if let Some(bone) = skeleton.get_bone_mut(BoneId(bone_i)) {
538                bone.position = blended.position;
539                bone.rotation = blended.rotation;
540                bone.scale = blended.scale;
541                bone.update_local_transform();
542            }
543        }
544    }
545
546    /// Spherical linear interpolation for a bone rotation (always available; no `dsp` feature needed).
547    impl Bone {
548        pub fn slerp_rotation(&mut self, target: UnitQuaternion<f32>, t: f32) {
549            self.rotation = self.rotation.slerp(&target, t.clamp(0.0, 1.0));
550            self.update_local_transform();
551        }
552    }
553
554    /// Per-joint model-space AABB used to build skinned cull bounds.
555    #[derive(Debug, Clone, Copy)]
556    pub struct JointAabb {
557        pub center: Vector3<f32>,
558        pub half_extents: Vector3<f32>,
559    }
560
561    impl JointAabb {
562        pub fn from_aabb(aabb: crate::bounds::Aabb) -> Self {
563            Self {
564                center: aabb.center,
565                half_extents: aabb.half_extents,
566            }
567        }
568
569        pub fn to_aabb(self) -> crate::bounds::Aabb {
570            crate::bounds::Aabb {
571                center: self.center,
572                half_extents: self.half_extents,
573            }
574        }
575    }
576
577    /// Build per-joint AABBs from bind-pose vertices + skinning weights (model space).
578    ///
579    /// Each joint's AABB encloses vertices that have a non-zero weight on that joint.
580    pub fn compute_joint_aabbs<const N: usize>(
581        skeleton_bones: usize,
582        skinning: &SkinningData,
583        vertices: &[[f32; 3]],
584    ) -> heapless::Vec<Option<JointAabb>, N> {
585        let mut mins = [Vector3::new(f32::MAX, f32::MAX, f32::MAX); 64];
586        let mut maxs = [Vector3::new(f32::MIN, f32::MIN, f32::MIN); 64];
587        let mut used = [false; 64];
588        let n = skeleton_bones.min(64);
589
590        for (vi, skin) in skinning.vertex_skinning.iter().enumerate() {
591            if vi >= vertices.len() {
592                break;
593            }
594            let p = Vector3::new(vertices[vi][0], vertices[vi][1], vertices[vi][2]);
595            for j in 0..skin.num_influences {
596                if skin.bone_weights[j] <= 0.0 {
597                    continue;
598                }
599                let bi = skin.bone_indices[j];
600                if bi >= n {
601                    continue;
602                }
603                used[bi] = true;
604                mins[bi].x = mins[bi].x.min(p.x);
605                mins[bi].y = mins[bi].y.min(p.y);
606                mins[bi].z = mins[bi].z.min(p.z);
607                maxs[bi].x = maxs[bi].x.max(p.x);
608                maxs[bi].y = maxs[bi].y.max(p.y);
609                maxs[bi].z = maxs[bi].z.max(p.z);
610            }
611        }
612
613        let mut out: heapless::Vec<Option<JointAabb>, N> = heapless::Vec::new();
614        for i in 0..skeleton_bones.min(N) {
615            let entry = if i < 64 && used[i] {
616                Some(JointAabb::from_aabb(crate::bounds::Aabb::from_min_max(
617                    mins[i], maxs[i],
618                )))
619            } else {
620                None
621            };
622            let _ = out.push(entry);
623        }
624        out
625    }
626
627    /// Conservative model-space AABB of a skinned mesh under the current pose
628    /// (Arvo OBB transform of each joint AABB, then union).
629    pub fn skinned_model_aabb<const N: usize>(
630        skeleton: &Skeleton<N>,
631        joint_aabbs: &[Option<JointAabb>],
632    ) -> Option<crate::bounds::Aabb> {
633        let mut acc: Option<crate::bounds::Aabb> = None;
634        for (i, ja) in joint_aabbs.iter().enumerate() {
635            let Some(ja) = ja else { continue };
636            let Some(bone) = skeleton.get_bone(BoneId(i)) else {
637                continue;
638            };
639            let skin = bone.world_transform * bone.inverse_bind_pose;
640            let local = ja.to_aabb();
641            let world = local.transformed(&skin);
642            acc = Some(match acc {
643                Some(a) => a.merge(world),
644                None => world,
645            });
646        }
647        acc
648    }
649}
650
651#[cfg(feature = "anim-blend")]
652pub use anim_blend_api::*;
653
654#[cfg(all(feature = "dsp", feature = "anim-blend"))]
655impl Bone {
656    /// Perform spherical linear interpolation (SLERP) between two bone rotations using `embedded-dsp` quaternions.
657    pub fn interpolate_rotation_dsp(&mut self, target_rotation: UnitQuaternion<f32>, t: f32) {
658        self.slerp_rotation(target_rotation, t);
659    }
660}
661
662#[cfg(all(feature = "dsp", not(feature = "anim-blend")))]
663impl Bone {
664    /// Perform spherical linear interpolation (SLERP) between two bone rotations using `embedded-dsp` quaternions.
665    pub fn interpolate_rotation_dsp(&mut self, target_rotation: UnitQuaternion<f32>, t: f32) {
666        let q1 = [
667            self.rotation.w,
668            self.rotation.i,
669            self.rotation.j,
670            self.rotation.k,
671        ];
672        let q2 = [
673            target_rotation.w,
674            target_rotation.i,
675            target_rotation.j,
676            target_rotation.k,
677        ];
678        let dot = q1[0] * q2[0] + q1[1] * q2[1] + q1[2] * q2[2] + q1[3] * q2[3];
679        let q2_adj = if dot < 0.0 {
680            [-q2[0], -q2[1], -q2[2], -q2[3]]
681        } else {
682            q2
683        };
684        let t_clamped = t.clamp(0.0, 1.0);
685        let mut interpolated = [
686            q1[0] + (q2_adj[0] - q1[0]) * t_clamped,
687            q1[1] + (q2_adj[1] - q1[1]) * t_clamped,
688            q1[2] + (q2_adj[2] - q1[2]) * t_clamped,
689            q1[3] + (q2_adj[3] - q1[3]) * t_clamped,
690        ];
691        let _ = embedded_dsp::quaternion_normalize_f32(&mut interpolated);
692
693        self.rotation = UnitQuaternion::new_normalize(nalgebra::Quaternion::new(
694            interpolated[0],
695            interpolated[1],
696            interpolated[2],
697            interpolated[3],
698        ));
699        self.update_local_transform();
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    #[test]
708    fn test_bone_creation() {
709        let bone = Bone::new("test_bone");
710        assert_eq!(bone.name.as_str(), "test_bone");
711        assert_eq!(bone.position, Vector3::zeros());
712        assert_eq!(bone.parent, None);
713    }
714
715    #[test]
716    fn test_skeleton_add_bone() {
717        let mut skeleton = Skeleton::<4>::new();
718
719        let root = skeleton.add_bone(Bone::new("root"), None);
720        assert!(root.is_ok());
721
722        let root_id = root.unwrap();
723        let child = skeleton.add_bone(Bone::new("child"), Some(root_id));
724        assert!(child.is_ok());
725
726        assert_eq!(skeleton.bones.len(), 2);
727    }
728
729    #[test]
730    fn test_hierarchy_transforms() {
731        let mut skeleton = Skeleton::<4>::new();
732
733        // Root at origin
734        let root = skeleton.add_bone(Bone::new("root"), None).unwrap();
735
736        // Child offset by (1, 0, 0)
737        let child = skeleton
738            .add_bone(
739                Bone::new("child").with_position(Vector3::new(1.0, 0.0, 0.0)),
740                Some(root),
741            )
742            .unwrap();
743
744        skeleton.update_transforms();
745
746        // Child's world position should be (1, 0, 0)
747        let child_bone = skeleton.get_bone(child).unwrap();
748        let world_pos = child_bone.world_transform.column(3);
749        assert!((world_pos.x - 1.0).abs() < 0.001);
750        assert!(world_pos.y.abs() < 0.001);
751        assert!(world_pos.z.abs() < 0.001);
752    }
753
754    #[test]
755    fn test_vertex_skinning_single_bone() {
756        let skinning = VertexSkinning::single_bone(0);
757        assert_eq!(skinning.num_influences, 1);
758        assert_eq!(skinning.bone_weights[0], 1.0);
759    }
760
761    #[test]
762    fn test_vertex_skinning_two_bones() {
763        let skinning = VertexSkinning::two_bones(0, 0.7, 1, 0.3);
764        assert_eq!(skinning.num_influences, 2);
765        assert_eq!(skinning.bone_weights[0], 0.7);
766        assert_eq!(skinning.bone_weights[1], 0.3);
767    }
768}