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