animsmith_core/model.rs
1//! The loader-facing layer: clips, tracks, and the skeleton before metric
2//! resampling or repair. The glTF loader preserves authored animation
3//! values; the FBX loader normalizes scene coordinates and bakes takes to
4//! linear TRS tracks. Mechanical checks (NaN, quaternion flips, key
5//! density, …) read this layer; semantic checks read the sampled layer
6//! built from it (see [`crate::sample`]).
7
8use glam::{Mat4, Quat, Vec3};
9
10/// Index into [`Skeleton::bones`].
11pub type BoneId = usize;
12
13/// Node-local TRS transform.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Transform {
16 /// Translation in scene units.
17 pub translation: Vec3,
18 /// Orientation relative to the parent node.
19 pub rotation: Quat,
20 /// Non-uniform local scale.
21 pub scale: Vec3,
22}
23
24impl Transform {
25 /// The identity transform: zero translation, identity rotation, and
26 /// unit scale.
27 pub const IDENTITY: Self = Self {
28 translation: Vec3::ZERO,
29 rotation: Quat::IDENTITY,
30 scale: Vec3::ONE,
31 };
32
33 /// Convert this TRS transform to a matrix using glam's
34 /// scale-rotation-translation order.
35 pub fn to_mat4(&self) -> Mat4 {
36 Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
37 }
38}
39
40impl Default for Transform {
41 fn default() -> Self {
42 Self::IDENTITY
43 }
44}
45
46/// One skeleton node/bone in parent-before-child order.
47#[derive(Debug, Clone)]
48pub struct Bone {
49 /// Bone/node name as authored or normalized by the loader.
50 pub name: String,
51 /// Parent bone index; `None` means this is a root bone.
52 pub parent: Option<BoneId>,
53 /// Rest pose, node-local. Whether this or the inverse-bind-derived
54 /// rest is authoritative is a `bind-pose` check concern.
55 pub rest: Transform,
56 /// Inverse bind matrix from a skin, when one references this bone.
57 pub inverse_bind: Option<Mat4>,
58}
59
60/// Bones in topological order: a bone's parent always precedes it.
61/// Loaders are responsible for establishing this invariant.
62#[derive(Debug, Clone, Default)]
63pub struct Skeleton {
64 /// Bones in topological order.
65 pub bones: Vec<Bone>,
66}
67
68impl Skeleton {
69 /// Name of the bone at `id`.
70 ///
71 /// # Panics
72 ///
73 /// Panics if `id` is not a valid index into [`Skeleton::bones`].
74 pub fn bone_name(&self, id: BoneId) -> &str {
75 &self.bones[id].name
76 }
77}
78
79/// Animated property targeted by a [`Track`].
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Property {
82 /// Local translation channel.
83 Translation,
84 /// Local rotation channel.
85 Rotation,
86 /// Local scale channel.
87 Scale,
88}
89
90impl Property {
91 /// Stable snake-case name used in diagnostics and serialized
92 /// metadata.
93 pub fn as_str(self) -> &'static str {
94 match self {
95 Property::Translation => "translation",
96 Property::Rotation => "rotation",
97 Property::Scale => "scale",
98 }
99 }
100}
101
102/// Interpolation mode for a [`Track`].
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum Interpolation {
105 /// Linear interpolation between key values.
106 Linear,
107 /// Hold the previous key until the next key.
108 Step,
109 /// glTF cubic spline: `values` holds `[in-tangent, value, out-tangent]`
110 /// triplets per keyframe. Use [`Track::value_index`] to address the
111 /// value elements.
112 CubicSpline,
113}
114
115/// Storage for a track's key values.
116#[derive(Debug, Clone)]
117pub enum TrackValues {
118 /// Translation or scale values.
119 Vec3s(Vec<Vec3>),
120 /// Rotation values.
121 Quats(Vec<Quat>),
122}
123
124impl TrackValues {
125 /// Number of stored values, including tangents for cubic-spline
126 /// tracks.
127 pub fn len(&self) -> usize {
128 match self {
129 TrackValues::Vec3s(v) => v.len(),
130 TrackValues::Quats(v) => v.len(),
131 }
132 }
133
134 /// Whether there are no stored values.
135 pub fn is_empty(&self) -> bool {
136 self.len() == 0
137 }
138}
139
140/// One animated property of one bone.
141#[derive(Debug, Clone)]
142pub struct Track {
143 /// Bone index targeted by this track.
144 pub bone: BoneId,
145 /// Property animated on the target bone.
146 pub property: Property,
147 /// Interpolation mode used between keys.
148 pub interpolation: Interpolation,
149 /// Keyframe times in seconds. Same length as the keyframe count
150 /// (tangent elements in cubic tracks do not add times).
151 pub times: Vec<f32>,
152 /// Key values, with cubic-spline tracks storing tangent triplets.
153 pub values: TrackValues,
154}
155
156impl Track {
157 /// Number of keyframes.
158 pub fn key_count(&self) -> usize {
159 self.times.len()
160 }
161
162 /// Index into `values` of keyframe `k`'s value element (skips
163 /// tangents for cubic tracks).
164 pub fn value_index(&self, k: usize) -> usize {
165 match self.interpolation {
166 Interpolation::CubicSpline => 3 * k + 1,
167 _ => k,
168 }
169 }
170
171 /// Keyframe `k`'s value, for Vec3 tracks.
172 pub fn key_vec3(&self, k: usize) -> Option<Vec3> {
173 match &self.values {
174 TrackValues::Vec3s(v) => v.get(self.value_index(k)).copied(),
175 TrackValues::Quats(_) => None,
176 }
177 }
178
179 /// Keyframe `k`'s value, for rotation tracks.
180 pub fn key_quat(&self, k: usize) -> Option<Quat> {
181 match &self.values {
182 TrackValues::Quats(v) => v.get(self.value_index(k)).copied(),
183 TrackValues::Vec3s(_) => None,
184 }
185 }
186
187 /// First key time, or `0.0` for an empty track.
188 pub fn start_time(&self) -> f32 {
189 self.times.first().copied().unwrap_or(0.0)
190 }
191
192 /// Last key time, or `0.0` for an empty track.
193 pub fn end_time(&self) -> f32 {
194 self.times.last().copied().unwrap_or(0.0)
195 }
196}
197
198/// One animation clip targeting the document skeleton.
199#[derive(Debug, Clone)]
200pub struct Clip {
201 /// Clip name, used as the key in measurement maps and config
202 /// expectations.
203 pub name: String,
204 /// Clip length in seconds (max sampler end time across tracks).
205 pub duration_s: f64,
206 /// Animated tracks belonging to this clip.
207 pub tracks: Vec<Track>,
208}
209
210/// Loader-provided provenance for a [`Document`].
211#[derive(Debug, Clone, Default)]
212pub struct SourceInfo {
213 /// Source path, when the loader was given one.
214 pub path: Option<String>,
215 /// Source format label such as `"glb"` or `"fbx"`.
216 pub format: Option<String>,
217}
218
219/// A loaded file: one skeleton, any number of clips targeting it, and
220/// the scene assets (meshes, materials, and textures) that rode in alongside
221/// them.
222/// `assets` is default-empty: the check catalog judges animation and
223/// ignores it, but the load/write round-trip carries it so `transform`
224/// and `convert` preserve geometry instead of silently dropping it.
225#[derive(Debug, Clone, Default)]
226pub struct Document {
227 /// Skeleton shared by every clip.
228 pub skeleton: Skeleton,
229 /// Animation clips targeting [`Document::skeleton`].
230 pub clips: Vec<Clip>,
231 /// Meshes, materials, and textures carried by the loaded scene.
232 pub assets: SceneAssets,
233 /// Optional source provenance.
234 pub source: SourceInfo,
235}
236
237// --- Scene assets (meshes/materials) -----------------------------------
238//
239// The geometry half of a [`Document`]. Populated by both format loaders and
240// emitted by the writer, so a full conversion preserves geometry. Primitives
241// may be indexed already; [`Primitive::weld`] can index unindexed exact
242// duplicates without collapsing authored seams.
243
244/// One triangle-list primitive sharing a material. Attribute arrays may be
245/// indexed already; [`Primitive::weld`] dedupes an unindexed primitive into
246/// indexed form.
247#[derive(Debug, Clone, Default)]
248pub struct Primitive {
249 /// Index into [`SceneAssets::materials`].
250 pub material: Option<usize>,
251 /// Triangle indices into the attribute arrays; empty = unindexed.
252 pub indices: Vec<u32>,
253 /// Vertex positions in scene units.
254 pub positions: Vec<Vec3>,
255 /// Same length as `positions`, or empty.
256 pub normals: Vec<Vec3>,
257 /// Same length as `positions`, or empty.
258 pub uvs: Vec<[f32; 2]>,
259 /// Indices into the owning mesh's `skin_joints`; empty if unskinned.
260 pub joints: Vec<[u16; 4]>,
261 /// Skinning weights parallel to [`Primitive::joints`].
262 pub weights: Vec<[f32; 4]>,
263}
264
265/// Mesh data attached to a scene node.
266#[derive(Debug, Clone, Default)]
267pub struct MeshAsset {
268 /// Mesh name.
269 pub name: String,
270 /// The node this mesh hangs off.
271 pub node: BoneId,
272 /// Triangle-list primitives belonging to this mesh.
273 pub primitives: Vec<Primitive>,
274 /// Skin joints in cluster order. Empty = unskinned.
275 pub skin_joints: Vec<BoneId>,
276 /// Per-joint inverse bind matrices, parallel to `skin_joints`
277 /// (glTF convention: joint-bind-world⁻¹ × geometry-to-world, all
278 /// in the converted scene space). Falls back to the bones'
279 /// `inverse_bind` when empty.
280 pub skin_ibms: Vec<Mat4>,
281}
282
283/// An embedded texture: raw encoded image bytes (glTF embeds the file
284/// as-is, no decoding).
285#[derive(Debug, Clone)]
286pub struct TextureAsset {
287 /// Encoded image bytes.
288 pub bytes: Vec<u8>,
289 /// "image/png" or "image/jpeg".
290 pub mime: String,
291}
292
293/// Factor-only material plus an optional embedded base-color texture.
294#[derive(Debug, Clone)]
295pub struct MaterialAsset {
296 /// Material name.
297 pub name: String,
298 /// Multiplied with the texture when one is present (set to white
299 /// by the FBX loader in that case, matching exporter convention).
300 pub base_color: [f32; 4],
301 /// Metallic factor.
302 pub metallic: f32,
303 /// Roughness factor.
304 pub roughness: f32,
305 /// Embedded base-color texture, if one was loaded.
306 pub base_color_texture: Option<TextureAsset>,
307}
308
309impl Primitive {
310 /// Dedupe identical corners into indexed triangles. Exact
311 /// bit-equality only — no tolerance welding, so seams authored via
312 /// split normals/UVs are preserved.
313 pub fn weld(&mut self) {
314 if !self.indices.is_empty() || self.positions.is_empty() {
315 return;
316 }
317 let corner_key = |i: usize| -> Vec<u8> {
318 let mut key = Vec::with_capacity(64);
319 let mut push_f32s = |vals: &[f32]| {
320 for v in vals {
321 key.extend_from_slice(&v.to_le_bytes());
322 }
323 };
324 push_f32s(&self.positions[i].to_array());
325 if let Some(n) = self.normals.get(i) {
326 push_f32s(&n.to_array());
327 }
328 if let Some(uv) = self.uvs.get(i) {
329 push_f32s(uv);
330 }
331 if let Some(w) = self.weights.get(i) {
332 push_f32s(w);
333 }
334 if let Some(j) = self.joints.get(i) {
335 for v in j {
336 key.extend_from_slice(&v.to_le_bytes());
337 }
338 }
339 key
340 };
341 let mut seen: std::collections::HashMap<Vec<u8>, u32> = std::collections::HashMap::new();
342 let mut indices = Vec::with_capacity(self.positions.len());
343 let mut positions = Vec::new();
344 let mut normals = Vec::new();
345 let mut uvs = Vec::new();
346 let mut joints = Vec::new();
347 let mut weights = Vec::new();
348 for i in 0..self.positions.len() {
349 let index = *seen.entry(corner_key(i)).or_insert_with(|| {
350 positions.push(self.positions[i]);
351 if let Some(n) = self.normals.get(i) {
352 normals.push(*n);
353 }
354 if let Some(uv) = self.uvs.get(i) {
355 uvs.push(*uv);
356 }
357 if let Some(j) = self.joints.get(i) {
358 joints.push(*j);
359 }
360 if let Some(w) = self.weights.get(i) {
361 weights.push(*w);
362 }
363 (positions.len() - 1) as u32
364 });
365 indices.push(index);
366 }
367 self.indices = indices;
368 self.positions = positions;
369 self.normals = normals;
370 self.uvs = uvs;
371 self.joints = joints;
372 self.weights = weights;
373 }
374}
375
376/// Mesh and material assets carried alongside animation data.
377#[derive(Debug, Clone, Default)]
378pub struct SceneAssets {
379 /// Meshes in document order.
380 pub meshes: Vec<MeshAsset>,
381 /// Materials referenced by mesh primitives.
382 pub materials: Vec<MaterialAsset>,
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn weld_preserves_uv_seams_at_shared_positions() {
391 let mut primitive = Primitive {
392 positions: vec![Vec3::ZERO, Vec3::ZERO, Vec3::ZERO],
393 uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 0.0]],
394 ..Primitive::default()
395 };
396
397 primitive.weld();
398
399 assert_eq!(primitive.positions.len(), 2);
400 let reconstructed_corners = primitive
401 .indices
402 .iter()
403 .map(|&index| {
404 let index = index as usize;
405 (primitive.positions[index], primitive.uvs[index])
406 })
407 .collect::<Vec<_>>();
408 assert_eq!(
409 reconstructed_corners,
410 vec![
411 (Vec3::ZERO, [0.0, 0.0]),
412 (Vec3::ZERO, [1.0, 0.0]),
413 (Vec3::ZERO, [0.0, 0.0]),
414 ]
415 );
416 }
417}