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