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 bind_pose(&self) -> Vec<Transform> {
154 self.joints.iter().map(|j| j.local_bind_transform).collect()
155 }
156
157 pub fn world_matrices(&self, local_poses: &[Transform]) -> Vec<glam::Mat4> {
162 assert!(
163 local_poses.len() == self.joints.len(),
164 "Skeleton::world_matrices: {} local poses given for {} joints",
165 local_poses.len(),
166 self.joints.len(),
167 );
168
169 let mut world = vec![glam::Mat4::IDENTITY; self.joints.len()];
170 for &i in &self.topo_order {
171 let local = local_poses[i].to_matrix();
172 world[i] = match self.joints[i].parent {
173 Some(parent) => world[parent] * local,
174 None => local,
175 };
176 }
177 world
178 }
179
180 pub fn skinning_matrices(&self, local_poses: &[Transform]) -> Vec<glam::Mat4> {
186 let world = self.world_matrices(local_poses);
187 world
188 .iter()
189 .zip(&self.joints)
190 .map(|(w, joint)| *w * joint.inverse_bind_matrix)
191 .collect()
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn joint(name: &str, parent: Option<usize>) -> Joint {
200 Joint {
201 name: name.to_string(),
202 parent,
203 inverse_bind_matrix: glam::Mat4::IDENTITY,
204 local_bind_transform: Transform::IDENTITY,
205 }
206 }
207
208 #[test]
209 fn transform_lerp_blends_translation_scale_and_rotation() {
210 let a = Transform {
211 translation: glam::Vec3::new(0.0, 0.0, 0.0),
212 rotation: glam::Quat::IDENTITY,
213 scale: glam::Vec3::new(1.0, 1.0, 1.0),
214 };
215 let b = Transform {
216 translation: glam::Vec3::new(10.0, 0.0, 0.0),
217 rotation: glam::Quat::from_rotation_y(std::f32::consts::PI),
218 scale: glam::Vec3::new(3.0, 3.0, 3.0),
219 };
220
221 let mid = a.lerp(&b, 0.5);
222 assert_eq!(mid.translation, glam::Vec3::new(5.0, 0.0, 0.0));
223 assert_eq!(mid.scale, glam::Vec3::new(2.0, 2.0, 2.0));
224 let angle = mid.rotation.to_axis_angle().1;
226 assert!((angle - std::f32::consts::FRAC_PI_2).abs() < 1e-5, "expected a ~90 degree rotation, got {angle}");
227 }
228
229 #[test]
230 fn transform_lerp_at_the_endpoints_returns_each_transform_unchanged() {
231 let a = Transform { translation: glam::Vec3::new(1.0, 2.0, 3.0), ..Transform::IDENTITY };
232 let b = Transform { translation: glam::Vec3::new(4.0, 5.0, 6.0), ..Transform::IDENTITY };
233 assert_eq!(a.lerp(&b, 0.0), a);
234 assert_eq!(a.lerp(&b, 1.0), b);
235 }
236
237 #[test]
238 fn out_of_range_parent_panics() {
239 let result = std::panic::catch_unwind(|| Skeleton::new(vec![joint("root", Some(1))]));
240 assert!(result.is_err(), "expected a panic for an out-of-range parent index");
241 }
242
243 #[test]
244 fn a_two_joint_cycle_panics() {
245 let result = std::panic::catch_unwind(|| {
246 Skeleton::new(vec![joint("a", Some(1)), joint("b", Some(0))])
247 });
248 assert!(result.is_err(), "expected a panic for a joint cycle");
249 }
250
251 #[test]
252 fn world_matrices_is_correct_even_when_input_order_is_not_topological() {
253 let joints = vec![
257 joint("child", Some(1)),
258 joint("mid", Some(2)),
259 joint("root", None),
260 ];
261 let skeleton = Skeleton::new(joints);
262
263 let poses = vec![
264 Transform { translation: glam::Vec3::new(1.0, 0.0, 0.0), ..Transform::IDENTITY },
265 Transform { translation: glam::Vec3::new(0.0, 1.0, 0.0), ..Transform::IDENTITY },
266 Transform { translation: glam::Vec3::new(0.0, 0.0, 1.0), ..Transform::IDENTITY },
267 ];
268 let world = skeleton.world_matrices(&poses);
269
270 assert_eq!(world[2].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(0.0, 0.0, 1.0));
272 assert_eq!(world[1].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(0.0, 1.0, 1.0));
273 assert_eq!(world[0].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(1.0, 1.0, 1.0));
274 }
275
276 #[test]
277 fn skinning_matrices_applies_inverse_bind_matrix() {
278 let mut root = joint("root", None);
279 root.inverse_bind_matrix = glam::Mat4::from_translation(glam::Vec3::new(-2.0, 0.0, 0.0));
280 let skeleton = Skeleton::new(vec![root]);
281
282 let poses = vec![Transform {
283 translation: glam::Vec3::new(5.0, 0.0, 0.0),
284 ..Transform::IDENTITY
285 }];
286 let skinning = skeleton.skinning_matrices(&poses);
287
288 assert_eq!(skinning[0].transform_point3(glam::Vec3::ZERO), glam::Vec3::new(3.0, 0.0, 0.0));
290 }
291
292 #[test]
293 fn world_matrices_panics_on_mismatched_pose_count() {
294 let skeleton = Skeleton::new(vec![joint("root", None)]);
295 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
296 skeleton.world_matrices(&[])
297 }));
298 assert!(result.is_err(), "expected a panic for a local_poses length mismatch");
299 }
300
301 #[test]
302 fn joint_index_by_name_finds_and_misses_correctly() {
303 let skeleton = Skeleton::new(vec![joint("root", None), joint("child", Some(0))]);
304 assert_eq!(skeleton.joint_index_by_name("child"), Some(1));
305 assert_eq!(skeleton.joint_index_by_name("missing"), None);
306 }
307}