Skip to main content

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}