1#[derive(Copy, Clone, Debug, PartialEq)]
15pub struct Transform {
16 pub translation: glam::Vec3,
17 pub rotation: glam::Quat,
18 pub scale: glam::Vec3,
19}
20
21impl Transform {
22 pub const IDENTITY: Self = Self {
23 translation: glam::Vec3::ZERO,
24 rotation: glam::Quat::IDENTITY,
25 scale: glam::Vec3::ONE,
26 };
27
28 pub fn to_matrix(&self) -> glam::Mat4 {
29 glam::Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
30 }
31
32 pub fn lerp(&self, other: &Transform, t: f32) -> Transform {
41 Transform {
42 translation: self.translation.lerp(other.translation, t),
43 rotation: self.rotation.slerp(other.rotation, t),
44 scale: self.scale.lerp(other.scale, t),
45 }
46 }
47}
48
49impl Default for Transform {
50 fn default() -> Self {
51 Self::IDENTITY
52 }
53}
54
55#[derive(Clone, Debug)]
59pub struct Joint {
60 pub name: String,
63 pub parent: Option<usize>,
66 pub inverse_bind_matrix: glam::Mat4,
70 pub local_bind_transform: Transform,
74}
75
76pub struct Skeleton {
83 joints: Vec<Joint>,
84 topo_order: Vec<usize>,
89}
90
91impl Skeleton {
92 pub fn new(joints: Vec<Joint>) -> Self {
98 let len = joints.len();
99 for (i, joint) in joints.iter().enumerate() {
100 if let Some(parent) = joint.parent {
101 assert!(
102 parent < len,
103 "Skeleton::new: joint {i} ('{}') has parent index {parent}, out of range for {len} joints",
104 joint.name,
105 );
106 }
107 }
108
109 let mut children: Vec<Vec<usize>> = vec![Vec::new(); len];
110 let mut roots: Vec<usize> = Vec::new();
111 for (i, joint) in joints.iter().enumerate() {
112 match joint.parent {
113 Some(parent) => children[parent].push(i),
114 None => roots.push(i),
115 }
116 }
117
118 let mut topo_order = Vec::with_capacity(len);
122 let mut queue = roots;
123 while let Some(i) = queue.pop() {
124 topo_order.push(i);
125 queue.extend(children[i].iter().copied());
126 }
127
128 assert!(
129 topo_order.len() == len,
130 "Skeleton::new: joint graph has a cycle — {} of {len} joints are unreachable from any \
131 root (a joint with no parent within this skeleton)",
132 len - topo_order.len(),
133 );
134
135 Self { joints, topo_order }
136 }
137
138 pub fn joint_count(&self) -> usize {
139 self.joints.len()
140 }
141
142 pub fn joint(&self, index: usize) -> &Joint {
143 &self.joints[index]
144 }
145
146 pub fn joint_index_by_name(&self, name: &str) -> Option<usize> {
147 self.joints.iter().position(|j| j.name == name)
148 }
149
150 pub fn world_matrices(&self, local_poses: &[Transform]) -> Vec<glam::Mat4> {
155 assert!(
156 local_poses.len() == self.joints.len(),
157 "Skeleton::world_matrices: {} local poses given for {} joints",
158 local_poses.len(),
159 self.joints.len(),
160 );
161
162 let mut world = vec![glam::Mat4::IDENTITY; self.joints.len()];
163 for &i in &self.topo_order {
164 let local = local_poses[i].to_matrix();
165 world[i] = match self.joints[i].parent {
166 Some(parent) => world[parent] * local,
167 None => local,
168 };
169 }
170 world
171 }
172
173 pub fn skinning_matrices(&self, local_poses: &[Transform]) -> Vec<glam::Mat4> {
179 let world = self.world_matrices(local_poses);
180 world
181 .iter()
182 .zip(&self.joints)
183 .map(|(w, joint)| *w * joint.inverse_bind_matrix)
184 .collect()
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn joint(name: &str, parent: Option<usize>) -> Joint {
193 Joint {
194 name: name.to_string(),
195 parent,
196 inverse_bind_matrix: glam::Mat4::IDENTITY,
197 local_bind_transform: Transform::IDENTITY,
198 }
199 }
200
201 #[test]
202 fn transform_lerp_blends_translation_scale_and_rotation() {
203 let a = Transform {
204 translation: glam::Vec3::new(0.0, 0.0, 0.0),
205 rotation: glam::Quat::IDENTITY,
206 scale: glam::Vec3::new(1.0, 1.0, 1.0),
207 };
208 let b = Transform {
209 translation: glam::Vec3::new(10.0, 0.0, 0.0),
210 rotation: glam::Quat::from_rotation_y(std::f32::consts::PI),
211 scale: glam::Vec3::new(3.0, 3.0, 3.0),
212 };
213
214 let mid = a.lerp(&b, 0.5);
215 assert_eq!(mid.translation, glam::Vec3::new(5.0, 0.0, 0.0));
216 assert_eq!(mid.scale, glam::Vec3::new(2.0, 2.0, 2.0));
217 let angle = mid.rotation.to_axis_angle().1;
219 assert!((angle - std::f32::consts::FRAC_PI_2).abs() < 1e-5, "expected a ~90 degree rotation, got {angle}");
220 }
221
222 #[test]
223 fn transform_lerp_at_the_endpoints_returns_each_transform_unchanged() {
224 let a = Transform { translation: glam::Vec3::new(1.0, 2.0, 3.0), ..Transform::IDENTITY };
225 let b = Transform { translation: glam::Vec3::new(4.0, 5.0, 6.0), ..Transform::IDENTITY };
226 assert_eq!(a.lerp(&b, 0.0), a);
227 assert_eq!(a.lerp(&b, 1.0), b);
228 }
229
230 #[test]
231 fn out_of_range_parent_panics() {
232 let result = std::panic::catch_unwind(|| Skeleton::new(vec![joint("root", Some(1))]));
233 assert!(result.is_err(), "expected a panic for an out-of-range parent index");
234 }
235
236 #[test]
237 fn a_two_joint_cycle_panics() {
238 let result = std::panic::catch_unwind(|| {
239 Skeleton::new(vec![joint("a", Some(1)), joint("b", Some(0))])
240 });
241 assert!(result.is_err(), "expected a panic for a joint cycle");
242 }
243
244 #[test]
245 fn world_matrices_is_correct_even_when_input_order_is_not_topological() {
246 let joints = vec![
250 joint("child", Some(1)),
251 joint("mid", Some(2)),
252 joint("root", None),
253 ];
254 let skeleton = Skeleton::new(joints);
255
256 let poses = vec![
257 Transform { translation: glam::Vec3::new(1.0, 0.0, 0.0), ..Transform::IDENTITY },
258 Transform { translation: glam::Vec3::new(0.0, 1.0, 0.0), ..Transform::IDENTITY },
259 Transform { translation: glam::Vec3::new(0.0, 0.0, 1.0), ..Transform::IDENTITY },
260 ];
261 let world = skeleton.world_matrices(&poses);
262
263 assert_eq!(world[2].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(0.0, 0.0, 1.0));
265 assert_eq!(world[1].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(0.0, 1.0, 1.0));
266 assert_eq!(world[0].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(1.0, 1.0, 1.0));
267 }
268
269 #[test]
270 fn skinning_matrices_applies_inverse_bind_matrix() {
271 let mut root = joint("root", None);
272 root.inverse_bind_matrix = glam::Mat4::from_translation(glam::Vec3::new(-2.0, 0.0, 0.0));
273 let skeleton = Skeleton::new(vec![root]);
274
275 let poses = vec![Transform {
276 translation: glam::Vec3::new(5.0, 0.0, 0.0),
277 ..Transform::IDENTITY
278 }];
279 let skinning = skeleton.skinning_matrices(&poses);
280
281 assert_eq!(skinning[0].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(3.0, 0.0, 0.0));
283 }
284
285 #[test]
286 fn world_matrices_panics_on_mismatched_pose_count() {
287 let skeleton = Skeleton::new(vec![joint("root", None)]);
288 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
289 skeleton.world_matrices(&[])
290 }));
291 assert!(result.is_err(), "expected a panic for a local_poses length mismatch");
292 }
293
294 #[test]
295 fn joint_index_by_name_finds_and_misses_correctly() {
296 let skeleton = Skeleton::new(vec![joint("root", None), joint("child", Some(0))]);
297 assert_eq!(skeleton.joint_index_by_name("child"), Some(1));
298 assert_eq!(skeleton.joint_index_by_name("missing"), None);
299 }
300}