Skip to main content

animsmith_fbx/
lib.rs

1//! [`load_source`] and [`load_source_bytes`] retain bounded importer-sensitive
2//! source facts beside the normalized [`animsmith_core::Document`] produced
3//! from the same exact FBX bytes. [`load`] and [`load_bytes`] remain the
4//! document-only compatibility APIs and deliberately discard that immutable
5//! sidecar. Parser and projection errors are normalized into [`LoadError`].
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//! The source-facts boundary is deliberately narrower than raw FBX bytes.
16//! Effective units, signed axes, FPS, take ranges, layer/property bindings,
17//! and component-curve presence are parser-projected from ufbx. Advisory
18//! `OriginalUnitScaleFactor`/`OriginalUpAxis` values are not substituted for
19//! the effective settings. Because animation clips pass through
20//! `ufbx::bake_anim`, the sidecar never claims authored interpolation, keys,
21//! or tangents from baked tracks. Resource rows retain bounded relative
22//! declarations only; unsafe spellings are classified and redacted, and no
23//! dependency is opened solely to build source facts.
24//!
25//! [`load_scale_source`] and [`load_scale_source_bytes`] retain a typed
26//! [`FbxScaleCapabilityInventory`] from the same parse. It gives every current
27//! Appendix D.4 domain an explicit status and records baked curves, normalized
28//! transforms, derived binds, truncated/renormalized influences,
29//! triangulation, welding, generated data, and unavailable raw-span proof.
30//! [`capability_facts`] remains the conservative generic refusal projection.
31//! [`rest_bind_capability_facts`] admits only the complete normalized subset
32//! used by the CLI's narrow FBX rest/bind path: it stages a new GLB, proves
33//! that emitted GLB, and never claims raw FBX preservation. Its source-aware
34//! companion admits material shader metadata, enumerated scale-invariant
35//! conversion-fidelity facts such as triangulation, exact welding, retained
36//! effective skinning, and omitted face/edge payload, and only those BindPoses
37//! whose converted rows reconcile with the cluster/node matrices consumed by
38//! that bridge. [`require_clip_track_capability_for_source`] keeps the same raw
39//! construct and animation/hierarchy checks while projecting away geometry,
40//! deformation, material, camera/light, and bind domains that a clip-only
41//! assembly input cannot contribute. The public inventory still records every
42//! conversion and its inventory-only projection remains conservative.
43//! Whole-document FBX scaling remains refused.
44//!
45//! # Quick start
46//!
47//! ```no_run
48//! fn lint_fbx(
49//!     path: &std::path::Path,
50//! ) -> Result<Vec<animsmith_core::Finding>, Box<dyn std::error::Error>> {
51//!     let doc = animsmith_fbx::load(path)?;
52//!     let roles = animsmith_core::detect_profile(&doc.skeleton).unwrap_or_default();
53//!     let config = animsmith_core::Config::default();
54//!     let grids = animsmith_core::MetricGrids::new(&doc);
55//!     let ctx = animsmith_core::CheckCtx::new(&grids, &roles, &config);
56//!     let results = animsmith_core::evaluate_checks(
57//!         &ctx,
58//!         &animsmith_core::all_checks(),
59//!         animsmith_core::CheckSelection::All,
60//!     )?;
61//!     Ok(results
62//!         .into_iter()
63//!         .flat_map(|check| check.findings().to_vec())
64//!         .collect())
65//! }
66//! ```
67//!
68//! # Build and API status
69//!
70//! The library crate has no public feature flags and supports the workspace
71//! MSRV, Rust 1.88. It includes the bundled `ufbx` C build. Its Rust API is
72//! pre-1.0; see `animsmith-core`'s crate-level API status for the shared
73//! stability boundary.
74//!
75//! See the GitHub [embedding guide] for crate selection and the [pipeline
76//! scenario guide] for FBX intake and conversion workflows.
77//!
78//! [embedding guide]: https://github.com/mmannerm/animsmith/blob/main/docs/embedding.md
79//! [pipeline scenario guide]: https://github.com/mmannerm/animsmith/blob/main/docs/pipeline-scenarios.md
80//!
81#![warn(missing_docs)]
82
83mod capability;
84mod exact_timing;
85mod raw_transform_paths;
86mod source_facts;
87
88/// FBX/KTime constants used by the exact source-timing projection.
89pub use exact_timing::{FBX_KTIME_LEGACY_UNITS_PER_SECOND, FBX_KTIME_STANDARD_UNITS_PER_SECOND};
90
91pub use capability::{
92    FbxBindMatrixProvenance, FbxCoordinateAxis, FbxCoordinateNormalization,
93    FbxScaleCapabilityInventory, FbxScaleDomainInventory, FbxScaleDomainStatus, FbxScaleSource,
94    FbxSourceIdentity, capability_facts, capability_facts_for_source,
95    require_clip_track_capability_for_source, rest_bind_capability_facts,
96    rest_bind_capability_facts_for_source,
97};
98
99use animsmith_core::model::{
100    Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, MeshInstance,
101    NormalTextureAsset, Primitive, Property, SceneAsset, SceneAssets, Skeleton, SourceInfo,
102    SourceInverseBindAccessor, SourceInverseBindAccessorStatus, SourceNodeAsset,
103    SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage, SourceSkinAsset,
104    SourceSkinAttachment, TextureAsset, Track, TrackValues, Transform,
105};
106use animsmith_core::{
107    DependencyClosureBuilderV1, DependencyResourceKeyV1, DependencyResourceRefusalReasonV1,
108    DependencyResourceUnavailableReasonV1, InputIdentity, LoadedSource, RawSourceFactsBuilderV1,
109    ResourceKeySyntaxV1, SourceFactsError, SourceResourceKindV1, SourceResourceLocatorV1,
110    SourceResourceReferenceV1,
111};
112use capability::AssetConversionFacts;
113use glam::{Mat4, Quat, Vec3};
114use std::{
115    collections::BTreeMap,
116    fs::File,
117    io::Read,
118    path::{Path, PathBuf},
119};
120
121/// Independent ceiling for texture bytes retained in normalized assets.
122///
123/// Dependency identity has its own core-owned capture budgets. This cap
124/// prevents multiple material aliases from multiplying retained image bytes.
125const FBX_MAX_ASSET_TEXTURE_BYTES: usize = 256 * 1024 * 1024;
126
127/// Errors returned while loading an FBX scene into the core model.
128///
129/// These errors describe input or parser failures. They do not represent
130/// animation check findings; once a [`Document`] loads, semantic problems
131/// are reported by `animsmith-core` checks instead.
132#[derive(Debug, thiserror::Error)]
133#[non_exhaustive]
134pub enum LoadError {
135    /// The input path could not be represented as UTF-8 for `ufbx`.
136    #[error("path is not valid UTF-8: {0}")]
137    Path(String),
138    /// `ufbx` rejected or could not parse the file.
139    #[error("FBX parse error: {0}")]
140    Fbx(String),
141    /// `ufbx` loaded the scene but failed while baking an animation take.
142    #[error("animation bake failed for take {take:?}: {message}")]
143    Bake {
144        /// Name of the animation take that failed to bake.
145        take: String,
146        /// Parser-provided bake failure detail.
147        message: String,
148    },
149    /// The loader produced source facts that violate the core binding contract.
150    #[error("invalid FBX source-facts projection: {0}")]
151    SourceFacts(#[from] SourceFactsError),
152    /// Exact FBX/KTime evidence violated its isolated in-memory contract.
153    #[error("invalid exact source timing projection: {0}")]
154    ExactTiming(String),
155    /// The same-load raw transform-path inventory violated its binding contract.
156    #[error("invalid raw FBX transform-path projection: {0}")]
157    RawTransformPath(#[from] animsmith_core::RawTransformPathBindingError),
158}
159
160fn vec3(v: ufbx::Vec3) -> Vec3 {
161    Vec3::new(v.x as f32, v.y as f32, v.z as f32)
162}
163
164fn quat(q: ufbx::Quat) -> Quat {
165    Quat::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32)
166}
167
168fn transform(t: &ufbx::Transform) -> Transform {
169    Transform {
170        translation: vec3(t.translation),
171        rotation: quat(t.rotation),
172        scale: vec3(t.scale),
173    }
174}
175
176/// ufbx matrices are 3×4 (rotation/scale columns + translation).
177fn mat4(m: &ufbx::Matrix) -> Mat4 {
178    Mat4::from_cols_array(&[
179        m.m00 as f32,
180        m.m10 as f32,
181        m.m20 as f32,
182        0.0,
183        m.m01 as f32,
184        m.m11 as f32,
185        m.m21 as f32,
186        0.0,
187        m.m02 as f32,
188        m.m12 as f32,
189        m.m22 as f32,
190        0.0,
191        m.m03 as f32,
192        m.m13 as f32,
193        m.m23 as f32,
194        1.0,
195    ])
196}
197
198/// Project one converted FBX cluster bind only when the complete derivation
199/// is finite. `Mat4::inverse()` returns non-finite components for a singular
200/// finite input, so checking the two inputs alone is not sufficient evidence.
201fn project_cluster_bind(cluster: &ufbx::SkinCluster) -> Option<(Mat4, Mat4)> {
202    cluster.bone_node.as_ref()?;
203    let bind_to_world = mat4(&cluster.bind_to_world);
204    let geometry_to_world = mat4(&cluster.geometry_to_world);
205    if !bind_to_world.is_finite() || !geometry_to_world.is_finite() {
206        return None;
207    }
208    let bone_inverse = bind_to_world.inverse();
209    let instance_inverse = bone_inverse * geometry_to_world;
210    (bone_inverse.is_finite() && instance_inverse.is_finite())
211        .then_some((bone_inverse, instance_inverse))
212}
213
214/// Load an `.fbx` file into a core [`Document`]: skeleton, animation,
215/// and scene assets (triangulated meshes, skins, factor-only
216/// materials). Consumers that only judge animation ignore
217/// [`Document::assets`].
218///
219/// # Errors
220///
221/// Returns [`LoadError::Path`] when the path cannot be passed to `ufbx`,
222/// [`LoadError::Fbx`] when the FBX container cannot be parsed, and
223/// [`LoadError::Bake`] when an animation stack cannot be baked into the
224/// linear TRS tracks that animsmith's checks consume, and
225/// [`LoadError::SourceFacts`] when the loader violates a core source-fact
226/// binding invariant.
227pub fn load(path: &Path) -> Result<Document, LoadError> {
228    Ok(load_source(path)?.into_document())
229}
230
231/// Load an `.fbx` file and retain bounded importer-sensitive source facts.
232///
233/// The returned immutable owner binds the normalized document and source facts
234/// to the exact primary bytes parsed by ufbx. Consuming it as a document
235/// deliberately discards the sidecar.
236///
237/// # Errors
238///
239/// Returns [`LoadError::Path`] when the path cannot be passed to `ufbx`,
240/// [`LoadError::Fbx`] when the FBX container cannot be parsed,
241/// [`LoadError::Bake`] when an animation take cannot be baked, and
242/// [`LoadError::SourceFacts`] when the loader violates a core source-fact
243/// binding invariant.
244pub fn load_source(path: &Path) -> Result<LoadedSource, LoadError> {
245    Ok(load_scale_source(path)?.into_source())
246}
247
248/// Load an `.fbx` file and retain its conservative scale capability inventory.
249///
250/// The returned source also owns the shared importer-sensitive source facts;
251/// the inventory remains the operation-specific scale view. Neither scale
252/// operation is enabled for FBX by this API.
253///
254/// # Errors
255///
256/// Returns [`LoadError::Path`] when the path cannot be passed to `ufbx`,
257/// [`LoadError::Fbx`] when the FBX container cannot be parsed, and
258/// [`LoadError::Bake`] when an animation take cannot be baked, and
259/// [`LoadError::SourceFacts`] when the loader violates a core source-fact
260/// binding invariant.
261pub fn load_scale_source(path: &Path) -> Result<FbxScaleSource, LoadError> {
262    path.to_str()
263        .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
264    let bytes = std::fs::read(path).map_err(|error| LoadError::Fbx(error.to_string()))?;
265    let resource_root = path
266        .parent()
267        .filter(|parent| !parent.as_os_str().is_empty())
268        .unwrap_or_else(|| Path::new("."));
269    load_scale_source_bytes_with_resource_root(path, &bytes, resource_root)
270}
271
272/// Load an FBX byte slice into a core [`Document`].
273///
274/// `bytes` supplies the top-level container exactly as captured by the
275/// caller. This legacy byte-only entry point does not permit external resource
276/// I/O; use [`load_bytes_with_resource_root`] when a trusted root is available.
277///
278/// # Errors
279///
280/// Returns [`LoadError::Path`] when `path` cannot be passed to `ufbx`,
281/// [`LoadError::Fbx`] when the FBX container cannot be parsed, and
282/// [`LoadError::Bake`] when an animation stack cannot be baked into the
283/// linear TRS tracks that animsmith's checks consume, and
284/// [`LoadError::SourceFacts`] when the loader violates a core source-fact
285/// binding invariant.
286pub fn load_bytes(path: &Path, bytes: &[u8]) -> Result<Document, LoadError> {
287    Ok(load_source_bytes(path, bytes)?.into_document())
288}
289
290/// Load captured FBX bytes with one explicit trusted resource root.
291///
292/// External declarations are resolved only below `resource_root`, after
293/// lexical normalization and component-by-component symbolic-link refusal.
294/// The exact bytes captured here are used both for dependency identity and
295/// optional normalized texture assets.
296///
297/// # Errors
298///
299/// Returns the same parser and source-facts errors as [`load_bytes`]. Resource
300/// failures are represented in the dependency closure rather than as a loader
301/// error.
302pub fn load_bytes_with_resource_root(
303    path: &Path,
304    bytes: &[u8],
305    resource_root: &Path,
306) -> Result<Document, LoadError> {
307    Ok(load_source_bytes_with_resource_root(path, bytes, resource_root)?.into_document())
308}
309
310/// Load captured FBX bytes and retain bounded importer-sensitive source facts.
311///
312/// `path` is diagnostics and parser context only. Source identity is computed
313/// exclusively from `bytes`; no host path enters the dependency closure. This
314/// entry point deliberately performs no external resource I/O.
315///
316/// # Errors
317///
318/// Returns [`LoadError::Path`] when `path` cannot be passed to `ufbx`,
319/// [`LoadError::Fbx`] when the FBX container cannot be parsed,
320/// [`LoadError::Bake`] when an animation take cannot be baked, and
321/// [`LoadError::SourceFacts`] when the loader violates a core source-fact
322/// binding invariant.
323pub fn load_source_bytes(path: &Path, bytes: &[u8]) -> Result<LoadedSource, LoadError> {
324    Ok(load_scale_source_bytes_inner(path, bytes, None)?.into_source())
325}
326
327/// Load captured FBX bytes and retain source facts plus a rooted dependency
328/// closure.
329///
330/// `resource_root` is the sole filesystem authority for external FBX
331/// resources. It is not serialized or included in diagnostics, reports, or
332/// dependency identity.
333///
334/// # Errors
335///
336/// Returns the same parser and source-facts errors as [`load_source_bytes`].
337pub fn load_source_bytes_with_resource_root(
338    path: &Path,
339    bytes: &[u8],
340    resource_root: &Path,
341) -> Result<LoadedSource, LoadError> {
342    Ok(load_scale_source_bytes_inner(path, bytes, Some(resource_root))?.into_source())
343}
344
345/// Load captured FBX bytes and retain the capability inventory from the same parse.
346///
347/// `path` supplies source provenance and parser context; `bytes` is the exact
348/// captured top-level FBX container. This legacy byte-only entry point does
349/// not permit external resource I/O; use
350/// [`load_scale_source_bytes_with_resource_root`] for rooted capture.
351///
352/// # Errors
353///
354/// Returns [`LoadError::Path`] when `path` cannot be passed to `ufbx`,
355/// [`LoadError::Fbx`] when the FBX container cannot be parsed, and
356/// [`LoadError::Bake`] when an animation take cannot be baked, and
357/// [`LoadError::SourceFacts`] when the loader violates a core source-fact
358/// binding invariant.
359pub fn load_scale_source_bytes(path: &Path, bytes: &[u8]) -> Result<FbxScaleSource, LoadError> {
360    load_scale_source_bytes_inner(path, bytes, None)
361}
362
363/// Load captured FBX bytes with a trusted external-resource root and retain
364/// its scale capability inventory.
365///
366/// The loader captures an accepted external file at most once by normalized
367/// logical key. It never follows a symbolic link or treats ufbx's resolved
368/// absolute-path field as a path.
369///
370/// # Errors
371///
372/// Returns the same parser and source-facts errors as
373/// [`load_scale_source_bytes`].
374pub fn load_scale_source_bytes_with_resource_root(
375    path: &Path,
376    bytes: &[u8],
377    resource_root: &Path,
378) -> Result<FbxScaleSource, LoadError> {
379    load_scale_source_bytes_inner(path, bytes, Some(resource_root))
380}
381
382fn load_scale_source_bytes_inner(
383    path: &Path,
384    bytes: &[u8],
385    resource_root: Option<&Path>,
386) -> Result<FbxScaleSource, LoadError> {
387    let filename = path
388        .to_str()
389        .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
390    let opts = ufbx::LoadOpts {
391        target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
392        target_unit_meters: 1.0,
393        space_conversion: ufbx::SpaceConversion::AdjustTransforms,
394        geometry_transform_handling: ufbx::GeometryTransformHandling::HelperNodes,
395        // FBX scale-compensation inheritance (Maya-style; ubiquitous in
396        // Mixamo rigs, every bone carrying scale 0.01) cannot be
397        // represented by plain TRS hierarchies like glTF's — ufbx
398        // compensates the transforms (with helper nodes as fallback)
399        // so standard composition is correct.
400        inherit_mode_handling: ufbx::InheritModeHandling::Compensate,
401        generate_missing_normals: true,
402        // All external capture is rooted, bounded, and accounted for below.
403        // Letting ufbx open resources would create an untracked second reader.
404        load_external_files: false,
405        ignore_missing_external_files: false,
406        filename: filename.into(),
407        ..Default::default()
408    };
409    let scene = ufbx::load_memory(bytes, opts).map_err(|e| LoadError::Fbx(format!("{e:?}")))?;
410
411    // Every node becomes a bone (the ufbx root included — it carries
412    // the axis/unit adjustment). scene.nodes is ordered parents-first,
413    // matching the skeleton invariant; typed_id indexes scene.nodes
414    // directly.
415    let mut bones: Vec<Bone> = Vec::with_capacity(scene.nodes.len());
416    for node in &scene.nodes {
417        let name = if node.element.name.is_empty() {
418            if node.is_root {
419                "<fbx-root>".to_string()
420            } else {
421                format!("node{}", node.element.typed_id)
422            }
423        } else {
424            node.element.name.to_string()
425        };
426        bones.push(Bone {
427            name,
428            parent: node.parent.as_ref().map(|p| p.element.typed_id as usize),
429            rest: transform(&node.local_transform),
430            inverse_bind: None,
431        });
432    }
433    for cluster in &scene.skin_clusters {
434        if let (Some(bone_node), Some((bone_inverse, _))) =
435            (&cluster.bone_node, project_cluster_bind(cluster))
436        {
437            let id = bone_node.element.typed_id as usize;
438            if id < bones.len() {
439                // Joint-centric bind inverse in the converted scene
440                // space; the mesh-dependent part lives per mesh in
441                // `MeshAsset::skin_ibms`.
442                bones[id].inverse_bind = Some(bone_inverse);
443            }
444        }
445    }
446
447    let mut clips = Vec::new();
448    for (index, stack) in scene.anim_stacks.iter().enumerate() {
449        let take = if stack.element.name.is_empty() {
450            format!("take{index}")
451        } else {
452            stack.element.name.to_string()
453        };
454        let baked = ufbx::bake_anim(
455            &scene,
456            &stack.anim,
457            ufbx::BakeOpts {
458                trim_start_time: true,
459                ..Default::default()
460            },
461        )
462        .map_err(|e| LoadError::Bake {
463            take: take.clone(),
464            message: format!("{e:?}"),
465        })?;
466
467        let mut tracks = Vec::new();
468        let mut duration = 0.0f64;
469        for node in &baked.nodes {
470            let bone = node.typed_id as usize;
471            if !node.translation_keys.is_empty() {
472                let times: Vec<f32> = node
473                    .translation_keys
474                    .iter()
475                    .map(|k| k.time as f32)
476                    .collect();
477                let values: Vec<Vec3> = node
478                    .translation_keys
479                    .iter()
480                    .map(|k| vec3(k.value))
481                    .collect();
482                duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
483                tracks.push(Track {
484                    bone,
485                    property: Property::Translation,
486                    interpolation: Interpolation::Linear,
487                    times,
488                    values: TrackValues::Vec3s(values),
489                });
490            }
491            if !node.rotation_keys.is_empty() {
492                let times: Vec<f32> = node.rotation_keys.iter().map(|k| k.time as f32).collect();
493                let values: Vec<Quat> = node.rotation_keys.iter().map(|k| quat(k.value)).collect();
494                duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
495                tracks.push(Track {
496                    bone,
497                    property: Property::Rotation,
498                    interpolation: Interpolation::Linear,
499                    times,
500                    values: TrackValues::Quats(values),
501                });
502            }
503            if !node.scale_keys.is_empty() {
504                let times: Vec<f32> = node.scale_keys.iter().map(|k| k.time as f32).collect();
505                let values: Vec<Vec3> = node.scale_keys.iter().map(|k| vec3(k.value)).collect();
506                duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
507                tracks.push(Track {
508                    bone,
509                    property: Property::Scale,
510                    interpolation: Interpolation::Linear,
511                    times,
512                    values: TrackValues::Vec3s(values),
513                });
514            }
515        }
516        clips.push(Clip {
517            name: take,
518            duration_s: duration,
519            tracks,
520        });
521    }
522
523    let construct_counts = source_facts::construct_counts(&scene);
524    let raw_facts = source_facts::project(&scene, construct_counts, bytes);
525    let raw_transform_paths = raw_transform_paths::project(
526        bytes,
527        filename,
528        &scene,
529        raw_facts.primary_identity().clone(),
530    );
531    let (dependency_closure, resource_capture) =
532        capture_dependency_closure(&scene, &raw_facts, resource_root)?;
533    let (assets, conversion) = extract_assets(&scene, &resource_capture);
534    let (inventory, rest_bind_mesh_payload_counts) =
535        capability::inventory(&scene, &conversion, construct_counts);
536
537    let document = Document {
538        skeleton: Skeleton { bones },
539        clips,
540        assets,
541        source: SourceInfo {
542            path: Some(path.display().to_string()),
543            format: Some("fbx".into()),
544        },
545    };
546    let source = raw_facts.finish_with_dependency_closure(document, dependency_closure)?;
547    let source_clips = source.source_facts().clips();
548    let exact_timing =
549        exact_timing::project(&scene, source_clips.coverage(), source_clips.rows().len())
550            .map_err(|error| LoadError::ExactTiming(error.to_string()))?;
551    let source = source
552        .with_exact_source_timing(exact_timing)
553        .map_err(|error| LoadError::ExactTiming(error.to_string()))?;
554    let source = source.with_raw_transform_path_inventory(raw_transform_paths)?;
555
556    Ok(FbxScaleSource {
557        source,
558        inventory,
559        rest_bind_construct_counts: construct_counts.rest_bind,
560        rest_bind_scale_invariant_payload_mesh_count: rest_bind_mesh_payload_counts
561            .scale_invariant_mesh_count,
562    })
563}
564
565/// The per-key capture result retained for later aliases without another open.
566#[derive(Debug, Clone, Copy)]
567enum ExternalCaptureOutcome {
568    Captured,
569    Refused(DependencyResourceRefusalReasonV1),
570    Unavailable(DependencyResourceUnavailableReasonV1),
571}
572
573/// Exact external bytes captured once, indexed by normalized logical key.
574#[derive(Debug, Default)]
575struct FbxResourceCapture {
576    outcomes: BTreeMap<DependencyResourceKeyV1, ExternalCaptureOutcome>,
577    bytes_by_key: BTreeMap<DependencyResourceKeyV1, Vec<u8>>,
578    texture_keys: BTreeMap<u64, DependencyResourceKeyV1>,
579}
580
581impl FbxResourceCapture {
582    fn record_resource_key(
583        &mut self,
584        kind: SourceResourceKindV1,
585        source_index: u64,
586        key: &DependencyResourceKeyV1,
587        outcome: ExternalCaptureOutcome,
588    ) {
589        if kind == SourceResourceKindV1::Texture
590            && matches!(outcome, ExternalCaptureOutcome::Captured)
591        {
592            self.texture_keys.insert(source_index, key.clone());
593        }
594    }
595
596    fn texture_bytes(&self, source_index: u64) -> Option<&[u8]> {
597        self.texture_keys
598            .get(&source_index)
599            .and_then(|key| self.bytes_by_key.get(key))
600            .map(Vec::as_slice)
601    }
602}
603
604/// The outcome of one rooted file read without retaining host error text.
605#[derive(Debug)]
606enum RootedCaptureError {
607    Refused(DependencyResourceRefusalReasonV1),
608    Unavailable(DependencyResourceUnavailableReasonV1),
609}
610
611/// Capture the exact dependency closure after the raw resource prefix exists.
612fn capture_dependency_closure(
613    scene: &ufbx::Scene,
614    facts: &RawSourceFactsBuilderV1,
615    resource_root: Option<&Path>,
616) -> Result<(animsmith_core::DependencyClosureV1, FbxResourceCapture), SourceFactsError> {
617    let mut closure = DependencyClosureBuilderV1::new(
618        facts.primary_identity().clone(),
619        facts.resource_coverage(),
620        facts.resource_rows().len(),
621    );
622    if !scene.audio_clips.is_empty() {
623        // Audio clips are an additional resource-bearing domain without a raw
624        // reference row. `texture_files`, in contrast, is ufbx's deduplicated
625        // view derived from the already-enumerated `textures` list: every file
626        // texture carries its `file_index`, and the texture row binds the same
627        // logical locator. Do not make that represented alias view unmodeled.
628        closure.mark_unmodeled_resource_domain();
629    }
630
631    let mut capture = FbxResourceCapture::default();
632    for resource in facts.resource_rows() {
633        if !capture_reference(resource, resource_root, &mut closure, &mut capture)? {
634            break;
635        }
636    }
637    Ok((closure.finish()?, capture))
638}
639
640fn capture_reference(
641    resource: &SourceResourceReferenceV1,
642    resource_root: Option<&Path>,
643    closure: &mut DependencyClosureBuilderV1,
644    capture: &mut FbxResourceCapture,
645) -> Result<bool, SourceFactsError> {
646    let order = resource.source_order_index();
647    let kind = resource.kind();
648    let source_index = resource.source_index();
649    match resource.locator() {
650        SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri => {
651            if !closure.begin_reference(0, 0) {
652                return Ok(false);
653            }
654            closure.push_primary(order, kind, source_index)?;
655        }
656        SourceResourceLocatorV1::Relative(locator) => {
657            if !closure.begin_reference(
658                locator.as_str().len(),
659                DependencyResourceKeyV1::source_component_count(locator),
660            ) {
661                return Ok(false);
662            }
663            let key = match DependencyResourceKeyV1::from_relative(
664                locator,
665                ResourceKeySyntaxV1::ParserRelativePath,
666            ) {
667                Ok(key) => key,
668                Err(animsmith_core::DependencyClosureError::ResourceKeyTooLong { .. }) => {
669                    closure.push_refused(
670                        order,
671                        kind,
672                        source_index,
673                        DependencyResourceRefusalReasonV1::Oversized,
674                    )?;
675                    return Ok(true);
676                }
677                Err(_) => {
678                    closure.push_refused(
679                        order,
680                        kind,
681                        source_index,
682                        DependencyResourceRefusalReasonV1::Malformed,
683                    )?;
684                    return Ok(true);
685                }
686            };
687            match closure.prepare_external_key(&key)? {
688                None => return Ok(false),
689                Some(false) => {
690                    let outcome =
691                        capture.outcomes.get(&key).copied().ok_or(
692                            animsmith_core::DependencyClosureError::ExternalIdentityMissing,
693                        )?;
694                    match outcome {
695                        ExternalCaptureOutcome::Captured => {
696                            closure.push_external_alias(order, kind, source_index, key.clone())?;
697                        }
698                        ExternalCaptureOutcome::Refused(reason) => {
699                            closure.push_refused(order, kind, source_index, reason)?;
700                        }
701                        ExternalCaptureOutcome::Unavailable(reason) => {
702                            closure.push_unavailable(
703                                order,
704                                kind,
705                                source_index,
706                                Some(key.clone()),
707                                reason,
708                            )?;
709                        }
710                    }
711                    capture.record_resource_key(kind, source_index, &key, outcome);
712                }
713                Some(true) => {
714                    let outcome = match resource_root {
715                        None => ExternalCaptureOutcome::Unavailable(
716                            DependencyResourceUnavailableReasonV1::ResourceRootUnavailable,
717                        ),
718                        Some(root) => {
719                            let byte_limit = closure
720                                .max_resource_bytes()
721                                .min(closure.remaining_external_bytes());
722                            // A zero remaining budget still reads at most one
723                            // byte. That bounded N+1 witness lets core retain
724                            // the current unavailable row and terminally stop
725                            // the prefix without a synthetic identity.
726                            let read = match checked_rooted_resource_path(root, &key) {
727                                Ok(path) => {
728                                    // Record exactly at the actual open boundary, after all
729                                    // root/component symlink refusals. A refused path is not
730                                    // an open attempt.
731                                    closure.record_external_open_attempt(&key)?;
732                                    read_file_bounded_path(path, byte_limit)
733                                }
734                                Err(error) => Err(error),
735                            };
736                            match read {
737                                Ok(bytes) => {
738                                    let identity = InputIdentity::from_bytes(&bytes);
739                                    if !closure.push_captured_external(
740                                        order,
741                                        kind,
742                                        source_index,
743                                        key.clone(),
744                                        identity,
745                                    )? {
746                                        return Ok(false);
747                                    }
748                                    capture.bytes_by_key.insert(key.clone(), bytes);
749                                    ExternalCaptureOutcome::Captured
750                                }
751                                Err(RootedCaptureError::Refused(reason)) => {
752                                    closure.push_refused(order, kind, source_index, reason)?;
753                                    ExternalCaptureOutcome::Refused(reason)
754                                }
755                                Err(RootedCaptureError::Unavailable(reason)) => {
756                                    closure.push_unavailable(
757                                        order,
758                                        kind,
759                                        source_index,
760                                        Some(key.clone()),
761                                        reason,
762                                    )?;
763                                    ExternalCaptureOutcome::Unavailable(reason)
764                                }
765                            }
766                        }
767                    };
768                    if resource_root.is_none() {
769                        closure.push_unavailable(
770                            order,
771                            kind,
772                            source_index,
773                            Some(key.clone()),
774                            DependencyResourceUnavailableReasonV1::ResourceRootUnavailable,
775                        )?;
776                    }
777                    capture.outcomes.insert(key.clone(), outcome);
778                    capture.record_resource_key(kind, source_index, &key, outcome);
779                }
780            }
781        }
782        locator => {
783            if !closure.begin_reference(0, 0) {
784                return Ok(false);
785            }
786            let reason = match locator {
787                SourceResourceLocatorV1::Absolute => DependencyResourceRefusalReasonV1::Absolute,
788                SourceResourceLocatorV1::Escaping => DependencyResourceRefusalReasonV1::Escaping,
789                SourceResourceLocatorV1::Remote => DependencyResourceRefusalReasonV1::Remote,
790                SourceResourceLocatorV1::Malformed => DependencyResourceRefusalReasonV1::Malformed,
791                // Redacted oversized strings consume no normalization work;
792                // their typed row remains visible instead of stopping capture.
793                SourceResourceLocatorV1::Oversized => DependencyResourceRefusalReasonV1::Oversized,
794                SourceResourceLocatorV1::Missing => {
795                    closure.push_unavailable(
796                        order,
797                        kind,
798                        source_index,
799                        None,
800                        DependencyResourceUnavailableReasonV1::Missing,
801                    )?;
802                    return Ok(true);
803                }
804                SourceResourceLocatorV1::Embedded
805                | SourceResourceLocatorV1::DataUri
806                | SourceResourceLocatorV1::Relative(_) => unreachable!(),
807            };
808            closure.push_refused(order, kind, source_index, reason)?;
809        }
810    }
811    Ok(true)
812}
813
814/// Validate a safe logical key below one trusted root without following symlinks.
815///
816/// This protects the intended root at lookup time. Like ordinary portable
817/// filesystem APIs, it assumes the root tree is not concurrently replaced
818/// between component inspection and open.
819fn checked_rooted_resource_path(
820    root: &Path,
821    key: &DependencyResourceKeyV1,
822) -> Result<PathBuf, RootedCaptureError> {
823    let root_metadata = std::fs::symlink_metadata(root).map_err(root_metadata_error)?;
824    if root_metadata.file_type().is_symlink() {
825        return Err(RootedCaptureError::Refused(
826            DependencyResourceRefusalReasonV1::Symlink,
827        ));
828    }
829    if !root_metadata.is_dir() {
830        return Err(RootedCaptureError::Unavailable(
831            DependencyResourceUnavailableReasonV1::Unreadable,
832        ));
833    }
834
835    let mut path = PathBuf::from(root);
836    let component_count = key.as_str().split('/').count();
837    for (index, component) in key.as_str().split('/').enumerate() {
838        path.push(component);
839        let metadata = std::fs::symlink_metadata(&path).map_err(resource_metadata_error)?;
840        if metadata.file_type().is_symlink() {
841            return Err(RootedCaptureError::Refused(
842                DependencyResourceRefusalReasonV1::Symlink,
843            ));
844        }
845        if index + 1 < component_count && !metadata.is_dir() {
846            return Err(RootedCaptureError::Unavailable(
847                DependencyResourceUnavailableReasonV1::Unreadable,
848            ));
849        }
850        if index + 1 == component_count && !metadata.is_file() {
851            // Devices, FIFOs, sockets, and directories are not dependency
852            // files. Refuse before File::open() so they cannot block or cause
853            // format-loader side effects.
854            return Err(RootedCaptureError::Unavailable(
855                DependencyResourceUnavailableReasonV1::Unreadable,
856            ));
857        }
858    }
859    Ok(path)
860}
861
862fn root_metadata_error(error: std::io::Error) -> RootedCaptureError {
863    let reason = if error.kind() == std::io::ErrorKind::NotFound {
864        DependencyResourceUnavailableReasonV1::ResourceRootUnavailable
865    } else {
866        DependencyResourceUnavailableReasonV1::Unreadable
867    };
868    RootedCaptureError::Unavailable(reason)
869}
870
871fn resource_metadata_error(error: std::io::Error) -> RootedCaptureError {
872    let reason = if error.kind() == std::io::ErrorKind::NotFound {
873        DependencyResourceUnavailableReasonV1::Missing
874    } else {
875        DependencyResourceUnavailableReasonV1::Unreadable
876    };
877    RootedCaptureError::Unavailable(reason)
878}
879
880fn read_file_bounded_path(path: PathBuf, byte_limit: u64) -> Result<Vec<u8>, RootedCaptureError> {
881    let mut file = File::open(path).map_err(resource_metadata_error)?;
882    let limit = usize::try_from(byte_limit).map_err(|_| {
883        RootedCaptureError::Unavailable(
884            DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
885        )
886    })?;
887    let mut bytes = Vec::new();
888    bytes.try_reserve(limit.min(8 * 1024)).map_err(|_| {
889        RootedCaptureError::Unavailable(DependencyResourceUnavailableReasonV1::Unreadable)
890    })?;
891    file.by_ref()
892        .take(byte_limit.saturating_add(1))
893        .read_to_end(&mut bytes)
894        .map_err(|_| {
895            RootedCaptureError::Unavailable(DependencyResourceUnavailableReasonV1::Unreadable)
896        })?;
897    Ok(bytes)
898}
899
900#[derive(Debug)]
901struct AssetTextureMaterializer {
902    remaining: usize,
903}
904
905impl Default for AssetTextureMaterializer {
906    fn default() -> Self {
907        Self {
908            remaining: FBX_MAX_ASSET_TEXTURE_BYTES,
909        }
910    }
911}
912
913impl AssetTextureMaterializer {
914    fn materialize(&mut self, bytes: &[u8], mime: &'static str) -> Option<TextureAsset> {
915        if bytes.len() > self.remaining {
916            return None;
917        }
918        let mut retained = Vec::new();
919        retained.try_reserve_exact(bytes.len()).ok()?;
920        retained.extend_from_slice(bytes);
921        self.remaining -= bytes.len();
922        Some(TextureAsset {
923            bytes: retained,
924            mime: mime.into(),
925        })
926    }
927}
928
929#[cfg(test)]
930mod asset_materializer_tests {
931    use super::{AssetTextureMaterializer, FBX_MAX_ASSET_TEXTURE_BYTES};
932    use std::io::Write;
933
934    #[test]
935    fn aliases_cannot_multiply_retained_texture_bytes_past_the_cap() {
936        let bytes = [7u8; 4];
937        let mut materializer = AssetTextureMaterializer { remaining: 8 };
938
939        assert!(materializer.materialize(&bytes, "image/png").is_some());
940        assert!(materializer.materialize(&bytes, "image/png").is_some());
941        assert!(materializer.materialize(&bytes, "image/png").is_none());
942        assert_eq!(materializer.remaining, 0);
943        assert_eq!(
944            AssetTextureMaterializer::default().remaining,
945            FBX_MAX_ASSET_TEXTURE_BYTES
946        );
947    }
948
949    #[test]
950    fn bounded_reader_returns_the_cap_plus_one_budget_witness() {
951        let mut file = tempfile::NamedTempFile::new().expect("temporary resource file");
952        file.write_all(&[1, 2, 3]).expect("write resource bytes");
953        file.flush().expect("flush resource bytes");
954
955        let bytes = super::read_file_bounded_path(file.path().to_path_buf(), 2)
956            .expect("bounded read succeeds");
957        assert_eq!(bytes, [1, 2, 3]);
958    }
959}
960
961/// Materialize one embedded or previously captured texture without re-opening
962/// any path. Only PNG/JPEG pass through (glTF's mandated formats).
963fn texture_asset(
964    texture: &ufbx::Texture,
965    capture: &FbxResourceCapture,
966    materializer: &mut AssetTextureMaterializer,
967) -> Option<TextureAsset> {
968    let bytes: &[u8] = if !texture.content.is_empty() {
969        texture.content.as_ref()
970    } else {
971        capture.texture_bytes(u64::from(texture.element.typed_id))?
972    };
973    let mime = match bytes.get(..3) {
974        Some([0x89, b'P', b'N']) => "image/png",
975        Some([0xFF, 0xD8, _]) => "image/jpeg",
976        _ => return None,
977    };
978    materializer.materialize(bytes, mime)
979}
980
981fn base_color_texture(
982    material: &ufbx::Material,
983    capture: &FbxResourceCapture,
984    materializer: &mut AssetTextureMaterializer,
985) -> Option<TextureAsset> {
986    let texture = material.pbr.base_color.texture.as_ref().or(material
987        .fbx
988        .diffuse_color
989        .texture
990        .as_ref())?;
991    texture_asset(texture, capture, materializer)
992}
993
994fn normal_texture(
995    material: &ufbx::Material,
996    capture: &FbxResourceCapture,
997    materializer: &mut AssetTextureMaterializer,
998) -> Option<NormalTextureAsset> {
999    let texture = material.pbr.normal_map.texture.as_ref().or(material
1000        .fbx
1001        .normal_map
1002        .texture
1003        .as_ref())?;
1004    texture_asset(texture, capture, materializer).map(|texture| NormalTextureAsset {
1005        texture,
1006        // ufbx exposes the linked image but no glTF-compatible normal X/Y
1007        // scalar for ordinary FBX materials. Preserve the image and use the
1008        // glTF default rather than guessing from unrelated bump fields.
1009        scale: 1.0,
1010    })
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq)]
1014enum ProjectedInfluence {
1015    Absent,
1016    Retained(u16, f32),
1017    Rejected,
1018}
1019
1020fn project_skin_influence(
1021    source_weight: f64,
1022    cluster_index: Option<usize>,
1023    cluster_count: usize,
1024    cluster_has_bone: bool,
1025) -> ProjectedInfluence {
1026    let weight = source_weight as f32;
1027    if !source_weight.is_finite()
1028        || source_weight < 0.0
1029        || !weight.is_finite()
1030        || (source_weight > 0.0 && weight == 0.0)
1031    {
1032        return ProjectedInfluence::Rejected;
1033    }
1034    if weight == 0.0 {
1035        return ProjectedInfluence::Absent;
1036    }
1037    let Some(cluster_index) = cluster_index else {
1038        return ProjectedInfluence::Rejected;
1039    };
1040    if cluster_index >= cluster_count || !cluster_has_bone {
1041        return ProjectedInfluence::Rejected;
1042    }
1043    match u16::try_from(cluster_index) {
1044        Ok(index) => ProjectedInfluence::Retained(index, weight),
1045        Err(_) => ProjectedInfluence::Rejected,
1046    }
1047}
1048
1049/// Project every normalized ufbx node and skin deformer in stable typed-list
1050/// order. These are source-side identities after the documented coordinate,
1051/// helper-node, and inherit-mode normalization; they are not raw FBX object
1052/// transforms.
1053fn extract_source_skeleton(scene: &ufbx::Scene) -> SourceSkeletonAssets {
1054    let nodes = scene
1055        .nodes
1056        .iter()
1057        .map(|node| {
1058            let mut source = SourceNodeAsset::new(
1059                node.element.typed_id as usize,
1060                SourceNodeLocalRest::Trs {
1061                    translation: vec3(node.local_transform.translation),
1062                    rotation: quat(node.local_transform.rotation),
1063                    scale: vec3(node.local_transform.scale),
1064                },
1065            );
1066            source.name = (!node.element.name.is_empty()).then(|| node.element.name.to_string());
1067            source.parent_source_node_index = node
1068                .parent
1069                .as_ref()
1070                .map(|parent| parent.element.typed_id as usize);
1071            source.scene_root_indices = if node.is_root { vec![0] } else { Vec::new() };
1072            source.bone = Some(node.element.typed_id as usize);
1073            source
1074        })
1075        .collect();
1076
1077    // A missing cluster bone removes a declared joint slot from the current
1078    // format-neutral shape: SourceSkinAsset has no optional/invalid joint-row
1079    // representation. Do not filter that slot and still claim complete source
1080    // coverage. The capability inventory retains the exact incomplete-cluster
1081    // count while the generic sidecar fails closed as globally unavailable.
1082    if scene.skin_clusters.iter().any(|cluster| {
1083        cluster.bone_node.as_ref().is_none_or(|bone| {
1084            usize::try_from(bone.element.typed_id)
1085                .ok()
1086                .is_none_or(|index| index >= scene.nodes.len())
1087        })
1088    }) {
1089        return SourceSkeletonAssets::default();
1090    }
1091
1092    let mut attachments = vec![Vec::new(); scene.skin_deformers.len()];
1093    for node in &scene.nodes {
1094        let Some(mesh) = &node.mesh else { continue };
1095        for skin in &mesh.skin_deformers {
1096            let Some(for_skin) = attachments.get_mut(skin.element.typed_id as usize) else {
1097                return SourceSkeletonAssets::default();
1098            };
1099            for_skin.push(SourceSkinAttachment {
1100                source_node_index: node.element.typed_id as usize,
1101                source_mesh_index: Some(mesh.element.typed_id as usize),
1102            });
1103        }
1104    }
1105
1106    let skins = scene
1107        .skin_deformers
1108        .iter()
1109        .map(|skin| {
1110            let source_skin_index = skin.element.typed_id as usize;
1111            let projected_matrices = skin
1112                .clusters
1113                .iter()
1114                .map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
1115                .collect::<Option<Vec<_>>>();
1116            let (status, matrices) = match (skin.clusters.is_empty(), projected_matrices) {
1117                (true, _) => (SourceInverseBindAccessorStatus::Absent, Vec::new()),
1118                (false, Some(matrices)) => (SourceInverseBindAccessorStatus::Available, matrices),
1119                // Unreadable is declaration-wide because the generic shape
1120                // cannot retain a hole without shifting later joint slots.
1121                (false, None) => (SourceInverseBindAccessorStatus::Unreadable, Vec::new()),
1122            };
1123            SourceSkinAsset {
1124                source_skin_index,
1125                name: (!skin.element.name.is_empty()).then(|| skin.element.name.to_string()),
1126                // FBX skin deformers do not carry a glTF-style explicit
1127                // skeleton-root declaration. Do not infer one.
1128                skeleton_root_source_node_index: None,
1129                joint_source_node_indices: skin
1130                    .clusters
1131                    .iter()
1132                    .filter_map(|cluster| {
1133                        cluster
1134                            .bone_node
1135                            .as_ref()
1136                            .map(|node| node.element.typed_id as usize)
1137                    })
1138                    .collect(),
1139                inverse_bind_accessor: SourceInverseBindAccessor {
1140                    status,
1141                    declared_count: (!skin.clusters.is_empty()).then_some(skin.clusters.len()),
1142                    matrices,
1143                },
1144                attachments: attachments
1145                    .get_mut(source_skin_index)
1146                    .map(std::mem::take)
1147                    .unwrap_or_default(),
1148            }
1149        })
1150        .collect();
1151
1152    SourceSkeletonAssets {
1153        coverage: SourceSkeletonCoverage::Complete,
1154        nodes,
1155        skins,
1156    }
1157}
1158
1159/// Extract triangulated geometry, skins, and factor-only materials with
1160/// optional base-color and normal textures. Corner attributes come straight
1161/// from ufbx's indexed accessors; skin weights keep the top four influences
1162/// per source vertex and are renormalized.
1163fn extract_assets(
1164    scene: &ufbx::Scene,
1165    capture: &FbxResourceCapture,
1166) -> (SceneAssets, AssetConversionFacts) {
1167    let mut assets = SceneAssets::default();
1168    let mut conversion = AssetConversionFacts::default();
1169    let mut materializer = AssetTextureMaterializer::default();
1170    let mut material_index: std::collections::BTreeMap<u32, usize> =
1171        std::collections::BTreeMap::new();
1172    let mut normalized_mesh_index_by_source = std::collections::BTreeMap::<u32, usize>::new();
1173
1174    for (source_node_index, node) in scene.nodes.iter().enumerate() {
1175        let Some(mesh) = &node.mesh else { continue };
1176        let node_id = node.element.typed_id as usize;
1177
1178        // Materials referenced by this mesh, deduped globally by id.
1179        let local_materials: Vec<usize> = mesh
1180            .materials
1181            .iter()
1182            .map(|m| {
1183                *material_index
1184                    .entry(m.element.element_id)
1185                    .or_insert_with(|| {
1186                        let base = if m.pbr.base_color.has_value {
1187                            m.pbr.base_color.value_vec4
1188                        } else {
1189                            m.fbx.diffuse_color.value_vec4
1190                        };
1191                        let texture = base_color_texture(m, capture, &mut materializer);
1192                        let normal_texture = normal_texture(m, capture, &mut materializer);
1193                        assets.materials.push(MaterialAsset {
1194                            name: m.element.name.to_string(),
1195                            // Exporter convention: a texture replaces
1196                            // the factor (they multiply in glTF).
1197                            base_color: if texture.is_some() {
1198                                [1.0, 1.0, 1.0, 1.0]
1199                            } else {
1200                                [base.x as f32, base.y as f32, base.z as f32, base.w as f32]
1201                            },
1202                            metallic: if m.pbr.metalness.has_value {
1203                                m.pbr.metalness.value_vec4.x as f32
1204                            } else {
1205                                0.0
1206                            },
1207                            roughness: if m.pbr.roughness.has_value {
1208                                m.pbr.roughness.value_vec4.x as f32
1209                            } else {
1210                                1.0
1211                            },
1212                            base_color_texture: texture,
1213                            normal_texture,
1214                            metallic_roughness_texture: None,
1215                            occlusion_texture: None,
1216                        });
1217                        assets.materials.len() - 1
1218                    })
1219            })
1220            .collect();
1221
1222        // Per-vertex skin influences (top 4, renormalized), cluster
1223        // order defines the joint list.
1224        let skin = mesh.skin_deformers.first();
1225        let skin_joints: Vec<usize> = skin
1226            .map(|s| {
1227                s.clusters
1228                    .iter()
1229                    .map(|c| {
1230                        c.bone_node
1231                            .as_ref()
1232                            .map(|b| b.element.typed_id as usize)
1233                            .unwrap_or(0)
1234                    })
1235                    .collect()
1236            })
1237            .unwrap_or_default();
1238        // glTF inverse bind per joint: bind-world⁻¹ × geometry-to-world,
1239        // both already in ufbx's converted (metres, Y-up) space —
1240        // `geometry_to_bone` is raw source units and NOT suitable.
1241        let skin_ibms: Vec<glam::Mat4> = skin
1242            .and_then(|s| {
1243                s.clusters
1244                    .iter()
1245                    .map(|cluster| project_cluster_bind(cluster).map(|(_, bind)| bind))
1246                    .collect::<Option<Vec<_>>>()
1247            })
1248            .unwrap_or_default();
1249        if let Some(&normalized_mesh_index) =
1250            normalized_mesh_index_by_source.get(&mesh.element.typed_id)
1251        {
1252            assets.instances.push(MeshInstance {
1253                source_node_index,
1254                node: node_id,
1255                mesh: normalized_mesh_index,
1256                skin_joints,
1257                skin_ibms,
1258            });
1259            continue;
1260        }
1261        let vertex_influences: Vec<Option<([u16; 4], [f32; 4])>> = skin
1262            .map(|s| {
1263                (0..mesh.num_vertices)
1264                    .map(|v| {
1265                        let mut pairs: Vec<(u16, f32)> = Vec::new();
1266                        if let Some(sv) = s.vertices.get(v) {
1267                            let begin = sv.weight_begin as usize;
1268                            let end = begin.saturating_add(sv.num_weights as usize);
1269                            for sw in s.weights.get(begin..end).unwrap_or_default() {
1270                                let source_weight = sw.weight;
1271                                let cluster_index = usize::try_from(sw.cluster_index).ok();
1272                                let cluster_has_bone = cluster_index
1273                                    .and_then(|index| s.clusters.get(index))
1274                                    .is_some_and(|cluster| cluster.bone_node.is_some());
1275                                match project_skin_influence(
1276                                    source_weight,
1277                                    cluster_index,
1278                                    s.clusters.len(),
1279                                    cluster_has_bone,
1280                                ) {
1281                                    ProjectedInfluence::Absent => {}
1282                                    ProjectedInfluence::Retained(index, weight) => {
1283                                        pairs.push((index, weight));
1284                                    }
1285                                    ProjectedInfluence::Rejected => {
1286                                        conversion.rejected_influence_count += 1;
1287                                    }
1288                                }
1289                            }
1290                        }
1291                        pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
1292                        if pairs.len() > 4 {
1293                            conversion.truncated_influence_vertex_count += 1;
1294                            conversion.discarded_influence_count += pairs.len() - 4;
1295                        }
1296                        pairs.truncate(4);
1297                        let total: f32 = pairs.iter().map(|p| p.1).sum();
1298                        if pairs.is_empty() || !total.is_finite() || total <= 0.0 {
1299                            return None;
1300                        }
1301                        let mut joints = [0u16; 4];
1302                        let mut weights = [0f32; 4];
1303                        let mut renormalized = false;
1304                        for (slot, (j, w)) in pairs.into_iter().enumerate() {
1305                            joints[slot] = j;
1306                            let normalized = if total > 0.0 { w / total } else { 0.0 };
1307                            renormalized |= normalized.to_bits() != w.to_bits();
1308                            weights[slot] = normalized;
1309                        }
1310                        if renormalized {
1311                            conversion.renormalized_influence_vertex_count += 1;
1312                        }
1313                        Some((joints, weights))
1314                    })
1315                    .collect()
1316            })
1317            .unwrap_or_default();
1318
1319        // One primitive per material slot (unindexed corners).
1320        let slots = local_materials.len().max(1);
1321        let mut primitives: Vec<Primitive> = (0..slots)
1322            .map(|slot| Primitive {
1323                material: local_materials.get(slot).copied(),
1324                ..Primitive::default()
1325            })
1326            .collect();
1327
1328        let mut tri_indices = vec![0u32; mesh.max_face_triangles * 3];
1329        for (face_index, &face) in mesh.faces.iter().enumerate() {
1330            let slot = mesh
1331                .face_material
1332                .get(face_index)
1333                .map(|&m| m as usize)
1334                .filter(|&m| m < slots)
1335                .unwrap_or(0);
1336            let prim = &mut primitives[slot];
1337            let tris = mesh.triangulate_face(&mut tri_indices, face) as usize;
1338            for &corner in &tri_indices[..tris * 3] {
1339                let corner = corner as usize;
1340                let p = mesh.vertex_position[corner];
1341                prim.positions
1342                    .push(Vec3::new(p.x as f32, p.y as f32, p.z as f32));
1343                if mesh.vertex_normal.exists {
1344                    let n = mesh.vertex_normal[corner];
1345                    prim.normals
1346                        .push(Vec3::new(n.x as f32, n.y as f32, n.z as f32));
1347                }
1348                if mesh.vertex_uv.exists {
1349                    let uv = mesh.vertex_uv[corner];
1350                    // glTF's texcoord origin is top-left; FBX's is
1351                    // bottom-left.
1352                    prim.uvs.push([uv.x as f32, 1.0 - uv.y as f32]);
1353                }
1354                if !vertex_influences.is_empty() {
1355                    let vertex = mesh.vertex_indices[corner] as usize;
1356                    let (joints, weights) = vertex_influences
1357                        .get(vertex)
1358                        .copied()
1359                        .flatten()
1360                        .unwrap_or_else(|| {
1361                            conversion.missing_skin_influence_corner_count += 1;
1362                            ([0; 4], [0.0; 4])
1363                        });
1364                    prim.joints.push(joints);
1365                    prim.weights.push(weights);
1366                }
1367            }
1368        }
1369        primitives.retain(|p| !p.positions.is_empty());
1370        for prim in &mut primitives {
1371            conversion.pre_weld_vertex_count += prim.positions.len();
1372            prim.weld();
1373            conversion.post_weld_vertex_count += prim.positions.len();
1374        }
1375        if primitives.is_empty() {
1376            continue;
1377        }
1378        let normalized_mesh_index = assets.meshes.len();
1379        let source_mesh_index = mesh.element.typed_id as usize;
1380        normalized_mesh_index_by_source.insert(mesh.element.typed_id, normalized_mesh_index);
1381        assets.meshes.push(MeshAsset {
1382            name: mesh.element.name.to_string(),
1383            // Retain the stable ufbx mesh identity even when an earlier
1384            // source definition emitted no normalized primitive. The compact
1385            // normalized vector index is owned independently by MeshInstance.
1386            source_mesh_index,
1387            primitives,
1388        });
1389        assets.instances.push(MeshInstance {
1390            source_node_index,
1391            node: node_id,
1392            mesh: normalized_mesh_index,
1393            skin_joints,
1394            skin_ibms,
1395        });
1396    }
1397    assets.scenes.push(SceneAsset {
1398        source_scene_index: 0,
1399        name: None,
1400        roots: scene
1401            .nodes
1402            .iter()
1403            .filter(|node| node.is_root)
1404            .map(|node| node.element.typed_id as usize)
1405            .collect(),
1406    });
1407    assets.default_scene = Some(0);
1408    assets.source_skeleton = extract_source_skeleton(scene);
1409    (assets, conversion)
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::{ProjectedInfluence, project_skin_influence};
1415
1416    #[test]
1417    fn influence_projection_checks_sign_range_and_u16_cluster_narrowing() {
1418        assert_eq!(
1419            project_skin_influence(0.0, Some(0), 1, true),
1420            ProjectedInfluence::Absent
1421        );
1422        assert_eq!(
1423            project_skin_influence(-0.25, Some(0), 1, true),
1424            ProjectedInfluence::Rejected
1425        );
1426        assert_eq!(
1427            project_skin_influence(1.0, Some(1), 1, true),
1428            ProjectedInfluence::Rejected,
1429            "a source cluster index outside the declaration must not survive"
1430        );
1431        assert_eq!(
1432            project_skin_influence(
1433                1.0,
1434                Some(usize::from(u16::MAX) + 1),
1435                usize::from(u16::MAX) + 2,
1436                true,
1437            ),
1438            ProjectedInfluence::Rejected,
1439            "u32/usize cluster identity must not wrap while narrowing to u16"
1440        );
1441        assert_eq!(
1442            project_skin_influence(0.5, Some(7), 8, true),
1443            ProjectedInfluence::Retained(7, 0.5)
1444        );
1445    }
1446}