Skip to main content

animsmith_gltf/
lib.rs

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