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