Skip to main content

animsmith_fbx/
lib.rs

1//! [`load`] reads FBX files into an
2//! [`animsmith_core::Document`], normalizing parser errors into
3//! [`LoadError`]. The resulting document carries skeletons, animation
4//! clips, and scene assets in the same core model used by the glTF
5//! loader.
6//!
7//! The loader normalizes FBX scenes into animsmith's runtime-oriented
8//! coordinate space before handing them to `animsmith-core`: right-handed
9//! +Y-up axes, metres, transform-adjust conversion, helper nodes for
10//! geometric transforms, and compensated scale inheritance. Depend on this
11//! crate only when your pipeline accepts FBX input; it brings the bundled
12//! `ufbx` C build that `animsmith-core` and `animsmith-gltf` intentionally
13//! avoid.
14//!
15//! # Quick start
16//!
17//! ```no_run
18//! fn lint_fbx(
19//!     path: &std::path::Path,
20//! ) -> Result<Vec<animsmith_core::Finding>, Box<dyn std::error::Error>> {
21//!     let doc = animsmith_fbx::load(path)?;
22//!     let roles = animsmith_core::detect_profile(&doc.skeleton).unwrap_or_default();
23//!     let config = animsmith_core::Config::default();
24//!     let grids = animsmith_core::MetricGrids::new(&doc);
25//!     let ctx = animsmith_core::CheckCtx::new(&grids, &roles, &config);
26//!     Ok(animsmith_core::run_checks(&ctx, &animsmith_core::all_checks()))
27//! }
28//! ```
29//!
30//! # Build and API status
31//!
32//! The library crate has no public feature flags and supports the workspace
33//! MSRV, Rust 1.88. It includes the bundled `ufbx` C build. Its Rust API is
34//! pre-1.0; see `animsmith-core`'s crate-level API status for the shared
35//! stability boundary.
36//!
37//! See the GitHub [embedding guide] for crate selection and the [pipeline
38//! scenario guide] for FBX intake and conversion workflows.
39//!
40//! [embedding guide]: https://github.com/mmannerm/animsmith/blob/main/docs/embedding.md
41//! [pipeline scenario guide]: https://github.com/mmannerm/animsmith/blob/main/docs/pipeline-scenarios.md
42//!
43#![warn(missing_docs)]
44
45use animsmith_core::model::{
46    Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, Primitive, Property,
47    SceneAssets, Skeleton, SourceInfo, TextureAsset, Track, TrackValues, Transform,
48};
49use glam::{Mat4, Quat, Vec3};
50use std::path::Path;
51
52/// Errors returned while loading an FBX scene into the core model.
53///
54/// These errors describe input or parser failures. They do not represent
55/// animation check findings; once a [`Document`] loads, semantic problems
56/// are reported by `animsmith-core` checks instead.
57#[derive(Debug, thiserror::Error)]
58#[non_exhaustive]
59pub enum LoadError {
60    /// The input path could not be represented as UTF-8 for `ufbx`.
61    #[error("path is not valid UTF-8: {0}")]
62    Path(String),
63    /// `ufbx` rejected or could not parse the file.
64    #[error("FBX parse error: {0}")]
65    Fbx(String),
66    /// `ufbx` loaded the scene but failed while baking an animation take.
67    #[error("animation bake failed for take {take:?}: {message}")]
68    Bake {
69        /// Name of the animation take that failed to bake.
70        take: String,
71        /// Parser-provided bake failure detail.
72        message: String,
73    },
74}
75
76fn vec3(v: ufbx::Vec3) -> Vec3 {
77    Vec3::new(v.x as f32, v.y as f32, v.z as f32)
78}
79
80fn quat(q: ufbx::Quat) -> Quat {
81    Quat::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32)
82}
83
84fn transform(t: &ufbx::Transform) -> Transform {
85    Transform {
86        translation: vec3(t.translation),
87        rotation: quat(t.rotation),
88        scale: vec3(t.scale),
89    }
90}
91
92/// ufbx matrices are 3×4 (rotation/scale columns + translation).
93fn mat4(m: &ufbx::Matrix) -> Mat4 {
94    Mat4::from_cols_array(&[
95        m.m00 as f32,
96        m.m10 as f32,
97        m.m20 as f32,
98        0.0,
99        m.m01 as f32,
100        m.m11 as f32,
101        m.m21 as f32,
102        0.0,
103        m.m02 as f32,
104        m.m12 as f32,
105        m.m22 as f32,
106        0.0,
107        m.m03 as f32,
108        m.m13 as f32,
109        m.m23 as f32,
110        1.0,
111    ])
112}
113
114/// Load an `.fbx` file into a core [`Document`]: skeleton, animation,
115/// and scene assets (triangulated meshes, skins, factor-only
116/// materials). Consumers that only judge animation ignore
117/// [`Document::assets`].
118///
119/// # Errors
120///
121/// Returns [`LoadError::Path`] when the path cannot be passed to `ufbx`,
122/// [`LoadError::Fbx`] when the FBX container cannot be parsed, and
123/// [`LoadError::Bake`] when an animation stack cannot be baked into the
124/// linear TRS tracks that animsmith's checks consume.
125pub fn load(path: &Path) -> Result<Document, LoadError> {
126    let filename = path
127        .to_str()
128        .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
129    let opts = ufbx::LoadOpts {
130        target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
131        target_unit_meters: 1.0,
132        space_conversion: ufbx::SpaceConversion::AdjustTransforms,
133        geometry_transform_handling: ufbx::GeometryTransformHandling::HelperNodes,
134        // FBX scale-compensation inheritance (Maya-style; ubiquitous in
135        // Mixamo rigs, every bone carrying scale 0.01) cannot be
136        // represented by plain TRS hierarchies like glTF's — ufbx
137        // compensates the transforms (with helper nodes as fallback)
138        // so standard composition is correct.
139        inherit_mode_handling: ufbx::InheritModeHandling::Compensate,
140        generate_missing_normals: true,
141        ..Default::default()
142    };
143    let scene = ufbx::load_file(filename, opts).map_err(|e| LoadError::Fbx(format!("{e:?}")))?;
144
145    // Every node becomes a bone (the ufbx root included — it carries
146    // the axis/unit adjustment). scene.nodes is ordered parents-first,
147    // matching the skeleton invariant; typed_id indexes scene.nodes
148    // directly.
149    let mut bones: Vec<Bone> = Vec::with_capacity(scene.nodes.len());
150    for node in &scene.nodes {
151        let name = if node.element.name.is_empty() {
152            if node.is_root {
153                "<fbx-root>".to_string()
154            } else {
155                format!("node{}", node.element.typed_id)
156            }
157        } else {
158            node.element.name.to_string()
159        };
160        bones.push(Bone {
161            name,
162            parent: node.parent.as_ref().map(|p| p.element.typed_id as usize),
163            rest: transform(&node.local_transform),
164            inverse_bind: None,
165        });
166    }
167    for cluster in &scene.skin_clusters {
168        if let Some(bone_node) = &cluster.bone_node {
169            let id = bone_node.element.typed_id as usize;
170            if id < bones.len() {
171                // Joint-centric bind inverse in the converted scene
172                // space; the mesh-dependent part lives per mesh in
173                // `MeshAsset::skin_ibms`.
174                bones[id].inverse_bind = Some(mat4(&cluster.bind_to_world).inverse());
175            }
176        }
177    }
178
179    let mut clips = Vec::new();
180    for (index, stack) in scene.anim_stacks.iter().enumerate() {
181        let take = if stack.element.name.is_empty() {
182            format!("take{index}")
183        } else {
184            stack.element.name.to_string()
185        };
186        let baked = ufbx::bake_anim(
187            &scene,
188            &stack.anim,
189            ufbx::BakeOpts {
190                trim_start_time: true,
191                ..Default::default()
192            },
193        )
194        .map_err(|e| LoadError::Bake {
195            take: take.clone(),
196            message: format!("{e:?}"),
197        })?;
198
199        let mut tracks = Vec::new();
200        let mut duration = 0.0f64;
201        for node in &baked.nodes {
202            let bone = node.typed_id as usize;
203            if !node.translation_keys.is_empty() {
204                let times: Vec<f32> = node
205                    .translation_keys
206                    .iter()
207                    .map(|k| k.time as f32)
208                    .collect();
209                let values: Vec<Vec3> = node
210                    .translation_keys
211                    .iter()
212                    .map(|k| vec3(k.value))
213                    .collect();
214                duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
215                tracks.push(Track {
216                    bone,
217                    property: Property::Translation,
218                    interpolation: Interpolation::Linear,
219                    times,
220                    values: TrackValues::Vec3s(values),
221                });
222            }
223            if !node.rotation_keys.is_empty() {
224                let times: Vec<f32> = node.rotation_keys.iter().map(|k| k.time as f32).collect();
225                let values: Vec<Quat> = node.rotation_keys.iter().map(|k| quat(k.value)).collect();
226                duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
227                tracks.push(Track {
228                    bone,
229                    property: Property::Rotation,
230                    interpolation: Interpolation::Linear,
231                    times,
232                    values: TrackValues::Quats(values),
233                });
234            }
235            if !node.scale_keys.is_empty() {
236                let times: Vec<f32> = node.scale_keys.iter().map(|k| k.time as f32).collect();
237                let values: Vec<Vec3> = node.scale_keys.iter().map(|k| vec3(k.value)).collect();
238                duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
239                tracks.push(Track {
240                    bone,
241                    property: Property::Scale,
242                    interpolation: Interpolation::Linear,
243                    times,
244                    values: TrackValues::Vec3s(values),
245                });
246            }
247        }
248        clips.push(Clip {
249            name: take,
250            duration_s: duration,
251            tracks,
252        });
253    }
254
255    let assets = extract_assets(&scene, path.parent());
256
257    Ok(Document {
258        skeleton: Skeleton { bones },
259        clips,
260        assets,
261        source: SourceInfo {
262            path: Some(path.display().to_string()),
263            format: Some("fbx".into()),
264        },
265    })
266}
267
268/// Triangulated, unindexed geometry + skins + factor-only materials.
269/// Corner attributes come straight from ufbx's indexed accessors; skin
270/// weights are per source vertex (top four, renormalized).
271/// Encoded image bytes for a material's base-color texture: embedded
272/// FBX content first, else the referenced file next to the source.
273/// Only PNG/JPEG pass through (glTF's mandated formats).
274fn base_color_texture(material: &ufbx::Material, base_dir: Option<&Path>) -> Option<TextureAsset> {
275    let texture = material.pbr.base_color.texture.as_ref().or(material
276        .fbx
277        .diffuse_color
278        .texture
279        .as_ref())?;
280    let bytes: Vec<u8> = if !texture.content.is_empty() {
281        texture.content.to_vec()
282    } else {
283        let mut found = None;
284        for candidate in [
285            texture.absolute_filename.as_ref(),
286            texture.relative_filename.as_ref(),
287            texture.filename.as_ref(),
288        ] {
289            if candidate.is_empty() {
290                continue;
291            }
292            let direct = Path::new(candidate);
293            let path = if direct.is_absolute() {
294                direct.to_path_buf()
295            } else {
296                base_dir.unwrap_or(Path::new(".")).join(direct)
297            };
298            if let Ok(data) = std::fs::read(&path) {
299                found = Some(data);
300                break;
301            }
302        }
303        found?
304    };
305    let mime = match bytes.get(..3) {
306        Some([0x89, b'P', b'N']) => "image/png",
307        Some([0xFF, 0xD8, _]) => "image/jpeg",
308        _ => return None,
309    };
310    Some(TextureAsset {
311        bytes,
312        mime: mime.into(),
313    })
314}
315
316fn extract_assets(scene: &ufbx::Scene, base_dir: Option<&Path>) -> SceneAssets {
317    let mut assets = SceneAssets::default();
318    let mut material_index: std::collections::BTreeMap<u32, usize> =
319        std::collections::BTreeMap::new();
320
321    for node in &scene.nodes {
322        let Some(mesh) = &node.mesh else { continue };
323        let node_id = node.element.typed_id as usize;
324
325        // Materials referenced by this mesh, deduped globally by id.
326        let local_materials: Vec<usize> = mesh
327            .materials
328            .iter()
329            .map(|m| {
330                *material_index
331                    .entry(m.element.element_id)
332                    .or_insert_with(|| {
333                        let base = if m.pbr.base_color.has_value {
334                            m.pbr.base_color.value_vec4
335                        } else {
336                            m.fbx.diffuse_color.value_vec4
337                        };
338                        let texture = base_color_texture(m, base_dir);
339                        assets.materials.push(MaterialAsset {
340                            name: m.element.name.to_string(),
341                            // Exporter convention: a texture replaces
342                            // the factor (they multiply in glTF).
343                            base_color: if texture.is_some() {
344                                [1.0, 1.0, 1.0, 1.0]
345                            } else {
346                                [base.x as f32, base.y as f32, base.z as f32, base.w as f32]
347                            },
348                            metallic: if m.pbr.metalness.has_value {
349                                m.pbr.metalness.value_vec4.x as f32
350                            } else {
351                                0.0
352                            },
353                            roughness: if m.pbr.roughness.has_value {
354                                m.pbr.roughness.value_vec4.x as f32
355                            } else {
356                                1.0
357                            },
358                            base_color_texture: texture,
359                        });
360                        assets.materials.len() - 1
361                    })
362            })
363            .collect();
364
365        // Per-vertex skin influences (top 4, renormalized), cluster
366        // order defines the joint list.
367        let skin = mesh.skin_deformers.first();
368        let skin_joints: Vec<usize> = skin
369            .map(|s| {
370                s.clusters
371                    .iter()
372                    .map(|c| {
373                        c.bone_node
374                            .as_ref()
375                            .map(|b| b.element.typed_id as usize)
376                            .unwrap_or(0)
377                    })
378                    .collect()
379            })
380            .unwrap_or_default();
381        // glTF inverse bind per joint: bind-world⁻¹ × geometry-to-world,
382        // both already in ufbx's converted (metres, Y-up) space —
383        // `geometry_to_bone` is raw source units and NOT suitable.
384        let skin_ibms: Vec<glam::Mat4> = skin
385            .map(|s| {
386                s.clusters
387                    .iter()
388                    .map(|c| mat4(&c.bind_to_world).inverse() * mat4(&c.geometry_to_world))
389                    .collect()
390            })
391            .unwrap_or_default();
392        let vertex_influences: Vec<([u16; 4], [f32; 4])> = skin
393            .map(|s| {
394                (0..mesh.num_vertices)
395                    .map(|v| {
396                        let mut pairs: Vec<(u16, f32)> = Vec::new();
397                        if let Some(sv) = s.vertices.get(v) {
398                            for w in 0..sv.num_weights as usize {
399                                let sw = &s.weights[sv.weight_begin as usize + w];
400                                pairs.push((sw.cluster_index as u16, sw.weight as f32));
401                            }
402                        }
403                        pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
404                        pairs.truncate(4);
405                        let total: f32 = pairs.iter().map(|p| p.1).sum();
406                        let mut joints = [0u16; 4];
407                        let mut weights = [0f32; 4];
408                        for (slot, (j, w)) in pairs.into_iter().enumerate() {
409                            joints[slot] = j;
410                            weights[slot] = if total > 0.0 { w / total } else { 0.0 };
411                        }
412                        (joints, weights)
413                    })
414                    .collect()
415            })
416            .unwrap_or_default();
417
418        // One primitive per material slot (unindexed corners).
419        let slots = local_materials.len().max(1);
420        let mut primitives: Vec<Primitive> = (0..slots)
421            .map(|slot| Primitive {
422                material: local_materials.get(slot).copied(),
423                ..Primitive::default()
424            })
425            .collect();
426
427        let mut tri_indices = vec![0u32; mesh.max_face_triangles * 3];
428        for (face_index, &face) in mesh.faces.iter().enumerate() {
429            let slot = mesh
430                .face_material
431                .get(face_index)
432                .map(|&m| m as usize)
433                .filter(|&m| m < slots)
434                .unwrap_or(0);
435            let prim = &mut primitives[slot];
436            let tris = mesh.triangulate_face(&mut tri_indices, face) as usize;
437            for &corner in &tri_indices[..tris * 3] {
438                let corner = corner as usize;
439                let p = mesh.vertex_position[corner];
440                prim.positions
441                    .push(Vec3::new(p.x as f32, p.y as f32, p.z as f32));
442                if mesh.vertex_normal.exists {
443                    let n = mesh.vertex_normal[corner];
444                    prim.normals
445                        .push(Vec3::new(n.x as f32, n.y as f32, n.z as f32));
446                }
447                if mesh.vertex_uv.exists {
448                    let uv = mesh.vertex_uv[corner];
449                    // glTF's texcoord origin is top-left; FBX's is
450                    // bottom-left.
451                    prim.uvs.push([uv.x as f32, 1.0 - uv.y as f32]);
452                }
453                if !vertex_influences.is_empty() {
454                    let vertex = mesh.vertex_indices[corner] as usize;
455                    let (joints, weights) = vertex_influences[vertex];
456                    prim.joints.push(joints);
457                    prim.weights.push(weights);
458                }
459            }
460        }
461        primitives.retain(|p| !p.positions.is_empty());
462        for prim in &mut primitives {
463            prim.weld();
464        }
465        if primitives.is_empty() {
466            continue;
467        }
468        assets.meshes.push(MeshAsset {
469            name: mesh.element.name.to_string(),
470            node: node_id,
471            primitives,
472            skin_joints,
473            skin_ibms,
474        });
475    }
476    assets
477}