Skip to main content

animsmith_gltf/
lib.rs

1//! [`load_source`] and [`load_source_bytes`] read `.gltf`/`.glb` input into
2//! an immutable normalized-document plus raw-source-facts owner. [`load`] and
3//! [`load_bytes`] retain the legacy document-only surface, and [`write::write`]
4//! emits a document as
5//! glTF/GLB, and the [`fix`] module provides byte-surgical quaternion
6//! repairs. [`preflight_scale_source`] inventories the original raw source and
7//! fails closed on domains that current scale producers cannot preserve, while
8//! [`preflight_clip_track_source`] captures the narrower role-specific
9//! animation projection used by clip-track consumers.
10//! Malformed inputs report [`LoadError`]; output failures
11//! report [`WriteError`].
12//!
13//! This crate is the glTF/GLB format edge around `animsmith-core`.
14//! Loading preserves authored animation values for checks and also carries
15//! meshes, skins, materials, and embedded textures into
16//! [`Document::assets`](animsmith_core::model::Document::assets).
17//! Writing is a model round-trip for `convert` and `transform`; use
18//! [`fix::FixSession`] when a repair must preserve every non-animation byte
19//! of the original container.
20//!
21//! # Quick start
22//!
23//! Load a document and run the shared core checks:
24//!
25//! ```no_run
26//! fn lint_clip(
27//!     path: &std::path::Path,
28//! ) -> Result<Vec<animsmith_core::Finding>, Box<dyn std::error::Error>> {
29//!     let doc = animsmith_gltf::load(path)?;
30//!     let roles = animsmith_core::detect_profile(&doc.skeleton).unwrap_or_default();
31//!     let config = animsmith_core::Config::default();
32//!     let grids = animsmith_core::MetricGrids::new(&doc);
33//!     let ctx = animsmith_core::CheckCtx::new(&grids, &roles, &config);
34//!     let results = animsmith_core::evaluate_checks(
35//!         &ctx,
36//!         &animsmith_core::all_checks(),
37//!         animsmith_core::CheckSelection::All,
38//!     )?;
39//!     Ok(results
40//!         .into_iter()
41//!         .flat_map(|check| check.findings().to_vec())
42//!         .collect())
43//! }
44//! ```
45//!
46//! Compose byte-surgical repairs through one session:
47//!
48//! ```no_run
49//! fn repair_quaternions(
50//!     input: &std::path::Path,
51//!     output: &std::path::Path,
52//! ) -> Result<(), Box<dyn std::error::Error>> {
53//!     use animsmith_gltf::fix::{FixSession, Repair};
54//!
55//!     let mut session = FixSession::read(input)?;
56//!     session.apply(Repair::QuatNorm);
57//!     session.apply(Repair::QuatFlip);
58//!     session.write(input, output)?;
59//!     Ok(())
60//! }
61//! ```
62//!
63//! # Build and API status
64//!
65//! This crate has no public feature flags and supports the workspace MSRV,
66//! Rust 1.88. Its Rust API is pre-1.0; see `animsmith-core`'s crate-level API
67//! status for the shared stability boundary.
68//!
69//! See the GitHub [embedding guide] for crate selection and the [pipeline
70//! scenario guide] for raw-to-game-ready workflows.
71//!
72//! [embedding guide]: https://github.com/mmannerm/animsmith/blob/main/docs/embedding.md
73//! [pipeline scenario guide]: https://github.com/mmannerm/animsmith/blob/main/docs/pipeline-scenarios.md
74//!
75#![warn(missing_docs)]
76
77mod capability;
78pub mod fix;
79mod scale;
80pub mod write;
81
82pub use capability::{
83    GltfAccessorCapability, GltfAnimationChannelCapability, GltfAttributeCapability,
84    GltfBufferCapability, GltfBufferSourceKind, GltfBufferViewCapability, GltfCapabilityManifest,
85    GltfCapabilityViolation, GltfCapabilityViolationKind, GltfContainerKind,
86    GltfInstancingCapability, GltfNodeCapability, GltfNodeRestKind, GltfPrimitiveCapability,
87    GltfScalePreflightError, GltfScaleSource, GltfSkinCapability, preflight_clip_track_source,
88    preflight_clip_track_source_bytes, preflight_scale_source, preflight_scale_source_bytes,
89};
90pub use scale::{
91    GltfRawJsonDifference, GltfRawJsonDifferenceKind, GltfRawJsonDifferenceSummary,
92    GltfScaleArtifact, GltfScaleArtifactProof, GltfScaleRewriteError, capability_facts,
93    capability_facts_for_source, operation_capability_facts, operation_capability_facts_for_source,
94    prove_rewritten_artifact, prove_rewritten_rest_bind, rewrite_linear_units, rewrite_rest_bind,
95    rewrite_scale_plan,
96};
97
98use animsmith_core::model::{
99    AdditionalInfluenceSet, Bone, Clip, DecodedImageColorType, Document, ImageContainerFormat,
100    ImageSourceKind, ImageUnavailableReason, Interpolation, MaterialAsset, MaterialResourceAssets,
101    MaterialResourceCoverage, MaterialTextureSlot, MeshAsset, MeshInstance, NormalTextureAsset,
102    OcclusionTextureAsset, Primitive, Property, SceneAsset, SceneAssets, Skeleton,
103    SourceImageAsset, SourceImageInspection, SourceInfo, SourceInverseBindAccessor,
104    SourceInverseBindAccessorStatus, SourceMaterialAsset, SourceMaterialTextureBinding,
105    SourceNodeAsset, SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage,
106    SourceSkinAsset, SourceSkinAttachment, SourceTextureAsset, TextureAsset, Track, TrackValues,
107    Transform,
108};
109use animsmith_core::{
110    DependencyClosureBuilderV1, DependencyClosureError, DependencyClosureV1,
111    DependencyResourceKeyV1, DependencyResourceRefusalReasonV1,
112    DependencyResourceUnavailableReasonV1, InputIdentity, LoadedSource,
113    RAW_SOURCE_V1_MAX_TEXT_BYTES, RawSourceFactsBuilderV1, ResourceKeySyntaxV1, SourceAxisV1,
114    SourceChannelFactV1, SourceChannelPropertyV1, SourceClipFactV1, SourceComponentMaskV1,
115    SourceConstructFactV1, SourceConstructKindV1, SourceCoordinateBasisV1, SourceFactDomainV1,
116    SourceFactSetV1, SourceFactsError, SourceFormatV1, SourceInterpolationV1, SourceLinearUnitV1,
117    SourceLoaderDispositionV1, SourceLogicalLocatorV1, SourceObservationV1, SourceProvenanceKindV1,
118    SourceProvenanceV1, SourceResourceKindV1, SourceResourceLocatorV1, SourceResourceReferenceV1,
119    SourceTargetKindV1, SourceTargetV1, SourceTextV1, SourceTimeRangeV1, SourceUnavailableReasonV1,
120};
121use base64::Engine as _;
122use glam::{Mat4, Quat, Vec3};
123use gltf::accessor::{DataType as ComponentType, Dimensions as AccessorType};
124use image::{ColorType, ImageError, ImageFormat, ImageReader, Limits};
125use std::collections::{BTreeMap, BTreeSet};
126use std::io::{Cursor, Read};
127use std::path::{Path, PathBuf};
128
129mod addressability;
130
131/// Errors returned while loading `.gltf` or `.glb` input.
132///
133/// These are structural or operator errors. Semantic animation defects,
134/// such as non-unit quaternions or seam pops, load successfully and are
135/// reported by `animsmith-core` checks.
136#[derive(Debug, thiserror::Error)]
137#[non_exhaustive]
138pub enum LoadError {
139    /// The source file or one of its external buffers could not be read.
140    #[error("failed to read {path}: {source}")]
141    Io {
142        /// Path that failed to read.
143        path: String,
144        /// Underlying filesystem error.
145        source: std::io::Error,
146    },
147    /// A rooted external dependency required to construct the document failed.
148    #[error("external resource load failed: {0}")]
149    ExternalResource(ExternalResourceFailure),
150    /// The `gltf` parser rejected the container.
151    #[error("glTF parse error: {0}")]
152    Gltf(#[from] gltf::Error),
153    /// The format-neutral source-facts projection contradicted a core invariant.
154    #[error("invalid raw-source facts: {0}")]
155    SourceFacts(#[from] SourceFactsError),
156    /// Buffer resolution or GLB framing failed.
157    #[error("buffer resolution failed: {0}")]
158    Buffer(String),
159    /// Animation data is structurally malformed.
160    #[error("malformed animation data: {0}")]
161    Malformed(String),
162    /// The node graph is not a forest that can become a skeleton.
163    #[error("malformed node graph: {0}")]
164    Topology(String),
165    /// A primitive's vertex-attribute or index accessor declares an element
166    /// encoding the loader cannot read as authored. Either the file
167    /// contradicts the glTF vertex-attribute rules (a `VEC3` `TEXCOORD_0`, or
168    /// an integer `TEXCOORD_0`/`WEIGHTS_0` without `normalized: true`), or it
169    /// uses a spec-permitted encoding the `gltf` crate's reader has no decoder
170    /// for (a `KHR_mesh_quantization` `POSITION`). The message reports the
171    /// encodings that slot accepts.
172    #[error(
173        "mesh {mesh} primitive {primitive} {attribute}: accessor {accessor} is {found}, but the loader reads {expected}"
174    )]
175    PrimitiveEncoding {
176        /// glTF index of the mesh holding the primitive.
177        mesh: usize,
178        /// Index of the primitive within that mesh.
179        primitive: usize,
180        /// Attribute semantic or slot name, such as `TEXCOORD_0`.
181        attribute: String,
182        /// Index of the offending accessor.
183        accessor: usize,
184        /// Declared encoding, such as `VEC3 of FLOAT`.
185        found: String,
186        /// Encodings the reader accepts, such as
187        /// `VEC2 of normalized UNSIGNED_BYTE, normalized UNSIGNED_SHORT, or
188        /// FLOAT`.
189        expected: String,
190    },
191    /// A primitive's vertex-attribute or index accessor declares an element
192    /// the loader does read, but addresses its bytes in a way the reader
193    /// cannot walk: a buffer view whose own extent overflows, exceeds its
194    /// buffer declaration, or exceeds the bytes that actually resolved; a
195    /// `byteStride` shorter than the element it strides over; a `sparse`
196    /// block of count 0; or a `count`/`byteOffset` whose required extent
197    /// overflows or exceeds its buffer view. `gltf`'s `Validate`
198    /// relates an accessor to none of these. The first shapes can panic in
199    /// the reader; a merely short extent instead makes the reader return
200    /// `None`, which is equally unsafe to substitute with empty geometry.
201    #[error("mesh {mesh} primitive {primitive} {attribute}: accessor {accessor} {problem}")]
202    PrimitiveAccessorLayout {
203        /// glTF index of the mesh holding the primitive.
204        mesh: usize,
205        /// Index of the primitive within that mesh.
206        primitive: usize,
207        /// Attribute semantic or slot name, such as `TEXCOORD_0`.
208        attribute: String,
209        /// Index of the offending accessor.
210        accessor: usize,
211        /// What the reader could not walk, such as `reads its elements from
212        /// buffer view 0 at byteStride 4, shorter than the 12-byte element
213        /// it strides over`.
214        problem: String,
215    },
216    /// An animation sampler's `input` or `output` accessor addresses its
217    /// bytes in a way the reader cannot walk — the same layout shapes
218    /// [`Self::PrimitiveAccessorLayout`] names, reached through
219    /// `read_inputs`/`read_outputs` instead of through a primitive.
220    ///
221    /// This is the sampler's *layout*, not its *encoding*: an accessor
222    /// typed for a different element than the sampler's reader decodes is a
223    /// separate class and is not judged here.
224    #[error("clip '{clip}' node {node} sampler {slot}: accessor {accessor} {problem}")]
225    AnimationAccessorLayout {
226        /// Name the clip loads under, as reported by every other animation
227        /// diagnostic.
228        clip: String,
229        /// glTF index of the node the offending channel targets.
230        node: usize,
231        /// Sampler slot the accessor fills: `input` or `output`.
232        slot: &'static str,
233        /// Index of the offending accessor.
234        accessor: usize,
235        /// What the reader could not walk, such as `reads its sparse indices
236        /// from buffer view 2, whose byteOffset 18446744073709551615 plus
237        /// byteLength 12 is a byte extent that overflows`.
238        problem: String,
239    },
240    /// An animation sampler's `input` or `output` accessor declares an
241    /// element encoding the property-specific reader cannot decode. The
242    /// reader always expects scalar `FLOAT` key times; outputs are `VEC3` of
243    /// `FLOAT` for translation/scale, or one of glTF's five component
244    /// encodings as `VEC4` rotation and scalar morph weights.
245    #[error(
246        "animation {animation} sampler {sampler} {slot} for node {node} {property}: accessor {accessor} is {found}, but the loader reads {expected}"
247    )]
248    AnimationEncoding {
249        /// glTF index of the animation holding the sampler.
250        animation: usize,
251        /// Index of the sampler within that animation.
252        sampler: usize,
253        /// Sampler slot the accessor fills: `input` or `output`.
254        slot: &'static str,
255        /// glTF index of the node the channel targets.
256        node: usize,
257        /// Target property selecting the output reader.
258        property: &'static str,
259        /// Index of the offending accessor.
260        accessor: usize,
261        /// Declared encoding, such as `VEC3 of FLOAT`.
262        found: String,
263        /// Encodings the selected reader accepts.
264        expected: String,
265    },
266}
267
268/// Sanitized failure classes for external resources required by the loader.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
270pub enum ExternalResourceFailure {
271    /// Captured bytes did not authorize a filesystem root.
272    #[error("external resource requires an explicit trusted root")]
273    ResourceRootRequired,
274    /// The source-controlled locator or resolved path crossed the refusal boundary.
275    #[error("unsafe external buffer resource")]
276    Refused,
277    /// The resource exceeded the bounded closure-capture limits.
278    #[error("external buffer resource exceeds capture limits")]
279    CaptureLimitExceeded,
280    /// The accepted resource was missing, unreadable, or changed before capture.
281    #[error("external buffer resource is unavailable")]
282    Unavailable,
283}
284
285/// `fix` errors are classified by defect, not by phase: [`LoadError`]
286/// means the *input* was unreadable or malformed (even when detected
287/// while assembling the output, e.g. re-deriving GLB chunk bounds or
288/// validating an input-supplied buffer URI); [`WriteError`] means
289/// emitting the output failed.
290#[derive(Debug, thiserror::Error)]
291#[non_exhaustive]
292pub enum FixError {
293    /// The input container could not be read, parsed, or safely framed.
294    #[error(transparent)]
295    Load(#[from] LoadError),
296    /// The patched output container could not be emitted.
297    #[error(transparent)]
298    Write(#[from] WriteError),
299}
300
301/// Errors returned while writing a core document as glTF/GLB.
302#[derive(Debug, thiserror::Error)]
303#[non_exhaustive]
304pub enum WriteError {
305    /// The output file could not be written.
306    #[error("failed to write {path}: {source}")]
307    Io {
308        /// Path that failed to write.
309        path: String,
310        /// Underlying filesystem error.
311        source: std::io::Error,
312    },
313    /// glTF JSON serialization failed.
314    #[error("failed to serialize glTF JSON: {0}")]
315    Serialize(#[from] serde_json::Error),
316    /// A GLB length field would exceed the format's `u32` byte limit.
317    #[error(
318        "GLB too large: {field} is {bytes} bytes, exceeding the 4 GiB limit of a GLB u32 length field"
319    )]
320    TooLarge {
321        /// Name of the GLB length field or chunk that overflowed.
322        field: &'static str,
323        /// Actual byte count that could not fit in the GLB field.
324        bytes: usize,
325    },
326}
327
328/// Convert an external-resource URI to the shared safe relative key.
329///
330/// This legacy helper is retained for writer and repair paths. Loader-side
331/// resource capture additionally validates a trusted root and rejects
332/// symlinks before opening the key.
333pub(crate) fn safe_external_buffer_path(uri: &str) -> Result<PathBuf, LoadError> {
334    DependencyResourceKeyV1::from_source_str(uri, ResourceKeySyntaxV1::GltfUri)
335        .map(|key| PathBuf::from(key.as_str()))
336        .map_err(|_| unsafe_external_uri())
337}
338
339fn unsafe_external_uri() -> LoadError {
340    LoadError::Buffer("unsafe external buffer URI: expected a relative child path".to_owned())
341}
342
343/// Reject a GLB whose 12-byte header declares a total length the file
344/// can't back, *before* handing the bytes to the `gltf` container parser.
345/// That parser computes `declared_len - HEADER_LEN`: a length below the
346/// header size underflows (panics under overflow checks, e.g. every debug
347/// build and `cargo test`), and a length past EOF drives a length-field
348/// allocation — both invariant-1 violations on arbitrary input. Plain
349/// glTF JSON (no `glTF` magic) passes through untouched. Found by the
350/// `gltf_load` / `gltf_fix_quat_hemisphere` fuzz targets (see `fuzz/`).
351pub(crate) fn validate_glb_framing(bytes: &[u8]) -> Result<(), LoadError> {
352    const GLB_MAGIC: &[u8; 4] = b"glTF";
353    const GLB_HEADER_LEN: usize = 12;
354    if !bytes.starts_with(GLB_MAGIC) {
355        return Ok(());
356    }
357    if bytes.len() < GLB_HEADER_LEN {
358        return Err(LoadError::Buffer(
359            "truncated GLB: file ends before the 12-byte header".into(),
360        ));
361    }
362    let declared =
363        u32::from_le_bytes(bytes[8..12].try_into().expect("slice has four bytes")) as usize;
364    if declared < GLB_HEADER_LEN || declared > bytes.len() {
365        return Err(LoadError::Buffer(format!(
366            "GLB header declares {declared} bytes but the file is {}",
367            bytes.len()
368        )));
369    }
370    Ok(())
371}
372
373/// Detect an `extensions` object key anywhere in the exact JSON payload.
374///
375/// `gltf-json` discards unknown extension payloads when its optional
376/// `extensions` feature is disabled, and it does not require payload names to
377/// appear in `extensionsUsed`. This allocation-free, nonrecursive scan keeps
378/// dependency closure coverage conservative even for that undeclared shape.
379/// It runs only after the ordinary glTF parser has accepted the JSON.
380fn has_extension_object(primary_bytes: &[u8]) -> bool {
381    let Some(json) = source_json_payload(primary_bytes) else {
382        return true;
383    };
384    json_has_object_key(json, b"extensions")
385}
386
387fn source_json_payload(primary_bytes: &[u8]) -> Option<&[u8]> {
388    if !primary_bytes.starts_with(b"glTF") {
389        return Some(primary_bytes);
390    }
391    const GLB_JSON_OFFSET: usize = 20;
392    let length = u32::from_le_bytes(primary_bytes.get(12..16)?.try_into().ok()?) as usize;
393    primary_bytes.get(GLB_JSON_OFFSET..GLB_JSON_OFFSET.checked_add(length)?)
394}
395
396fn json_has_object_key(json: &[u8], target: &[u8]) -> bool {
397    let mut cursor = 0;
398    while cursor < json.len() {
399        if json[cursor] != b'"' {
400            cursor += 1;
401            continue;
402        }
403        cursor += 1;
404        let mut target_index = 0;
405        let mut candidate = true;
406        loop {
407            let Some(&byte) = json.get(cursor) else {
408                return true;
409            };
410            if byte == b'"' {
411                cursor += 1;
412                break;
413            }
414            let decoded = if byte == b'\\' {
415                cursor += 1;
416                let Some(&escape) = json.get(cursor) else {
417                    return true;
418                };
419                match escape {
420                    b'u' => {
421                        let Some(hex) = json.get(cursor + 1..cursor + 5) else {
422                            return true;
423                        };
424                        cursor += 5;
425                        decode_json_hex_quad(hex).and_then(|value| u8::try_from(value).ok())
426                    }
427                    b'"' | b'\\' | b'/' => {
428                        cursor += 1;
429                        Some(escape)
430                    }
431                    b'b' | b'f' | b'n' | b'r' | b't' => {
432                        cursor += 1;
433                        None
434                    }
435                    _ => return true,
436                }
437            } else {
438                cursor += 1;
439                Some(byte)
440            };
441            if candidate {
442                match decoded {
443                    Some(decoded) if target.get(target_index) == Some(&decoded) => {
444                        target_index += 1;
445                    }
446                    _ => candidate = false,
447                }
448            }
449        }
450        let mut delimiter = cursor;
451        while matches!(json.get(delimiter), Some(b' ' | b'\t' | b'\r' | b'\n')) {
452            delimiter += 1;
453        }
454        if candidate && target_index == target.len() && json.get(delimiter).copied() == Some(b':') {
455            return true;
456        }
457    }
458    false
459}
460
461fn decode_json_hex_quad(hex: &[u8]) -> Option<u16> {
462    hex.iter().try_fold(0_u16, |value, byte| {
463        let digit = match byte {
464            b'0'..=b'9' => u16::from(*byte - b'0'),
465            b'a'..=b'f' => u16::from(*byte - b'a' + 10),
466            b'A'..=b'F' => u16::from(*byte - b'A' + 10),
467            _ => return None,
468        };
469        Some((value << 4) | digit)
470    })
471}
472
473/// Reject animation data the `gltf` crate leaves un-validated but then
474/// panics on. Its hand-written `Animation::validate` checks samplers and
475/// the sampler *index*, but not the pieces below — each slips past
476/// `Gltf::from_slice`'s validation and crashes a high-level getter on
477/// arbitrary input (invariant-1). Found by the `gltf_load` /
478/// `gltf_fix_quat_hemisphere` fuzz targets (see `fuzz/`).
479///
480/// - An unknown `target.path` (`Checked::Invalid`) or out-of-range
481///   `target.node`: `Target::property()` / `Target::node()` both
482///   `.unwrap()`.
483///
484/// Element encodings are judged separately after this raw channel validation
485/// makes the high-level target accessors safe to call; see
486/// [`validate_animation_accessor_encodings`].
487pub(crate) fn validate_animation_channels(root: &gltf::json::Root) -> Result<(), LoadError> {
488    use gltf::json::validation::Checked;
489    let node_count = root.nodes.len();
490    for (ai, anim) in root.animations.iter().enumerate() {
491        for (ci, channel) in anim.channels.iter().enumerate() {
492            if matches!(channel.target.path, Checked::Invalid) {
493                return Err(LoadError::Malformed(format!(
494                    "animation {ai} channel {ci}: unknown target path"
495                )));
496            }
497            if channel.target.node.value() >= node_count {
498                return Err(LoadError::Malformed(format!(
499                    "animation {ai} channel {ci}: target node index {} out of range ({node_count} nodes)",
500                    channel.target.node.value()
501                )));
502            }
503        }
504    }
505    Ok(())
506}
507
508/// Validate every animation reader boundary after raw channel indices and
509/// target paths are known to be safe to project through `gltf`'s high-level
510/// API.
511pub(crate) fn validate_animations(doc: &gltf::Document) -> Result<(), LoadError> {
512    validate_animation_channels(doc.as_json())?;
513    validate_animation_accessor_encodings(doc)
514}
515
516/// Apply the typed glTF validation boundary while admitting declarations for
517/// extensions this loader inventories but does not implement. The `gltf`
518/// crate reports those required-extension declarations as `Unsupported`; all
519/// structural validation failures remain load errors.
520pub(crate) fn validate_document(document: &gltf::Document) -> Result<(), gltf::Error> {
521    use gltf::json::validation::{Error, Validate};
522
523    let root = document.as_json();
524    let mut errors = Vec::new();
525    root.validate(root, gltf::json::Path::new, &mut |path, error| {
526        errors.push((path(), error));
527    });
528    if errors.iter().all(|(_, error)| *error == Error::Unsupported) {
529        Ok(())
530    } else {
531        Err(gltf::Error::Validation(errors))
532    }
533}
534
535/// The element encoding one `gltf` reader can decode: the single accessor
536/// `type` its element matches, and the `componentType`s it dispatches on.
537struct ReaderEncoding {
538    /// Accessor `type` whose element size the reader's element type equals.
539    accessor_type: AccessorType,
540    /// Accepted `componentType`s, in glTF enum order.
541    component_types: &'static [ComponentType],
542    /// Whether accepted integer components must declare `normalized: true`.
543    ///
544    /// `gltf`'s float conversion rescales integer texture coordinates and
545    /// weights even when this flag is absent, so the loader must enforce the
546    /// declaration before it lets those readers reinterpret the values.
547    normalized_integers: bool,
548}
549
550/// `read_positions` is `Iter<[f32; 3]>` with no dispatch on the accessor.
551/// `KHR_mesh_quantization` also permits normalized `BYTE`/`SHORT` here; the
552/// `gltf` reader has no decoder for those, so they are refused rather than
553/// misread.
554const POSITION_ENCODING: ReaderEncoding = ReaderEncoding {
555    accessor_type: AccessorType::Vec3,
556    component_types: &[ComponentType::F32],
557    normalized_integers: false,
558};
559/// `read_normals` is `Iter<[f32; 3]>`, with the same quantization caveat as
560/// [`POSITION_ENCODING`].
561const NORMAL_ENCODING: ReaderEncoding = ReaderEncoding {
562    accessor_type: AccessorType::Vec3,
563    component_types: &[ComponentType::F32],
564    normalized_integers: false,
565};
566/// `read_tex_coords` dispatches over the three encodings glTF permits for
567/// `TEXCOORD_n`: `FLOAT`, or normalized `UNSIGNED_BYTE`/`UNSIGNED_SHORT`.
568/// `gltf`'s `into_f32()` rescales either integer width even when the accessor
569/// omits `normalized: true`, so admission must consult the flag before that
570/// reader is built. Otherwise measurements would report values the document
571/// never authorized the loader to derive.
572const TEX_COORD_ENCODING: ReaderEncoding = ReaderEncoding {
573    accessor_type: AccessorType::Vec2,
574    component_types: &[ComponentType::U8, ComponentType::U16, ComponentType::F32],
575    normalized_integers: true,
576};
577/// `read_joints` dispatches over both encodings glTF permits for `JOINTS_n`.
578/// Joint indices are never `FLOAT` and never normalized.
579const JOINTS_ENCODING: ReaderEncoding = ReaderEncoding {
580    accessor_type: AccessorType::Vec4,
581    component_types: &[ComponentType::U8, ComponentType::U16],
582    normalized_integers: false,
583};
584/// `read_weights` dispatches over the three encodings glTF permits for
585/// `WEIGHTS_n`: `FLOAT`, or normalized `UNSIGNED_BYTE`/`UNSIGNED_SHORT`,
586/// with the same admission requirement as [`TEX_COORD_ENCODING`].
587const WEIGHTS_ENCODING: ReaderEncoding = ReaderEncoding {
588    accessor_type: AccessorType::Vec4,
589    component_types: &[ComponentType::U8, ComponentType::U16, ComponentType::F32],
590    normalized_integers: true,
591};
592/// `read_indices` dispatches over the three index encodings glTF permits.
593const INDEX_ENCODING: ReaderEncoding = ReaderEncoding {
594    accessor_type: AccessorType::Scalar,
595    component_types: &[ComponentType::U8, ComponentType::U16, ComponentType::U32],
596    normalized_integers: false,
597};
598/// `read_inverse_bind_matrices` is `Iter<[[f32; 4]; 4]>`; glTF permits only
599/// `MAT4` of `FLOAT` there.
600const INVERSE_BIND_ENCODING: ReaderEncoding = ReaderEncoding {
601    accessor_type: AccessorType::Mat4,
602    component_types: &[ComponentType::F32],
603    normalized_integers: false,
604};
605/// `read_inputs` is an un-dispatched `Iter<f32>`.
606const ANIMATION_INPUT_ENCODING: ReaderEncoding = ReaderEncoding {
607    accessor_type: AccessorType::Scalar,
608    component_types: &[ComponentType::F32],
609    normalized_integers: false,
610};
611/// Translation and scale outputs are un-dispatched `Iter<[f32; 3]>` values.
612const ANIMATION_VEC3_OUTPUT_ENCODING: ReaderEncoding = ReaderEncoding {
613    accessor_type: AccessorType::Vec3,
614    component_types: &[ComponentType::F32],
615    normalized_integers: false,
616};
617/// Rotation outputs dispatch over every quaternion encoding glTF permits and
618/// the `gltf` reader decodes.
619const ANIMATION_ROTATION_OUTPUT_ENCODING: ReaderEncoding = ReaderEncoding {
620    accessor_type: AccessorType::Vec4,
621    component_types: &[
622        ComponentType::I8,
623        ComponentType::U8,
624        ComponentType::I16,
625        ComponentType::U16,
626        ComponentType::F32,
627    ],
628    normalized_integers: false,
629};
630/// Morph-weight outputs dispatch over the same five component encodings as
631/// rotations, but use scalar elements because one key contains one scalar per
632/// morph target. The loader does not retain them, but it still constructs the
633/// property-selected reader before skipping them, so their reader boundary
634/// must remain both safe and spec-complete.
635const ANIMATION_WEIGHT_OUTPUT_ENCODING: ReaderEncoding = ReaderEncoding {
636    accessor_type: AccessorType::Scalar,
637    component_types: &[
638        ComponentType::I8,
639        ComponentType::U8,
640        ComponentType::I16,
641        ComponentType::U16,
642        ComponentType::F32,
643    ],
644    normalized_integers: false,
645};
646
647/// Reject sampler accessors whose declared element disagrees with the exact
648/// `gltf` reader selected by their slot and target property.
649fn validate_animation_accessor_encodings(doc: &gltf::Document) -> Result<(), LoadError> {
650    for animation in doc.animations() {
651        for channel in animation.channels() {
652            let sampler = channel.sampler();
653            let target = channel.target();
654            let node = target.node().index();
655            let property = target.property();
656            check_animation_accessor_encoding(
657                animation.index(),
658                sampler.index(),
659                node,
660                animation_property_name(property),
661                "input",
662                &sampler.input(),
663                &ANIMATION_INPUT_ENCODING,
664            )?;
665            let output_encoding = match property {
666                gltf::animation::Property::Translation | gltf::animation::Property::Scale => {
667                    &ANIMATION_VEC3_OUTPUT_ENCODING
668                }
669                gltf::animation::Property::Rotation => &ANIMATION_ROTATION_OUTPUT_ENCODING,
670                gltf::animation::Property::MorphTargetWeights => &ANIMATION_WEIGHT_OUTPUT_ENCODING,
671            };
672            check_animation_accessor_encoding(
673                animation.index(),
674                sampler.index(),
675                node,
676                animation_property_name(property),
677                "output",
678                &sampler.output(),
679                output_encoding,
680            )?;
681        }
682    }
683    Ok(())
684}
685
686fn check_animation_accessor_encoding(
687    animation: usize,
688    sampler: usize,
689    node: usize,
690    property: &'static str,
691    slot: &'static str,
692    accessor: &gltf::Accessor<'_>,
693    required: &ReaderEncoding,
694) -> Result<(), LoadError> {
695    if encoding_matches(accessor, required) {
696        return Ok(());
697    }
698    Err(LoadError::AnimationEncoding {
699        animation,
700        sampler,
701        slot,
702        node,
703        property,
704        accessor: accessor.index(),
705        found: format!(
706            "{} of {}",
707            accessor_type_name(accessor.dimensions()),
708            component_type_name(accessor.data_type())
709        ),
710        expected: describe_encoding(required),
711    })
712}
713
714fn animation_property_name(property: gltf::animation::Property) -> &'static str {
715    match property {
716        gltf::animation::Property::Translation => "translation",
717        gltf::animation::Property::Rotation => "rotation",
718        gltf::animation::Property::Scale => "scale",
719        gltf::animation::Property::MorphTargetWeights => "weights",
720    }
721}
722
723/// The encoding the loader requires of one attribute semantic, or `None`
724/// when no reader is ever built for it.
725///
726/// The match is exhaustive over `gltf::Semantic` so that a **new variant in
727/// `gltf`** has to be answered here before this crate compiles. That is the
728/// whole of what the compiler enforces, and it is worth stating what it
729/// does *not*: `gltf::Semantic` is a plain enum, so an exhaustive match
730/// cannot notice a new *read site*. Adding `reader.read_tangents()` or
731/// `reader.read_tex_coords(1)` to `extract_assets` compiles unchanged
732/// against the `None` arms below and reinstates the panic this module
733/// exists to prevent — a `TANGENT` declared `VEC3` of `FLOAT` would go
734/// straight into `Iter::<[f32; 4]>::new`.
735///
736/// So each `None` is a claim about `extract_assets` as written — "no reader
737/// is ever built for this semantic" — that only this comment and the reason
738/// recorded on each arm keep true. Turning one into a read means giving it
739/// an encoding here first.
740fn required_attribute_encoding(semantic: &gltf::Semantic) -> Option<&'static ReaderEncoding> {
741    match semantic {
742        gltf::Semantic::Positions => Some(&POSITION_ENCODING),
743        gltf::Semantic::Normals => Some(&NORMAL_ENCODING),
744        // Only set 0 of each of these reaches a reader; the core model
745        // carries one UV channel and one influence set.
746        gltf::Semantic::TexCoords(0) => Some(&TEX_COORD_ENCODING),
747        gltf::Semantic::Joints(0) => Some(&JOINTS_ENCODING),
748        gltf::Semantic::Weights(0) => Some(&WEIGHTS_ENCODING),
749        // Not read. `TANGENT` and `COLOR_n` have no core-model slot, and
750        // `TEXCOORD_n`/`JOINTS_n`/`WEIGHTS_n` above set 0 are recorded from
751        // `Accessor::count()` alone (see `additional_influence_sets`), which
752        // never builds a reader and so cannot panic on a mistyped accessor.
753        gltf::Semantic::Tangents
754        | gltf::Semantic::Colors(_)
755        | gltf::Semantic::TexCoords(_)
756        | gltf::Semantic::Joints(_)
757        | gltf::Semantic::Weights(_) => None,
758    }
759}
760
761/// Reject primitive accessors the reader that will decode them cannot
762/// decode. `gltf`'s `Validate` checks each accessor in isolation, never
763/// cross-checks it against the slot that references it, and never relates
764/// it to the buffer view it reads — so a `VEC3` `TEXCOORD_0` and a
765/// `POSITION` on a stride-4 view both parse cleanly and then trip a
766/// `debug_assert`, an `unreachable!()`, or an arithmetic overflow inside
767/// the reader: a panic on arbitrary input (invariant-1).
768///
769/// Two shapes of *element encoding* leak through, both fatal:
770///
771/// - **Wrong `type`.** `Iter::<T>::new` asserts `size_of::<T>() ==
772///   accessor.size()`. A `VEC3` `TEXCOORD_0` is 12 bytes against
773///   `[f32; 2]`'s 8, so `read_tex_coords` panics.
774/// - **Wrong `componentType`.** The dispatching readers (`read_tex_coords`,
775///   `read_joints`, `read_weights`, `read_indices`) have an `unreachable!()`
776///   arm for the component types they cannot decode, so a `BYTE`
777///   `TEXCOORD_0` panics there instead.
778///
779/// A third shape is worse than a panic: when the wrong `componentType`
780/// happens to preserve the element size and the reader does not dispatch on
781/// it, nothing fires and the bytes are silently reinterpreted. A `VEC3` of
782/// `UNSIGNED_INT` `NORMAL` is 12 bytes just like `[f32; 3]`, so every normal
783/// would load as the float reading of an integer's bits. Refusing that too
784/// is what keeps invariant-9 honest for these slots: what the loader reads
785/// is decoded as the encoding it declares, or the file is refused — never
786/// reinterpreted as a different element.
787///
788/// [`unreadable_primitive_layout`] covers the rest: an accessor whose element
789/// the loader does read, addressed in a way the reader cannot walk against
790/// either its declarations or its resolved bytes.
791///
792/// ## What this does not promise
793///
794/// "Never reinterpreted" is narrower than "every authored value survives",
795/// and deliberately so, in two directions.
796///
797/// *Values are rescaled where the accessor authorizes scaling.*
798/// `into_f32()` rescales a normalized `UNSIGNED_BYTE`/`UNSIGNED_SHORT`
799/// `TEXCOORD_0` or `WEIGHTS_0` from full scale (see
800/// [`TEX_COORD_ENCODING`]). Checks therefore see those slots as floats, not
801/// as the integers on disk. An integer accessor missing `normalized: true`
802/// is refused before this reader boundary because `gltf` would otherwise
803/// perform the same rescaling without the document declaring it.
804///
805/// *Unreadable authored values are not absence.*
806/// [`unreadable_primitive_layout`] relates every dense and sparse walk to the
807/// buffer view and resolved buffer bytes that must satisfy it. A short
808/// `POSITION` or index read is refused rather than mapped to an empty vector,
809/// so checks can distinguish an authored empty slot from authored values the
810/// loader could not read. Inverse binds deliberately use the other established
811/// treatment: their shortfall remains explicit source evidence through
812/// [`inverse_bind_is_readable`].
813///
814/// ## Scope
815///
816/// Only what the loader actually reads is checked. Non-triangle primitives
817/// are skipped whole by `extract_assets`, so their accessors are never
818/// decoded and are not judged here. Within an ingested primitive the check
819/// is deliberately independent of the count-zero and `JOINTS_0`/`WEIGHTS_0`
820/// pairing guards that decide whether a particular read happens: those
821/// guards are free to move without opening a hole.
822///
823/// A skin's `inverseBindMatrices` accessor has the same panics, but not the
824/// same answer — see [`inverse_bind_is_readable`].
825fn validate_primitive_accessors(
826    doc: &gltf::Document,
827    buffers: &[Vec<u8>],
828) -> Result<(), LoadError> {
829    for mesh in doc.meshes() {
830        for primitive in mesh.primitives() {
831            if primitive.mode() != gltf::mesh::Mode::Triangles {
832                continue;
833            }
834            for (semantic, accessor) in primitive.attributes() {
835                let Some(required) = required_attribute_encoding(&semantic) else {
836                    continue;
837                };
838                check_primitive_accessor(
839                    &mesh,
840                    &primitive,
841                    &semantic.to_string(),
842                    &accessor,
843                    required,
844                    buffers,
845                )?;
846            }
847            if let Some(accessor) = primitive.indices() {
848                check_primitive_accessor(
849                    &mesh,
850                    &primitive,
851                    "indices",
852                    &accessor,
853                    &INDEX_ENCODING,
854                    buffers,
855                )?;
856            }
857        }
858    }
859    Ok(())
860}
861
862/// One slot of one ingested primitive: the element must be one the reader
863/// decodes, and its bytes must be laid out so the reader can walk them.
864fn check_primitive_accessor(
865    mesh: &gltf::Mesh<'_>,
866    primitive: &gltf::Primitive<'_>,
867    attribute: &str,
868    accessor: &gltf::Accessor<'_>,
869    required: &ReaderEncoding,
870    buffers: &[Vec<u8>],
871) -> Result<(), LoadError> {
872    if !encoding_matches(accessor, required) {
873        return Err(LoadError::PrimitiveEncoding {
874            mesh: mesh.index(),
875            primitive: primitive.index(),
876            attribute: attribute.to_owned(),
877            accessor: accessor.index(),
878            found: format!(
879                "{} of {}",
880                accessor_type_name(accessor.dimensions()),
881                component_type_name(accessor.data_type())
882            ),
883            expected: describe_encoding(required),
884        });
885    }
886    if let Some(problem) = unreadable_primitive_layout(accessor, buffers) {
887        return Err(LoadError::PrimitiveAccessorLayout {
888            mesh: mesh.index(),
889            primitive: primitive.index(),
890            attribute: attribute.to_owned(),
891            accessor: accessor.index(),
892            problem,
893        });
894    }
895    Ok(())
896}
897
898/// Why a modeled primitive accessor cannot be walked against both the JSON
899/// declarations and the bytes that buffer resolution actually returned.
900fn unreadable_primitive_layout(
901    accessor: &gltf::Accessor<'_>,
902    buffers: &[Vec<u8>],
903) -> Option<String> {
904    unreadable_layout(accessor).or_else(|| {
905        if let Some(view) = accessor.view()
906            && let Some(problem) = loaded_buffer_shortfall("elements", &view, buffers)
907        {
908            return Some(problem);
909        }
910        let sparse = accessor.sparse()?;
911        loaded_buffer_shortfall("sparse indices", &sparse.indices().view(), buffers)
912            .or_else(|| loaded_buffer_shortfall("sparse values", &sparse.values().view(), buffers))
913    })
914}
915
916/// A resolved external file, data URI, or GLB BIN chunk can be shorter than
917/// the buffer's declared `byteLength`. `gltf` slices the complete view before
918/// walking an accessor, so even a smaller accessor extent cannot be decoded
919/// when that declared view extends beyond the bytes that actually loaded.
920fn loaded_buffer_shortfall(
921    subject: &str,
922    view: &gltf::buffer::View<'_>,
923    buffers: &[Vec<u8>],
924) -> Option<String> {
925    let view_end = view_end(view)?;
926    let buffer_index = view.buffer().index();
927    let loaded_length = buffers.get(buffer_index).map_or(0, Vec::len);
928    (view_end > loaded_length).then(|| {
929        format!(
930            "reads its {subject} from buffer view {}, whose byte extent ends at {view_end} \
931             beyond loaded buffer {buffer_index}'s {loaded_length} bytes",
932            view.index()
933        )
934    })
935}
936
937/// One sampler accessor of one animation channel the loader reads: its
938/// bytes must be laid out so the channel's reader can walk them.
939///
940/// `read_inputs` and `read_outputs` each build their own `Iter` over their
941/// own accessor, so an `input` and an `output` are judged separately and
942/// both before either reader exists. The layout shapes are exactly the ones
943/// [`unreadable_layout`] names for a primitive slot — the panic is in the
944/// shared accessor iterator, not in anything primitive-specific.
945///
946/// **Layout only.** This judges *how* a sampler accessor addresses its
947/// bytes: its view's extent, its `byteStride`, its `sparse` count, and the
948/// extent its own `count` walks. [`validate_animation_accessor_encodings`]
949/// has already judged *what* element the accessor declares against its
950/// property-selected reader; keeping the checks separate preserves their
951/// distinct public error classifications.
952fn check_sampler_accessor(
953    clip: &str,
954    node: usize,
955    slot: &'static str,
956    accessor: &gltf::Accessor<'_>,
957) -> Result<(), LoadError> {
958    match unreadable_layout(accessor) {
959        Some(problem) => Err(LoadError::AnimationAccessorLayout {
960            clip: clip.to_owned(),
961            node,
962            slot,
963            accessor: accessor.index(),
964            problem,
965        }),
966        None => Ok(()),
967    }
968}
969
970/// Why `gltf`'s reader cannot walk an accessor's bytes, independent of the
971/// element it declares — or `None` when it can.
972///
973/// `Iter::new` first slices a buffer view as `view.byteOffset +
974/// view.byteLength`, then steps the accessor inside it as `byteOffset +
975/// byteStride * (count - 1) + size` — over the accessor's own buffer view
976/// and, for a sparse accessor, over its index and value views too. Six
977/// layout failures are unsafe here, and `gltf-json`'s `Validate` catches
978/// none of them:
979///
980/// - **A buffer view whose own extent overflows.** `USize64`'s validator
981///   only rejects a value past `usize`, and `View`'s derived `Validate`
982///   relates `byteOffset` neither to `byteLength` nor to the buffer, so
983///   `buffer_view_slice` adds the two unchecked before any accessor
984///   arithmetic runs (see [`view_end`]).
985/// - **A buffer view whose extent exceeds its buffer.** The same missing
986///   relationship lets a view point beyond the buffer's declared bytes;
987///   the reader then has no slice to walk and answers `None`.
988/// - **A `byteStride` shorter than the element.** `Stride`'s validator
989///   accepts any `4..=252`, so a `VEC3` of `FLOAT` `POSITION` on a stride-4
990///   view validates and then trips `debug_assert!(stride >=
991///   size_of::<T>())`. Losing that assertion in a release build does not
992///   make the shortfall survivable: the truncated slice reaches
993///   `Item::from_slice`, whose `assert!(slice.len() >= N *
994///   size_of::<T>())` is a *hard* assert, and it fires on the ordinary
995///   dense path just as it does on the sparse one — which compiles no
996///   stride assertion at all, in either profile. Deleting this branch and
997///   running the suite under `--release` panics a dense `POSITION` at
998///   `util.rs:266`. This is the one shape here that still panics with
999///   `overflow-checks` off; the arithmetic overflow/underflow shapes go
1000///   quiet instead, which is worse.
1001/// - **`sparse.count` of 0.** `sparse_count - 1` underflows, with or
1002///   without a base `bufferView`. Every count-zero guard in this loader
1003///   reads `Accessor::count()`; none of them sees this one.
1004/// - **An extent that overflows `usize`.** Nothing bounds `count` or
1005///   `sparse.count`, so `stride * (count - 1)` overflows on a large enough
1006///   declaration.
1007/// - **An extent that exceeds its buffer view.** A merely large `count` or
1008///   `byteOffset` does not overflow, but `Iter::new` answers `None` when the
1009///   declared view cannot satisfy the walk. Treating that as an empty vector
1010///   would silently replace authored geometry with absence. Sparse index and
1011///   value views are judged on the same boundary.
1012///
1013/// "`gltf-json`'s `Validate` catches none of them" is a claim about this
1014/// crate's own JSON validation, which is all that stands between
1015/// `from_slice` and the reader — not about the Khronos glTF-Validator,
1016/// which does name several of these (`ACCESSOR_SMALL_BYTESTRIDE`, and a
1017/// `sparse.count` of 0 against the schema's `minimum: 1`). That separate
1018/// tool is what `docs/cli.md` points authors at; this loader never runs it.
1019///
1020/// One near neighbour is deliberately *not* refused. `Accessor::count()` of
1021/// 0 stays loadable: every read site already treats a count-zero accessor as
1022/// absent, so no reader is built and the arithmetic is never reached.
1023fn unreadable_layout(accessor: &gltf::Accessor<'_>) -> Option<String> {
1024    let size = accessor.size();
1025    if let Some(view) = accessor.view()
1026        && let Some(problem) =
1027            unwalkable("elements", &view, accessor.offset(), accessor.count(), size)
1028    {
1029        return Some(problem);
1030    }
1031    let sparse = accessor.sparse()?;
1032    if sparse.count() == 0 {
1033        return Some("declares a sparse block of count 0, which its reader cannot walk".to_owned());
1034    }
1035    let indices = sparse.indices();
1036    let values = sparse.values();
1037    // A sparse index is 1, 2, or 4 bytes and `byteStride` is validated into
1038    // `4..=252`, so an index view can never stride shorter than the element
1039    // it strides over; only its extent can overflow.
1040    unwalkable(
1041        "sparse indices",
1042        &indices.view(),
1043        indices.offset(),
1044        sparse.count(),
1045        indices.index_type().size(),
1046    )
1047    .or_else(|| {
1048        unwalkable(
1049            "sparse values",
1050            &values.view(),
1051            values.offset(),
1052            sparse.count(),
1053            size,
1054        )
1055    })
1056}
1057
1058/// Where a buffer view's declared extent ends, or `None` when it has no
1059/// end: `byteOffset` and `byteLength` are both file-derived, and nothing in
1060/// `gltf-json` relates them to each other or to the buffer, so their sum
1061/// overflows on arbitrary input — a panic in a debug build, and a wrong
1062/// extent in a release one.
1063///
1064/// No read of a view's declared extent in this loader escapes that answer.
1065/// The image path slices through this function directly. Every reader-driven
1066/// read is gated instead: the reader would perform the same unchecked add
1067/// itself, so [`unwalkable`] asks this before the reader is ever built — for a
1068/// primitive slot in [`check_primitive_accessor`], for a skin's
1069/// `inverseBindMatrices` in [`inverse_bind_is_readable`], and for an animation
1070/// sampler's `input` and `output` in [`check_sampler_accessor`]. Primitive
1071/// slots additionally compare the result with the resolved buffer length in
1072/// [`loaded_buffer_shortfall`].
1073fn view_end(view: &gltf::buffer::View<'_>) -> Option<usize> {
1074    view.offset().checked_add(view.length())
1075}
1076
1077/// Whether one strided walk of `count` elements of `size` bytes is one
1078/// `Iter::new` can perform, phrased for the refusal message when it is not.
1079fn unwalkable(
1080    subject: &str,
1081    view: &gltf::buffer::View<'_>,
1082    offset: usize,
1083    count: usize,
1084    size: usize,
1085) -> Option<String> {
1086    // `Iter::new` slices the view before it strides over anything, so the
1087    // view's own extent is judged before the accessor's.
1088    let Some(view_end) = view_end(view) else {
1089        return Some(format!(
1090            "reads its {subject} from buffer view {}, whose byteOffset {} plus byteLength {} \
1091             is a byte extent that overflows",
1092            view.index(),
1093            view.offset(),
1094            view.length()
1095        ));
1096    };
1097    if view_end > view.buffer().length() {
1098        return Some(format!(
1099            "reads its {subject} from buffer view {}, whose byte extent ends at {view_end} \
1100             beyond buffer {}'s byteLength {}",
1101            view.index(),
1102            view.buffer().index(),
1103            view.buffer().length()
1104        ));
1105    }
1106    let stride = view.stride().unwrap_or(size);
1107    if stride < size {
1108        return Some(format!(
1109            "reads its {subject} from buffer view {} at byteStride {stride}, \
1110             shorter than the {size}-byte element it strides over",
1111            view.index()
1112        ));
1113    }
1114    let required_end = count
1115        .checked_sub(1)
1116        .and_then(|last| stride.checked_mul(last))
1117        .and_then(|span| span.checked_add(offset))
1118        .and_then(|end| end.checked_add(size));
1119    // `count` 0 never reaches that arithmetic: no read site builds a reader
1120    // for a count-zero accessor, so its underflow is unreachable rather than
1121    // refused.
1122    if count == 0 {
1123        return None;
1124    }
1125    let Some(required_end) = required_end else {
1126        return Some(format!(
1127            "walks {count} {subject} of {size} bytes at byteStride {stride} \
1128             from byteOffset {offset}, a byte extent that overflows"
1129        ));
1130    };
1131    (required_end > view.length()).then(|| {
1132        format!(
1133            "walks {count} {subject} of {size} bytes at byteStride {stride} from byteOffset \
1134             {offset}, requiring byte extent {required_end} beyond buffer view {}'s byteLength {}",
1135            view.index(),
1136            view.length()
1137        )
1138    })
1139}
1140
1141/// Whether a skin's `inverseBindMatrices` accessor is one
1142/// `read_inverse_bind_matrices` can decode — both the element it declares
1143/// and the layout it declares it in.
1144///
1145/// An unreadable one panics exactly like an unreadable vertex attribute,
1146/// but it is not a load error: the loader's contract is that an unusable
1147/// inverse-bind declaration is *source evidence* — the skin's accessor
1148/// state is reported as [`SourceInverseBindAccessorStatus::Unreadable`] and
1149/// the rest of the file still measures. Refusing the document would replace
1150/// that evidence with an exit code. So the three inverse-bind read sites
1151/// gate on this instead, and never build the reader when it is false.
1152fn inverse_bind_is_readable(accessor: &gltf::Accessor<'_>) -> bool {
1153    encoding_matches(accessor, &INVERSE_BIND_ENCODING) && unreadable_layout(accessor).is_none()
1154}
1155
1156fn encoding_matches(accessor: &gltf::Accessor<'_>, required: &ReaderEncoding) -> bool {
1157    accessor.dimensions() == required.accessor_type
1158        && required.component_types.contains(&accessor.data_type())
1159        && (!required.normalized_integers
1160            || accessor.data_type() == ComponentType::F32
1161            || accessor.normalized())
1162}
1163
1164/// Render an accepted encoding in the file's own vocabulary, so the refusal
1165/// reads as the glTF the author would have to write.
1166fn describe_encoding(required: &ReaderEncoding) -> String {
1167    let names: Vec<String> = required
1168        .component_types
1169        .iter()
1170        .copied()
1171        .map(|component| {
1172            let name = component_type_name(component);
1173            if required.normalized_integers && component != ComponentType::F32 {
1174                format!("normalized {name}")
1175            } else {
1176                name.to_owned()
1177            }
1178        })
1179        .collect();
1180    let components = match names.as_slice() {
1181        [] => String::new(),
1182        [only] => only.clone(),
1183        [first, last] => format!("{first} or {last}"),
1184        [rest @ .., last] => format!("{}, or {last}", rest.join(", ")),
1185    };
1186    format!(
1187        "{} of {components}",
1188        accessor_type_name(required.accessor_type)
1189    )
1190}
1191
1192fn accessor_type_name(accessor_type: AccessorType) -> &'static str {
1193    match accessor_type {
1194        AccessorType::Scalar => "SCALAR",
1195        AccessorType::Vec2 => "VEC2",
1196        AccessorType::Vec3 => "VEC3",
1197        AccessorType::Vec4 => "VEC4",
1198        AccessorType::Mat2 => "MAT2",
1199        AccessorType::Mat3 => "MAT3",
1200        AccessorType::Mat4 => "MAT4",
1201    }
1202}
1203
1204fn component_type_name(component_type: ComponentType) -> &'static str {
1205    match component_type {
1206        ComponentType::I8 => "BYTE",
1207        ComponentType::U8 => "UNSIGNED_BYTE",
1208        ComponentType::I16 => "SHORT",
1209        ComponentType::U16 => "UNSIGNED_SHORT",
1210        ComponentType::U32 => "UNSIGNED_INT",
1211        ComponentType::F32 => "FLOAT",
1212    }
1213}
1214
1215/// Structural validation for one animation channel: key/value counts
1216/// must agree (x3 for CUBICSPLINE's [in-tangent, value, out-tangent]
1217/// triplets) and a track must have at least one key. Violations are
1218/// container-level malformation -> [`LoadError::Malformed`], exit 2 at
1219/// the CLI; semantic problems (NaN, flips, seams) stay findings.
1220fn validate_track_lengths(
1221    clip: &str,
1222    node: usize,
1223    interpolation: Interpolation,
1224    times: &[f32],
1225    values: &TrackValues,
1226) -> Result<(), LoadError> {
1227    if times.is_empty() {
1228        return Err(LoadError::Malformed(format!(
1229            "clip '{clip}' node {node}: animation channel with zero keyframes"
1230        )));
1231    }
1232    let per_key = match interpolation {
1233        Interpolation::CubicSpline => 3,
1234        _ => 1,
1235    };
1236    let expected = times.len() * per_key;
1237    let actual = match values {
1238        TrackValues::Vec3s(v) => v.len(),
1239        TrackValues::Quats(v) => v.len(),
1240    };
1241    if actual != expected {
1242        return Err(LoadError::Malformed(format!(
1243            "clip '{clip}' node {node}: {} keyframe times but {actual} output values (expected {expected})",
1244            times.len()
1245        )));
1246    }
1247    Ok(())
1248}
1249
1250/// Load a `.glb` or `.gltf` file into a core [`Document`], including the
1251/// scene assets (meshes, skins, materials, and embedded base-color and normal textures)
1252/// its geometry describes — the
1253/// symmetric read side of [`write::write`], and the same one-call shape
1254/// `animsmith_fbx::load` uses. Consumers that judge only animation
1255/// (`lint`, `inspect`) simply ignore [`Document::assets`].
1256/// Non-triangle primitives are skipped rather than reinterpreted.
1257///
1258/// # Errors
1259///
1260/// Returns [`LoadError`] for unreadable files, unsafe or missing external
1261/// buffers, malformed GLB framing, parser rejection, structurally invalid
1262/// animation channels, geometry or animation accessors typed for an element
1263/// the selected reader cannot decode or laid out so it cannot walk them, or
1264/// node graphs that cannot be represented as a skeleton forest.
1265pub fn load(path: &Path) -> Result<Document, LoadError> {
1266    load_source(path).map(LoadedSource::into_document)
1267}
1268
1269/// Load a `.gltf` or `.glb` file with immutable importer-sensitive source facts.
1270///
1271/// The returned owner binds the normalized document and raw facts to the exact
1272/// primary bytes read here. It intentionally exposes no mutable document
1273/// access; consume it with [`LoadedSource::into_document`] to discard the
1274/// sidecar and recover the legacy document-only value.
1275///
1276/// # Errors
1277///
1278/// Returns [`LoadError`] under the same conditions as [`load`]. Projection
1279/// budget exhaustion is represented as partial fact coverage and is never a
1280/// load failure.
1281pub fn load_source(path: &Path) -> Result<LoadedSource, LoadError> {
1282    let bytes = std::fs::read(path).map_err(|source| LoadError::Io {
1283        path: path.display().to_string(),
1284        source,
1285    })?;
1286    let root = path.parent().unwrap_or_else(|| Path::new("."));
1287    load_source_bytes_with_resource_root(path, &bytes, root)
1288}
1289
1290/// Load a `.glb` or `.gltf` byte slice into a core [`Document`].
1291///
1292/// `bytes` supplies the top-level container exactly as captured by the
1293/// caller. `path` is retained for source provenance and diagnostics only;
1294/// captured-byte inputs need an explicit root API before any external
1295/// resource may be resolved.
1296///
1297/// # Errors
1298///
1299/// Returns [`LoadError`] for unsafe or missing external buffers, malformed
1300/// GLB framing, parser rejection, structurally invalid animation channels,
1301/// geometry or animation accessors typed for an element the selected reader
1302/// cannot decode or laid out so it cannot walk them, or node graphs that
1303/// cannot be represented as a skeleton forest.
1304pub fn load_bytes(path: &Path, bytes: &[u8]) -> Result<Document, LoadError> {
1305    load_source_bytes(path, bytes).map(LoadedSource::into_document)
1306}
1307
1308/// Load captured bytes with an explicit trusted local root for external resources.
1309///
1310/// The root is capability input from the caller; it is never retained in raw
1311/// facts, the dependency closure, diagnostics for source-controlled locators,
1312/// or its digest.
1313///
1314/// # Errors
1315///
1316/// Returns [`LoadError`] under the same conditions as [`load_bytes`], while
1317/// allowing safe external resources to resolve under `resource_root`.
1318pub fn load_bytes_with_resource_root(
1319    path: &Path,
1320    bytes: &[u8],
1321    resource_root: &Path,
1322) -> Result<Document, LoadError> {
1323    load_source_bytes_with_resource_root(path, bytes, resource_root)
1324        .map(LoadedSource::into_document)
1325}
1326
1327/// Load captured `.gltf` or `.glb` bytes with immutable raw-source facts.
1328///
1329/// `bytes` is both the parser input and the authority for
1330/// [`animsmith_core::InputIdentity`]. This self-contained entry point refuses
1331/// safe relative external declarations because captured bytes alone do not
1332/// authorize a local filesystem root; use
1333/// [`load_source_bytes_with_resource_root`] for such inputs.
1334///
1335/// # Errors
1336///
1337/// Returns [`LoadError`] under the same conditions as [`load_bytes`]. A
1338/// source-facts projection limit produces a successful value with partial
1339/// coverage.
1340pub fn load_source_bytes(path: &Path, bytes: &[u8]) -> Result<LoadedSource, LoadError> {
1341    load_source_bytes_inner(path, bytes, None)
1342}
1343
1344/// Load captured bytes with an explicit trusted local root for external resources.
1345///
1346/// `resource_root` is an authority supplied by the caller, not a source fact.
1347/// Resource keys are normalized and opened only beneath that root. The final
1348/// root and every locator-derived symlink component are refused; ancestors of
1349/// the explicitly supplied root are part of the caller's capability path. The
1350/// root itself never enters public evidence.
1351///
1352/// # Errors
1353///
1354/// Returns [`LoadError`] for a malformed source, an essential unavailable
1355/// external buffer, or an unsafe resource declaration. Missing/unreadable
1356/// external images remain typed unavailable source evidence.
1357pub fn load_source_bytes_with_resource_root(
1358    path: &Path,
1359    bytes: &[u8],
1360    resource_root: &Path,
1361) -> Result<LoadedSource, LoadError> {
1362    load_source_bytes_inner(path, bytes, Some(resource_root))
1363}
1364
1365fn load_source_bytes_inner(
1366    path: &Path,
1367    bytes: &[u8],
1368    resource_root: Option<&Path>,
1369) -> Result<LoadedSource, LoadError> {
1370    load_source_bytes_inner_with_reader(path, bytes, resource_root, read_external_file)
1371}
1372
1373fn load_source_bytes_inner_with_reader<F>(
1374    path: &Path,
1375    bytes: &[u8],
1376    resource_root: Option<&Path>,
1377    mut read_external: F,
1378) -> Result<LoadedSource, LoadError>
1379where
1380    F: FnMut(&Path, u64) -> CapturedResource,
1381{
1382    // Parse from the supplied slice rather than via `Gltf::open`: the reader
1383    // path (`Glb::from_reader`) trusts the GLB header's declared length and
1384    // pre-allocates `vec![0; declared_len]` before reading a byte, so a
1385    // spoofed length OOMs on tiny input. The slice path validates the declared
1386    // length against the bytes actually present, keeping malformed containers
1387    // within invariant-1 (LoadError, never an unbounded allocation). This
1388    // mirrors what `fix` already does.
1389    validate_glb_framing(bytes)?;
1390    // Keep the legacy loader's strict validation boundary: required
1391    // extensions the parser cannot implement remain load errors. The
1392    // permissive validator is reserved for scale preflight, which must
1393    // inventory unsupported declarations before refusing an operation.
1394    let gltf = gltf::Gltf::from_slice(bytes)?;
1395    validate_animations(&gltf.document)?;
1396    let mut facts = source_facts_builder(bytes)?;
1397    project_extension_facts(&gltf.document, &mut facts);
1398    project_resource_facts(&gltf.document, &mut facts);
1399    let has_unmodeled_extension_domain = has_extension_object(bytes)
1400        || gltf.document.extensions_used().next().is_some()
1401        || gltf.document.extensions_required().next().is_some();
1402    let (dependency_closure, mut resources) = capture_dependency_closure(
1403        &facts,
1404        resource_root,
1405        has_unmodeled_extension_domain,
1406        &mut read_external,
1407    )?;
1408    let buffers = resolve_captured_buffers(&gltf, &mut resources)?;
1409    validate_primitive_accessors(&gltf.document, &buffers)?;
1410    // Derive the node topology once and share it: the skeleton build and
1411    // asset extraction must agree on which bone each node became, and it is
1412    // also where malformed graphs are rejected (so that runs once too).
1413    let topo = topology(&gltf.document)?;
1414    let source_skeleton = extract_source_skeleton(&gltf.document, &buffers, &topo);
1415    let mut doc = build_document(&gltf, &buffers, path, &topo, &mut facts)?;
1416    doc.assets = extract_assets(&gltf.document, &buffers, &mut resources, &topo.bone_of_node);
1417    doc.assets.scenes = extract_scenes(&gltf.document, &topo.bone_of_node);
1418    doc.assets.default_scene = gltf.document.default_scene().map(|scene| scene.index());
1419    doc.assets.source_skeleton = source_skeleton;
1420    let source = facts
1421        .finish_with_dependency_closure(doc, dependency_closure)
1422        .map_err(LoadError::from)?;
1423    let addressability_inventory = addressability::project(
1424        &gltf.document,
1425        &topo,
1426        source.source_facts().primary_identity().clone(),
1427        source.dependency_closure().clone(),
1428    )
1429    .map_err(|error| {
1430        LoadError::Malformed(format!("raw glTF addressability projection: {error}"))
1431    })?;
1432    let inventory = capability::raw_scene_attachment_inventory_from_bytes(bytes, &source)?;
1433    source
1434        .with_raw_gltf_addressability_inventory(addressability_inventory)
1435        .map_err(|error| LoadError::Malformed(format!("raw glTF addressability binding: {error}")))?
1436        .with_raw_scene_attachment_inventory(inventory)
1437        .map_err(|error| LoadError::Malformed(format!("raw scene/attachment binding: {error}")))
1438}
1439
1440fn source_facts_builder(primary_bytes: &[u8]) -> Result<RawSourceFactsBuilderV1, SourceFactsError> {
1441    let format = if primary_bytes.starts_with(b"glTF") {
1442        SourceFormatV1::Glb
1443    } else {
1444        SourceFormatV1::GltfJson
1445    };
1446    let mut facts = RawSourceFactsBuilderV1::new(format, InputIdentity::from_bytes(primary_bytes));
1447    facts.set_linear_unit(SourceObservationV1::observed(
1448        SourceLinearUnitV1::new(1.0)?,
1449        SourceProvenanceV1::format_defined(),
1450        SourceLoaderDispositionV1::Preserved,
1451    ));
1452    facts.set_coordinate_basis(SourceObservationV1::observed(
1453        SourceCoordinateBasisV1::new(
1454            SourceAxisV1::PositiveX,
1455            SourceAxisV1::PositiveY,
1456            SourceAxisV1::PositiveZ,
1457        )?,
1458        SourceProvenanceV1::format_defined(),
1459        SourceLoaderDispositionV1::Preserved,
1460    ));
1461    facts.set_frames_per_second(SourceObservationV1::proven_absent(
1462        SourceProvenanceV1::format_defined(),
1463    ));
1464
1465    Ok(facts)
1466}
1467
1468fn project_extension_facts(document: &gltf::Document, facts: &mut RawSourceFactsBuilderV1) {
1469    let mut source_order_index = 0;
1470    for name in document.extensions_used() {
1471        if !project_extension_declaration(name, false, "/extensionsUsed", source_order_index, facts)
1472        {
1473            return;
1474        }
1475        source_order_index += 1;
1476    }
1477    for name in document.extensions_required() {
1478        if !project_extension_declaration(
1479            name,
1480            true,
1481            "/extensionsRequired",
1482            source_order_index,
1483            facts,
1484        ) {
1485            return;
1486        }
1487        source_order_index += 1;
1488    }
1489    facts.mark_complete(SourceFactDomainV1::Constructs);
1490}
1491
1492fn project_extension_declaration(
1493    name: &str,
1494    required: bool,
1495    provenance: &'static str,
1496    source_order_index: usize,
1497    facts: &mut RawSourceFactsBuilderV1,
1498) -> bool {
1499    if facts.remaining_observation_rows() == 0 {
1500        facts.mark_budget_exceeded(SourceFactDomainV1::Constructs);
1501        return false;
1502    }
1503    if name.len().saturating_add(provenance.len()) > facts.remaining_text_bytes()
1504        || name.len() > RAW_SOURCE_V1_MAX_TEXT_BYTES
1505    {
1506        facts.mark_budget_exceeded(SourceFactDomainV1::Constructs);
1507        return false;
1508    }
1509    let row = SourceConstructFactV1::new(
1510        source_order_index,
1511        SourceConstructKindV1::Extension,
1512        SourceTextV1::new(name).expect("extension name was checked against the text bound"),
1513        required,
1514        1,
1515        SourceLoaderDispositionV1::Unsupported,
1516        located_provenance(
1517            SourceProvenanceKindV1::SourceDeclared,
1518            provenance.to_owned(),
1519        ),
1520    )
1521    .expect("an extension declaration has a positive count");
1522    facts.push_construct(row)
1523}
1524
1525fn project_resource_facts(document: &gltf::Document, facts: &mut RawSourceFactsBuilderV1) {
1526    let mut source_order_index = 0;
1527    for buffer in document.buffers() {
1528        if facts.remaining_resource_rows() == 0 || facts.remaining_observation_rows() == 0 {
1529            facts.mark_budget_exceeded(SourceFactDomainV1::Resources);
1530            return;
1531        }
1532        let (possible_locator_bytes, pointer_len) = match buffer.source() {
1533            gltf::buffer::Source::Bin => (0, "/buffers/".len() + decimal_len(buffer.index())),
1534            gltf::buffer::Source::Uri(uri) => (
1535                SourceResourceLocatorV1::retained_relative_bytes(uri),
1536                "/buffers/".len() + decimal_len(buffer.index()) + "/uri".len(),
1537            ),
1538        };
1539        if possible_locator_bytes.saturating_add(pointer_len) > facts.remaining_text_bytes() {
1540            facts.mark_budget_exceeded(SourceFactDomainV1::Resources);
1541            return;
1542        }
1543        let (locator, pointer) = match buffer.source() {
1544            gltf::buffer::Source::Bin => (
1545                SourceResourceLocatorV1::Embedded,
1546                format!("/buffers/{}", buffer.index()),
1547            ),
1548            gltf::buffer::Source::Uri(uri) => (
1549                SourceResourceLocatorV1::classify(uri),
1550                format!("/buffers/{}/uri", buffer.index()),
1551            ),
1552        };
1553        if !facts.push_resource(SourceResourceReferenceV1::new(
1554            source_order_index,
1555            SourceResourceKindV1::Buffer,
1556            buffer.index() as u64,
1557            locator,
1558            SourceLoaderDispositionV1::Preserved,
1559            located_provenance(SourceProvenanceKindV1::SourceDeclared, pointer),
1560        )) {
1561            return;
1562        }
1563        source_order_index += 1;
1564    }
1565    for image in document.images() {
1566        if facts.remaining_resource_rows() == 0 || facts.remaining_observation_rows() == 0 {
1567            facts.mark_budget_exceeded(SourceFactDomainV1::Resources);
1568            return;
1569        }
1570        let (possible_locator_bytes, pointer_len) = match image.source() {
1571            gltf::image::Source::View { .. } => (0, "/images/".len() + decimal_len(image.index())),
1572            gltf::image::Source::Uri { uri, .. } => (
1573                SourceResourceLocatorV1::retained_relative_bytes(uri),
1574                "/images/".len() + decimal_len(image.index()) + "/uri".len(),
1575            ),
1576        };
1577        if possible_locator_bytes.saturating_add(pointer_len) > facts.remaining_text_bytes() {
1578            facts.mark_budget_exceeded(SourceFactDomainV1::Resources);
1579            return;
1580        }
1581        let (locator, pointer) = match image.source() {
1582            gltf::image::Source::View { .. } => (
1583                SourceResourceLocatorV1::Embedded,
1584                format!("/images/{}", image.index()),
1585            ),
1586            gltf::image::Source::Uri { uri, .. } => (
1587                SourceResourceLocatorV1::classify(uri),
1588                format!("/images/{}/uri", image.index()),
1589            ),
1590        };
1591        if !facts.push_resource(SourceResourceReferenceV1::new(
1592            source_order_index,
1593            SourceResourceKindV1::Image,
1594            image.index() as u64,
1595            locator,
1596            SourceLoaderDispositionV1::Preserved,
1597            located_provenance(SourceProvenanceKindV1::SourceDeclared, pointer),
1598        )) {
1599            return;
1600        }
1601        source_order_index += 1;
1602    }
1603    facts.mark_complete(SourceFactDomainV1::Resources);
1604}
1605
1606fn located_provenance(kind: SourceProvenanceKindV1, locator: String) -> SourceProvenanceV1 {
1607    let locator = SourceLogicalLocatorV1::gltf_json_pointer(locator)
1608        .expect("generated glTF locator is valid and bounded");
1609    match kind {
1610        SourceProvenanceKindV1::SourceDeclared => SourceProvenanceV1::source_declared(locator),
1611        SourceProvenanceKindV1::ParserProjected => SourceProvenanceV1::parser_projected(locator),
1612        SourceProvenanceKindV1::DerivedFromSource => {
1613            SourceProvenanceV1::derived_from_source(locator)
1614        }
1615        SourceProvenanceKindV1::FormatDefined => {
1616            unreachable!("located glTF provenance is never format-defined")
1617        }
1618    }
1619}
1620
1621pub(crate) fn resolve_buffers(
1622    gltf: &gltf::Gltf,
1623    base: Option<&Path>,
1624) -> Result<Vec<Vec<u8>>, LoadError> {
1625    let mut buffers = Vec::new();
1626    for buffer in gltf.buffers() {
1627        let data = match buffer.source() {
1628            gltf::buffer::Source::Bin => gltf
1629                .blob
1630                .clone()
1631                .ok_or_else(|| LoadError::Buffer("GLB has no BIN chunk".into()))?,
1632            gltf::buffer::Source::Uri(uri) => {
1633                if let Some(encoded) = uri.strip_prefix("data:") {
1634                    let payload =
1635                        encoded
1636                            .split_once("base64,")
1637                            .map(|(_, p)| p)
1638                            .ok_or_else(|| {
1639                                LoadError::Buffer("unsupported data URI in buffer".to_owned())
1640                            })?;
1641                    base64::engine::general_purpose::STANDARD
1642                        .decode(payload)
1643                        .map_err(|e| LoadError::Buffer(format!("bad base64 data URI: {e}")))?
1644                } else {
1645                    let root = base.ok_or_else(resource_root_required)?;
1646                    let path = root.join(safe_external_buffer_path(uri)?);
1647                    std::fs::read(&path).map_err(|source| LoadError::Io {
1648                        // Do not reproduce a source-controlled resource
1649                        // locator or its resolved host path through the new
1650                        // evidence-aware API's error surface.
1651                        path: "<external glTF buffer>".to_owned(),
1652                        source,
1653                    })?
1654                }
1655            }
1656        };
1657        buffers.push(data);
1658    }
1659    Ok(buffers)
1660}
1661
1662/// Loader-private cap on duplicate external `Vec` slots. Closure I/O records
1663/// unique opened/hashed bytes separately, so aliases cannot multiply memory.
1664const MAX_EXTERNAL_MATERIALIZED_BYTES: u64 = 256 * 1024 * 1024;
1665
1666#[derive(Clone, Copy)]
1667enum CapturedResourceFailure {
1668    Refused(DependencyResourceRefusalReasonV1),
1669    Unavailable(DependencyResourceUnavailableReasonV1),
1670}
1671
1672enum CapturedResource {
1673    Bytes(Vec<u8>),
1674    Failure(CapturedResourceFailure),
1675}
1676
1677#[derive(Clone)]
1678enum CapturedReference {
1679    Primary,
1680    External(DependencyResourceKeyV1),
1681    Failure(CapturedResourceFailure),
1682}
1683
1684/// One bounded local-file resource capture tied to a single loader invocation.
1685///
1686/// The map keeps exact bytes only until all document consumers have reused
1687/// them. The resulting core closure receives identities and safe logical keys,
1688/// never this root or any resolved host path.
1689struct ResourceCaptureSession {
1690    root: TrustedResourceRoot,
1691    resources: BTreeMap<DependencyResourceKeyV1, CapturedResource>,
1692    references: BTreeMap<(SourceResourceKindV1, u64), CapturedReference>,
1693    materialized_external_bytes: u64,
1694    materialized_external_limit: u64,
1695}
1696
1697enum TrustedResourceRoot {
1698    Absent,
1699    Available(PathBuf),
1700    Failure(CapturedResourceFailure),
1701}
1702
1703impl ResourceCaptureSession {
1704    fn new(root: Option<&Path>) -> Self {
1705        Self {
1706            root: trusted_resource_root(root),
1707            resources: BTreeMap::new(),
1708            references: BTreeMap::new(),
1709            materialized_external_bytes: 0,
1710            materialized_external_limit: MAX_EXTERNAL_MATERIALIZED_BYTES,
1711        }
1712    }
1713
1714    fn insert_reference(
1715        &mut self,
1716        kind: SourceResourceKindV1,
1717        source_index: u64,
1718        reference: CapturedReference,
1719    ) {
1720        self.references.insert((kind, source_index), reference);
1721    }
1722
1723    fn reference(&self, kind: SourceResourceKindV1, source_index: u64) -> CapturedReference {
1724        self.references
1725            .get(&(kind, source_index))
1726            .cloned()
1727            .unwrap_or(CapturedReference::Failure(
1728                CapturedResourceFailure::Unavailable(
1729                    DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
1730                ),
1731            ))
1732    }
1733
1734    /// Validate every resource component before an external open. This makes a
1735    /// symlink a pure refusal: no source-controlled file is opened first.
1736    fn preflight_external(
1737        &self,
1738        key: &DependencyResourceKeyV1,
1739    ) -> Result<PathBuf, CapturedResourceFailure> {
1740        let root = match &self.root {
1741            TrustedResourceRoot::Available(root) => root,
1742            TrustedResourceRoot::Absent => {
1743                return Err(CapturedResourceFailure::Unavailable(
1744                    DependencyResourceUnavailableReasonV1::ResourceRootUnavailable,
1745                ));
1746            }
1747            TrustedResourceRoot::Failure(failure) => return Err(*failure),
1748        };
1749        let mut path = root.clone();
1750        let mut components = key.as_str().split('/').peekable();
1751        while let Some(component) = components.next() {
1752            let is_final = components.peek().is_none();
1753            path.push(component);
1754            match std::fs::symlink_metadata(&path) {
1755                Ok(metadata) if metadata.file_type().is_symlink() => {
1756                    return Err(CapturedResourceFailure::Refused(
1757                        DependencyResourceRefusalReasonV1::Symlink,
1758                    ));
1759                }
1760                Ok(metadata)
1761                    if (!is_final && metadata.is_dir()) || (is_final && metadata.is_file()) => {}
1762                Ok(_) => {
1763                    return Err(CapturedResourceFailure::Unavailable(
1764                        DependencyResourceUnavailableReasonV1::Unreadable,
1765                    ));
1766                }
1767                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1768                    return Err(CapturedResourceFailure::Unavailable(
1769                        DependencyResourceUnavailableReasonV1::Missing,
1770                    ));
1771                }
1772                Err(_) => {
1773                    return Err(CapturedResourceFailure::Unavailable(
1774                        DependencyResourceUnavailableReasonV1::Unreadable,
1775                    ));
1776                }
1777            }
1778        }
1779        Ok(path)
1780    }
1781
1782    fn materialize_external(
1783        &mut self,
1784        kind: SourceResourceKindV1,
1785        source_index: u64,
1786    ) -> Result<Vec<u8>, CapturedResourceFailure> {
1787        let CapturedReference::External(key) = self.reference(kind, source_index) else {
1788            return Err(reference_failure(self.reference(kind, source_index)));
1789        };
1790        let length = match self.resources.get(&key) {
1791            Some(CapturedResource::Bytes(bytes)) => bytes.len() as u64,
1792            Some(CapturedResource::Failure(failure)) => return Err(*failure),
1793            None => {
1794                return Err(CapturedResourceFailure::Unavailable(
1795                    DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
1796                ));
1797            }
1798        };
1799        let Some(next) = self.materialized_external_bytes.checked_add(length) else {
1800            return Err(CapturedResourceFailure::Unavailable(
1801                DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
1802            ));
1803        };
1804        if next > self.materialized_external_limit {
1805            return Err(CapturedResourceFailure::Unavailable(
1806                DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
1807            ));
1808        }
1809        let bytes = match self.resources.get(&key) {
1810            Some(CapturedResource::Bytes(bytes)) => bytes.clone(),
1811            _ => unreachable!("captured resource state changed without mutation"),
1812        };
1813        self.materialized_external_bytes = next;
1814        Ok(bytes)
1815    }
1816
1817    fn external_image_payload(
1818        &self,
1819        image_index: usize,
1820    ) -> (Option<&[u8]>, ImageUnavailableReason) {
1821        let CapturedReference::External(key) =
1822            self.reference(SourceResourceKindV1::Image, image_index as u64)
1823        else {
1824            return (None, ImageUnavailableReason::SourceUnavailable);
1825        };
1826        match self.resources.get(&key) {
1827            Some(CapturedResource::Bytes(bytes)) => (
1828                Some(bytes.as_slice()),
1829                ImageUnavailableReason::SourceUnavailable,
1830            ),
1831            _ => (None, ImageUnavailableReason::SourceUnavailable),
1832        }
1833    }
1834
1835    fn external_image_is_available(&self, image_index: usize) -> bool {
1836        self.external_image_payload(image_index).0.is_some()
1837    }
1838
1839    fn clone_image_for_material(
1840        &mut self,
1841        image_index: usize,
1842        texture: &TextureAsset,
1843    ) -> Option<TextureAsset> {
1844        if matches!(
1845            self.reference(SourceResourceKindV1::Image, image_index as u64),
1846            CapturedReference::External(_)
1847        ) {
1848            let length = texture.bytes.len() as u64;
1849            let next = self.materialized_external_bytes.checked_add(length)?;
1850            if next > self.materialized_external_limit {
1851                return None;
1852            }
1853            self.materialized_external_bytes = next;
1854        }
1855        Some(texture.clone())
1856    }
1857}
1858
1859/// Read one already-preflighted resource exactly once. `limit + 1` is a
1860/// bounded witness for the core closure's terminal resource-budget row.
1861fn read_external_file(path: &Path, limit: u64) -> CapturedResource {
1862    let file = match std::fs::File::open(path) {
1863        Ok(file) => file,
1864        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1865            return CapturedResource::Failure(CapturedResourceFailure::Unavailable(
1866                DependencyResourceUnavailableReasonV1::Missing,
1867            ));
1868        }
1869        Err(_) => {
1870            return CapturedResource::Failure(CapturedResourceFailure::Unavailable(
1871                DependencyResourceUnavailableReasonV1::Unreadable,
1872            ));
1873        }
1874    };
1875    let max_read = limit.saturating_add(1);
1876    let mut bytes = Vec::new();
1877    let read = file.take(max_read).read_to_end(&mut bytes);
1878    if read.is_err() {
1879        return CapturedResource::Failure(CapturedResourceFailure::Unavailable(
1880            DependencyResourceUnavailableReasonV1::Unreadable,
1881        ));
1882    }
1883    CapturedResource::Bytes(bytes)
1884}
1885
1886/// Validate the caller-supplied root itself without inspecting its ancestors.
1887///
1888/// Ancestors are part of the capability path the caller explicitly supplied;
1889/// only the final root and locator-derived children belong to this loader's
1890/// symlink-refusal boundary. A relative root is made absolute once here, never
1891/// inferred from a source locator.
1892fn trusted_resource_root(root: Option<&Path>) -> TrustedResourceRoot {
1893    let Some(root) = root else {
1894        return TrustedResourceRoot::Absent;
1895    };
1896    let root = if root.is_absolute() {
1897        root.to_path_buf()
1898    } else {
1899        match std::env::current_dir() {
1900            Ok(current) => current.join(root),
1901            Err(_) => {
1902                return TrustedResourceRoot::Failure(CapturedResourceFailure::Unavailable(
1903                    DependencyResourceUnavailableReasonV1::ResourceRootUnavailable,
1904                ));
1905            }
1906        }
1907    };
1908    match std::fs::symlink_metadata(&root) {
1909        Ok(metadata) if metadata.is_dir() => TrustedResourceRoot::Available(root),
1910        Ok(metadata) if metadata.file_type().is_symlink() => TrustedResourceRoot::Failure(
1911            CapturedResourceFailure::Refused(DependencyResourceRefusalReasonV1::Symlink),
1912        ),
1913        Ok(_) | Err(_) => TrustedResourceRoot::Failure(CapturedResourceFailure::Unavailable(
1914            DependencyResourceUnavailableReasonV1::ResourceRootUnavailable,
1915        )),
1916    }
1917}
1918
1919fn reference_failure(reference: CapturedReference) -> CapturedResourceFailure {
1920    match reference {
1921        CapturedReference::Failure(failure) => failure,
1922        CapturedReference::Primary | CapturedReference::External(_) => {
1923            CapturedResourceFailure::Unavailable(
1924                DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
1925            )
1926        }
1927    }
1928}
1929
1930fn capture_dependency_closure<F>(
1931    facts: &RawSourceFactsBuilderV1,
1932    root: Option<&Path>,
1933    has_unmodeled_resource_domain: bool,
1934    read_external: &mut F,
1935) -> Result<(DependencyClosureV1, ResourceCaptureSession), LoadError>
1936where
1937    F: FnMut(&Path, u64) -> CapturedResource,
1938{
1939    let mut closure = DependencyClosureBuilderV1::new(
1940        facts.primary_identity().clone(),
1941        facts.resource_coverage(),
1942        facts.resource_rows().len(),
1943    );
1944    if has_unmodeled_resource_domain {
1945        closure.mark_unmodeled_resource_domain();
1946    }
1947    let mut session = ResourceCaptureSession::new(root);
1948    for row in facts.resource_rows() {
1949        let (locator_bytes, components) = match row.locator() {
1950            SourceResourceLocatorV1::Relative(locator) => (
1951                locator.as_str().len(),
1952                DependencyResourceKeyV1::source_component_count(locator),
1953            ),
1954            _ => (0, 0),
1955        };
1956        if !closure.begin_reference(locator_bytes, components) {
1957            break;
1958        }
1959        let kind = row.kind();
1960        let source_index = row.source_index();
1961        let source_order_index = row.source_order_index();
1962        match row.locator() {
1963            SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri => {
1964                closure
1965                    .push_primary(source_order_index, kind, source_index)
1966                    .map_err(SourceFactsError::from)?;
1967                session.insert_reference(kind, source_index, CapturedReference::Primary);
1968            }
1969            SourceResourceLocatorV1::Absolute => {
1970                record_refused(
1971                    &mut closure,
1972                    &mut session,
1973                    source_order_index,
1974                    kind,
1975                    source_index,
1976                    DependencyResourceRefusalReasonV1::Absolute,
1977                )?;
1978            }
1979            SourceResourceLocatorV1::Escaping => {
1980                record_refused(
1981                    &mut closure,
1982                    &mut session,
1983                    source_order_index,
1984                    kind,
1985                    source_index,
1986                    DependencyResourceRefusalReasonV1::Escaping,
1987                )?;
1988            }
1989            SourceResourceLocatorV1::Remote => {
1990                record_refused(
1991                    &mut closure,
1992                    &mut session,
1993                    source_order_index,
1994                    kind,
1995                    source_index,
1996                    DependencyResourceRefusalReasonV1::Remote,
1997                )?;
1998            }
1999            SourceResourceLocatorV1::Malformed => {
2000                record_refused(
2001                    &mut closure,
2002                    &mut session,
2003                    source_order_index,
2004                    kind,
2005                    source_index,
2006                    DependencyResourceRefusalReasonV1::Malformed,
2007                )?;
2008            }
2009            SourceResourceLocatorV1::Oversized => {
2010                record_refused(
2011                    &mut closure,
2012                    &mut session,
2013                    source_order_index,
2014                    kind,
2015                    source_index,
2016                    DependencyResourceRefusalReasonV1::Oversized,
2017                )?;
2018            }
2019            SourceResourceLocatorV1::Missing => {
2020                closure
2021                    .push_unavailable(
2022                        source_order_index,
2023                        kind,
2024                        source_index,
2025                        None,
2026                        DependencyResourceUnavailableReasonV1::Missing,
2027                    )
2028                    .map_err(SourceFactsError::from)?;
2029                session.insert_reference(
2030                    kind,
2031                    source_index,
2032                    CapturedReference::Failure(CapturedResourceFailure::Unavailable(
2033                        DependencyResourceUnavailableReasonV1::Missing,
2034                    )),
2035                );
2036            }
2037            SourceResourceLocatorV1::Relative(locator) => {
2038                // Captured bytes carry no ambient filesystem authority. This
2039                // applies to optional images as well as essential buffers.
2040                if root.is_none() {
2041                    return Err(resource_root_required());
2042                }
2043                let key = match DependencyResourceKeyV1::from_relative(
2044                    locator,
2045                    ResourceKeySyntaxV1::GltfUri,
2046                ) {
2047                    Ok(key) => key,
2048                    Err(error) => {
2049                        let reason = match error {
2050                            DependencyClosureError::ResourceKeyTooLong { .. }
2051                            | DependencyClosureError::TooManyPathComponents { .. } => {
2052                                DependencyResourceRefusalReasonV1::Oversized
2053                            }
2054                            _ => DependencyResourceRefusalReasonV1::Malformed,
2055                        };
2056                        record_refused(
2057                            &mut closure,
2058                            &mut session,
2059                            source_order_index,
2060                            kind,
2061                            source_index,
2062                            reason,
2063                        )?;
2064                        continue;
2065                    }
2066                };
2067                match closure
2068                    .prepare_external_key(&key)
2069                    .map_err(SourceFactsError::from)?
2070                {
2071                    None => break,
2072                    Some(false) => {
2073                        let reference = session
2074                            .resources
2075                            .get(&key)
2076                            .map(|resource| match resource {
2077                                CapturedResource::Bytes(_) => {
2078                                    CapturedReference::External(key.clone())
2079                                }
2080                                CapturedResource::Failure(failure) => {
2081                                    CapturedReference::Failure(*failure)
2082                                }
2083                            })
2084                            .unwrap_or(CapturedReference::Failure(
2085                                CapturedResourceFailure::Unavailable(
2086                                    DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
2087                                ),
2088                            ));
2089                        record_cached_reference(
2090                            &mut closure,
2091                            &mut session,
2092                            source_order_index,
2093                            kind,
2094                            source_index,
2095                            key,
2096                            reference,
2097                        )?;
2098                    }
2099                    Some(true) => {
2100                        let limit = closure
2101                            .max_resource_bytes()
2102                            .min(closure.remaining_external_bytes());
2103                        let resource = match session.preflight_external(&key) {
2104                            Ok(path) => {
2105                                // Count the attempt immediately before the
2106                                // only File::open, after all refusal checks.
2107                                closure
2108                                    .record_external_open_attempt(&key)
2109                                    .map_err(SourceFactsError::from)?;
2110                                read_external(&path, limit)
2111                            }
2112                            Err(failure) => CapturedResource::Failure(failure),
2113                        };
2114                        let reference = match &resource {
2115                            CapturedResource::Bytes(bytes) => {
2116                                let identity = InputIdentity::from_bytes(bytes);
2117                                let captured = closure
2118                                    .push_captured_external(
2119                                        source_order_index,
2120                                        kind,
2121                                        source_index,
2122                                        key.clone(),
2123                                        identity,
2124                                    )
2125                                    .map_err(SourceFactsError::from)?;
2126                                if captured {
2127                                    CapturedReference::External(key.clone())
2128                                } else {
2129                                    CapturedReference::Failure(
2130                                        CapturedResourceFailure::Unavailable(
2131                                            DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
2132                                        ),
2133                                    )
2134                                }
2135                            }
2136                            CapturedResource::Failure(failure) => {
2137                                record_failure(
2138                                    &mut closure,
2139                                    source_order_index,
2140                                    kind,
2141                                    source_index,
2142                                    Some(key.clone()),
2143                                    *failure,
2144                                )?;
2145                                CapturedReference::Failure(*failure)
2146                            }
2147                        };
2148                        let resource = match reference {
2149                            CapturedReference::Failure(failure)
2150                                if matches!(&resource, CapturedResource::Bytes(_)) =>
2151                            {
2152                                CapturedResource::Failure(failure)
2153                            }
2154                            _ => resource,
2155                        };
2156                        session.resources.insert(key, resource);
2157                        session.insert_reference(kind, source_index, reference);
2158                    }
2159                }
2160            }
2161        }
2162    }
2163    let closure = closure.finish().map_err(SourceFactsError::from)?;
2164    Ok((closure, session))
2165}
2166
2167fn record_refused(
2168    closure: &mut DependencyClosureBuilderV1,
2169    session: &mut ResourceCaptureSession,
2170    source_order_index: usize,
2171    kind: SourceResourceKindV1,
2172    source_index: u64,
2173    reason: DependencyResourceRefusalReasonV1,
2174) -> Result<(), LoadError> {
2175    closure
2176        .push_refused(source_order_index, kind, source_index, reason)
2177        .map_err(SourceFactsError::from)?;
2178    session.insert_reference(
2179        kind,
2180        source_index,
2181        CapturedReference::Failure(CapturedResourceFailure::Refused(reason)),
2182    );
2183    Ok(())
2184}
2185
2186fn record_failure(
2187    closure: &mut DependencyClosureBuilderV1,
2188    source_order_index: usize,
2189    kind: SourceResourceKindV1,
2190    source_index: u64,
2191    key: Option<DependencyResourceKeyV1>,
2192    failure: CapturedResourceFailure,
2193) -> Result<(), LoadError> {
2194    match failure {
2195        CapturedResourceFailure::Refused(reason) => closure
2196            .push_refused(source_order_index, kind, source_index, reason)
2197            .map_err(SourceFactsError::from)?,
2198        CapturedResourceFailure::Unavailable(reason) => closure
2199            .push_unavailable(source_order_index, kind, source_index, key, reason)
2200            .map_err(SourceFactsError::from)?,
2201    }
2202    Ok(())
2203}
2204
2205fn record_cached_reference(
2206    closure: &mut DependencyClosureBuilderV1,
2207    session: &mut ResourceCaptureSession,
2208    source_order_index: usize,
2209    kind: SourceResourceKindV1,
2210    source_index: u64,
2211    key: DependencyResourceKeyV1,
2212    reference: CapturedReference,
2213) -> Result<(), LoadError> {
2214    match &reference {
2215        CapturedReference::External(_) => closure
2216            .push_external_alias(source_order_index, kind, source_index, key)
2217            .map_err(SourceFactsError::from)?,
2218        CapturedReference::Failure(failure) => record_failure(
2219            closure,
2220            source_order_index,
2221            kind,
2222            source_index,
2223            Some(key),
2224            *failure,
2225        )?,
2226        CapturedReference::Primary => unreachable!("external aliases never map to primary"),
2227    }
2228    session.insert_reference(kind, source_index, reference);
2229    Ok(())
2230}
2231
2232fn resolve_captured_buffers(
2233    gltf: &gltf::Gltf,
2234    resources: &mut ResourceCaptureSession,
2235) -> Result<Vec<Vec<u8>>, LoadError> {
2236    let mut buffers = Vec::new();
2237    for buffer in gltf.buffers() {
2238        let data = match buffer.source() {
2239            gltf::buffer::Source::Bin => gltf
2240                .blob
2241                .clone()
2242                .ok_or_else(|| LoadError::Buffer("GLB has no BIN chunk".into()))?,
2243            gltf::buffer::Source::Uri(uri) if uri.starts_with("data:") => {
2244                let payload = uri
2245                    .strip_prefix("data:")
2246                    .and_then(|encoded| encoded.split_once("base64,").map(|(_, payload)| payload))
2247                    .ok_or_else(|| {
2248                        LoadError::Buffer("unsupported data URI in buffer".to_owned())
2249                    })?;
2250                base64::engine::general_purpose::STANDARD
2251                    .decode(payload)
2252                    .map_err(|_| LoadError::Buffer("invalid data URI in buffer".to_owned()))?
2253            }
2254            gltf::buffer::Source::Uri(_) => resources
2255                .materialize_external(SourceResourceKindV1::Buffer, buffer.index() as u64)
2256                .map_err(buffer_capture_error)?,
2257        };
2258        buffers.push(data);
2259    }
2260    Ok(buffers)
2261}
2262
2263fn buffer_capture_error(failure: CapturedResourceFailure) -> LoadError {
2264    match failure {
2265        CapturedResourceFailure::Refused(_) => {
2266            LoadError::ExternalResource(ExternalResourceFailure::Refused)
2267        }
2268        CapturedResourceFailure::Unavailable(
2269            DependencyResourceUnavailableReasonV1::ResourceRootUnavailable,
2270        ) => resource_root_required(),
2271        CapturedResourceFailure::Unavailable(
2272            DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
2273        ) => LoadError::ExternalResource(ExternalResourceFailure::CaptureLimitExceeded),
2274        CapturedResourceFailure::Unavailable(_) => {
2275            LoadError::ExternalResource(ExternalResourceFailure::Unavailable)
2276        }
2277    }
2278}
2279
2280fn resource_root_required() -> LoadError {
2281    LoadError::ExternalResource(ExternalResourceFailure::ResourceRootRequired)
2282}
2283
2284struct PendingGltfClipFacts {
2285    animation_index: usize,
2286    source_name: SourceObservationV1<SourceTextV1>,
2287    normalized_clip_provenance: SourceProvenanceV1,
2288    range_provenance: SourceProvenanceV1,
2289    channels: Vec<SourceChannelFactV1>,
2290    channel_limit: usize,
2291    remaining_text: usize,
2292    minimum: f64,
2293    maximum: f64,
2294    saw_input: bool,
2295    sampler_inputs_available: bool,
2296    sampler_inputs_finite: bool,
2297    truncated: bool,
2298}
2299
2300impl PendingGltfClipFacts {
2301    fn begin(
2302        animation: &gltf::Animation<'_>,
2303        builder: &mut RawSourceFactsBuilderV1,
2304    ) -> Option<Self> {
2305        if builder.remaining_clip_rows() == 0 || builder.remaining_observation_rows() == 0 {
2306            builder.mark_budget_exceeded(SourceFactDomainV1::Clips);
2307            return None;
2308        }
2309        let animation_index = animation.index();
2310        let prefix_len = "/animations/".len() + decimal_len(animation_index);
2311        let name_locator_len = prefix_len + "/name".len();
2312        let normalized_locator_len = prefix_len;
2313        let range_locator_len = prefix_len + "/samplers/*/input".len();
2314        let retained_name_len = animation.name().map_or(0, |name| {
2315            if name.len() <= RAW_SOURCE_V1_MAX_TEXT_BYTES {
2316                name.len()
2317            } else {
2318                0
2319            }
2320        });
2321        let fixed_text_len = name_locator_len
2322            .saturating_add(retained_name_len)
2323            .saturating_add(normalized_locator_len)
2324            .saturating_add(range_locator_len);
2325        if fixed_text_len > builder.remaining_text_bytes() {
2326            builder.mark_budget_exceeded(SourceFactDomainV1::Clips);
2327            return None;
2328        }
2329
2330        let name_locator = format!("/animations/{animation_index}/name");
2331        let name_provenance =
2332            located_provenance(SourceProvenanceKindV1::SourceDeclared, name_locator);
2333        let source_name = match animation.name() {
2334            Some(name) if name.len() <= RAW_SOURCE_V1_MAX_TEXT_BYTES => {
2335                SourceObservationV1::observed(
2336                    SourceTextV1::new(name).expect("source name length checked before cloning"),
2337                    name_provenance,
2338                    SourceLoaderDispositionV1::Preserved,
2339                )
2340            }
2341            Some(_) => SourceObservationV1::unavailable(
2342                SourceUnavailableReasonV1::ProjectionBudgetExceeded,
2343                Some(name_provenance),
2344                SourceLoaderDispositionV1::Preserved,
2345            ),
2346            None => SourceObservationV1::proven_absent(name_provenance),
2347        };
2348
2349        Some(Self {
2350            animation_index,
2351            source_name,
2352            normalized_clip_provenance: located_provenance(
2353                SourceProvenanceKindV1::ParserProjected,
2354                format!("/animations/{animation_index}"),
2355            ),
2356            range_provenance: located_provenance(
2357                SourceProvenanceKindV1::DerivedFromSource,
2358                format!("/animations/{animation_index}/samplers/*/input"),
2359            ),
2360            channels: Vec::new(),
2361            channel_limit: builder.remaining_observation_rows().saturating_sub(1),
2362            remaining_text: builder.remaining_text_bytes() - fixed_text_len,
2363            minimum: f64::INFINITY,
2364            maximum: f64::NEG_INFINITY,
2365            saw_input: false,
2366            sampler_inputs_available: true,
2367            sampler_inputs_finite: true,
2368            truncated: false,
2369        })
2370    }
2371
2372    fn record_channel(&mut self, channel: &gltf::animation::Channel<'_>, times: Option<&[f32]>) {
2373        if self.channels.len() >= self.channel_limit {
2374            self.truncated = true;
2375            return;
2376        }
2377        let sampler = channel.sampler();
2378        let channel_index = channel.index();
2379        let interpolation_locator_len = "/animations/".len()
2380            + decimal_len(self.animation_index)
2381            + "/samplers/".len()
2382            + decimal_len(sampler.index())
2383            + "/interpolation".len();
2384        let channel_locator_len = "/animations/".len()
2385            + decimal_len(self.animation_index)
2386            + "/channels/".len()
2387            + decimal_len(channel_index);
2388        let row_text_len = interpolation_locator_len.saturating_add(channel_locator_len);
2389        if row_text_len > self.remaining_text {
2390            self.truncated = true;
2391            return;
2392        }
2393
2394        let (property, components, disposition) = match channel.target().property() {
2395            gltf::animation::Property::Translation => (
2396                SourceChannelPropertyV1::Translation,
2397                SourceComponentMaskV1::new(true, true, true),
2398                SourceLoaderDispositionV1::Preserved,
2399            ),
2400            gltf::animation::Property::Rotation => (
2401                SourceChannelPropertyV1::Rotation,
2402                SourceComponentMaskV1::new(true, true, true),
2403                SourceLoaderDispositionV1::Preserved,
2404            ),
2405            gltf::animation::Property::Scale => (
2406                SourceChannelPropertyV1::Scale,
2407                SourceComponentMaskV1::new(true, true, true),
2408                SourceLoaderDispositionV1::Preserved,
2409            ),
2410            gltf::animation::Property::MorphTargetWeights => (
2411                SourceChannelPropertyV1::Weights,
2412                SourceComponentMaskV1::new(false, false, false),
2413                SourceLoaderDispositionV1::Discarded,
2414            ),
2415        };
2416        let interpolation = match sampler.interpolation() {
2417            gltf::animation::Interpolation::Linear => SourceInterpolationV1::Linear,
2418            gltf::animation::Interpolation::Step => SourceInterpolationV1::Step,
2419            gltf::animation::Interpolation::CubicSpline => SourceInterpolationV1::CubicSpline,
2420        };
2421        let interpolation_provenance = located_provenance(
2422            // `gltf` projects an omitted interpolation member to LINEAR, so
2423            // this typed value is parser-effective rather than proof that the
2424            // JSON member was explicitly authored.
2425            SourceProvenanceKindV1::ParserProjected,
2426            format!(
2427                "/animations/{}/samplers/{}/interpolation",
2428                self.animation_index,
2429                sampler.index()
2430            ),
2431        );
2432        let channel_provenance = located_provenance(
2433            SourceProvenanceKindV1::SourceDeclared,
2434            format!(
2435                "/animations/{}/channels/{channel_index}",
2436                self.animation_index
2437            ),
2438        );
2439        self.channels.push(
2440            SourceChannelFactV1::new(
2441                channel_index,
2442                SourceTargetV1::new(
2443                    SourceTargetKindV1::Node,
2444                    channel.target().node().index() as u64,
2445                ),
2446                property,
2447                components,
2448                SourceObservationV1::observed(interpolation, interpolation_provenance, disposition),
2449                disposition,
2450                channel_provenance,
2451            )
2452            .with_accessors(sampler.input().index(), sampler.output().index()),
2453        );
2454        self.remaining_text -= row_text_len;
2455
2456        match times {
2457            Some(times) => {
2458                for &time in times {
2459                    self.saw_input = true;
2460                    if time.is_finite() {
2461                        self.minimum = self.minimum.min(f64::from(time));
2462                        self.maximum = self.maximum.max(f64::from(time));
2463                    } else {
2464                        self.sampler_inputs_finite = false;
2465                    }
2466                }
2467            }
2468            None => self.sampler_inputs_available = false,
2469        }
2470    }
2471
2472    fn finish(self) -> Result<SourceClipFactV1, SourceFactsError> {
2473        let sampler_range = if self.truncated {
2474            SourceObservationV1::unavailable(
2475                SourceUnavailableReasonV1::ProjectionBudgetExceeded,
2476                Some(self.range_provenance),
2477                SourceLoaderDispositionV1::Preserved,
2478            )
2479        } else if !self.sampler_inputs_available {
2480            SourceObservationV1::unavailable(
2481                SourceUnavailableReasonV1::ParserUnavailable,
2482                Some(self.range_provenance),
2483                SourceLoaderDispositionV1::Unknown,
2484            )
2485        } else if !self.sampler_inputs_finite {
2486            SourceObservationV1::unavailable(
2487                SourceUnavailableReasonV1::Malformed,
2488                Some(self.range_provenance),
2489                SourceLoaderDispositionV1::Preserved,
2490            )
2491        } else if self.saw_input {
2492            SourceObservationV1::observed(
2493                SourceTimeRangeV1::new(self.minimum, self.maximum)?,
2494                self.range_provenance,
2495                SourceLoaderDispositionV1::Preserved,
2496            )
2497        } else {
2498            SourceObservationV1::proven_absent(self.range_provenance)
2499        };
2500        let channels = if self.truncated {
2501            SourceFactSetV1::partial(
2502                self.channels,
2503                SourceUnavailableReasonV1::ProjectionBudgetExceeded,
2504            )
2505        } else {
2506            SourceFactSetV1::complete(self.channels)
2507        };
2508        Ok(SourceClipFactV1::new(
2509            self.animation_index,
2510            self.source_name,
2511            SourceObservationV1::observed(
2512                self.animation_index,
2513                self.normalized_clip_provenance,
2514                SourceLoaderDispositionV1::Preserved,
2515            ),
2516            SourceObservationV1::proven_absent(SourceProvenanceV1::format_defined()),
2517            sampler_range,
2518            channels,
2519        ))
2520    }
2521}
2522
2523fn decimal_len(mut value: usize) -> usize {
2524    let mut len = 1;
2525    while value >= 10 {
2526        value /= 10;
2527        len += 1;
2528    }
2529    len
2530}
2531
2532fn build_document(
2533    gltf: &gltf::Gltf,
2534    buffers: &[Vec<u8>],
2535    path: &Path,
2536    topo: &Topology,
2537    source_facts: &mut RawSourceFactsBuilderV1,
2538) -> Result<Document, LoadError> {
2539    let doc = &gltf.document;
2540
2541    let nodes: Vec<gltf::Node> = doc.nodes().collect();
2542    let Topology {
2543        order,
2544        parent,
2545        bone_of_node,
2546    } = topo;
2547
2548    let mut bones: Vec<Bone> = Vec::with_capacity(nodes.len());
2549    for &node_index in order {
2550        let node = &nodes[node_index];
2551        let (t, r, s) = node.transform().decomposed();
2552        bones.push(Bone {
2553            name: node
2554                .name()
2555                .map(str::to_owned)
2556                .unwrap_or_else(|| format!("node{node_index}")),
2557            parent: parent[node_index].and_then(|p| bone_of_node[p]),
2558            rest: Transform {
2559                translation: Vec3::from_array(t),
2560                rotation: Quat::from_array(r),
2561                scale: Vec3::from_array(s),
2562            },
2563            inverse_bind: None,
2564        });
2565    }
2566
2567    // Existing compatibility representation: one bone can carry only one
2568    // inverse bind, so the last source skin wins here. `source_skeleton`
2569    // retains the complete per-skin source evidence for measurements.
2570    for skin in doc.skins() {
2571        // Skip a count-0 IBM accessor: gltf 1.4's reader underflows and
2572        // panics iterating one. An accessor that is not `MAT4` of `FLOAT`
2573        // panics in that reader too; both are skipped here and recorded as
2574        // source evidence by `extract_source_skeleton`.
2575        if skin
2576            .inverse_bind_matrices()
2577            .is_none_or(|accessor| accessor.count() == 0 || !inverse_bind_is_readable(&accessor))
2578        {
2579            continue;
2580        }
2581        let reader = skin.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
2582        if let Some(ibms) = reader.read_inverse_bind_matrices() {
2583            for (joint, ibm) in skin.joints().zip(ibms) {
2584                if let Some(bone_id) = bone_of_node[joint.index()] {
2585                    bones[bone_id].inverse_bind = Some(Mat4::from_cols_array_2d(&ibm));
2586                }
2587            }
2588        }
2589    }
2590
2591    // Animations → clips. Unnamed clips get stable positional names.
2592    let mut clips = Vec::new();
2593    let mut name_uses: BTreeMap<String, usize> = BTreeMap::new();
2594    let mut facts_complete = true;
2595    for animation in doc.animations() {
2596        let mut pending_facts = if facts_complete {
2597            PendingGltfClipFacts::begin(&animation, source_facts)
2598        } else {
2599            None
2600        };
2601        if pending_facts.is_none() {
2602            facts_complete = false;
2603        }
2604        let base_name = animation
2605            .name()
2606            .map(str::to_owned)
2607            .unwrap_or_else(|| format!("animation{}", animation.index()));
2608        let uses = name_uses.entry(base_name.clone()).or_insert(0);
2609        let name = if *uses == 0 {
2610            base_name.clone()
2611        } else {
2612            format!("{base_name}#{uses}")
2613        };
2614        *uses += 1;
2615
2616        let mut tracks = Vec::new();
2617        let mut duration = 0.0f64;
2618        for channel in animation.channels() {
2619            let Some(bone) = bone_of_node[channel.target().node().index()] else {
2620                continue;
2621            };
2622            // Nothing below this point may build a channel reader that has
2623            // not been judged first: `read_inputs` and `read_outputs` each
2624            // hand their own accessor to `Iter::new`, which panics on
2625            // arbitrary input in two independent ways.
2626            //
2627            // A count-0 accessor underflows in that iterator, and is
2628            // malformed animation rather than an unwalkable layout, so it
2629            // keeps its own message.
2630            let sampler = channel.sampler();
2631            let node = channel.target().node().index();
2632            if sampler.input().count() == 0 || sampler.output().count() == 0 {
2633                return Err(LoadError::Malformed(format!(
2634                    "clip '{name}' node {node}: animation channel with zero keyframes"
2635                )));
2636            }
2637            // The layout the accessor declares is the other way, and each
2638            // half of the reader has to be judged on its own accessor.
2639            check_sampler_accessor(&name, node, "input", &sampler.input())?;
2640            check_sampler_accessor(&name, node, "output", &sampler.output())?;
2641            let reader = channel.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
2642            let times = reader.read_inputs().map(|it| it.collect::<Vec<f32>>());
2643            if let Some(pending) = pending_facts.as_mut()
2644                && !pending.truncated
2645            {
2646                pending.record_channel(&channel, times.as_deref());
2647            }
2648            let Some(times) = times else {
2649                continue;
2650            };
2651            let (property, values) = match reader.read_outputs() {
2652                Some(gltf::animation::util::ReadOutputs::Translations(it)) => (
2653                    Property::Translation,
2654                    TrackValues::Vec3s(it.map(Vec3::from_array).collect()),
2655                ),
2656                Some(gltf::animation::util::ReadOutputs::Rotations(r)) => (
2657                    Property::Rotation,
2658                    TrackValues::Quats(r.into_f32().map(Quat::from_array).collect()),
2659                ),
2660                Some(gltf::animation::util::ReadOutputs::Scales(it)) => (
2661                    Property::Scale,
2662                    TrackValues::Vec3s(it.map(Vec3::from_array).collect()),
2663                ),
2664                // Morph-target weights are out of scope for the
2665                // skeletal check catalog (P2 revisits them).
2666                Some(gltf::animation::util::ReadOutputs::MorphTargetWeights(_)) | None => continue,
2667            };
2668            let interpolation = match channel.sampler().interpolation() {
2669                gltf::animation::Interpolation::Linear => Interpolation::Linear,
2670                gltf::animation::Interpolation::Step => Interpolation::Step,
2671                gltf::animation::Interpolation::CubicSpline => Interpolation::CubicSpline,
2672            };
2673            validate_track_lengths(&name, node, interpolation, &times, &values)?;
2674            duration = times
2675                .iter()
2676                .copied()
2677                .filter(|time| time.is_finite())
2678                .map(f64::from)
2679                .fold(duration, f64::max);
2680            tracks.push(Track {
2681                bone,
2682                property,
2683                interpolation,
2684                times,
2685                values,
2686            });
2687        }
2688        clips.push(Clip {
2689            name,
2690            duration_s: duration,
2691            tracks,
2692        });
2693        if let Some(pending) = pending_facts {
2694            let truncated = pending.truncated;
2695            if !source_facts.push_clip(pending.finish()?) || truncated {
2696                facts_complete = false;
2697            }
2698        }
2699    }
2700    if facts_complete {
2701        source_facts.mark_complete(SourceFactDomainV1::Clips);
2702    }
2703
2704    Ok(Document {
2705        skeleton: Skeleton { bones },
2706        clips,
2707        // `build_document` covers skeleton + animation; `load` fills
2708        // `assets` from `extract_assets` before returning.
2709        assets: SceneAssets::default(),
2710        source: SourceInfo {
2711            path: Some(path.display().to_string()),
2712            format: Some("gltf".into()),
2713        },
2714    })
2715}
2716
2717/// The node-graph derivation [`topology`] produces once per load, shared
2718/// by the skeleton build and asset extraction so both agree on which bone
2719/// a node became. All three arrays are indexed by glTF node index.
2720struct Topology {
2721    /// Node indices in bone order: DFS from roots, file order among
2722    /// siblings — the order `build_document` assigns bone ids in.
2723    order: Vec<usize>,
2724    /// Each node's parent node index (`None` for roots), as reached by the
2725    /// DFS — always pushed to `order` before the child.
2726    parent: Vec<Option<usize>>,
2727    /// Each node's assigned bone id. `Some` for every node after a
2728    /// successful `topology` (all nodes are reached); the `Option` keeps
2729    /// index alignment and lets consumers skip gracefully.
2730    bone_of_node: Vec<Option<usize>>,
2731}
2732
2733/// Derives the bone [`Topology`] from the glTF node graph: a DFS from the
2734/// roots, file order among siblings, over ALL nodes (scene membership
2735/// doesn't matter — animations may target unreferenced subtrees). This is
2736/// the order `build_document` assigns bone ids in.
2737///
2738/// glTF requires the node graph to be a forest. A malformed file can
2739/// break that two ways, and both are rejected as [`LoadError::Topology`]
2740/// rather than silently repaired — recovering would force an arbitrary
2741/// choice (which of two parents a node inherits, or dropping a cyclic
2742/// subtree) that quietly corrupts every downstream world transform:
2743///
2744/// - **Duplicate parent** — a node claimed as a child by more than one
2745///   node. Caught by the reference count below, before any traversal.
2746/// - **Cycle** — a closed loop. A cycle *reachable* from a root gives its
2747///   entry node a second parent, so it is caught by the duplicate-parent
2748///   check above. A *rootless* cycle has no root to descend from, so the
2749///   DFS never enters it and its nodes stay unreached — caught by the
2750///   post-DFS reachability check. Either way the DFS never actually walks
2751///   a cycle.
2752///
2753/// Both checks are O(nodes + edges). Because duplicate parents are
2754/// rejected first, every surviving node has at most one parent, so the
2755/// DFS reaches each node at most once and cannot loop — the walk is
2756/// bounded without relying on cycle detection mid-traversal, keeping
2757/// hostile input within invariant-1 (a `LoadError`, never a panic or
2758/// OOM). The `gltf_load` fuzz target (cycle → OOM under the old
2759/// best-effort recovery) and the audit (multi-parent → bad FK) motivated
2760/// the hardening.
2761fn topology(doc: &gltf::Document) -> Result<Topology, LoadError> {
2762    let node_count = doc.nodes().count();
2763    // Count parent claims per node. A forest allows at most one; two or
2764    // more is a duplicate-parent malformation. Also drives root detection:
2765    // a node with zero claims is a root.
2766    // `child.index()` is in range: `Gltf::from_slice` validates node child
2767    // indices. `saturating_add` keeps the count panic-free even on a
2768    // pathological file-derived edge multiplicity (invariant-1); any value
2769    // above 1 is a duplicate parent regardless.
2770    let mut parent_refs: Vec<u32> = vec![0; node_count];
2771    for node in doc.nodes() {
2772        for child in node.children() {
2773            let refs = &mut parent_refs[child.index()];
2774            *refs = refs.saturating_add(1);
2775        }
2776    }
2777    if let Some(dup) = parent_refs.iter().position(|&refs| refs > 1) {
2778        return Err(LoadError::Topology(format!(
2779            "node {dup} is a child of {} nodes; glTF requires a forest (one parent per node)",
2780            parent_refs[dup]
2781        )));
2782    }
2783
2784    let nodes: Vec<gltf::Node> = doc.nodes().collect();
2785    let mut order: Vec<usize> = Vec::with_capacity(node_count);
2786    let mut parent: Vec<Option<usize>> = vec![None; node_count];
2787    let mut stack: Vec<usize> = doc
2788        .nodes()
2789        .filter(|n| parent_refs[n.index()] == 0)
2790        .map(|n| n.index())
2791        .collect();
2792    stack.reverse(); // keep file order among roots
2793    // DFS records `parent` as the node it reached the child *through*,
2794    // which was pushed to `order` before the child — keeping every
2795    // parent's bone id below its children's, the ordering `sample_clip`'s
2796    // single ascending FK pass relies on. With duplicate parents already
2797    // rejected, each child has exactly one parent, so this is unambiguous.
2798    // The `visited` re-entry guard is defensive: that same one-parent
2799    // property means each node is pushed at most once, so the guard is not
2800    // normally hit — it keeps the walk self-bounding if that upstream
2801    // guarantee is ever weakened.
2802    let mut visited: Vec<bool> = vec![false; node_count];
2803    while let Some(i) = stack.pop() {
2804        if visited[i] {
2805            continue;
2806        }
2807        visited[i] = true;
2808        order.push(i);
2809        let children: Vec<usize> = nodes[i].children().map(|c| c.index()).collect();
2810        for &c in children.iter().rev() {
2811            parent[c] = Some(i);
2812            stack.push(c);
2813        }
2814    }
2815
2816    // Any node the DFS never reached has a parent (it is not a root) yet no
2817    // root-anchored path — it is trapped in a rootless cycle. (A cycle
2818    // reachable from a root can't reach here: its entry node has two
2819    // parents and was rejected above.) Reject rather than load a partial
2820    // skeleton silently missing those bones.
2821    if order.len() != node_count {
2822        let orphan = (0..node_count).find(|&n| !visited[n]).unwrap();
2823        return Err(LoadError::Topology(format!(
2824            "node {orphan} is unreachable from any root; the node graph contains a cycle"
2825        )));
2826    }
2827
2828    let mut bone_of_node: Vec<Option<usize>> = vec![None; node_count];
2829    for (bone_id, &node_index) in order.iter().enumerate() {
2830        bone_of_node[node_index] = Some(bone_id);
2831    }
2832    Ok(Topology {
2833        order,
2834        parent,
2835        bone_of_node,
2836    })
2837}
2838
2839/// Extract source-order skeleton evidence without conflating it with the
2840/// parent-before-child core skeleton used for sampling.
2841///
2842/// This reads every source node and skin, including skin attachments whose
2843/// mesh definition is later skipped by the triangle-only asset importer.
2844/// Inverse-bind accessor failures are source evidence rather than load errors:
2845/// callers can measure a parseable file's incomplete or malformed binding
2846/// declaration without silently falling back to a bone-level matrix.
2847fn extract_source_skeleton(
2848    doc: &gltf::Document,
2849    buffers: &[Vec<u8>],
2850    topo: &Topology,
2851) -> SourceSkeletonAssets {
2852    let mut scene_root_indices = vec![Vec::new(); doc.nodes().count()];
2853    for scene in doc.scenes() {
2854        for root in scene.nodes() {
2855            if let Some(indices) = scene_root_indices.get_mut(root.index()) {
2856                indices.push(scene.index());
2857            }
2858        }
2859    }
2860    for indices in &mut scene_root_indices {
2861        indices.sort_unstable();
2862        indices.dedup();
2863    }
2864    let mut attachments = vec![Vec::new(); doc.skins().count()];
2865    for node in doc.nodes() {
2866        let Some(skin) = node.skin() else {
2867            continue;
2868        };
2869        let Some(for_skin) = attachments.get_mut(skin.index()) else {
2870            return SourceSkeletonAssets::default();
2871        };
2872        for_skin.push(SourceSkinAttachment {
2873            source_node_index: node.index(),
2874            source_mesh_index: node.mesh().map(|mesh| mesh.index()),
2875        });
2876    }
2877
2878    let mut nodes = Vec::with_capacity(doc.nodes().count());
2879    for node in doc.nodes() {
2880        let local_rest = match node.transform() {
2881            gltf::scene::Transform::Decomposed {
2882                translation,
2883                rotation,
2884                scale,
2885            } => SourceNodeLocalRest::Trs {
2886                translation: Vec3::from_array(translation),
2887                rotation: Quat::from_array(rotation),
2888                scale: Vec3::from_array(scale),
2889            },
2890            gltf::scene::Transform::Matrix { matrix } => {
2891                SourceNodeLocalRest::Matrix(Mat4::from_cols_array_2d(&matrix))
2892            }
2893        };
2894        let mut source_node = SourceNodeAsset::new(node.index(), local_rest);
2895        source_node.name = node.name().map(str::to_owned);
2896        source_node.parent_source_node_index = topo.parent[node.index()];
2897        source_node.scene_root_indices = std::mem::take(&mut scene_root_indices[node.index()]);
2898        source_node.bone = topo.bone_of_node[node.index()];
2899        nodes.push(source_node);
2900    }
2901
2902    let mut skins = Vec::with_capacity(doc.skins().count());
2903    for skin in doc.skins() {
2904        let joints = skin.joints().map(|joint| joint.index()).collect::<Vec<_>>();
2905        let skeleton_root = skin.skeleton().map(|node| node.index());
2906        let inverse_bind_accessor = match skin.inverse_bind_matrices() {
2907            None => SourceInverseBindAccessor::default(),
2908            Some(accessor) if accessor.count() == 0 => SourceInverseBindAccessor {
2909                status: SourceInverseBindAccessorStatus::EmptyAccessor,
2910                declared_count: Some(0),
2911                matrices: Vec::new(),
2912            },
2913            // An accessor the matrix reader cannot decode is unreadable
2914            // evidence, not a load error; building the reader for it would
2915            // panic (see `inverse_bind_is_readable`).
2916            Some(accessor) if !inverse_bind_is_readable(&accessor) => SourceInverseBindAccessor {
2917                status: SourceInverseBindAccessorStatus::Unreadable,
2918                declared_count: Some(accessor.count()),
2919                matrices: Vec::new(),
2920            },
2921            Some(accessor) => {
2922                let declared_count = accessor.count();
2923                let reader = skin.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
2924                match reader.read_inverse_bind_matrices() {
2925                    Some(matrices) => {
2926                        let matrices = matrices
2927                            .map(|matrix| Mat4::from_cols_array_2d(&matrix))
2928                            .collect::<Vec<_>>();
2929                        SourceInverseBindAccessor {
2930                            status: if matrices.len() >= joints.len() {
2931                                SourceInverseBindAccessorStatus::Available
2932                            } else {
2933                                SourceInverseBindAccessorStatus::CountMismatch
2934                            },
2935                            declared_count: Some(declared_count),
2936                            matrices,
2937                        }
2938                    }
2939                    None => SourceInverseBindAccessor {
2940                        status: SourceInverseBindAccessorStatus::Unreadable,
2941                        declared_count: Some(declared_count),
2942                        matrices: Vec::new(),
2943                    },
2944                }
2945            }
2946        };
2947        skins.push(SourceSkinAsset {
2948            source_skin_index: skin.index(),
2949            name: skin.name().map(str::to_owned),
2950            skeleton_root_source_node_index: skeleton_root,
2951            joint_source_node_indices: joints,
2952            inverse_bind_accessor,
2953            attachments: std::mem::take(&mut attachments[skin.index()]),
2954        });
2955    }
2956
2957    SourceSkeletonAssets {
2958        coverage: SourceSkeletonCoverage::Complete,
2959        nodes,
2960        skins,
2961    }
2962}
2963
2964/// Parse meshes (indexed or unindexed), skins (joints + inverse bind
2965/// matrices), and materials (PBR factors + embedded base-color and normal textures)
2966/// into the core [`SceneAssets`] model — the symmetric read side of
2967/// [`write::write`], mirroring `animsmith-fbx`'s `extract_assets`.
2968///
2969/// Triangle-list vertex data is kept in glTF coordinates without unit
2970/// conversion or UV flipping; other primitive modes are skipped. Materials
2971/// keep their glTF array index so a primitive's `material()` index maps
2972/// straight into `assets.materials`.
2973fn extract_assets(
2974    doc: &gltf::Document,
2975    buffers: &[Vec<u8>],
2976    resources: &mut ResourceCaptureSession,
2977    bone_of_node: &[Option<usize>],
2978) -> SceneAssets {
2979    let mut assets = SceneAssets::default();
2980
2981    let source_images = extract_source_images(doc, buffers, resources);
2982    let (raw_images, source_image_records): (Vec<_>, Vec<_>) = source_images
2983        .into_iter()
2984        .map(|image| (image.texture, image.record))
2985        .unzip();
2986    assets.material_resources = MaterialResourceAssets {
2987        coverage: MaterialResourceCoverage::Complete,
2988        materials: Vec::new(),
2989        textures: extract_source_textures(doc),
2990        images: source_image_records,
2991    };
2992
2993    // `doc.materials()` yields defined materials in index order (the
2994    // synthesized default material has no index and is skipped), so
2995    // pushing in iteration order keeps `assets.materials[i]` aligned
2996    // with glTF material index `i`.
2997    for material in doc.materials() {
2998        let Some(material_index) = material.index() else {
2999            continue;
3000        };
3001        let pbr = material.pbr_metallic_roughness();
3002        assets
3003            .material_resources
3004            .materials
3005            .push(SourceMaterialAsset {
3006                material_index,
3007                name: material.name().map(str::to_owned),
3008                texture_bindings: source_material_texture_bindings(&material),
3009            });
3010        let base_color_texture = pbr.base_color_texture().and_then(|info| {
3011            material_texture(&raw_images, info.texture().source().index(), resources)
3012        });
3013        let normal_texture = material.normal_texture().and_then(|info| {
3014            material_texture(&raw_images, info.texture().source().index(), resources).map(
3015                |texture| NormalTextureAsset {
3016                    texture,
3017                    scale: info.scale(),
3018                },
3019            )
3020        });
3021        let metallic_roughness_texture = pbr.metallic_roughness_texture().and_then(|info| {
3022            material_texture(&raw_images, info.texture().source().index(), resources)
3023        });
3024        let occlusion_texture = material.occlusion_texture().and_then(|info| {
3025            material_texture(&raw_images, info.texture().source().index(), resources).map(
3026                |texture| OcclusionTextureAsset {
3027                    texture,
3028                    strength: info.strength(),
3029                },
3030            )
3031        });
3032        assets.materials.push(MaterialAsset {
3033            name: material.name().unwrap_or("material").to_string(),
3034            base_color: pbr.base_color_factor(),
3035            metallic: pbr.metallic_factor(),
3036            roughness: pbr.roughness_factor(),
3037            base_color_texture,
3038            normal_texture,
3039            metallic_roughness_texture,
3040            occlusion_texture,
3041        });
3042    }
3043
3044    // Keep definitions apart from their node instances. In particular, a
3045    // valid definition that no node references is still observable to later
3046    // definition-domain measurement work.
3047    let mut core_mesh_of_source = vec![None; doc.meshes().count()];
3048    for mesh in doc.meshes() {
3049        let mut primitives = Vec::new();
3050        for prim in mesh.primitives() {
3051            // Only triangle lists are ingested. The core model and the
3052            // writer are triangle-only (no primitive `mode` field), and
3053            // measure/checks assume triangulated geometry; a points/
3054            // lines/strip/fan primitive read as a triangle list would be
3055            // silently corrupted, so skip it rather than misinterpret it.
3056            // Skinned rigs — the target inputs — are triangle lists.
3057            if prim.mode() != gltf::mesh::Mode::Triangles {
3058                continue;
3059            }
3060            // Every accessor read below has already had its `type`,
3061            // `componentType`, and buffer layout checked against what its
3062            // reader decodes; see `validate_primitive_accessors`, which
3063            // `load_bytes` runs before any reader exists. Adding a read here
3064            // for a semantic `required_attribute_encoding` answers `None` —
3065            // `read_tangents`, or any set index above 0 — compiles fine and
3066            // reopens the panic, so give it an encoding there first.
3067            let reader = prim.reader(|b| buffers.get(b.index()).map(Vec::as_slice));
3068            // Never iterate a count-0 accessor: gltf 1.4's reader
3069            // underflows and panics on one (invariant: hostile input must
3070            // not crash the loader). Treat a zero-count attribute as
3071            // absent, and skip a primitive whose POSITION is missing or
3072            // empty — a primitive without positions carries no geometry.
3073            let has = |sem: gltf::Semantic| prim.get(&sem).is_some_and(|a| a.count() > 0);
3074            if !has(gltf::Semantic::Positions) {
3075                continue;
3076            }
3077            let positions: Vec<Vec3> = reader
3078                .read_positions()
3079                .map(|it| it.map(Vec3::from_array).collect())
3080                .unwrap_or_default();
3081            let normals = if has(gltf::Semantic::Normals) {
3082                reader
3083                    .read_normals()
3084                    .map(|it| it.map(Vec3::from_array).collect())
3085                    .unwrap_or_default()
3086            } else {
3087                Vec::new()
3088            };
3089            let uvs = if has(gltf::Semantic::TexCoords(0)) {
3090                reader
3091                    .read_tex_coords(0)
3092                    .map(|tc| tc.into_f32().collect())
3093                    .unwrap_or_default()
3094            } else {
3095                Vec::new()
3096            };
3097            // JOINTS_0/WEIGHTS_0 come as a pair; keep them parallel.
3098            let (joints, weights) =
3099                if has(gltf::Semantic::Joints(0)) && has(gltf::Semantic::Weights(0)) {
3100                    match (reader.read_joints(0), reader.read_weights(0)) {
3101                        (Some(j), Some(w)) => (j.into_u16().collect(), w.into_f32().collect()),
3102                        _ => (Vec::new(), Vec::new()),
3103                    }
3104                } else {
3105                    (Vec::new(), Vec::new())
3106                };
3107            // Secondary influence attributes do not change the core's
3108            // primary-set semantics, but their independent presence matters
3109            // to consumers that must reject or report unsupported influence
3110            // sets. Only retain nonzero accessors, matching the loader's
3111            // count-zero-as-absent hardening policy above.
3112            let mut additional_influence_sets: BTreeMap<u32, AdditionalInfluenceSet> =
3113                BTreeMap::new();
3114            for (semantic, accessor) in prim.attributes() {
3115                if accessor.count() == 0 {
3116                    continue;
3117                }
3118                match semantic {
3119                    gltf::Semantic::Joints(set) if set >= 1 => {
3120                        additional_influence_sets
3121                            .entry(set)
3122                            .and_modify(|entry| entry.joints_present = true)
3123                            .or_insert(AdditionalInfluenceSet {
3124                                set_index: set,
3125                                joints_present: true,
3126                                weights_present: false,
3127                            });
3128                    }
3129                    gltf::Semantic::Weights(set) if set >= 1 => {
3130                        additional_influence_sets
3131                            .entry(set)
3132                            .and_modify(|entry| entry.weights_present = true)
3133                            .or_insert(AdditionalInfluenceSet {
3134                                set_index: set,
3135                                joints_present: false,
3136                                weights_present: true,
3137                            });
3138                    }
3139                    _ => {}
3140                }
3141            }
3142            let indices = if prim.indices().is_some_and(|a| a.count() > 0) {
3143                reader
3144                    .read_indices()
3145                    .map(|it| it.into_u32().collect())
3146                    .unwrap_or_default()
3147            } else {
3148                Vec::new()
3149            };
3150            primitives.push(Primitive {
3151                source_primitive_index: Some(prim.index()),
3152                material: prim.material().index(),
3153                indices,
3154                positions,
3155                normals,
3156                uvs,
3157                joints,
3158                weights,
3159                additional_influence_sets: additional_influence_sets.into_values().collect(),
3160            });
3161        }
3162        if primitives.is_empty() {
3163            continue;
3164        }
3165        let core_mesh = assets.meshes.len();
3166        core_mesh_of_source[mesh.index()] = Some(core_mesh);
3167        assets.meshes.push(MeshAsset {
3168            name: mesh.name().unwrap_or("mesh").to_string(),
3169            source_mesh_index: mesh.index(),
3170            primitives,
3171        });
3172    }
3173
3174    for node in doc.nodes() {
3175        let Some(source_mesh) = node.mesh() else {
3176            continue;
3177        };
3178        let Some(mesh) = core_mesh_of_source[source_mesh.index()] else {
3179            continue;
3180        };
3181        let skin = node.skin();
3182        let skin_joints = skin
3183            .as_ref()
3184            .map(|skin| {
3185                skin.joints()
3186                    .map(|joint| bone_of_node[joint.index()].unwrap_or(0))
3187                    .collect()
3188            })
3189            .unwrap_or_default();
3190        let skin_ibms = skin
3191            .as_ref()
3192            .filter(|skin| {
3193                skin.inverse_bind_matrices().is_some_and(|accessor| {
3194                    accessor.count() > 0 && inverse_bind_is_readable(&accessor)
3195                })
3196            })
3197            .map(|skin| {
3198                let reader = skin.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
3199                reader
3200                    .read_inverse_bind_matrices()
3201                    .map(|matrices| {
3202                        matrices
3203                            .map(|matrix| Mat4::from_cols_array_2d(&matrix))
3204                            .collect()
3205                    })
3206                    .unwrap_or_default()
3207            })
3208            .unwrap_or_default();
3209        assets.instances.push(MeshInstance {
3210            source_node_index: node.index(),
3211            node: bone_of_node[node.index()].unwrap_or(0),
3212            mesh,
3213            skin_joints,
3214            skin_ibms,
3215        });
3216    }
3217
3218    assets
3219}
3220
3221fn material_texture(
3222    raw_images: &[Option<TextureAsset>],
3223    image_index: usize,
3224    resources: &mut ResourceCaptureSession,
3225) -> Option<TextureAsset> {
3226    let texture = raw_images.get(image_index)?.as_ref()?;
3227    resources.clone_image_for_material(image_index, texture)
3228}
3229
3230/// Preserve declared glTF scene membership separately from the all-node
3231/// skeleton topology. A node can be reachable from the forest yet absent from
3232/// any declared scene, so membership must not be inferred from `Bone::parent`.
3233fn extract_scenes(doc: &gltf::Document, bone_of_node: &[Option<usize>]) -> Vec<SceneAsset> {
3234    doc.scenes()
3235        .map(|scene| SceneAsset {
3236            source_scene_index: scene.index(),
3237            name: scene.name().map(str::to_owned),
3238            roots: scene
3239                .nodes()
3240                .filter_map(|node| bone_of_node[node.index()])
3241                .collect(),
3242        })
3243        .collect()
3244}
3245
3246/// Maximum encoded source image size considered for metadata inspection.
3247const MAX_IMAGE_ENCODED_BYTES: usize = 64 * 1024 * 1024;
3248/// Maximum allocation the image decoder may request during inspection.
3249const MAX_IMAGE_DECODE_ALLOC_BYTES: u64 = 192 * 1024 * 1024;
3250
3251/// One image record plus raw bytes for the legacy writer-facing material
3252/// slots. The sidecar reports bounded inspection facts; it never changes
3253/// whether a resolvable source image remains writable.
3254struct LoadedSourceImage {
3255    record: SourceImageAsset,
3256    texture: Option<TextureAsset>,
3257}
3258
3259/// Read source-image definitions once, in glTF source order. This retains
3260/// independent image rows (including unreferenced images), while later
3261/// texture and material records refer to them by their glTF array indices.
3262fn extract_source_images(
3263    doc: &gltf::Document,
3264    buffers: &[Vec<u8>],
3265    resources: &mut ResourceCaptureSession,
3266) -> Vec<LoadedSourceImage> {
3267    let writer_images = writer_image_indices(doc);
3268    doc.images()
3269        .map(|image| {
3270            let image_index = image.index();
3271            let retain_raw = writer_images.contains(&image_index);
3272            let name = image.name().map(str::to_owned);
3273            let (source_kind, declared_mime_type, raw, unavailable_reason, inspected) =
3274                match image.source() {
3275                    gltf::image::Source::View { view, mime_type } => {
3276                        let bytes = buffers.get(view.buffer().index()).and_then(|buffer| {
3277                            // A view with no `view_end` has no bytes here: an
3278                            // image is source evidence, so a failed range is an
3279                            // explicit source gap rather than a refusal.
3280                            view_end(&view).and_then(|end| buffer.get(view.offset()..end))
3281                        });
3282                        let (raw, reason) = match bytes {
3283                            Some(bytes) if !retain_raw && bytes.len() > MAX_IMAGE_ENCODED_BYTES => {
3284                                (None, ImageUnavailableReason::ResourceLimit)
3285                            }
3286                            Some(bytes) => (
3287                                Some(TextureAsset {
3288                                    bytes: bytes.to_vec(),
3289                                    mime: mime_type.to_string(),
3290                                }),
3291                                ImageUnavailableReason::SourceUnavailable,
3292                            ),
3293                            None => (None, ImageUnavailableReason::SourceUnavailable),
3294                        };
3295                        (
3296                            ImageSourceKind::Embedded,
3297                            Some(mime_type.to_string()),
3298                            raw,
3299                            reason,
3300                            None,
3301                        )
3302                    }
3303                    gltf::image::Source::Uri { uri, mime_type } => {
3304                        if let Some(encoded) = uri.strip_prefix("data:") {
3305                            let (mime_from_uri, raw, reason) =
3306                                read_data_uri_image(encoded, mime_type, retain_raw);
3307                            (
3308                                ImageSourceKind::DataUri,
3309                                mime_type.map(str::to_owned).or(mime_from_uri),
3310                                raw,
3311                                reason,
3312                                None,
3313                            )
3314                        } else {
3315                            let (detected_container, leading_magic_hex, inspection) = {
3316                                let (bytes, reason) = resources.external_image_payload(image_index);
3317                                inspect_source_image(bytes, reason)
3318                            };
3319                            let raw = retain_raw.then(|| {
3320                                resources
3321                                    .materialize_external(
3322                                        SourceResourceKindV1::Image,
3323                                        image_index as u64,
3324                                    )
3325                                    .ok()
3326                                    .map(|bytes| TextureAsset {
3327                                        bytes,
3328                                        mime: mime_type.unwrap_or_default().to_owned(),
3329                                    })
3330                            });
3331                            let raw = raw.flatten();
3332                            let materialization_limited = retain_raw
3333                                && raw.is_none()
3334                                && resources.external_image_is_available(image_index);
3335                            (
3336                                ImageSourceKind::External,
3337                                mime_type.map(str::to_owned),
3338                                raw,
3339                                if materialization_limited {
3340                                    ImageUnavailableReason::ResourceLimit
3341                                } else {
3342                                    ImageUnavailableReason::SourceUnavailable
3343                                },
3344                                Some(if materialization_limited {
3345                                    (
3346                                        None,
3347                                        None,
3348                                        SourceImageInspection::Unavailable {
3349                                            reason: ImageUnavailableReason::ResourceLimit,
3350                                        },
3351                                    )
3352                                } else {
3353                                    (detected_container, leading_magic_hex, inspection)
3354                                }),
3355                            )
3356                        }
3357                    }
3358                };
3359            let (detected_container, leading_magic_hex, inspection) =
3360                inspected.unwrap_or_else(|| {
3361                    inspect_source_image(
3362                        raw.as_ref().map(|texture| texture.bytes.as_slice()),
3363                        unavailable_reason,
3364                    )
3365                });
3366            LoadedSourceImage {
3367                record: SourceImageAsset {
3368                    image_index,
3369                    name,
3370                    source_kind,
3371                    declared_mime_type,
3372                    detected_container,
3373                    leading_magic_hex,
3374                    inspection,
3375                },
3376                texture: if retain_raw { raw } else { None },
3377            }
3378        })
3379        .collect()
3380}
3381
3382/// Images used by writer-facing material slots retain their full encoded
3383/// payload, preserving the loader's established round-trip behavior. Other
3384/// source rows need only a bounded payload long enough for inspection.
3385fn writer_image_indices(doc: &gltf::Document) -> BTreeSet<usize> {
3386    let mut images = BTreeSet::new();
3387    for material in doc.materials() {
3388        let pbr = material.pbr_metallic_roughness();
3389        for texture in [
3390            pbr.base_color_texture().map(|info| info.texture()),
3391            material.normal_texture().map(|info| info.texture()),
3392            pbr.metallic_roughness_texture().map(|info| info.texture()),
3393            material.occlusion_texture().map(|info| info.texture()),
3394        ]
3395        .into_iter()
3396        .flatten()
3397        {
3398            images.insert(texture.source().index());
3399        }
3400    }
3401    images
3402}
3403
3404/// Decode a glTF `data:` image URI without treating malformed URI input as a
3405/// load error. The sidecar preserves the stable reason while the legacy
3406/// writer slot remains absent, as it was before material-resource evidence.
3407fn read_data_uri_image(
3408    encoded: &str,
3409    mime_type: Option<&str>,
3410    retain_raw: bool,
3411) -> (Option<String>, Option<TextureAsset>, ImageUnavailableReason) {
3412    let Some((metadata, payload)) = encoded.split_once(',') else {
3413        return (None, None, ImageUnavailableReason::InvalidDataUri);
3414    };
3415    if !metadata.ends_with(";base64") {
3416        return (None, None, ImageUnavailableReason::InvalidDataUri);
3417    }
3418    let mime_from_uri = metadata
3419        .strip_suffix(";base64")
3420        .filter(|mime| !mime.is_empty())
3421        .map(str::to_owned);
3422    if !retain_raw && estimated_base64_decoded_len(payload.len()) > MAX_IMAGE_ENCODED_BYTES {
3423        return (mime_from_uri, None, ImageUnavailableReason::ResourceLimit);
3424    }
3425    let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(payload) else {
3426        return (mime_from_uri, None, ImageUnavailableReason::InvalidDataUri);
3427    };
3428    if !retain_raw && bytes.len() > MAX_IMAGE_ENCODED_BYTES {
3429        return (mime_from_uri, None, ImageUnavailableReason::ResourceLimit);
3430    }
3431    let mime = mime_type
3432        .map(str::to_owned)
3433        .or_else(|| mime_from_uri.clone())
3434        .unwrap_or_default();
3435    (
3436        mime_from_uri,
3437        Some(TextureAsset { bytes, mime }),
3438        ImageUnavailableReason::InvalidDataUri,
3439    )
3440}
3441
3442fn estimated_base64_decoded_len(encoded_len: usize) -> usize {
3443    encoded_len.saturating_add(3) / 4 * 3
3444}
3445
3446/// Extract one material's bindings in fixed semantic order. This is called
3447/// from the single source-material walk that also keeps writer-facing slots.
3448fn source_material_texture_bindings(
3449    material: &gltf::Material<'_>,
3450) -> Vec<SourceMaterialTextureBinding> {
3451    let pbr = material.pbr_metallic_roughness();
3452    let mut texture_bindings = Vec::with_capacity(5);
3453    let mut push = |slot, texture: Option<gltf::Texture>| {
3454        if let Some(texture) = texture {
3455            texture_bindings.push(SourceMaterialTextureBinding {
3456                slot,
3457                texture_index: texture.index(),
3458            });
3459        }
3460    };
3461    push(
3462        MaterialTextureSlot::BaseColor,
3463        pbr.base_color_texture().map(|info| info.texture()),
3464    );
3465    push(
3466        MaterialTextureSlot::Normal,
3467        material.normal_texture().map(|info| info.texture()),
3468    );
3469    push(
3470        MaterialTextureSlot::MetallicRoughness,
3471        pbr.metallic_roughness_texture().map(|info| info.texture()),
3472    );
3473    push(
3474        MaterialTextureSlot::Occlusion,
3475        material.occlusion_texture().map(|info| info.texture()),
3476    );
3477    push(
3478        MaterialTextureSlot::Emissive,
3479        material.emissive_texture().map(|info| info.texture()),
3480    );
3481    texture_bindings
3482}
3483
3484/// Project texture definitions in source order, retaining unreferenced rows
3485/// and their source image identity.
3486fn extract_source_textures(doc: &gltf::Document) -> Vec<SourceTextureAsset> {
3487    doc.textures()
3488        .map(|texture| SourceTextureAsset {
3489            texture_index: texture.index(),
3490            name: texture.name().map(str::to_owned),
3491            image_index: texture.source().index(),
3492        })
3493        .collect()
3494}
3495
3496/// Inspect an image payload under strict encoded-size and decoder-allocation
3497/// bounds. Inspection decodes only long enough to obtain metadata; the decoded
3498/// image is immediately dropped and never becomes part of the core model.
3499fn inspect_source_image(
3500    bytes: Option<&[u8]>,
3501    unavailable_reason: ImageUnavailableReason,
3502) -> (
3503    Option<ImageContainerFormat>,
3504    Option<String>,
3505    SourceImageInspection,
3506) {
3507    let Some(bytes) = bytes else {
3508        return (
3509            None,
3510            None,
3511            SourceImageInspection::Unavailable {
3512                reason: unavailable_reason,
3513            },
3514        );
3515    };
3516    if bytes.len() > MAX_IMAGE_ENCODED_BYTES {
3517        return (
3518            detect_container(bytes),
3519            None,
3520            SourceImageInspection::Unavailable {
3521                reason: ImageUnavailableReason::ResourceLimit,
3522            },
3523        );
3524    }
3525    let Some((format, detected_container)) = image_format(bytes) else {
3526        return (
3527            None,
3528            leading_magic_hex(bytes),
3529            SourceImageInspection::Unavailable {
3530                reason: ImageUnavailableReason::UnsupportedContainer,
3531            },
3532        );
3533    };
3534    let mut reader = ImageReader::new(Cursor::new(bytes));
3535    reader.set_format(format);
3536    let mut limits = Limits::default();
3537    limits.max_alloc = Some(MAX_IMAGE_DECODE_ALLOC_BYTES);
3538    reader.limits(limits);
3539    match reader.decode() {
3540        Ok(decoded) => {
3541            let color_type = match decoded.color() {
3542                ColorType::L8 => Some(DecodedImageColorType::L8),
3543                ColorType::La8 => Some(DecodedImageColorType::La8),
3544                ColorType::Rgb8 => Some(DecodedImageColorType::Rgb8),
3545                ColorType::Rgba8 => Some(DecodedImageColorType::Rgba8),
3546                ColorType::L16 => Some(DecodedImageColorType::L16),
3547                ColorType::La16 => Some(DecodedImageColorType::La16),
3548                ColorType::Rgb16 => Some(DecodedImageColorType::Rgb16),
3549                ColorType::Rgba16 => Some(DecodedImageColorType::Rgba16),
3550                _ => None,
3551            };
3552            let (width, height) = (decoded.width(), decoded.height());
3553            match color_type {
3554                Some(color_type) => (
3555                    Some(detected_container),
3556                    None,
3557                    SourceImageInspection::Available {
3558                        width,
3559                        height,
3560                        channel_count: decoded.color().channel_count(),
3561                        color_type,
3562                    },
3563                ),
3564                None => (
3565                    Some(detected_container),
3566                    None,
3567                    SourceImageInspection::Unavailable {
3568                        reason: ImageUnavailableReason::DecodeFailed,
3569                    },
3570                ),
3571            }
3572        }
3573        Err(ImageError::Limits(_)) => (
3574            Some(detected_container),
3575            None,
3576            SourceImageInspection::Unavailable {
3577                reason: ImageUnavailableReason::ResourceLimit,
3578            },
3579        ),
3580        Err(_) => (
3581            Some(detected_container),
3582            None,
3583            SourceImageInspection::Unavailable {
3584                reason: ImageUnavailableReason::DecodeFailed,
3585            },
3586        ),
3587    }
3588}
3589
3590/// Capture at most the first 16 bytes of an unsupported nonempty payload as
3591/// lowercase even-length hexadecimal evidence.
3592fn leading_magic_hex(bytes: &[u8]) -> Option<String> {
3593    if bytes.is_empty() {
3594        return None;
3595    }
3596    Some(
3597        bytes
3598            .iter()
3599            .take(16)
3600            .map(|byte| format!("{byte:02x}"))
3601            .collect(),
3602    )
3603}
3604
3605/// Return the supported container format and its core vocabulary variant.
3606fn image_format(bytes: &[u8]) -> Option<(ImageFormat, ImageContainerFormat)> {
3607    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
3608        Some((ImageFormat::Png, ImageContainerFormat::Png))
3609    } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
3610        Some((ImageFormat::Jpeg, ImageContainerFormat::Jpeg))
3611    } else {
3612        None
3613    }
3614}
3615
3616/// Detect only the container formats the bounded inspector supports.
3617fn detect_container(bytes: &[u8]) -> Option<ImageContainerFormat> {
3618    image_format(bytes).map(|(_, container)| container)
3619}
3620
3621#[cfg(test)]
3622mod dependency_capture_tests {
3623    use super::*;
3624
3625    fn key() -> DependencyResourceKeyV1 {
3626        DependencyResourceKeyV1::from_source_str("shared.bin", ResourceKeySyntaxV1::GltfUri)
3627            .expect("safe test key")
3628    }
3629
3630    fn session_with_aliases(limit: u64) -> ResourceCaptureSession {
3631        let key = key();
3632        let mut session = ResourceCaptureSession::new(None);
3633        session.materialized_external_limit = limit;
3634        session
3635            .resources
3636            .insert(key.clone(), CapturedResource::Bytes(vec![1, 2]));
3637        for (kind, index) in [
3638            (SourceResourceKindV1::Buffer, 0),
3639            (SourceResourceKindV1::Buffer, 1),
3640            (SourceResourceKindV1::Image, 0),
3641            (SourceResourceKindV1::Image, 1),
3642        ] {
3643            session.insert_reference(kind, index, CapturedReference::External(key.clone()));
3644        }
3645        session
3646    }
3647
3648    #[test]
3649    fn recording_reader_binds_one_capture_to_the_digest_and_document() {
3650        let dir = tempfile::tempdir().expect("temp dir");
3651        let external_path = dir.path().join("shared.bin");
3652        std::fs::write(&external_path, [0_u8; 36]).expect("decoy external bytes");
3653
3654        let mut captured = Vec::new();
3655        for value in [0.0_f32, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0] {
3656            captured.extend_from_slice(&value.to_le_bytes());
3657        }
3658        let primary = serde_json::to_vec(&serde_json::json!({
3659            "asset": { "version": "2.0" },
3660            "buffers": [
3661                { "uri": "shared.bin", "byteLength": captured.len() },
3662                { "uri": "shared.bin", "byteLength": captured.len() }
3663            ],
3664            "bufferViews": [{ "buffer": 0, "byteOffset": 0, "byteLength": captured.len() }],
3665            "accessors": [{
3666                "bufferView": 0,
3667                "componentType": 5126,
3668                "count": 3,
3669                "type": "VEC3",
3670                "min": [0.0, 0.0, 0.0],
3671                "max": [2.0, 3.0, 0.0]
3672            }],
3673            "meshes": [{ "primitives": [{ "attributes": { "POSITION": 0 } }] }],
3674            "nodes": [{ "mesh": 0 }],
3675            "scenes": [{ "nodes": [0] }],
3676            "scene": 0
3677        }))
3678        .expect("analytic glTF JSON");
3679        let mut opens = 0;
3680        let loaded = load_source_bytes_inner_with_reader(
3681            &dir.path().join("recorded.gltf"),
3682            &primary,
3683            Some(dir.path()),
3684            |path, limit| {
3685                opens += 1;
3686                assert_eq!(path, external_path);
3687                assert!(limit >= captured.len() as u64);
3688                CapturedResource::Bytes(captured.clone())
3689            },
3690        )
3691        .expect("recorded external capture loads");
3692
3693        assert_eq!(opens, 1, "two aliases cause one resolver open");
3694        let closure = loaded.dependency_closure();
3695        assert!(closure.coverage().is_complete());
3696        assert_eq!(closure.references().len(), 2);
3697        assert_eq!(closure.external_resources().len(), 1);
3698        assert_eq!(
3699            closure.external_resources()[0].identity(),
3700            &InputIdentity::from_bytes(&captured),
3701            "the closure hashes the resolver-returned capture"
3702        );
3703        assert_eq!(
3704            loaded.document().assets.meshes[0].primitives[0].positions,
3705            [
3706                Vec3::new(0.0, 0.0, 0.0),
3707                Vec3::new(2.0, 0.0, 0.0),
3708                Vec3::new(0.0, 3.0, 0.0),
3709            ],
3710            "the Document consumes the recorded capture, not the on-disk decoy"
3711        );
3712    }
3713
3714    #[test]
3715    fn raw_extension_key_scan_handles_nesting_escaping_and_non_key_text() {
3716        assert!(json_has_object_key(
3717            br#"{"meshes":[{"primitives":[{"extensio\u006es":{"X":{}}}]}]}"#,
3718            b"extensions"
3719        ));
3720        assert!(!json_has_object_key(
3721            br#"{"extras":{"label":"extensions","note":"\"extensions\":"}}"#,
3722            b"extensions"
3723        ));
3724    }
3725
3726    #[test]
3727    fn materialization_cap_refuses_essential_buffer_alias_without_leaking_a_path() {
3728        let gltf = gltf::Gltf::from_slice(
3729            br#"{
3730                "asset":{"version":"2.0"},
3731                "buffers":[
3732                    {"uri":"shared.bin","byteLength":2},
3733                    {"uri":"shared.bin","byteLength":2}
3734                ]
3735            }"#,
3736        )
3737        .expect("test glTF");
3738        let mut session = session_with_aliases(3);
3739        let error = resolve_captured_buffers(&gltf, &mut session)
3740            .expect_err("second essential clone exceeds the internal cap");
3741        assert!(
3742            error
3743                .to_string()
3744                .contains("external buffer resource exceeds capture limits"),
3745            "{error}"
3746        );
3747    }
3748
3749    #[test]
3750    fn materialization_cap_omits_optional_image_alias_while_its_capture_stays_reusable() {
3751        let gltf = gltf::Gltf::from_slice(
3752            br#"{
3753                "asset":{"version":"2.0"},
3754                "images":[
3755                    {"uri":"shared.bin"},
3756                    {"uri":"shared.bin"}
3757                ],
3758                "textures":[{"source":0},{"source":1}],
3759                "materials":[
3760                    {"pbrMetallicRoughness":{"baseColorTexture":{"index":0}}},
3761                    {"pbrMetallicRoughness":{"baseColorTexture":{"index":1}}}
3762                ]
3763            }"#,
3764        )
3765        .expect("test glTF");
3766        let mut session = session_with_aliases(3);
3767        let images = extract_source_images(&gltf.document, &[], &mut session);
3768        assert!(images[0].texture.is_some());
3769        assert!(images[1].texture.is_none());
3770        assert!(matches!(
3771            images[1].record.inspection,
3772            SourceImageInspection::Unavailable {
3773                reason: ImageUnavailableReason::ResourceLimit
3774            }
3775        ));
3776        assert!(session.external_image_is_available(1));
3777    }
3778}