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