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//! [`load_scale_source`] and [`load_scale_source_bytes`] retain a typed
16//! [`FbxScaleCapabilityInventory`] from the same parse. It gives every current
17//! Appendix D.4 domain an explicit status and records baked curves, normalized
18//! transforms, derived binds, truncated/renormalized influences,
19//! triangulation, welding, generated data, and unavailable raw-span proof.
20//! [`capability_facts`] projects that explicit inventory into core facts, but
21//! the result remains unsupported: both scale operations are intentionally
22//! refused until a later FBX writer and proof boundary exists.
23//!
24//! # Quick start
25//!
26//! ```no_run
27//! fn lint_fbx(
28//!     path: &std::path::Path,
29//! ) -> Result<Vec<animsmith_core::Finding>, Box<dyn std::error::Error>> {
30//!     let doc = animsmith_fbx::load(path)?;
31//!     let roles = animsmith_core::detect_profile(&doc.skeleton).unwrap_or_default();
32//!     let config = animsmith_core::Config::default();
33//!     let grids = animsmith_core::MetricGrids::new(&doc);
34//!     let ctx = animsmith_core::CheckCtx::new(&grids, &roles, &config);
35//!     let results = animsmith_core::evaluate_checks(
36//!         &ctx,
37//!         &animsmith_core::all_checks(),
38//!         animsmith_core::CheckSelection::All,
39//!     )?;
40//!     Ok(results
41//!         .into_iter()
42//!         .flat_map(|check| check.findings().to_vec())
43//!         .collect())
44//! }
45//! ```
46//!
47//! # Build and API status
48//!
49//! The library crate has no public feature flags and supports the workspace
50//! MSRV, Rust 1.88. It includes the bundled `ufbx` C build. Its Rust API is
51//! pre-1.0; see `animsmith-core`'s crate-level API status for the shared
52//! stability boundary.
53//!
54//! See the GitHub [embedding guide] for crate selection and the [pipeline
55//! scenario guide] for FBX intake and conversion workflows.
56//!
57//! [embedding guide]: https://github.com/mmannerm/animsmith/blob/main/docs/embedding.md
58//! [pipeline scenario guide]: https://github.com/mmannerm/animsmith/blob/main/docs/pipeline-scenarios.md
59//!
60#![warn(missing_docs)]
61
62mod capability;
63
64pub use capability::{
65    FbxBindMatrixProvenance, FbxCoordinateAxis, FbxCoordinateNormalization,
66    FbxScaleCapabilityInventory, FbxScaleDomainInventory, FbxScaleDomainStatus, FbxScaleSource,
67    FbxSourceIdentity, capability_facts,
68};
69
70use animsmith_core::model::{
71    Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, MeshInstance,
72    NormalTextureAsset, Primitive, Property, SceneAsset, SceneAssets, Skeleton, SourceInfo,
73    SourceInverseBindAccessor, SourceInverseBindAccessorStatus, SourceNodeAsset,
74    SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage, SourceSkinAsset,
75    SourceSkinAttachment, TextureAsset, Track, TrackValues, Transform,
76};
77use capability::AssetConversionFacts;
78use glam::{Mat4, Quat, Vec3};
79use std::path::Path;
80
81/// Errors returned while loading an FBX scene into the core model.
82///
83/// These errors describe input or parser failures. They do not represent
84/// animation check findings; once a [`Document`] loads, semantic problems
85/// are reported by `animsmith-core` checks instead.
86#[derive(Debug, thiserror::Error)]
87#[non_exhaustive]
88pub enum LoadError {
89    /// The input path could not be represented as UTF-8 for `ufbx`.
90    #[error("path is not valid UTF-8: {0}")]
91    Path(String),
92    /// `ufbx` rejected or could not parse the file.
93    #[error("FBX parse error: {0}")]
94    Fbx(String),
95    /// `ufbx` loaded the scene but failed while baking an animation take.
96    #[error("animation bake failed for take {take:?}: {message}")]
97    Bake {
98        /// Name of the animation take that failed to bake.
99        take: String,
100        /// Parser-provided bake failure detail.
101        message: String,
102    },
103}
104
105fn vec3(v: ufbx::Vec3) -> Vec3 {
106    Vec3::new(v.x as f32, v.y as f32, v.z as f32)
107}
108
109fn quat(q: ufbx::Quat) -> Quat {
110    Quat::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32)
111}
112
113fn transform(t: &ufbx::Transform) -> Transform {
114    Transform {
115        translation: vec3(t.translation),
116        rotation: quat(t.rotation),
117        scale: vec3(t.scale),
118    }
119}
120
121/// ufbx matrices are 3×4 (rotation/scale columns + translation).
122fn mat4(m: &ufbx::Matrix) -> Mat4 {
123    Mat4::from_cols_array(&[
124        m.m00 as f32,
125        m.m10 as f32,
126        m.m20 as f32,
127        0.0,
128        m.m01 as f32,
129        m.m11 as f32,
130        m.m21 as f32,
131        0.0,
132        m.m02 as f32,
133        m.m12 as f32,
134        m.m22 as f32,
135        0.0,
136        m.m03 as f32,
137        m.m13 as f32,
138        m.m23 as f32,
139        1.0,
140    ])
141}
142
143/// Project one converted FBX cluster bind only when the complete derivation
144/// is finite. `Mat4::inverse()` returns non-finite components for a singular
145/// finite input, so checking the two inputs alone is not sufficient evidence.
146fn project_cluster_bind(cluster: &ufbx::SkinCluster) -> Option<(Mat4, Mat4)> {
147    cluster.bone_node.as_ref()?;
148    let bind_to_world = mat4(&cluster.bind_to_world);
149    let geometry_to_world = mat4(&cluster.geometry_to_world);
150    if !bind_to_world.is_finite() || !geometry_to_world.is_finite() {
151        return None;
152    }
153    let bone_inverse = bind_to_world.inverse();
154    let instance_inverse = bone_inverse * geometry_to_world;
155    (bone_inverse.is_finite() && instance_inverse.is_finite())
156        .then_some((bone_inverse, instance_inverse))
157}
158
159/// Load an `.fbx` file into a core [`Document`]: skeleton, animation,
160/// and scene assets (triangulated meshes, skins, factor-only
161/// materials). Consumers that only judge animation ignore
162/// [`Document::assets`].
163///
164/// # Errors
165///
166/// Returns [`LoadError::Path`] when the path cannot be passed to `ufbx`,
167/// [`LoadError::Fbx`] when the FBX container cannot be parsed, and
168/// [`LoadError::Bake`] when an animation stack cannot be baked into the
169/// linear TRS tracks that animsmith's checks consume.
170pub fn load(path: &Path) -> Result<Document, LoadError> {
171    Ok(load_scale_source(path)?.into_document())
172}
173
174/// Load an `.fbx` file and retain its conservative scale capability inventory.
175///
176/// The returned source is inventory-only: neither scale operation is enabled
177/// for FBX by this API.
178///
179/// # Errors
180///
181/// Returns [`LoadError::Path`] when the path cannot be passed to `ufbx`,
182/// [`LoadError::Fbx`] when the FBX container cannot be parsed, and
183/// [`LoadError::Bake`] when an animation take cannot be baked.
184pub fn load_scale_source(path: &Path) -> Result<FbxScaleSource, LoadError> {
185    path.to_str()
186        .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
187    let bytes = std::fs::read(path).map_err(|error| LoadError::Fbx(error.to_string()))?;
188    load_scale_source_bytes(path, &bytes)
189}
190
191/// Load an FBX byte slice into a core [`Document`].
192///
193/// `bytes` supplies the top-level container exactly as captured by the
194/// caller. `path` is retained for source provenance, diagnostics, and
195/// resolving external resources relative to its parent directory.
196///
197/// # Errors
198///
199/// Returns [`LoadError::Path`] when `path` cannot be passed to `ufbx`,
200/// [`LoadError::Fbx`] when the FBX container cannot be parsed, and
201/// [`LoadError::Bake`] when an animation stack cannot be baked into the
202/// linear TRS tracks that animsmith's checks consume.
203pub fn load_bytes(path: &Path, bytes: &[u8]) -> Result<Document, LoadError> {
204    Ok(load_scale_source_bytes(path, bytes)?.into_document())
205}
206
207/// Load captured FBX bytes and retain the capability inventory from the same parse.
208///
209/// `path` supplies source provenance and the base for external resources;
210/// `bytes` is the exact captured top-level FBX container.
211///
212/// # Errors
213///
214/// Returns [`LoadError::Path`] when `path` cannot be passed to `ufbx`,
215/// [`LoadError::Fbx`] when the FBX container cannot be parsed, and
216/// [`LoadError::Bake`] when an animation take cannot be baked.
217pub fn load_scale_source_bytes(path: &Path, bytes: &[u8]) -> Result<FbxScaleSource, LoadError> {
218    let filename = path
219        .to_str()
220        .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
221    let opts = ufbx::LoadOpts {
222        target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
223        target_unit_meters: 1.0,
224        space_conversion: ufbx::SpaceConversion::AdjustTransforms,
225        geometry_transform_handling: ufbx::GeometryTransformHandling::HelperNodes,
226        // FBX scale-compensation inheritance (Maya-style; ubiquitous in
227        // Mixamo rigs, every bone carrying scale 0.01) cannot be
228        // represented by plain TRS hierarchies like glTF's — ufbx
229        // compensates the transforms (with helper nodes as fallback)
230        // so standard composition is correct.
231        inherit_mode_handling: ufbx::InheritModeHandling::Compensate,
232        generate_missing_normals: true,
233        filename: filename.into(),
234        ..Default::default()
235    };
236    let scene = ufbx::load_memory(bytes, opts).map_err(|e| LoadError::Fbx(format!("{e:?}")))?;
237
238    // Every node becomes a bone (the ufbx root included — it carries
239    // the axis/unit adjustment). scene.nodes is ordered parents-first,
240    // matching the skeleton invariant; typed_id indexes scene.nodes
241    // directly.
242    let mut bones: Vec<Bone> = Vec::with_capacity(scene.nodes.len());
243    for node in &scene.nodes {
244        let name = if node.element.name.is_empty() {
245            if node.is_root {
246                "<fbx-root>".to_string()
247            } else {
248                format!("node{}", node.element.typed_id)
249            }
250        } else {
251            node.element.name.to_string()
252        };
253        bones.push(Bone {
254            name,
255            parent: node.parent.as_ref().map(|p| p.element.typed_id as usize),
256            rest: transform(&node.local_transform),
257            inverse_bind: None,
258        });
259    }
260    for cluster in &scene.skin_clusters {
261        if let (Some(bone_node), Some((bone_inverse, _))) =
262            (&cluster.bone_node, project_cluster_bind(cluster))
263        {
264            let id = bone_node.element.typed_id as usize;
265            if id < bones.len() {
266                // Joint-centric bind inverse in the converted scene
267                // space; the mesh-dependent part lives per mesh in
268                // `MeshAsset::skin_ibms`.
269                bones[id].inverse_bind = Some(bone_inverse);
270            }
271        }
272    }
273
274    let mut clips = Vec::new();
275    for (index, stack) in scene.anim_stacks.iter().enumerate() {
276        let take = if stack.element.name.is_empty() {
277            format!("take{index}")
278        } else {
279            stack.element.name.to_string()
280        };
281        let baked = ufbx::bake_anim(
282            &scene,
283            &stack.anim,
284            ufbx::BakeOpts {
285                trim_start_time: true,
286                ..Default::default()
287            },
288        )
289        .map_err(|e| LoadError::Bake {
290            take: take.clone(),
291            message: format!("{e:?}"),
292        })?;
293
294        let mut tracks = Vec::new();
295        let mut duration = 0.0f64;
296        for node in &baked.nodes {
297            let bone = node.typed_id as usize;
298            if !node.translation_keys.is_empty() {
299                let times: Vec<f32> = node
300                    .translation_keys
301                    .iter()
302                    .map(|k| k.time as f32)
303                    .collect();
304                let values: Vec<Vec3> = node
305                    .translation_keys
306                    .iter()
307                    .map(|k| vec3(k.value))
308                    .collect();
309                duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
310                tracks.push(Track {
311                    bone,
312                    property: Property::Translation,
313                    interpolation: Interpolation::Linear,
314                    times,
315                    values: TrackValues::Vec3s(values),
316                });
317            }
318            if !node.rotation_keys.is_empty() {
319                let times: Vec<f32> = node.rotation_keys.iter().map(|k| k.time as f32).collect();
320                let values: Vec<Quat> = node.rotation_keys.iter().map(|k| quat(k.value)).collect();
321                duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
322                tracks.push(Track {
323                    bone,
324                    property: Property::Rotation,
325                    interpolation: Interpolation::Linear,
326                    times,
327                    values: TrackValues::Quats(values),
328                });
329            }
330            if !node.scale_keys.is_empty() {
331                let times: Vec<f32> = node.scale_keys.iter().map(|k| k.time as f32).collect();
332                let values: Vec<Vec3> = node.scale_keys.iter().map(|k| vec3(k.value)).collect();
333                duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
334                tracks.push(Track {
335                    bone,
336                    property: Property::Scale,
337                    interpolation: Interpolation::Linear,
338                    times,
339                    values: TrackValues::Vec3s(values),
340                });
341            }
342        }
343        clips.push(Clip {
344            name: take,
345            duration_s: duration,
346            tracks,
347        });
348    }
349
350    let (assets, conversion) = extract_assets(&scene, path.parent());
351    let inventory = capability::inventory(&scene, &conversion);
352
353    Ok(FbxScaleSource {
354        document: Document {
355            skeleton: Skeleton { bones },
356            clips,
357            assets,
358            source: SourceInfo {
359                path: Some(path.display().to_string()),
360                format: Some("fbx".into()),
361            },
362        },
363        inventory,
364    })
365}
366
367/// Read one ufbx texture: embedded FBX content first, else a referenced file
368/// next to the source. Only PNG/JPEG pass through (glTF's mandated formats).
369fn texture_asset(texture: &ufbx::Texture, base_dir: Option<&Path>) -> Option<TextureAsset> {
370    let bytes: Vec<u8> = if !texture.content.is_empty() {
371        texture.content.to_vec()
372    } else {
373        let mut found = None;
374        for candidate in [
375            texture.absolute_filename.as_ref(),
376            texture.relative_filename.as_ref(),
377            texture.filename.as_ref(),
378        ] {
379            if candidate.is_empty() {
380                continue;
381            }
382            let direct = Path::new(candidate);
383            let path = if direct.is_absolute() {
384                direct.to_path_buf()
385            } else {
386                base_dir.unwrap_or(Path::new(".")).join(direct)
387            };
388            if let Ok(data) = std::fs::read(&path) {
389                found = Some(data);
390                break;
391            }
392        }
393        found?
394    };
395    let mime = match bytes.get(..3) {
396        Some([0x89, b'P', b'N']) => "image/png",
397        Some([0xFF, 0xD8, _]) => "image/jpeg",
398        _ => return None,
399    };
400    Some(TextureAsset {
401        bytes,
402        mime: mime.into(),
403    })
404}
405
406fn base_color_texture(material: &ufbx::Material, base_dir: Option<&Path>) -> Option<TextureAsset> {
407    let texture = material.pbr.base_color.texture.as_ref().or(material
408        .fbx
409        .diffuse_color
410        .texture
411        .as_ref())?;
412    texture_asset(texture, base_dir)
413}
414
415fn normal_texture(
416    material: &ufbx::Material,
417    base_dir: Option<&Path>,
418) -> Option<NormalTextureAsset> {
419    let texture = material.pbr.normal_map.texture.as_ref().or(material
420        .fbx
421        .normal_map
422        .texture
423        .as_ref())?;
424    texture_asset(texture, base_dir).map(|texture| NormalTextureAsset {
425        texture,
426        // ufbx exposes the linked image but no glTF-compatible normal X/Y
427        // scalar for ordinary FBX materials. Preserve the image and use the
428        // glTF default rather than guessing from unrelated bump fields.
429        scale: 1.0,
430    })
431}
432
433#[derive(Debug, Clone, Copy, PartialEq)]
434enum ProjectedInfluence {
435    Absent,
436    Retained(u16, f32),
437    Rejected,
438}
439
440fn project_skin_influence(
441    source_weight: f64,
442    cluster_index: Option<usize>,
443    cluster_count: usize,
444    cluster_has_bone: bool,
445) -> ProjectedInfluence {
446    let weight = source_weight as f32;
447    if !source_weight.is_finite()
448        || source_weight < 0.0
449        || !weight.is_finite()
450        || (source_weight > 0.0 && weight == 0.0)
451    {
452        return ProjectedInfluence::Rejected;
453    }
454    if weight == 0.0 {
455        return ProjectedInfluence::Absent;
456    }
457    let Some(cluster_index) = cluster_index else {
458        return ProjectedInfluence::Rejected;
459    };
460    if cluster_index >= cluster_count || !cluster_has_bone {
461        return ProjectedInfluence::Rejected;
462    }
463    match u16::try_from(cluster_index) {
464        Ok(index) => ProjectedInfluence::Retained(index, weight),
465        Err(_) => ProjectedInfluence::Rejected,
466    }
467}
468
469/// Project every normalized ufbx node and skin deformer in stable typed-list
470/// order. These are source-side identities after the documented coordinate,
471/// helper-node, and inherit-mode normalization; they are not raw FBX object
472/// transforms.
473fn extract_source_skeleton(scene: &ufbx::Scene) -> SourceSkeletonAssets {
474    let nodes = scene
475        .nodes
476        .iter()
477        .map(|node| {
478            let mut source = SourceNodeAsset::new(
479                node.element.typed_id as usize,
480                SourceNodeLocalRest::Trs {
481                    translation: vec3(node.local_transform.translation),
482                    rotation: quat(node.local_transform.rotation),
483                    scale: vec3(node.local_transform.scale),
484                },
485            );
486            source.name = (!node.element.name.is_empty()).then(|| node.element.name.to_string());
487            source.parent_source_node_index = node
488                .parent
489                .as_ref()
490                .map(|parent| parent.element.typed_id as usize);
491            source.scene_root_indices = if node.is_root { vec![0] } else { Vec::new() };
492            source.bone = Some(node.element.typed_id as usize);
493            source
494        })
495        .collect();
496
497    // A missing cluster bone removes a declared joint slot from the current
498    // format-neutral shape: SourceSkinAsset has no optional/invalid joint-row
499    // representation. Do not filter that slot and still claim complete source
500    // coverage. The capability inventory retains the exact incomplete-cluster
501    // count while the generic sidecar fails closed as globally unavailable.
502    if scene.skin_clusters.iter().any(|cluster| {
503        cluster.bone_node.as_ref().is_none_or(|bone| {
504            usize::try_from(bone.element.typed_id)
505                .ok()
506                .is_none_or(|index| index >= scene.nodes.len())
507        })
508    }) {
509        return SourceSkeletonAssets::default();
510    }
511
512    let mut attachments = vec![Vec::new(); scene.skin_deformers.len()];
513    for node in &scene.nodes {
514        let Some(mesh) = &node.mesh else { continue };
515        for skin in &mesh.skin_deformers {
516            let Some(for_skin) = attachments.get_mut(skin.element.typed_id as usize) else {
517                return SourceSkeletonAssets::default();
518            };
519            for_skin.push(SourceSkinAttachment {
520                source_node_index: node.element.typed_id as usize,
521                source_mesh_index: Some(mesh.element.typed_id as usize),
522            });
523        }
524    }
525
526    let skins = scene
527        .skin_deformers
528        .iter()
529        .map(|skin| {
530            let source_skin_index = skin.element.typed_id as usize;
531            let projected_matrices = skin
532                .clusters
533                .iter()
534                .map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
535                .collect::<Option<Vec<_>>>();
536            let (status, matrices) = match (skin.clusters.is_empty(), projected_matrices) {
537                (true, _) => (SourceInverseBindAccessorStatus::Absent, Vec::new()),
538                (false, Some(matrices)) => (SourceInverseBindAccessorStatus::Available, matrices),
539                // Unreadable is declaration-wide because the generic shape
540                // cannot retain a hole without shifting later joint slots.
541                (false, None) => (SourceInverseBindAccessorStatus::Unreadable, Vec::new()),
542            };
543            SourceSkinAsset {
544                source_skin_index,
545                name: (!skin.element.name.is_empty()).then(|| skin.element.name.to_string()),
546                // FBX skin deformers do not carry a glTF-style explicit
547                // skeleton-root declaration. Do not infer one.
548                skeleton_root_source_node_index: None,
549                joint_source_node_indices: skin
550                    .clusters
551                    .iter()
552                    .filter_map(|cluster| {
553                        cluster
554                            .bone_node
555                            .as_ref()
556                            .map(|node| node.element.typed_id as usize)
557                    })
558                    .collect(),
559                inverse_bind_accessor: SourceInverseBindAccessor {
560                    status,
561                    declared_count: (!skin.clusters.is_empty()).then_some(skin.clusters.len()),
562                    matrices,
563                },
564                attachments: attachments
565                    .get_mut(source_skin_index)
566                    .map(std::mem::take)
567                    .unwrap_or_default(),
568            }
569        })
570        .collect();
571
572    SourceSkeletonAssets {
573        coverage: SourceSkeletonCoverage::Complete,
574        nodes,
575        skins,
576    }
577}
578
579/// Extract triangulated geometry, skins, and factor-only materials with
580/// optional base-color and normal textures. Corner attributes come straight
581/// from ufbx's indexed accessors; skin weights keep the top four influences
582/// per source vertex and are renormalized.
583fn extract_assets(
584    scene: &ufbx::Scene,
585    base_dir: Option<&Path>,
586) -> (SceneAssets, AssetConversionFacts) {
587    let mut assets = SceneAssets::default();
588    let mut conversion = AssetConversionFacts::default();
589    let mut material_index: std::collections::BTreeMap<u32, usize> =
590        std::collections::BTreeMap::new();
591    let mut normalized_mesh_index_by_source = std::collections::BTreeMap::<u32, usize>::new();
592
593    for (source_node_index, node) in scene.nodes.iter().enumerate() {
594        let Some(mesh) = &node.mesh else { continue };
595        let node_id = node.element.typed_id as usize;
596
597        // Materials referenced by this mesh, deduped globally by id.
598        let local_materials: Vec<usize> = mesh
599            .materials
600            .iter()
601            .map(|m| {
602                *material_index
603                    .entry(m.element.element_id)
604                    .or_insert_with(|| {
605                        let base = if m.pbr.base_color.has_value {
606                            m.pbr.base_color.value_vec4
607                        } else {
608                            m.fbx.diffuse_color.value_vec4
609                        };
610                        let texture = base_color_texture(m, base_dir);
611                        let normal_texture = normal_texture(m, base_dir);
612                        assets.materials.push(MaterialAsset {
613                            name: m.element.name.to_string(),
614                            // Exporter convention: a texture replaces
615                            // the factor (they multiply in glTF).
616                            base_color: if texture.is_some() {
617                                [1.0, 1.0, 1.0, 1.0]
618                            } else {
619                                [base.x as f32, base.y as f32, base.z as f32, base.w as f32]
620                            },
621                            metallic: if m.pbr.metalness.has_value {
622                                m.pbr.metalness.value_vec4.x as f32
623                            } else {
624                                0.0
625                            },
626                            roughness: if m.pbr.roughness.has_value {
627                                m.pbr.roughness.value_vec4.x as f32
628                            } else {
629                                1.0
630                            },
631                            base_color_texture: texture,
632                            normal_texture,
633                            metallic_roughness_texture: None,
634                            occlusion_texture: None,
635                        });
636                        assets.materials.len() - 1
637                    })
638            })
639            .collect();
640
641        // Per-vertex skin influences (top 4, renormalized), cluster
642        // order defines the joint list.
643        let skin = mesh.skin_deformers.first();
644        let skin_joints: Vec<usize> = skin
645            .map(|s| {
646                s.clusters
647                    .iter()
648                    .map(|c| {
649                        c.bone_node
650                            .as_ref()
651                            .map(|b| b.element.typed_id as usize)
652                            .unwrap_or(0)
653                    })
654                    .collect()
655            })
656            .unwrap_or_default();
657        // glTF inverse bind per joint: bind-world⁻¹ × geometry-to-world,
658        // both already in ufbx's converted (metres, Y-up) space —
659        // `geometry_to_bone` is raw source units and NOT suitable.
660        let skin_ibms: Vec<glam::Mat4> = skin
661            .and_then(|s| {
662                s.clusters
663                    .iter()
664                    .map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
665                    .collect::<Option<Vec<_>>>()
666            })
667            .unwrap_or_default();
668        if let Some(&normalized_mesh_index) =
669            normalized_mesh_index_by_source.get(&mesh.element.typed_id)
670        {
671            assets.instances.push(MeshInstance {
672                source_node_index,
673                node: node_id,
674                mesh: normalized_mesh_index,
675                skin_joints,
676                skin_ibms,
677            });
678            continue;
679        }
680        let vertex_influences: Vec<Option<([u16; 4], [f32; 4])>> = skin
681            .map(|s| {
682                (0..mesh.num_vertices)
683                    .map(|v| {
684                        let mut pairs: Vec<(u16, f32)> = Vec::new();
685                        if let Some(sv) = s.vertices.get(v) {
686                            let begin = sv.weight_begin as usize;
687                            let end = begin.saturating_add(sv.num_weights as usize);
688                            for sw in s.weights.get(begin..end).unwrap_or_default() {
689                                let source_weight = sw.weight;
690                                let cluster_index = usize::try_from(sw.cluster_index).ok();
691                                let cluster_has_bone = cluster_index
692                                    .and_then(|index| s.clusters.get(index))
693                                    .is_some_and(|cluster| cluster.bone_node.is_some());
694                                match project_skin_influence(
695                                    source_weight,
696                                    cluster_index,
697                                    s.clusters.len(),
698                                    cluster_has_bone,
699                                ) {
700                                    ProjectedInfluence::Absent => {}
701                                    ProjectedInfluence::Retained(index, weight) => {
702                                        pairs.push((index, weight));
703                                    }
704                                    ProjectedInfluence::Rejected => {
705                                        conversion.rejected_influence_count += 1;
706                                    }
707                                }
708                            }
709                        }
710                        pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
711                        if pairs.len() > 4 {
712                            conversion.truncated_influence_vertex_count += 1;
713                            conversion.discarded_influence_count += pairs.len() - 4;
714                        }
715                        pairs.truncate(4);
716                        let total: f32 = pairs.iter().map(|p| p.1).sum();
717                        if pairs.is_empty() || !total.is_finite() || total <= 0.0 {
718                            return None;
719                        }
720                        let mut joints = [0u16; 4];
721                        let mut weights = [0f32; 4];
722                        let mut renormalized = false;
723                        for (slot, (j, w)) in pairs.into_iter().enumerate() {
724                            joints[slot] = j;
725                            let normalized = if total > 0.0 { w / total } else { 0.0 };
726                            renormalized |= normalized.to_bits() != w.to_bits();
727                            weights[slot] = normalized;
728                        }
729                        if renormalized {
730                            conversion.renormalized_influence_vertex_count += 1;
731                        }
732                        Some((joints, weights))
733                    })
734                    .collect()
735            })
736            .unwrap_or_default();
737
738        // One primitive per material slot (unindexed corners).
739        let slots = local_materials.len().max(1);
740        let mut primitives: Vec<Primitive> = (0..slots)
741            .map(|slot| Primitive {
742                material: local_materials.get(slot).copied(),
743                ..Primitive::default()
744            })
745            .collect();
746
747        let mut tri_indices = vec![0u32; mesh.max_face_triangles * 3];
748        for (face_index, &face) in mesh.faces.iter().enumerate() {
749            let slot = mesh
750                .face_material
751                .get(face_index)
752                .map(|&m| m as usize)
753                .filter(|&m| m < slots)
754                .unwrap_or(0);
755            let prim = &mut primitives[slot];
756            let tris = mesh.triangulate_face(&mut tri_indices, face) as usize;
757            for &corner in &tri_indices[..tris * 3] {
758                let corner = corner as usize;
759                let p = mesh.vertex_position[corner];
760                prim.positions
761                    .push(Vec3::new(p.x as f32, p.y as f32, p.z as f32));
762                if mesh.vertex_normal.exists {
763                    let n = mesh.vertex_normal[corner];
764                    prim.normals
765                        .push(Vec3::new(n.x as f32, n.y as f32, n.z as f32));
766                }
767                if mesh.vertex_uv.exists {
768                    let uv = mesh.vertex_uv[corner];
769                    // glTF's texcoord origin is top-left; FBX's is
770                    // bottom-left.
771                    prim.uvs.push([uv.x as f32, 1.0 - uv.y as f32]);
772                }
773                if !vertex_influences.is_empty() {
774                    let vertex = mesh.vertex_indices[corner] as usize;
775                    let (joints, weights) = vertex_influences
776                        .get(vertex)
777                        .copied()
778                        .flatten()
779                        .unwrap_or_else(|| {
780                            conversion.missing_skin_influence_corner_count += 1;
781                            ([0; 4], [0.0; 4])
782                        });
783                    prim.joints.push(joints);
784                    prim.weights.push(weights);
785                }
786            }
787        }
788        primitives.retain(|p| !p.positions.is_empty());
789        for prim in &mut primitives {
790            conversion.pre_weld_vertex_count += prim.positions.len();
791            prim.weld();
792            conversion.post_weld_vertex_count += prim.positions.len();
793        }
794        if primitives.is_empty() {
795            continue;
796        }
797        let normalized_mesh_index = assets.meshes.len();
798        let source_mesh_index = mesh.element.typed_id as usize;
799        normalized_mesh_index_by_source.insert(mesh.element.typed_id, normalized_mesh_index);
800        assets.meshes.push(MeshAsset {
801            name: mesh.element.name.to_string(),
802            // Retain the stable ufbx mesh identity even when an earlier
803            // source definition emitted no normalized primitive. The compact
804            // normalized vector index is owned independently by MeshInstance.
805            source_mesh_index,
806            primitives,
807        });
808        assets.instances.push(MeshInstance {
809            source_node_index,
810            node: node_id,
811            mesh: normalized_mesh_index,
812            skin_joints,
813            skin_ibms,
814        });
815    }
816    assets.scenes.push(SceneAsset {
817        source_scene_index: 0,
818        name: None,
819        roots: scene
820            .nodes
821            .iter()
822            .filter(|node| node.is_root)
823            .map(|node| node.element.typed_id as usize)
824            .collect(),
825    });
826    assets.default_scene = Some(0);
827    assets.source_skeleton = extract_source_skeleton(scene);
828    (assets, conversion)
829}
830
831#[cfg(test)]
832mod tests {
833    use super::{ProjectedInfluence, project_skin_influence};
834
835    #[test]
836    fn influence_projection_checks_sign_range_and_u16_cluster_narrowing() {
837        assert_eq!(
838            project_skin_influence(0.0, Some(0), 1, true),
839            ProjectedInfluence::Absent
840        );
841        assert_eq!(
842            project_skin_influence(-0.25, Some(0), 1, true),
843            ProjectedInfluence::Rejected
844        );
845        assert_eq!(
846            project_skin_influence(1.0, Some(1), 1, true),
847            ProjectedInfluence::Rejected,
848            "a source cluster index outside the declaration must not survive"
849        );
850        assert_eq!(
851            project_skin_influence(
852                1.0,
853                Some(usize::from(u16::MAX) + 1),
854                usize::from(u16::MAX) + 2,
855                true,
856            ),
857            ProjectedInfluence::Rejected,
858            "u32/usize cluster identity must not wrap while narrowing to u16"
859        );
860        assert_eq!(
861            project_skin_influence(0.5, Some(7), 8, true),
862            ProjectedInfluence::Retained(7, 0.5)
863        );
864    }
865}