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