Skip to main content

animsmith_fbx/
lib.rs

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