Skip to main content

animsmith_gltf/
capability.rs

1//! Read-only raw glTF capability inventory for scale producers.
2//!
3//! A normalized [`animsmith_core::Document`] cannot prove that a source file
4//! lacked data the loader does not model. This module therefore inventories
5//! the original glTF JSON and resolved buffers before any scale plan or
6//! candidate document exists.
7
8use crate::{
9    LoadError, build_document, capture_dependency_closure, extract_source_skeleton,
10    has_extension_object, project_extension_facts, project_resource_facts, resolve_buffers,
11    source_facts_builder, topology, validate_animations, validate_document, validate_glb_framing,
12};
13use animsmith_core::{Document, LoadedSource, SourceFactsViewV1, SourceSetCoverageStateV1};
14use serde::Serialize;
15use serde_json::{Map, Value};
16use std::collections::{BTreeMap, BTreeSet};
17use std::path::Path;
18
19const GLB_MAGIC: &[u8; 4] = b"glTF";
20const GLB_JSON_CHUNK: u32 = 0x4e4f_534a;
21
22/// Whether the captured top-level source is JSON glTF or a binary GLB.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum GltfContainerKind {
26    /// A plain JSON `.gltf` document.
27    Gltf,
28    /// A binary `.glb` container.
29    Glb,
30}
31
32/// How one source buffer was declared.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
34#[serde(rename_all = "snake_case")]
35pub enum GltfBufferSourceKind {
36    /// The GLB BIN chunk.
37    BinaryChunk,
38    /// A base64 data URI.
39    DataUri,
40    /// An external relative URI.
41    External,
42}
43
44/// One source buffer recorded before normalized loading.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46pub struct GltfBufferCapability {
47    /// Stable source buffer index.
48    pub buffer_index: usize,
49    /// Source declaration kind.
50    pub source_kind: GltfBufferSourceKind,
51    /// Declared byte length.
52    pub declared_byte_length: u64,
53}
54
55/// Whether a node authored decomposed TRS or a matrix.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum GltfNodeRestKind {
59    /// No matrix was declared, so the node uses glTF TRS properties/defaults.
60    Trs,
61    /// The node declared a local matrix.
62    Matrix,
63}
64
65/// One source node identity and authored rest representation.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct GltfNodeCapability {
68    /// Stable source node index.
69    pub node_index: usize,
70    /// Authored rest representation.
71    pub rest_kind: GltfNodeRestKind,
72    /// Referenced mesh index, when present.
73    pub mesh_index: Option<usize>,
74    /// Referenced skin index, when present.
75    pub skin_index: Option<usize>,
76}
77
78/// One animation channel and its exact accessor identities.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80pub struct GltfAnimationChannelCapability {
81    /// Stable source animation index.
82    pub animation_index: usize,
83    /// Channel index inside the animation.
84    pub channel_index: usize,
85    /// Source target node index.
86    pub target_node_index: usize,
87    /// glTF target path (`translation`, `rotation`, `scale`, or `weights`).
88    pub target_path: String,
89    /// glTF interpolation spelling.
90    pub interpolation: String,
91    /// Input time accessor index.
92    pub input_accessor_index: usize,
93    /// Output value accessor index.
94    pub output_accessor_index: usize,
95}
96
97/// One vertex attribute declaration and its source accessor.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub struct GltfAttributeCapability {
100    /// glTF attribute semantic such as `POSITION` or `JOINTS_0`.
101    pub semantic: String,
102    /// Stable source accessor index.
103    pub accessor_index: usize,
104}
105
106/// One source primitive and every declared attribute semantic.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
108pub struct GltfPrimitiveCapability {
109    /// Stable source mesh index.
110    pub mesh_index: usize,
111    /// Primitive index inside the mesh.
112    pub primitive_index: usize,
113    /// Raw glTF primitive mode value (default `4`, triangles).
114    pub mode: u64,
115    /// Attributes in lexical semantic order with exact accessor identities.
116    pub attributes: Vec<GltfAttributeCapability>,
117    /// Number of declared morph targets.
118    pub morph_target_count: usize,
119    /// `POSITION` accessor indices from morph targets in target order.
120    pub morph_position_accessors: Vec<usize>,
121    /// Located morph semantics this scale boundary cannot preserve.
122    #[serde(skip)]
123    pub unsupported_morph_locations: Vec<String>,
124}
125
126/// One raw `EXT_mesh_gpu_instancing` declaration and its accessor identities.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128pub struct GltfInstancingCapability {
129    /// Stable source node index carrying the instancing payload.
130    pub node_index: usize,
131    /// Instancing attributes in lexical semantic order.
132    pub attributes: Vec<GltfAttributeCapability>,
133}
134
135/// One raw accessor layout required by a future exact-source rewrite.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137pub struct GltfAccessorCapability {
138    /// Stable source accessor index.
139    pub accessor_index: usize,
140    /// Referenced buffer-view index, when present.
141    pub buffer_view_index: Option<usize>,
142    /// Byte offset relative to the buffer view.
143    pub byte_offset: u64,
144    /// Raw glTF component-type value.
145    pub component_type: u64,
146    /// Raw glTF accessor type such as `VEC3` or `MAT4`.
147    pub accessor_type: String,
148    /// Declared element count.
149    pub count: u64,
150    /// Whether normalized integer interpretation was requested.
151    pub normalized: bool,
152    /// Whether the accessor declares sparse replacement data.
153    pub sparse: bool,
154}
155
156/// One raw buffer-view layout.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158pub struct GltfBufferViewCapability {
159    /// Stable source buffer-view index.
160    pub buffer_view_index: usize,
161    /// Stable source buffer index.
162    pub buffer_index: usize,
163    /// Byte offset relative to the buffer.
164    pub byte_offset: u64,
165    /// Declared byte length.
166    pub byte_length: u64,
167    /// Optional element stride.
168    pub byte_stride: Option<u64>,
169}
170
171/// Read-side inverse-bind declaration for one source skin.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
173pub struct GltfSkinCapability {
174    /// Stable source skin index.
175    pub skin_index: usize,
176    /// Number of declared joints.
177    pub joint_count: usize,
178    /// Declared inverse-bind accessor index, when present.
179    pub inverse_bind_accessor_index: Option<usize>,
180    /// Declared inverse-bind accessor count, when readable from raw JSON.
181    pub inverse_bind_count: Option<u64>,
182}
183
184/// Deterministic facts captured from the original glTF/GLB source.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186pub struct GltfCapabilityManifest {
187    /// Top-level container kind.
188    pub container: GltfContainerKind,
189    /// Source buffers in source order.
190    pub buffers: Vec<GltfBufferCapability>,
191    /// Source buffer views in source order.
192    pub buffer_views: Vec<GltfBufferViewCapability>,
193    /// Source accessors in source order.
194    pub accessors: Vec<GltfAccessorCapability>,
195    /// Source nodes in source order.
196    pub nodes: Vec<GltfNodeCapability>,
197    /// Animation channels in animation/channel order.
198    pub animation_channels: Vec<GltfAnimationChannelCapability>,
199    /// Mesh primitives in mesh/primitive order.
200    pub primitives: Vec<GltfPrimitiveCapability>,
201    /// Static and animated morph-weight locations in lexical order.
202    pub morph_weight_locations: Vec<String>,
203    /// GPU-instancing declarations in source node order.
204    pub instancing: Vec<GltfInstancingCapability>,
205    /// Source skins in source order.
206    pub skins: Vec<GltfSkinCapability>,
207    /// Number of declared cameras.
208    pub camera_count: usize,
209    /// Declared extension names in lexical order.
210    pub extensions: Vec<String>,
211    /// JSON pointers of extension payloads in lexical order.
212    pub extension_locations: Vec<String>,
213    /// JSON pointers of external buffer/image declarations in lexical order.
214    pub external_resource_locations: Vec<String>,
215    /// JSON pointers of every non-null `extras` value in lexical order.
216    pub extras_locations: Vec<String>,
217    /// JSON pointers of unknown members in lexical order.
218    pub unknown_member_locations: Vec<String>,
219}
220
221/// Stable machine identity for one fail-closed capability violation.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
223#[non_exhaustive]
224#[serde(rename_all = "snake_case")]
225pub enum GltfCapabilityViolationKind {
226    /// A source buffer or image uses an external URI.
227    ExternalResource,
228    /// A morph target is present.
229    MorphTarget,
230    /// Static or animated morph weights are present.
231    MorphWeights,
232    /// A camera definition or reference is present.
233    Camera,
234    /// A punctual-light declaration or payload is present.
235    Light,
236    /// An `EXT_mesh_gpu_instancing` declaration or payload is present.
237    Instancing,
238    /// An extension declaration is not covered by a registered handler.
239    ExtensionDeclaration,
240    /// An extension payload is not covered by a registered handler.
241    ExtensionPayload,
242    /// Non-null application-specific extras are present.
243    Extras,
244    /// A JSON member outside the glTF 2.0 schema was ignored by the typed parser.
245    UnknownJsonMember,
246    /// A primitive mode other than triangle lists is present.
247    NonTrianglePrimitive,
248    /// A vertex attribute is outside the normalized writer subset.
249    UnsupportedVertexAttribute,
250    /// A secondary `JOINTS_n` or `WEIGHTS_n` set is present.
251    SecondarySkinInfluences,
252    /// A skin omitted its inverse-bind accessor.
253    MissingInverseBinds,
254    /// A skin declared an empty inverse-bind accessor.
255    EmptyInverseBindAccessor,
256    /// A skin's inverse-bind count does not equal its joint count.
257    InverseBindCountMismatch,
258    /// A declared inverse-bind accessor is not a dense f32 MAT4 source.
259    UnreadableInverseBinds,
260    /// A used accessor cannot be safely bounded, or a rewrite accessor is not dense f32.
261    UnsafeAccessorLayout,
262    /// One accessor is shared between scale-bearing and dimensionless semantics.
263    ConflictingAccessorUse,
264    /// A scale-bearing accessor overlaps another owned byte range in a source
265    /// buffer. The other range is an accessor, or an `image` payload reported
266    /// alongside it as [`GltfCapabilityViolationKind::ImagePayloadOverlap`].
267    OverlappingAccessorRanges,
268    /// A node declares `matrix` alongside `translation`, `rotation` or
269    /// `scale`, which glTF 2.0 §3.5 forbids.
270    ConflictingNodeTransform,
271    /// A node `matrix` is not TRS-decomposable: its last row is not
272    /// `(0, 0, 0, 1)`.
273    NonAffineNodeMatrix,
274    /// An animation targets a node that authored its rest transform as a
275    /// `matrix`. glTF animation channels replace TRS properties, so there is
276    /// no raw TRS property this operation can reparameterize safely.
277    AnimatedMatrixNode,
278    /// An `image` reads a buffer view overlapping a scale-bearing accessor.
279    ImagePayloadOverlap,
280}
281
282/// One deterministic, source-indexed preflight rejection.
283#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
284pub struct GltfCapabilityViolation {
285    /// JSON pointer or stable source identity for the rejected domain.
286    pub location: String,
287    /// Stable violation kind.
288    pub kind: GltfCapabilityViolationKind,
289}
290
291/// A captured, immutable source that passed its declared preflight policy.
292///
293/// This type deliberately has no mutation or write method. Scale operations
294/// consume its manifest and captured bytes without reopening the input.
295#[derive(Debug)]
296pub struct GltfScaleSource {
297    loaded_source: LoadedSource,
298    #[cfg(test)]
299    document_override: Option<Document>,
300    manifest: GltfCapabilityManifest,
301    source_bytes: Vec<u8>,
302    raw_json: Value,
303    resolved_buffers: Vec<Vec<u8>>,
304    clip_track_projection_required: bool,
305}
306
307impl GltfScaleSource {
308    /// The normalized read-only document built from the captured bytes.
309    pub fn document(&self) -> &Document {
310        #[cfg(test)]
311        if let Some(document) = self.document_override.as_ref() {
312            return document;
313        }
314        self.loaded_source.document()
315    }
316
317    /// Importer-sensitive raw source facts bound to the normalized document.
318    pub fn source_facts(&self) -> SourceFactsViewV1<'_> {
319        self.loaded_source.source_facts()
320    }
321
322    /// The deterministic raw capability manifest.
323    pub fn manifest(&self) -> &GltfCapabilityManifest {
324        &self.manifest
325    }
326
327    /// The exact captured top-level input bytes.
328    pub fn source_bytes(&self) -> &[u8] {
329        &self.source_bytes
330    }
331
332    /// The original top-level JSON tree.
333    pub fn raw_json(&self) -> &Value {
334        &self.raw_json
335    }
336
337    /// Resolved source buffers in buffer-index order.
338    pub fn resolved_buffers(&self) -> &[Vec<u8>] {
339        &self.resolved_buffers
340    }
341
342    /// Whether clip-track capture excluded one or more raw source violations.
343    ///
344    /// A caller selecting between its established whole-source path and a
345    /// clip-track projection uses this only as an in-memory policy result. It
346    /// does not alter the exact source bytes, source identity, or manifest.
347    pub const fn requires_clip_track_projection(&self) -> bool {
348        self.clip_track_projection_required
349    }
350}
351
352/// Failure to load or safely preflight a captured scale source.
353#[derive(Debug, thiserror::Error)]
354#[non_exhaustive]
355pub enum GltfScalePreflightError {
356    /// The source was malformed or unreadable.
357    #[error(transparent)]
358    Load(#[from] LoadError),
359    /// The source was parseable but contains unsupported raw domains.
360    #[error("glTF scale preflight rejected {count} unsupported source domain(s)")]
361    Unsupported {
362        /// Complete inventory gathered before rejection.
363        manifest: Box<GltfCapabilityManifest>,
364        /// Deterministically ordered typed violations.
365        violations: Vec<GltfCapabilityViolation>,
366        /// Number of violations, repeated for stable error rendering.
367        count: usize,
368    },
369}
370
371/// Read and preflight a glTF/GLB file without creating a candidate or output.
372///
373/// # Errors
374///
375/// Returns [`GltfScalePreflightError::Load`] for unreadable or malformed input
376/// and [`GltfScalePreflightError::Unsupported`] for a parseable source whose
377/// complete raw domain is not covered by the initial scale boundary.
378pub fn preflight_scale_source(path: &Path) -> Result<GltfScaleSource, GltfScalePreflightError> {
379    let bytes = std::fs::read(path).map_err(|source| LoadError::Io {
380        path: path.display().to_string(),
381        source,
382    })?;
383    preflight_scale_source_bytes(path, &bytes)
384}
385
386/// Preflight captured glTF/GLB bytes without creating a candidate or output.
387///
388/// `path` is used only for source provenance and resolving resources. The
389/// initial accepted subset rejects external resources before resolving them,
390/// so a successful value is fully captured in memory.
391///
392/// # Errors
393///
394/// Returns [`GltfScalePreflightError::Load`] for malformed input and
395/// [`GltfScalePreflightError::Unsupported`] for unsupported raw domains.
396pub fn preflight_scale_source_bytes(
397    path: &Path,
398    bytes: &[u8],
399) -> Result<GltfScaleSource, GltfScalePreflightError> {
400    capture_scale_source(path, bytes, GatePolicy::Enforce)
401}
402
403/// Read and preflight a glTF/GLB clip-track source without creating output.
404///
405/// This role-specific capture admits only the raw domains an assembly-style
406/// clip-track projection consumes: the framing and parsed document, node
407/// topology and rest transforms, animation data, raw construct/resource
408/// coverage, and complete captured source identity. Geometry, material,
409/// deformation, and inverse-bind payload violations are ignored only when
410/// they are exclusively in those projected-away domains. The returned
411/// [`GltfScaleSource`] still retains their complete manifest, so it cannot be
412/// mistaken for a whole-document or rest/bind scale admission.
413///
414/// Accessor layout and alias violations remain fatal whenever they name an
415/// animation sampler accessor. This is deliberately a role-specific policy,
416/// not a bypass of the common preflight gate.
417///
418/// # Errors
419///
420/// Returns [`GltfScalePreflightError::Load`] for malformed framing, parser,
421/// document, topology, dependency, or animation input. Returns
422/// [`GltfScalePreflightError::Unsupported`] when a retained raw domain is not
423/// covered, including animation-owned accessor faults.
424pub fn preflight_clip_track_source(
425    path: &Path,
426) -> Result<GltfScaleSource, GltfScalePreflightError> {
427    let bytes = std::fs::read(path).map_err(|source| LoadError::Io {
428        path: path.display().to_string(),
429        source,
430    })?;
431    preflight_clip_track_source_bytes(path, &bytes)
432}
433
434/// Preflight captured glTF/GLB bytes for an animation clip-track projection.
435///
436/// `path` is retained only as source provenance. Successful captures are
437/// fully in-memory and retain the exact primary bytes and raw manifest.
438///
439/// # Errors
440///
441/// Returns the same errors as [`preflight_clip_track_source`].
442pub fn preflight_clip_track_source_bytes(
443    path: &Path,
444    bytes: &[u8],
445) -> Result<GltfScaleSource, GltfScalePreflightError> {
446    capture_source(path, bytes, CapturePolicy::ClipTracks)
447}
448
449/// Whether a captured source must clear the preflight's violation gate.
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451enum GatePolicy {
452    /// A source with any violation is refused. The only policy in a
453    /// non-test build.
454    Enforce,
455    /// Violations are inventoried and then ignored, so a
456    /// [`GltfScaleSource`] is built for a source the gate would refuse.
457    ///
458    /// Every operation below the gate keeps its own guard for the source
459    /// facts the gate decides — [`crate::scale::rewrite_linear_units`]
460    /// re-checks out-of-contract node transforms and image payloads aliasing
461    /// a converted accessor. Those guards are what must hold if the gate is
462    /// ever relaxed, which is exactly the property no test can observe while
463    /// the gate refuses every source that would reach them: deleting the
464    /// guard's call site leaves the public API's behaviour unchanged. This
465    /// policy is the synthetic relaxation those tests need, and it exists
466    /// only under `cfg(test)` so no release path can select it.
467    #[cfg(test)]
468    Bypass,
469}
470
471/// Capture a scale source, applying `policy` to the preflight's violations.
472fn capture_scale_source(
473    path: &Path,
474    bytes: &[u8],
475    policy: GatePolicy,
476) -> Result<GltfScaleSource, GltfScalePreflightError> {
477    capture_source(path, bytes, CapturePolicy::Scale(policy))
478}
479
480/// Policy selected by the public capture boundaries after shared raw preflight.
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482enum CapturePolicy {
483    /// Preserve the whole-source scale contract, with its test-only bypass.
484    Scale(GatePolicy),
485    /// Admit only raw violations discarded by the clip-track projection.
486    ClipTracks,
487}
488
489/// Capture one raw source through the common parser, inventory, topology, and
490/// accessor-layout preflight, then apply the role's explicit admission policy.
491fn capture_source(
492    path: &Path,
493    bytes: &[u8],
494    policy: CapturePolicy,
495) -> Result<GltfScaleSource, GltfScalePreflightError> {
496    validate_glb_framing(bytes)?;
497    let (container, json_bytes) = raw_json_bytes(bytes)?;
498    let raw_json: Value = serde_json::from_slice(json_bytes)
499        .map_err(|error| LoadError::Malformed(format!("invalid top-level JSON: {error}")))?;
500    if !raw_json.is_object() {
501        return Err(LoadError::Malformed("top-level glTF JSON is not an object".into()).into());
502    }
503    let gltf = gltf::Gltf::from_slice_without_validation(bytes).map_err(LoadError::Gltf)?;
504
505    let mut violations = Vec::new();
506    let manifest = inventory(&raw_json, container, &mut violations);
507    let accessor_uses = inspect_accessor_uses(&raw_json, &mut violations);
508    match validate_document(&gltf.document) {
509        Ok(()) => {}
510        Err(error) => return Err(LoadError::Gltf(error).into()),
511    }
512    validate_animations(&gltf.document)?;
513    let topology = topology(&gltf.document)?;
514
515    let can_resolve_buffers = !manifest
516        .buffers
517        .iter()
518        .any(|buffer| buffer.source_kind == GltfBufferSourceKind::External);
519    let resolved_buffers = if can_resolve_buffers {
520        resolve_buffers(&gltf, path.parent())?
521    } else {
522        Vec::new()
523    };
524    if can_resolve_buffers {
525        inspect_accessor_layouts(
526            &raw_json,
527            &resolved_buffers,
528            &accessor_uses,
529            &mut violations,
530        );
531    }
532    let (clip_track_projection_required, refuse) = match policy {
533        CapturePolicy::Scale(policy) => {
534            violations.sort();
535            violations.dedup();
536            (
537                false,
538                match policy {
539                    GatePolicy::Enforce => !violations.is_empty(),
540                    #[cfg(test)]
541                    GatePolicy::Bypass => false,
542                },
543            )
544        }
545        CapturePolicy::ClipTracks => {
546            let animation_accessors = animation_accessor_indices(&raw_json);
547            let projection_required = violations
548                .iter()
549                .any(|violation| clip_track_projects_away(violation, &animation_accessors));
550            violations
551                .retain(|violation| !clip_track_projects_away(violation, &animation_accessors));
552            violations.sort();
553            violations.dedup();
554            (projection_required, !violations.is_empty())
555        }
556    };
557    if refuse {
558        let count = violations.len();
559        return Err(GltfScalePreflightError::Unsupported {
560            manifest: Box::new(manifest),
561            violations,
562            count,
563        });
564    }
565
566    // A clean source follows the exact strict-loader path, preserving its
567    // complete normalized document and assets for callers that retain the
568    // established rest/bind application. Only a source that needed this role
569    // projection takes the narrower document build below.
570    if !clip_track_projection_required {
571        let loaded_source = crate::load_source_bytes(path, bytes)?;
572        if matches!(policy, CapturePolicy::ClipTracks)
573            && !clip_track_source_facts_complete(loaded_source.source_facts())
574        {
575            return Err(LoadError::Malformed(
576                "clip-track raw source facts coverage is incomplete".into(),
577            )
578            .into());
579        }
580        return Ok(captured_scale_source(
581            loaded_source,
582            bytes,
583            manifest,
584            raw_json,
585            resolved_buffers,
586            false,
587        ));
588    }
589
590    // This mirrors the strict loader's source-fact/dependency binding but
591    // intentionally stops before primitive validation and asset extraction.
592    // Those are exactly the raw domains a clip-track projection removes.
593    let mut facts = source_facts_builder(bytes).map_err(LoadError::from)?;
594    project_extension_facts(&gltf.document, &mut facts);
595    project_resource_facts(&gltf.document, &mut facts);
596    let has_unmodeled_extension_domain = has_extension_object(bytes)
597        || gltf.document.extensions_used().next().is_some()
598        || gltf.document.extensions_required().next().is_some();
599    let (dependency_closure, _) = capture_dependency_closure(
600        &facts,
601        None,
602        has_unmodeled_extension_domain,
603        &mut crate::read_external_file,
604    )?;
605    let source_skeleton = extract_source_skeleton(&gltf.document, &resolved_buffers, &topology);
606    let mut document = build_document(&gltf, &resolved_buffers, path, &topology, &mut facts)?;
607    document.assets.source_skeleton = source_skeleton;
608    let loaded_source = facts
609        .finish_with_dependency_closure(document, dependency_closure)
610        .map_err(LoadError::from)?;
611    if !clip_track_source_facts_complete(loaded_source.source_facts()) {
612        return Err(LoadError::Malformed(
613            "clip-track raw source facts coverage is incomplete".into(),
614        )
615        .into());
616    }
617
618    Ok(captured_scale_source(
619        loaded_source,
620        bytes,
621        manifest,
622        raw_json,
623        resolved_buffers,
624        clip_track_projection_required,
625    ))
626}
627
628/// Package one policy-admitted raw capture without duplicating identity state.
629fn captured_scale_source(
630    loaded_source: LoadedSource,
631    source_bytes: &[u8],
632    manifest: GltfCapabilityManifest,
633    raw_json: Value,
634    resolved_buffers: Vec<Vec<u8>>,
635    clip_track_projection_required: bool,
636) -> GltfScaleSource {
637    GltfScaleSource {
638        loaded_source,
639        #[cfg(test)]
640        document_override: None,
641        manifest,
642        source_bytes: source_bytes.to_vec(),
643        raw_json,
644        resolved_buffers,
645        clip_track_projection_required,
646    }
647}
648
649/// Whether one common-preflight violation belongs solely to a discarded
650/// clip-source domain.
651fn clip_track_projects_away(
652    violation: &GltfCapabilityViolation,
653    animation_accessors: &BTreeSet<usize>,
654) -> bool {
655    use GltfCapabilityViolationKind as Kind;
656    match violation.kind {
657        Kind::MorphTarget
658        | Kind::MorphWeights
659        | Kind::NonTrianglePrimitive
660        | Kind::UnsupportedVertexAttribute
661        | Kind::SecondarySkinInfluences
662        | Kind::MissingInverseBinds
663        | Kind::EmptyInverseBindAccessor
664        | Kind::InverseBindCountMismatch
665        | Kind::UnreadableInverseBinds => true,
666        Kind::UnsafeAccessorLayout
667        | Kind::ConflictingAccessorUse
668        | Kind::OverlappingAccessorRanges => accessor_index_at(&violation.location)
669            .is_some_and(|accessor| !animation_accessors.contains(&accessor)),
670        // Image payload overlap is emitted only for a scale-bearing accessor.
671        // Its paired accessor violation above retains the error whenever that
672        // accessor belongs to animation; without that paired animation row,
673        // the image/material payload is projected away.
674        Kind::ImagePayloadOverlap => true,
675        Kind::ExternalResource
676        | Kind::Camera
677        | Kind::Light
678        | Kind::Instancing
679        | Kind::ExtensionDeclaration
680        | Kind::ExtensionPayload
681        | Kind::Extras
682        | Kind::UnknownJsonMember
683        | Kind::ConflictingNodeTransform
684        | Kind::NonAffineNodeMatrix
685        | Kind::AnimatedMatrixNode => false,
686    }
687}
688
689/// Exact sampler accessor identities for every raw animation declaration.
690fn animation_accessor_indices(root: &Value) -> BTreeSet<usize> {
691    let Some(animations) = root.get("animations").and_then(Value::as_array) else {
692        return BTreeSet::new();
693    };
694    animations
695        .iter()
696        .flat_map(|animation| {
697            animation
698                .get("samplers")
699                .and_then(Value::as_array)
700                .into_iter()
701                .flatten()
702        })
703        .flat_map(|sampler| {
704            [
705                as_index(sampler.get("input")),
706                as_index(sampler.get("output")),
707            ]
708        })
709        .flatten()
710        .collect()
711}
712
713/// Parse the accessor index from an inventory location when it names one.
714fn accessor_index_at(location: &str) -> Option<usize> {
715    location
716        .strip_prefix("/accessors/")?
717        .split('/')
718        .next()?
719        .parse()
720        .ok()
721}
722
723/// Whether every raw source-fact domain a clip projection relies on was
724/// retained within the V1 capture budget.
725fn clip_track_source_facts_complete(source: SourceFactsViewV1<'_>) -> bool {
726    [
727        source.clips().coverage().state(),
728        source.constructs().coverage().state(),
729        source.resources().coverage().state(),
730    ]
731    .into_iter()
732    .all(|state| state == SourceSetCoverageStateV1::Complete)
733}
734
735/// Capture a [`GltfScaleSource`] from bytes the preflight gate would refuse.
736///
737/// See [`GatePolicy::Bypass`] for why this exists. It is not a public API and
738/// not reachable from an integration test: the gate is the only way to build a
739/// [`GltfScaleSource`] outside this crate, and that stays true.
740///
741/// # Errors
742///
743/// Returns [`GltfScalePreflightError::Load`] for input that is malformed
744/// rather than merely out of contract. `Unsupported` is never returned.
745#[cfg(test)]
746pub(crate) fn scale_source_past_the_gate(
747    path: &Path,
748    bytes: &[u8],
749) -> Result<GltfScaleSource, GltfScalePreflightError> {
750    capture_scale_source(path, bytes, GatePolicy::Bypass)
751}
752
753/// A captured source with its normalized document replaced.
754///
755/// The sibling of [`scale_source_past_the_gate`], for the one relaxation that
756/// gate bypass cannot supply. A captured source's raw child arrays,
757/// `SourceNodeAsset::parent_source_node_index` and `Skeleton::parent` all come
758/// from a single `topology()` pass over a single parsed document, so no glTF
759/// byte sequence makes them contradict each other — which is what issue #309
760/// records, and what leaves
761/// [`crate::scale::rest_bind`]'s hierarchy cross-check with no reachable
762/// input. Handing the rewriter a source whose document says one thing and
763/// whose bytes say another is the only way to falsify that check's *wiring*
764/// rather than only its classification.
765///
766/// The document is the sole field replaced: the bytes, the raw JSON, the
767/// manifest and the resolved buffers stay the captured ones, so the rewriter
768/// still reads a real source and only the normalized projection disagrees.
769/// It is not a public API and not reachable from an integration test.
770#[cfg(test)]
771pub(crate) fn scale_source_with_document(
772    mut source: GltfScaleSource,
773    document: Document,
774) -> GltfScaleSource {
775    source.document_override = Some(document);
776    source
777}
778
779/// Split a captured container into its kind and its top-level JSON bytes.
780///
781/// Shared with [`crate::scale`], whose artifact proof must re-read the
782/// emitted container through exactly the same framing the preflight used.
783pub(crate) fn raw_json_bytes(bytes: &[u8]) -> Result<(GltfContainerKind, &[u8]), LoadError> {
784    if !bytes.starts_with(GLB_MAGIC) {
785        return Ok((GltfContainerKind::Gltf, bytes));
786    }
787    let chunk_length = bytes
788        .get(12..16)
789        .and_then(|slice| slice.try_into().ok())
790        .map(u32::from_le_bytes)
791        .ok_or_else(|| LoadError::Buffer("malformed GLB JSON chunk header".into()))?
792        as usize;
793    let chunk_type = bytes
794        .get(16..20)
795        .and_then(|slice| slice.try_into().ok())
796        .map(u32::from_le_bytes)
797        .ok_or_else(|| LoadError::Buffer("malformed GLB JSON chunk header".into()))?;
798    if chunk_type != GLB_JSON_CHUNK {
799        return Err(LoadError::Buffer(
800            "GLB first chunk is not a JSON chunk".into(),
801        ));
802    }
803    let end = 20usize
804        .checked_add(chunk_length)
805        .ok_or_else(|| LoadError::Buffer("GLB JSON chunk range overflow".into()))?;
806    let json = bytes
807        .get(20..end)
808        .ok_or_else(|| LoadError::Buffer("malformed GLB JSON chunk length".into()))?;
809    Ok((GltfContainerKind::Glb, json))
810}
811
812fn violation(
813    violations: &mut Vec<GltfCapabilityViolation>,
814    kind: GltfCapabilityViolationKind,
815    location: impl Into<String>,
816) {
817    violations.push(GltfCapabilityViolation {
818        kind,
819        location: location.into(),
820    });
821}
822
823fn as_index(value: Option<&Value>) -> Option<usize> {
824    value?.as_u64()?.try_into().ok()
825}
826
827fn inventory(
828    root: &Value,
829    container: GltfContainerKind,
830    violations: &mut Vec<GltfCapabilityViolation>,
831) -> GltfCapabilityManifest {
832    let Some(object) = root.as_object() else {
833        return GltfCapabilityManifest {
834            container,
835            buffers: Vec::new(),
836            buffer_views: Vec::new(),
837            accessors: Vec::new(),
838            nodes: Vec::new(),
839            animation_channels: Vec::new(),
840            primitives: Vec::new(),
841            morph_weight_locations: Vec::new(),
842            instancing: Vec::new(),
843            skins: Vec::new(),
844            camera_count: 0,
845            extensions: Vec::new(),
846            extension_locations: Vec::new(),
847            external_resource_locations: Vec::new(),
848            extras_locations: Vec::new(),
849            unknown_member_locations: Vec::new(),
850        };
851    };
852    let mut manifest = GltfCapabilityManifest {
853        container,
854        buffers: Vec::new(),
855        buffer_views: Vec::new(),
856        accessors: Vec::new(),
857        nodes: Vec::new(),
858        animation_channels: Vec::new(),
859        primitives: Vec::new(),
860        morph_weight_locations: Vec::new(),
861        instancing: Vec::new(),
862        skins: Vec::new(),
863        camera_count: object
864            .get("cameras")
865            .and_then(Value::as_array)
866            .map_or(0, Vec::len),
867        extensions: Vec::new(),
868        extension_locations: Vec::new(),
869        external_resource_locations: Vec::new(),
870        extras_locations: Vec::new(),
871        unknown_member_locations: Vec::new(),
872    };
873
874    inspect_schema_members(root, "", &mut manifest, violations);
875    inventory_extensions(object, &mut manifest, violations);
876    inventory_buffers(object, container, &mut manifest, violations);
877    inventory_buffer_views_and_accessors(object, &mut manifest);
878    inventory_nodes(object, &mut manifest, violations);
879    inventory_animations(object, &mut manifest, violations);
880    inventory_meshes(object, &mut manifest, violations);
881    inventory_skins(object, &mut manifest, violations);
882
883    if manifest.camera_count > 0 {
884        violation(violations, GltfCapabilityViolationKind::Camera, "/cameras");
885    }
886    manifest.extensions.sort();
887    manifest.extensions.dedup();
888    manifest.extension_locations.sort();
889    manifest.extension_locations.dedup();
890    manifest.external_resource_locations.sort();
891    manifest.external_resource_locations.dedup();
892    manifest.extras_locations.sort();
893    manifest.extras_locations.dedup();
894    manifest.unknown_member_locations.sort();
895    manifest.unknown_member_locations.dedup();
896    manifest.morph_weight_locations.sort();
897    manifest.morph_weight_locations.dedup();
898    manifest
899}
900
901fn inventory_extensions(
902    root: &Map<String, Value>,
903    manifest: &mut GltfCapabilityManifest,
904    violations: &mut Vec<GltfCapabilityViolation>,
905) {
906    for key in ["extensionsUsed", "extensionsRequired"] {
907        let Some(values) = root.get(key).and_then(Value::as_array) else {
908            continue;
909        };
910        for (index, value) in values.iter().enumerate() {
911            let Some(name) = value.as_str() else { continue };
912            manifest.extensions.push(name.to_owned());
913            let kind = match name {
914                "KHR_lights_punctual" => GltfCapabilityViolationKind::Light,
915                "EXT_mesh_gpu_instancing" => GltfCapabilityViolationKind::Instancing,
916                _ => GltfCapabilityViolationKind::ExtensionDeclaration,
917            };
918            violation(violations, kind, format!("/{key}/{index}"));
919        }
920    }
921}
922
923fn inventory_buffers(
924    root: &Map<String, Value>,
925    container: GltfContainerKind,
926    manifest: &mut GltfCapabilityManifest,
927    violations: &mut Vec<GltfCapabilityViolation>,
928) {
929    let Some(buffers) = root.get("buffers").and_then(Value::as_array) else {
930        return;
931    };
932    for (buffer_index, buffer) in buffers.iter().enumerate() {
933        let Some(buffer) = buffer.as_object() else {
934            continue;
935        };
936        let uri = buffer.get("uri").and_then(Value::as_str);
937        let source_kind = match uri {
938            Some(uri) if uri.starts_with("data:") => GltfBufferSourceKind::DataUri,
939            Some(_) => GltfBufferSourceKind::External,
940            None if container == GltfContainerKind::Glb => GltfBufferSourceKind::BinaryChunk,
941            None => GltfBufferSourceKind::External,
942        };
943        if source_kind == GltfBufferSourceKind::External {
944            manifest
945                .external_resource_locations
946                .push(format!("/buffers/{buffer_index}/uri"));
947            violation(
948                violations,
949                GltfCapabilityViolationKind::ExternalResource,
950                format!("/buffers/{buffer_index}/uri"),
951            );
952        }
953        manifest.buffers.push(GltfBufferCapability {
954            buffer_index,
955            source_kind,
956            declared_byte_length: buffer
957                .get("byteLength")
958                .and_then(Value::as_u64)
959                .unwrap_or(0),
960        });
961    }
962    if let Some(images) = root.get("images").and_then(Value::as_array) {
963        for (image_index, image) in images.iter().enumerate() {
964            if image
965                .get("uri")
966                .and_then(Value::as_str)
967                .is_some_and(|uri| !uri.starts_with("data:"))
968            {
969                manifest
970                    .external_resource_locations
971                    .push(format!("/images/{image_index}/uri"));
972                violation(
973                    violations,
974                    GltfCapabilityViolationKind::ExternalResource,
975                    format!("/images/{image_index}/uri"),
976                );
977            }
978        }
979    }
980}
981
982fn inventory_buffer_views_and_accessors(
983    root: &Map<String, Value>,
984    manifest: &mut GltfCapabilityManifest,
985) {
986    if let Some(buffer_views) = root.get("bufferViews").and_then(Value::as_array) {
987        for (buffer_view_index, view) in buffer_views.iter().enumerate() {
988            let Some(view) = view.as_object() else {
989                continue;
990            };
991            manifest.buffer_views.push(GltfBufferViewCapability {
992                buffer_view_index,
993                buffer_index: as_index(view.get("buffer")).unwrap_or(usize::MAX),
994                byte_offset: view.get("byteOffset").and_then(Value::as_u64).unwrap_or(0),
995                byte_length: view.get("byteLength").and_then(Value::as_u64).unwrap_or(0),
996                byte_stride: view.get("byteStride").and_then(Value::as_u64),
997            });
998        }
999    }
1000    if let Some(accessors) = root.get("accessors").and_then(Value::as_array) {
1001        for (accessor_index, accessor) in accessors.iter().enumerate() {
1002            let Some(accessor) = accessor.as_object() else {
1003                continue;
1004            };
1005            manifest.accessors.push(GltfAccessorCapability {
1006                accessor_index,
1007                buffer_view_index: as_index(accessor.get("bufferView")),
1008                byte_offset: accessor
1009                    .get("byteOffset")
1010                    .and_then(Value::as_u64)
1011                    .unwrap_or(0),
1012                component_type: accessor
1013                    .get("componentType")
1014                    .and_then(Value::as_u64)
1015                    .unwrap_or(0),
1016                accessor_type: accessor
1017                    .get("type")
1018                    .and_then(Value::as_str)
1019                    .unwrap_or_default()
1020                    .to_owned(),
1021                count: accessor.get("count").and_then(Value::as_u64).unwrap_or(0),
1022                normalized: accessor
1023                    .get("normalized")
1024                    .and_then(Value::as_bool)
1025                    .unwrap_or(false),
1026                sparse: accessor.contains_key("sparse"),
1027            });
1028        }
1029    }
1030}
1031
1032/// The last row of a column-major glTF node `matrix`, and the only values
1033/// glTF 2.0 permits there.
1034///
1035/// Shared with [`crate::scale`], whose rewriter keeps its own guard as
1036/// defence in depth: re-deriving the row there would let two definitions of
1037/// "affine" drift apart, which is exactly how this workspace's two affine
1038/// classifiers once came to disagree.
1039pub(crate) const AFFINE_LAST_ROW: [(usize, f64); 4] = [(3, 0.0), (7, 0.0), (11, 0.0), (15, 1.0)];
1040
1041/// One way a source node's transform is outside the glTF 2.0 contract.
1042#[derive(Debug, Clone, Copy, PartialEq)]
1043pub(crate) enum NodeTransformFault {
1044    /// A TRS member is declared alongside `matrix`.
1045    TrsBesideMatrix {
1046        /// Stable source node index.
1047        node_index: usize,
1048        /// The offending member's glTF spelling.
1049        member: &'static str,
1050    },
1051    /// A last-row `matrix` entry is a number other than the affine one.
1052    ProjectiveMatrixEntry {
1053        /// Stable source node index.
1054        node_index: usize,
1055        /// Component index inside the column-major `matrix`.
1056        component: usize,
1057        /// The authored value.
1058        value: f64,
1059        /// The only value glTF 2.0 permits there.
1060        expected: f64,
1061    },
1062    /// A last-row `matrix` entry is not a JSON number at all, so it cannot be
1063    /// shown to be the affine value.
1064    UnreadableMatrixEntry {
1065        /// Stable source node index.
1066        node_index: usize,
1067        /// Component index inside the column-major `matrix`.
1068        component: usize,
1069    },
1070}
1071
1072impl NodeTransformFault {
1073    /// JSON pointer of the offending member or `matrix` entry.
1074    pub(crate) fn location(self) -> String {
1075        match self {
1076            Self::TrsBesideMatrix { node_index, member } => format!("/nodes/{node_index}/{member}"),
1077            Self::ProjectiveMatrixEntry {
1078                node_index,
1079                component,
1080                ..
1081            }
1082            | Self::UnreadableMatrixEntry {
1083                node_index,
1084                component,
1085            } => format!("/nodes/{node_index}/matrix/{component}"),
1086        }
1087    }
1088
1089    /// The preflight violation kind this fault is reported as.
1090    fn kind(self) -> GltfCapabilityViolationKind {
1091        match self {
1092            Self::TrsBesideMatrix { .. } => GltfCapabilityViolationKind::ConflictingNodeTransform,
1093            // An entry that is not a readable number is not the affine value
1094            // either, so it fails closed as the same kind.
1095            Self::ProjectiveMatrixEntry { .. } | Self::UnreadableMatrixEntry { .. } => {
1096                GltfCapabilityViolationKind::NonAffineNodeMatrix
1097            }
1098        }
1099    }
1100}
1101
1102/// The value `object` declares for `member`, treating an explicit JSON `null`
1103/// as no declaration at all.
1104///
1105/// `serde_json` reports `"matrix": null` as `Some(Value::Null)`, while the
1106/// typed glTF parse deserializes the same member into `Option<[f32; 16]>` as
1107/// `None`. A raw-JSON walker asking only whether the key is *present*
1108/// therefore disagrees with the typed parse about what the node declared: it
1109/// reads `{"matrix": null, "translation": [...]}` as a node declaring both,
1110/// and refuses a document the typed parse reads as a plain TRS node — naming
1111/// the innocent `translation` as the offender.
1112///
1113/// Every walker deciding whether a node authored a transform goes through
1114/// here, so the gate, the rewriter's guard and [`crate::scale`]'s rewrite
1115/// selection cannot disagree about it. Presence checks whose only outcome is
1116/// a fail-closed refusal — `/nodes/*/camera` and `/nodes/*/weights` — are
1117/// deliberately left key-based: over-refusing a `null` there costs a source
1118/// nothing that could have converted, while over-refusing a transform member
1119/// costs a source that converts correctly.
1120pub(crate) fn declared<'a>(object: &'a Value, member: &str) -> Option<&'a Value> {
1121    object.get(member).filter(|value| !value.is_null())
1122}
1123
1124/// Every glTF 2.0 node-transform contract violation in `nodes`, in node order
1125/// and, within a node, TRS members before `matrix` entries.
1126///
1127/// The `gltf` crate parses both shapes, so neither is refused by the typed
1128/// parse and neither is a wrong answer on schema-valid input:
1129///
1130/// * A node declaring `matrix` **and** a TRS member. glTF 2.0 §3.5 makes the
1131///   two mutually exclusive, and the typed parse silently honours `matrix`
1132///   while ignoring the TRS members, so a consumer cannot know which the
1133///   author meant.
1134/// * A node `matrix` whose last row is not `(0, 0, 0, 1)`. glTF 2.0 requires
1135///   `matrix` to be decomposable to translation, rotation and scale. The
1136///   whole-document conversion's `M' = U M U^-1` identity leaves entries 3, 7,
1137///   11 and 15 alone, which is only correct when they are the affine row: a
1138///   projective row transforms as `1/q`, so treating it as invariant would
1139///   emit a matrix that is not the converted transform.
1140///
1141/// A `matrix` of the wrong arity fails the typed glTF parse — which
1142/// deserializes it as `[f32; 16]` — before either caller runs, so shape
1143/// errors keep their existing owner rather than gaining a second report here.
1144///
1145/// A member authored as JSON `null` is not a declaration: see [`declared`].
1146pub(crate) fn node_transform_faults(nodes: &[Value]) -> Vec<NodeTransformFault> {
1147    let mut faults = Vec::new();
1148    for (node_index, node) in nodes.iter().enumerate() {
1149        let Some(matrix) = declared(node, "matrix") else {
1150            continue;
1151        };
1152        for member in ["translation", "rotation", "scale"] {
1153            if declared(node, member).is_some() {
1154                faults.push(NodeTransformFault::TrsBesideMatrix { node_index, member });
1155            }
1156        }
1157        let Some(values) = matrix.as_array().filter(|values| values.len() == 16) else {
1158            continue;
1159        };
1160        for (component, expected) in AFFINE_LAST_ROW {
1161            match values[component].as_f64() {
1162                None => faults.push(NodeTransformFault::UnreadableMatrixEntry {
1163                    node_index,
1164                    component,
1165                }),
1166                Some(value) if value != expected => {
1167                    faults.push(NodeTransformFault::ProjectiveMatrixEntry {
1168                        node_index,
1169                        component,
1170                        value,
1171                        expected,
1172                    });
1173                }
1174                Some(_) => {}
1175            }
1176        }
1177    }
1178    faults
1179}
1180
1181fn inventory_nodes(
1182    root: &Map<String, Value>,
1183    manifest: &mut GltfCapabilityManifest,
1184    violations: &mut Vec<GltfCapabilityViolation>,
1185) {
1186    let Some(nodes) = root.get("nodes").and_then(Value::as_array) else {
1187        return;
1188    };
1189    for fault in node_transform_faults(nodes) {
1190        violation(violations, fault.kind(), fault.location());
1191    }
1192    for (node_index, node) in nodes.iter().enumerate() {
1193        if !node.is_object() {
1194            continue;
1195        }
1196        // `weights` stays key-based because the raw writer preserves the
1197        // complete JSON value byte-for-byte; its presence is still an
1198        // operation-aware capability fact. `camera` remains a rejection.
1199        // `matrix` below cannot use a key-based check, because there a false
1200        // positive refuses a source that converts correctly.
1201        if node.get("weights").is_some() {
1202            manifest
1203                .morph_weight_locations
1204                .push(format!("/nodes/{node_index}/weights"));
1205        }
1206        if node.get("camera").is_some() {
1207            violation(
1208                violations,
1209                GltfCapabilityViolationKind::Camera,
1210                format!("/nodes/{node_index}/camera"),
1211            );
1212        }
1213        if let Some(attributes) = node
1214            .get("extensions")
1215            .and_then(|extensions| extensions.get("EXT_mesh_gpu_instancing"))
1216            .and_then(|extension| extension.get("attributes"))
1217            .and_then(Value::as_object)
1218        {
1219            let mut attributes = attributes
1220                .iter()
1221                .map(|(semantic, accessor)| GltfAttributeCapability {
1222                    semantic: semantic.clone(),
1223                    accessor_index: as_index(Some(accessor)).unwrap_or(usize::MAX),
1224                })
1225                .collect::<Vec<_>>();
1226            attributes.sort_by(|left, right| left.semantic.cmp(&right.semantic));
1227            manifest.instancing.push(GltfInstancingCapability {
1228                node_index,
1229                attributes,
1230            });
1231        }
1232        manifest.nodes.push(GltfNodeCapability {
1233            node_index,
1234            // A key-based check would report a `"matrix": null` node as
1235            // `Matrix` while the typed parse reads it as `Trs`.
1236            rest_kind: if declared(node, "matrix").is_some() {
1237                GltfNodeRestKind::Matrix
1238            } else {
1239                GltfNodeRestKind::Trs
1240            },
1241            mesh_index: as_index(node.get("mesh")),
1242            skin_index: as_index(node.get("skin")),
1243        });
1244    }
1245}
1246
1247fn inventory_animations(
1248    root: &Map<String, Value>,
1249    manifest: &mut GltfCapabilityManifest,
1250    violations: &mut Vec<GltfCapabilityViolation>,
1251) {
1252    let Some(animations) = root.get("animations").and_then(Value::as_array) else {
1253        return;
1254    };
1255    for (animation_index, animation) in animations.iter().enumerate() {
1256        let Some(animation) = animation.as_object() else {
1257            continue;
1258        };
1259        let samplers = animation
1260            .get("samplers")
1261            .and_then(Value::as_array)
1262            .map(Vec::as_slice)
1263            .unwrap_or_default();
1264        let channels = animation
1265            .get("channels")
1266            .and_then(Value::as_array)
1267            .map(Vec::as_slice)
1268            .unwrap_or_default();
1269        for (channel_index, channel) in channels.iter().enumerate() {
1270            let Some(channel) = channel.as_object() else {
1271                continue;
1272            };
1273            let sampler_index = as_index(channel.get("sampler")).unwrap_or(usize::MAX);
1274            let Some(sampler) = samplers.get(sampler_index).and_then(Value::as_object) else {
1275                continue;
1276            };
1277            let Some(target) = channel.get("target").and_then(Value::as_object) else {
1278                continue;
1279            };
1280            let target_path = target
1281                .get("path")
1282                .and_then(Value::as_str)
1283                .unwrap_or_default()
1284                .to_owned();
1285            if target_path == "weights" {
1286                manifest.morph_weight_locations.push(format!(
1287                    "/animations/{animation_index}/channels/{channel_index}/target/path"
1288                ));
1289            }
1290            let target_node_index = as_index(target.get("node")).unwrap_or(usize::MAX);
1291            if manifest
1292                .nodes
1293                .get(target_node_index)
1294                .is_some_and(|node| node.rest_kind == GltfNodeRestKind::Matrix)
1295            {
1296                violation(
1297                    violations,
1298                    GltfCapabilityViolationKind::AnimatedMatrixNode,
1299                    format!("/animations/{animation_index}/channels/{channel_index}/target"),
1300                );
1301            }
1302            manifest
1303                .animation_channels
1304                .push(GltfAnimationChannelCapability {
1305                    animation_index,
1306                    channel_index,
1307                    target_node_index,
1308                    target_path,
1309                    interpolation: sampler
1310                        .get("interpolation")
1311                        .and_then(Value::as_str)
1312                        .unwrap_or("LINEAR")
1313                        .to_owned(),
1314                    input_accessor_index: as_index(sampler.get("input")).unwrap_or(usize::MAX),
1315                    output_accessor_index: as_index(sampler.get("output")).unwrap_or(usize::MAX),
1316                });
1317        }
1318    }
1319}
1320
1321fn inventory_meshes(
1322    root: &Map<String, Value>,
1323    manifest: &mut GltfCapabilityManifest,
1324    violations: &mut Vec<GltfCapabilityViolation>,
1325) {
1326    let Some(meshes) = root.get("meshes").and_then(Value::as_array) else {
1327        return;
1328    };
1329    for (mesh_index, mesh) in meshes.iter().enumerate() {
1330        let Some(mesh) = mesh.as_object() else {
1331            continue;
1332        };
1333        if mesh.contains_key("weights") {
1334            manifest
1335                .morph_weight_locations
1336                .push(format!("/meshes/{mesh_index}/weights"));
1337        }
1338        let primitives = mesh
1339            .get("primitives")
1340            .and_then(Value::as_array)
1341            .map(Vec::as_slice)
1342            .unwrap_or_default();
1343        for (primitive_index, primitive) in primitives.iter().enumerate() {
1344            let Some(primitive) = primitive.as_object() else {
1345                continue;
1346            };
1347            let mode = primitive.get("mode").and_then(Value::as_u64).unwrap_or(4);
1348            if mode != 4 {
1349                violation(
1350                    violations,
1351                    GltfCapabilityViolationKind::NonTrianglePrimitive,
1352                    format!("/meshes/{mesh_index}/primitives/{primitive_index}/mode"),
1353                );
1354            }
1355            let mut attributes = primitive
1356                .get("attributes")
1357                .and_then(Value::as_object)
1358                .map(|attributes| {
1359                    attributes
1360                        .iter()
1361                        .map(|(semantic, accessor)| GltfAttributeCapability {
1362                            semantic: semantic.clone(),
1363                            accessor_index: as_index(Some(accessor)).unwrap_or(usize::MAX),
1364                        })
1365                        .collect::<Vec<_>>()
1366                })
1367                .unwrap_or_default();
1368            attributes.sort_by(|left, right| left.semantic.cmp(&right.semantic));
1369            for attribute in &attributes {
1370                let semantic = &attribute.semantic;
1371                let semantic_pointer = json_pointer_token(semantic);
1372                let location = format!(
1373                    "/meshes/{mesh_index}/primitives/{primitive_index}/attributes/{semantic_pointer}"
1374                );
1375                if is_secondary_influence(semantic) {
1376                    violation(
1377                        violations,
1378                        GltfCapabilityViolationKind::SecondarySkinInfluences,
1379                        location,
1380                    );
1381                } else if !matches!(
1382                    semantic.as_str(),
1383                    "POSITION" | "NORMAL" | "TEXCOORD_0" | "JOINTS_0" | "WEIGHTS_0"
1384                ) {
1385                    violation(
1386                        violations,
1387                        GltfCapabilityViolationKind::UnsupportedVertexAttribute,
1388                        location,
1389                    );
1390                }
1391            }
1392            let morph_target_count = primitive
1393                .get("targets")
1394                .and_then(Value::as_array)
1395                .map_or(0, Vec::len);
1396            let mut morph_position_accessors = Vec::new();
1397            let mut unsupported_morph_locations = Vec::new();
1398            for (target_index, target) in primitive
1399                .get("targets")
1400                .and_then(Value::as_array)
1401                .into_iter()
1402                .flatten()
1403                .enumerate()
1404            {
1405                let Some(target) = target.as_object() else {
1406                    continue;
1407                };
1408                for (semantic, accessor) in target {
1409                    let location = format!(
1410                        "/meshes/{mesh_index}/primitives/{primitive_index}/targets/{target_index}/{}",
1411                        json_pointer_token(semantic)
1412                    );
1413                    if semantic == "POSITION" {
1414                        if let Some(accessor_index) = as_index(Some(accessor)) {
1415                            morph_position_accessors.push(accessor_index);
1416                        }
1417                    } else {
1418                        violation(
1419                            violations,
1420                            GltfCapabilityViolationKind::MorphTarget,
1421                            location.clone(),
1422                        );
1423                        unsupported_morph_locations.push(location);
1424                    }
1425                }
1426            }
1427            manifest.primitives.push(GltfPrimitiveCapability {
1428                mesh_index,
1429                primitive_index,
1430                mode,
1431                attributes,
1432                morph_target_count,
1433                morph_position_accessors,
1434                unsupported_morph_locations,
1435            });
1436        }
1437    }
1438}
1439
1440fn is_secondary_influence(semantic: &str) -> bool {
1441    semantic
1442        .strip_prefix("JOINTS_")
1443        .or_else(|| semantic.strip_prefix("WEIGHTS_"))
1444        .and_then(|index| index.parse::<u32>().ok())
1445        .is_some_and(|index| index >= 1)
1446}
1447
1448fn inventory_skins(
1449    root: &Map<String, Value>,
1450    manifest: &mut GltfCapabilityManifest,
1451    violations: &mut Vec<GltfCapabilityViolation>,
1452) {
1453    let accessors = root
1454        .get("accessors")
1455        .and_then(Value::as_array)
1456        .map(Vec::as_slice)
1457        .unwrap_or_default();
1458    let Some(skins) = root.get("skins").and_then(Value::as_array) else {
1459        return;
1460    };
1461    for (skin_index, skin) in skins.iter().enumerate() {
1462        let Some(skin) = skin.as_object() else {
1463            continue;
1464        };
1465        let joint_count = skin
1466            .get("joints")
1467            .and_then(Value::as_array)
1468            .map_or(0, Vec::len);
1469        let inverse_bind_accessor_index = as_index(skin.get("inverseBindMatrices"));
1470        let inverse_bind_count = inverse_bind_accessor_index
1471            .and_then(|index| accessors.get(index))
1472            .and_then(|accessor| accessor.get("count"))
1473            .and_then(Value::as_u64);
1474        let inverse_bind_readable = inverse_bind_accessor_index
1475            .and_then(|index| accessors.get(index))
1476            .and_then(Value::as_object)
1477            .is_some_and(|accessor| {
1478                accessor.get("bufferView").and_then(Value::as_u64).is_some()
1479                    && accessor.get("componentType").and_then(Value::as_u64) == Some(5126)
1480                    && accessor.get("type").and_then(Value::as_str) == Some("MAT4")
1481                    && !accessor.contains_key("sparse")
1482            });
1483        match (inverse_bind_accessor_index, inverse_bind_count) {
1484            (None, _) => violation(
1485                violations,
1486                GltfCapabilityViolationKind::MissingInverseBinds,
1487                format!("/skins/{skin_index}/inverseBindMatrices"),
1488            ),
1489            (Some(_), Some(0)) => violation(
1490                violations,
1491                GltfCapabilityViolationKind::EmptyInverseBindAccessor,
1492                format!("/skins/{skin_index}/inverseBindMatrices"),
1493            ),
1494            (Some(_), Some(count)) if count != joint_count as u64 => violation(
1495                violations,
1496                GltfCapabilityViolationKind::InverseBindCountMismatch,
1497                format!("/skins/{skin_index}/inverseBindMatrices"),
1498            ),
1499            (Some(_), _) if !inverse_bind_readable => violation(
1500                violations,
1501                GltfCapabilityViolationKind::UnreadableInverseBinds,
1502                format!("/skins/{skin_index}/inverseBindMatrices"),
1503            ),
1504            _ => {}
1505        }
1506        manifest.skins.push(GltfSkinCapability {
1507            skin_index,
1508            joint_count,
1509            inverse_bind_accessor_index,
1510            inverse_bind_count,
1511        });
1512    }
1513}
1514
1515#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1516enum AccessorUse {
1517    ScaleBearing,
1518    Dimensionless,
1519}
1520
1521/// Which source object owns one byte range in the disjointness inspection.
1522#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1523enum RangeOwner {
1524    /// An accessor's dense element range.
1525    Accessor(usize),
1526    /// An accessor's sparse-index element range.
1527    SparseIndices(usize),
1528    /// An accessor's sparse-value element range.
1529    SparseValues(usize),
1530    /// An `image`'s complete buffer view.
1531    ImagePayload(usize),
1532}
1533
1534impl RangeOwner {
1535    /// JSON pointer identifying the owner.
1536    fn location(self) -> String {
1537        match self {
1538            Self::Accessor(index) => format!("/accessors/{index}"),
1539            Self::SparseIndices(index) => {
1540                format!("/accessors/{index}/sparse/indices/bufferView")
1541            }
1542            Self::SparseValues(index) => {
1543                format!("/accessors/{index}/sparse/values/bufferView")
1544            }
1545            Self::ImagePayload(index) => format!("/images/{index}/bufferView"),
1546        }
1547    }
1548
1549    /// The violation kind reported when this owner's range is not disjoint
1550    /// from a scale-bearing accessor's.
1551    fn overlap_kind(self) -> GltfCapabilityViolationKind {
1552        match self {
1553            Self::Accessor(_) | Self::SparseIndices(_) | Self::SparseValues(_) => {
1554                GltfCapabilityViolationKind::OverlappingAccessorRanges
1555            }
1556            Self::ImagePayload(_) => GltfCapabilityViolationKind::ImagePayloadOverlap,
1557        }
1558    }
1559}
1560
1561/// One `(buffer, start, end, owner, scale_bearing)` range entry.
1562type OwnedRange = (usize, usize, usize, RangeOwner, bool);
1563
1564fn inspect_accessor_layouts(
1565    root: &Value,
1566    buffers: &[Vec<u8>],
1567    uses: &BTreeMap<usize, BTreeSet<AccessorUse>>,
1568    violations: &mut Vec<GltfCapabilityViolation>,
1569) {
1570    let Some(root) = root.as_object() else { return };
1571    let mut ranges: Vec<OwnedRange> = Vec::new();
1572    let accessors = root
1573        .get("accessors")
1574        .and_then(Value::as_array)
1575        .map(Vec::as_slice)
1576        .unwrap_or_default();
1577    for accessor_index in 0..accessors.len() {
1578        let accessor_uses = uses.get(&accessor_index);
1579        let scale_bearing =
1580            accessor_uses.is_some_and(|uses| uses.contains(&AccessorUse::ScaleBearing));
1581        let accessor_ranges = if scale_bearing {
1582            dense_f32_accessor_range(root, buffers, accessor_index).map(|range| {
1583                vec![(
1584                    range.0,
1585                    range.1,
1586                    range.2,
1587                    RangeOwner::Accessor(accessor_index),
1588                    true,
1589                )]
1590            })
1591        } else if accessor_uses.is_some() {
1592            accessor_range(root, buffers, accessor_index).map(|range| {
1593                vec![(
1594                    range.buffer,
1595                    range.start,
1596                    range.end,
1597                    RangeOwner::Accessor(accessor_index),
1598                    false,
1599                )]
1600            })
1601        } else {
1602            preserved_accessor_ranges(root, buffers, accessor_index)
1603        };
1604        match accessor_ranges {
1605            Some(accessor_ranges) => ranges.extend(accessor_ranges),
1606            None => violation(
1607                violations,
1608                GltfCapabilityViolationKind::UnsafeAccessorLayout,
1609                format!("/accessors/{accessor_index}"),
1610            ),
1611        }
1612    }
1613    ranges.extend(image_payload_ranges(root));
1614    ranges.sort_unstable();
1615
1616    let mut overlapping = BTreeSet::new();
1617    let mut prior_scale: Option<(usize, usize, RangeOwner)> = None;
1618    for &(buffer, start, end, owner, scale_bearing) in &ranges {
1619        if let Some((left_buffer, left_end, left_owner)) = prior_scale
1620            && left_buffer == buffer
1621            && start < left_end
1622        {
1623            overlapping.insert(left_owner);
1624            overlapping.insert(owner);
1625        }
1626        if scale_bearing
1627            && prior_scale
1628                .is_none_or(|(left_buffer, left_end, _)| left_buffer != buffer || end > left_end)
1629        {
1630            prior_scale = Some((buffer, end, owner));
1631        }
1632    }
1633    let mut later_scale: Option<(usize, usize, RangeOwner)> = None;
1634    for &(buffer, start, end, owner, scale_bearing) in ranges.iter().rev() {
1635        if let Some((right_buffer, right_start, right_owner)) = later_scale
1636            && right_buffer == buffer
1637            && right_start < end
1638        {
1639            overlapping.insert(owner);
1640            overlapping.insert(right_owner);
1641        }
1642        if scale_bearing
1643            && later_scale.is_none_or(|(right_buffer, right_start, _)| {
1644                right_buffer != buffer || start < right_start
1645            })
1646        {
1647            later_scale = Some((buffer, start, owner));
1648        }
1649    }
1650    for owner in overlapping {
1651        violation(violations, owner.overlap_kind(), owner.location());
1652    }
1653}
1654
1655/// Every byte range owned by an unreferenced accessor.
1656///
1657/// Unlike a referenced sparse accessor, which remains an unsupported reader
1658/// layout, an unreferenced sparse accessor is source payload to preserve. Its
1659/// optional dense base plus its sparse indices and values therefore enter the
1660/// same disjointness ledger as ordinary dense accessors. Returning `None`
1661/// fails closed when a declared span cannot be resolved; an empty vector is a
1662/// valid accessor with no owned bytes.
1663fn preserved_accessor_ranges(
1664    root: &Map<String, Value>,
1665    buffers: &[Vec<u8>],
1666    accessor_index: usize,
1667) -> Option<Vec<OwnedRange>> {
1668    let accessor = root
1669        .get("accessors")?
1670        .as_array()?
1671        .get(accessor_index)?
1672        .as_object()?;
1673    let count: usize = accessor.get("count")?.as_u64()?.try_into().ok()?;
1674    if count == 0 {
1675        return Some(Vec::new());
1676    }
1677    let Some(sparse) = accessor.get("sparse") else {
1678        let range = accessor_range(root, buffers, accessor_index)?;
1679        return Some(vec![(
1680            range.buffer,
1681            range.start,
1682            range.end,
1683            RangeOwner::Accessor(accessor_index),
1684            false,
1685        )]);
1686    };
1687
1688    let mut ranges = Vec::with_capacity(3);
1689    if accessor.get("bufferView").is_some() {
1690        let range = dense_accessor_range(root, buffers, accessor_index)?;
1691        ranges.push((
1692            range.buffer,
1693            range.start,
1694            range.end,
1695            RangeOwner::Accessor(accessor_index),
1696            false,
1697        ));
1698    }
1699
1700    let sparse = sparse.as_object()?;
1701    let sparse_count: usize = sparse.get("count")?.as_u64()?.try_into().ok()?;
1702    if sparse_count == 0 {
1703        return Some(ranges);
1704    }
1705    let indices = sparse.get("indices")?.as_object()?;
1706    let index_size = match indices.get("componentType")?.as_u64()? {
1707        5121 => 1,
1708        5123 => 2,
1709        5125 => 4,
1710        _ => return None,
1711    };
1712    let indices_range = packed_view_range(
1713        root,
1714        buffers,
1715        as_index(indices.get("bufferView"))?,
1716        indices
1717            .get("byteOffset")
1718            .and_then(Value::as_u64)
1719            .unwrap_or(0),
1720        sparse_count,
1721        index_size,
1722        index_size,
1723    )?;
1724    ranges.push((
1725        indices_range.0,
1726        indices_range.1,
1727        indices_range.2,
1728        RangeOwner::SparseIndices(accessor_index),
1729        false,
1730    ));
1731
1732    let values = sparse.get("values")?.as_object()?;
1733    let component_size = component_size(accessor.get("componentType")?.as_u64()?)?;
1734    let element_layout = accessor_element_layout(accessor.get("type")?.as_str()?, component_size)?;
1735    let values_range = packed_view_range(
1736        root,
1737        buffers,
1738        as_index(values.get("bufferView"))?,
1739        values
1740            .get("byteOffset")
1741            .and_then(Value::as_u64)
1742            .unwrap_or(0),
1743        sparse_count,
1744        element_layout.stride,
1745        element_layout.terminal_size,
1746    )?;
1747    ranges.push((
1748        values_range.0,
1749        values_range.1,
1750        values_range.2,
1751        RangeOwner::SparseValues(accessor_index),
1752        false,
1753    ));
1754    Some(ranges)
1755}
1756
1757/// Resolve one tightly packed walk within a declared buffer view.
1758fn packed_view_range(
1759    root: &Map<String, Value>,
1760    buffers: &[Vec<u8>],
1761    view_index: usize,
1762    relative_offset: u64,
1763    count: usize,
1764    element_stride: usize,
1765    terminal_size: usize,
1766) -> Option<(usize, usize, usize)> {
1767    let view = root
1768        .get("bufferViews")?
1769        .as_array()?
1770        .get(view_index)?
1771        .as_object()?;
1772    let buffer_index = as_index(view.get("buffer"))?;
1773    let buffer = buffers.get(buffer_index)?;
1774    let view_offset: usize = view
1775        .get("byteOffset")
1776        .and_then(Value::as_u64)
1777        .unwrap_or(0)
1778        .try_into()
1779        .ok()?;
1780    let view_length: usize = view.get("byteLength")?.as_u64()?.try_into().ok()?;
1781    if view_offset.checked_add(view_length)? > buffer.len() {
1782        return None;
1783    }
1784    let relative_offset: usize = relative_offset.try_into().ok()?;
1785    let relative_end = relative_offset
1786        .checked_add(count.checked_sub(1)?.checked_mul(element_stride)?)?
1787        .checked_add(terminal_size)?;
1788    if relative_end > view_length {
1789        return None;
1790    }
1791    let start = view_offset.checked_add(relative_offset)?;
1792    let end = view_offset.checked_add(relative_end)?;
1793    Some((buffer_index, start, end))
1794}
1795
1796/// The byte range every `image` reads directly from a buffer view.
1797///
1798/// # Why images, and why only images
1799///
1800/// An `image` is the one consumer in the supported subset that reads a
1801/// `bufferView` without ever becoming an accessor, so its bytes are invisible
1802/// to a disjointness proof built from accessor ranges alone. The complete
1803/// enumeration of `bufferView` consumers in glTF 2.0 core is:
1804///
1805/// | Consumer | Treatment |
1806/// |---|---|
1807/// | `/accessors/*/bufferView` | Every accessor is ranged by [`inspect_accessor_layouts`] above, whether referenced or not. |
1808/// | `/accessors/*/sparse/indices/bufferView`, `/accessors/*/sparse/values/bufferView` | Ranged for unreferenced accessors. A referenced sparse accessor remains an `UnsafeAccessorLayout` refusal and never reaches a rewrite. |
1809/// | `/images/*/bufferView` | Ranged here. |
1810/// | Extension payloads such as `EXT_meshopt_compression` or `KHR_draco_mesh_compression` | Out of range: this crate registers no extension handler, so every extension declaration *and* every extension payload is already an `ExtensionDeclaration`/`ExtensionPayload` violation. |
1811///
1812/// # Bounds
1813///
1814/// The range is taken from the declared view, without requiring it to fit the
1815/// resolved buffer: a view running past the buffer still aliases whatever real
1816/// bytes it starts on. Where `usize` is narrower than `u64`, a declared value
1817/// past `usize::MAX` clamps, and neither clamp can hide a real overlap.
1818/// [`accessor_range`] admits a range only when its end is within the resolved
1819/// buffer's length, so every range compared against ends below `usize::MAX`: a
1820/// start large enough to clamp is already past all of them, and a clamped end
1821/// only widens the image range.
1822fn image_payload_ranges(root: &Map<String, Value>) -> Vec<OwnedRange> {
1823    let Some(images) = root.get("images").and_then(Value::as_array) else {
1824        return Vec::new();
1825    };
1826    let buffer_views = root
1827        .get("bufferViews")
1828        .and_then(Value::as_array)
1829        .map(Vec::as_slice)
1830        .unwrap_or_default();
1831    let mut out = Vec::new();
1832    for (image_index, image) in images.iter().enumerate() {
1833        let Some(view_index) = as_index(image.get("bufferView")) else {
1834            continue;
1835        };
1836        // An out-of-range index is an `IndexOutOfBounds` validation error,
1837        // which `validate_document` raises before this inspection runs.
1838        let Some(view) = buffer_views.get(view_index).and_then(Value::as_object) else {
1839            continue;
1840        };
1841        let Some(buffer) = as_index(view.get("buffer")) else {
1842            continue;
1843        };
1844        let start = clamped_usize(view.get("byteOffset").and_then(Value::as_u64).unwrap_or(0));
1845        let end = start.saturating_add(clamped_usize(
1846            view.get("byteLength").and_then(Value::as_u64).unwrap_or(0),
1847        ));
1848        // An empty view shares no byte with anything under the half-open
1849        // comparison every range here uses, so it is dropped rather than
1850        // ranged. [`crate::scale::reject_image_payload_overlap`] skips the
1851        // same shape, so the gate and the guard give one answer for it.
1852        if start < end {
1853            out.push((
1854                buffer,
1855                start,
1856                end,
1857                RangeOwner::ImagePayload(image_index),
1858                false,
1859            ));
1860        }
1861    }
1862    out
1863}
1864
1865fn clamped_usize(value: u64) -> usize {
1866    usize::try_from(value).unwrap_or(usize::MAX)
1867}
1868
1869fn inspect_accessor_uses(
1870    root: &Value,
1871    violations: &mut Vec<GltfCapabilityViolation>,
1872) -> BTreeMap<usize, BTreeSet<AccessorUse>> {
1873    let Some(root) = root.as_object() else {
1874        return BTreeMap::new();
1875    };
1876    let uses = collect_accessor_uses(root);
1877    for (accessor_index, accessor_uses) in &uses {
1878        if accessor_uses.len() > 1 {
1879            violation(
1880                violations,
1881                GltfCapabilityViolationKind::ConflictingAccessorUse,
1882                format!("/accessors/{accessor_index}"),
1883            );
1884        }
1885    }
1886    uses
1887}
1888
1889fn collect_accessor_uses(root: &Map<String, Value>) -> BTreeMap<usize, BTreeSet<AccessorUse>> {
1890    let mut uses: BTreeMap<usize, BTreeSet<AccessorUse>> = BTreeMap::new();
1891    let mut add = |index: Option<usize>, kind| {
1892        if let Some(index) = index {
1893            uses.entry(index).or_default().insert(kind);
1894        }
1895    };
1896    if let Some(meshes) = root.get("meshes").and_then(Value::as_array) {
1897        for mesh in meshes {
1898            let Some(primitives) = mesh.get("primitives").and_then(Value::as_array) else {
1899                continue;
1900            };
1901            for primitive in primitives {
1902                if let Some(attributes) = primitive.get("attributes").and_then(Value::as_object) {
1903                    for (semantic, index) in attributes {
1904                        add(
1905                            as_index(Some(index)),
1906                            if semantic == "POSITION" {
1907                                AccessorUse::ScaleBearing
1908                            } else {
1909                                AccessorUse::Dimensionless
1910                            },
1911                        );
1912                    }
1913                }
1914                add(
1915                    as_index(primitive.get("indices")),
1916                    AccessorUse::Dimensionless,
1917                );
1918                if let Some(targets) = primitive.get("targets").and_then(Value::as_array) {
1919                    for target in targets {
1920                        if let Some(target) = target.as_object() {
1921                            for (semantic, index) in target {
1922                                add(
1923                                    as_index(Some(index)),
1924                                    if semantic == "POSITION" {
1925                                        AccessorUse::ScaleBearing
1926                                    } else {
1927                                        AccessorUse::Dimensionless
1928                                    },
1929                                );
1930                            }
1931                        }
1932                    }
1933                }
1934            }
1935        }
1936    }
1937    if let Some(skins) = root.get("skins").and_then(Value::as_array) {
1938        for skin in skins {
1939            add(
1940                as_index(skin.get("inverseBindMatrices")),
1941                AccessorUse::ScaleBearing,
1942            );
1943        }
1944    }
1945    if let Some(animations) = root.get("animations").and_then(Value::as_array) {
1946        for animation in animations {
1947            let samplers = animation
1948                .get("samplers")
1949                .and_then(Value::as_array)
1950                .map(Vec::as_slice)
1951                .unwrap_or_default();
1952            let channels = animation
1953                .get("channels")
1954                .and_then(Value::as_array)
1955                .map(Vec::as_slice)
1956                .unwrap_or_default();
1957            let referenced: BTreeSet<usize> = channels
1958                .iter()
1959                .filter_map(|channel| as_index(channel.get("sampler")))
1960                .collect();
1961            for (sampler_index, sampler) in samplers.iter().enumerate() {
1962                add(as_index(sampler.get("input")), AccessorUse::Dimensionless);
1963                if !referenced.contains(&sampler_index) {
1964                    add(as_index(sampler.get("output")), AccessorUse::Dimensionless);
1965                }
1966            }
1967            for channel in channels {
1968                let sampler_index = as_index(channel.get("sampler")).unwrap_or(usize::MAX);
1969                let Some(sampler) = samplers.get(sampler_index) else {
1970                    continue;
1971                };
1972                let path = channel
1973                    .get("target")
1974                    .and_then(|target| target.get("path"))
1975                    .and_then(Value::as_str);
1976                add(
1977                    as_index(sampler.get("output")),
1978                    if path == Some("translation") {
1979                        AccessorUse::ScaleBearing
1980                    } else {
1981                        AccessorUse::Dimensionless
1982                    },
1983                );
1984            }
1985        }
1986    }
1987    uses
1988}
1989
1990/// The `(buffer, start, end)` byte range of a dense, non-normalized,
1991/// non-sparse, 4-byte-aligned `f32` accessor, or `None` when the accessor is
1992/// not in that shape.
1993///
1994/// Shared with [`crate::scale`]: the byte rewriter must resolve exactly the
1995/// same range this preflight vouched for, so re-deriving it there would let
1996/// the two definitions drift apart.
1997pub(crate) fn dense_f32_accessor_range(
1998    root: &Map<String, Value>,
1999    buffers: &[Vec<u8>],
2000    accessor_index: usize,
2001) -> Option<(usize, usize, usize)> {
2002    let accessors = root.get("accessors")?.as_array()?;
2003    let accessor = accessors.get(accessor_index)?.as_object()?;
2004    if accessor.get("componentType")?.as_u64()? != 5126
2005        || accessor.get("normalized").and_then(Value::as_bool) == Some(true)
2006        || accessor.contains_key("sparse")
2007    {
2008        return None;
2009    }
2010    let range = accessor_range(root, buffers, accessor_index)?;
2011    if range.stride != range.element_stride || !range.start.is_multiple_of(4) {
2012        return None;
2013    }
2014    Some((range.buffer, range.start, range.end))
2015}
2016
2017/// The complete resolved range of any dense used accessor, irrespective of
2018/// component type or stride.
2019///
2020/// Rest/bind rewriting uses this only for operation-specific alias checks:
2021/// a rewritten `f32` accessor must not overlap bytes owned by a preserved
2022/// integer attribute, index accessor, or animation sampler. Keeping range
2023/// arithmetic here gives that guard exactly the same layout interpretation
2024/// as the common preflight.
2025pub(crate) fn resolved_accessor_range(
2026    root: &Map<String, Value>,
2027    buffers: &[Vec<u8>],
2028    accessor_index: usize,
2029) -> Option<(usize, usize, usize)> {
2030    accessor_range(root, buffers, accessor_index)
2031        .map(|range| (range.buffer, range.start, range.end))
2032}
2033
2034#[derive(Debug, Clone, Copy)]
2035struct AccessorRange {
2036    buffer: usize,
2037    start: usize,
2038    end: usize,
2039    stride: usize,
2040    element_stride: usize,
2041}
2042
2043fn accessor_range(
2044    root: &Map<String, Value>,
2045    buffers: &[Vec<u8>],
2046    accessor_index: usize,
2047) -> Option<AccessorRange> {
2048    let accessor = root
2049        .get("accessors")?
2050        .as_array()?
2051        .get(accessor_index)?
2052        .as_object()?;
2053    if accessor.contains_key("sparse") {
2054        return None;
2055    }
2056    dense_accessor_range(root, buffers, accessor_index)
2057}
2058
2059/// The dense base range of an accessor, including one that also declares
2060/// sparse replacement data.
2061fn dense_accessor_range(
2062    root: &Map<String, Value>,
2063    buffers: &[Vec<u8>],
2064    accessor_index: usize,
2065) -> Option<AccessorRange> {
2066    let accessors = root.get("accessors")?.as_array()?;
2067    let buffer_views = root.get("bufferViews")?.as_array()?;
2068    let accessor = accessors.get(accessor_index)?.as_object()?;
2069    let component_size = component_size(accessor.get("componentType")?.as_u64()?)?;
2070    let element_layout = accessor_element_layout(accessor.get("type")?.as_str()?, component_size)?;
2071    let count: usize = accessor.get("count")?.as_u64()?.try_into().ok()?;
2072    if count == 0 {
2073        return None;
2074    }
2075    let view_index = as_index(accessor.get("bufferView"))?;
2076    let view = buffer_views.get(view_index)?.as_object()?;
2077    let buffer_index = as_index(view.get("buffer"))?;
2078    let buffer = buffers.get(buffer_index)?;
2079    let view_offset: usize = view
2080        .get("byteOffset")
2081        .and_then(Value::as_u64)
2082        .unwrap_or(0)
2083        .try_into()
2084        .ok()?;
2085    let view_length: usize = view.get("byteLength")?.as_u64()?.try_into().ok()?;
2086    if view_offset.checked_add(view_length)? > buffer.len() {
2087        return None;
2088    }
2089    let accessor_offset: usize = accessor
2090        .get("byteOffset")
2091        .and_then(Value::as_u64)
2092        .unwrap_or(0)
2093        .try_into()
2094        .ok()?;
2095    let stride: usize = view
2096        .get("byteStride")
2097        .and_then(Value::as_u64)
2098        .unwrap_or(element_layout.stride as u64)
2099        .try_into()
2100        .ok()?;
2101    if stride < element_layout.stride {
2102        return None;
2103    }
2104    let relative_end = accessor_offset
2105        .checked_add(count.checked_sub(1)?.checked_mul(stride)?)?
2106        .checked_add(element_layout.terminal_size)?;
2107    if relative_end > view_length {
2108        return None;
2109    }
2110    let start = view_offset.checked_add(accessor_offset)?;
2111    let end = view_offset.checked_add(relative_end)?;
2112    (end <= buffer.len()).then_some(AccessorRange {
2113        buffer: buffer_index,
2114        start,
2115        end,
2116        stride,
2117        element_stride: element_layout.stride,
2118    })
2119}
2120
2121fn component_size(component_type: u64) -> Option<usize> {
2122    match component_type {
2123        5120 | 5121 => Some(1),
2124        5122 | 5123 => Some(2),
2125        5125 | 5126 => Some(4),
2126        _ => None,
2127    }
2128}
2129
2130/// The stored spacing and terminal occupied extent of one accessor element.
2131///
2132/// Integer `MAT2` and `MAT3` columns begin on four-byte boundaries. That
2133/// padding contributes to the stride between elements, but glTF permits the
2134/// trailing padding after the final matrix column to be omitted when no data
2135/// follows. Keeping the two lengths separate admits that compact final
2136/// element without weakening the bounds on preceding elements.
2137#[derive(Debug, Clone, Copy)]
2138struct AccessorElementLayout {
2139    stride: usize,
2140    terminal_size: usize,
2141}
2142
2143fn accessor_element_layout(
2144    accessor_type: &str,
2145    component_size: usize,
2146) -> Option<AccessorElementLayout> {
2147    let (columns, rows, matrix) = match accessor_type {
2148        "SCALAR" => (1usize, 1usize, false),
2149        "VEC2" => (1, 2, false),
2150        "VEC3" => (1, 3, false),
2151        "VEC4" => (1, 4, false),
2152        "MAT2" => (2, 2, true),
2153        "MAT3" => (3, 3, true),
2154        "MAT4" => (4, 4, true),
2155        _ => return None,
2156    };
2157    let column_size = rows.checked_mul(component_size)?;
2158    let stored_column_size = if matrix {
2159        column_size.checked_add(3)? & !3
2160    } else {
2161        column_size
2162    };
2163    let stride = columns.checked_mul(stored_column_size)?;
2164    let terminal_size = columns
2165        .checked_sub(1)?
2166        .checked_mul(stored_column_size)?
2167        .checked_add(column_size)?;
2168    Some(AccessorElementLayout {
2169        stride,
2170        terminal_size,
2171    })
2172}
2173
2174fn inspect_schema_members(
2175    value: &Value,
2176    pointer: &str,
2177    manifest: &mut GltfCapabilityManifest,
2178    violations: &mut Vec<GltfCapabilityViolation>,
2179) {
2180    match value {
2181        Value::Object(object) => {
2182            if object.get("extras").is_some_and(|value| !value.is_null()) {
2183                let location = format!("{pointer}/extras");
2184                manifest.extras_locations.push(location.clone());
2185                violation(violations, GltfCapabilityViolationKind::Extras, location);
2186            }
2187            if let Some(extensions) = object.get("extensions").and_then(Value::as_object) {
2188                for name in extensions.keys() {
2189                    let location = json_pointer_child(&format!("{pointer}/extensions"), name);
2190                    manifest.extensions.push(name.clone());
2191                    manifest.extension_locations.push(location.clone());
2192                    violation(
2193                        violations,
2194                        match name.as_str() {
2195                            "KHR_lights_punctual" => GltfCapabilityViolationKind::Light,
2196                            "EXT_mesh_gpu_instancing" => GltfCapabilityViolationKind::Instancing,
2197                            _ => GltfCapabilityViolationKind::ExtensionPayload,
2198                        },
2199                        location,
2200                    );
2201                }
2202            }
2203            if let Some(allowed) = allowed_members(pointer) {
2204                for key in object.keys() {
2205                    if !allowed.contains(&key.as_str()) {
2206                        let location = json_pointer_child(pointer, key);
2207                        manifest.unknown_member_locations.push(location.clone());
2208                        violation(
2209                            violations,
2210                            GltfCapabilityViolationKind::UnknownJsonMember,
2211                            location,
2212                        );
2213                    }
2214                }
2215            }
2216            for (key, child) in object {
2217                if key == "extras" || key == "extensions" {
2218                    continue;
2219                }
2220                inspect_schema_members(
2221                    child,
2222                    &json_pointer_child(pointer, key),
2223                    manifest,
2224                    violations,
2225                );
2226            }
2227        }
2228        Value::Array(values) => {
2229            for (index, child) in values.iter().enumerate() {
2230                inspect_schema_members(child, &format!("{pointer}/{index}"), manifest, violations);
2231            }
2232        }
2233        _ => {}
2234    }
2235}
2236
2237fn json_pointer_child(pointer: &str, token: &str) -> String {
2238    format!("{pointer}/{}", json_pointer_token(token))
2239}
2240
2241fn json_pointer_token(token: &str) -> String {
2242    token.replace('~', "~0").replace('/', "~1")
2243}
2244
2245fn allowed_members(pointer: &str) -> Option<&'static [&'static str]> {
2246    const ROOT: &[&str] = &[
2247        "accessors",
2248        "animations",
2249        "asset",
2250        "buffers",
2251        "bufferViews",
2252        "cameras",
2253        "extensions",
2254        "extensionsRequired",
2255        "extensionsUsed",
2256        "extras",
2257        "images",
2258        "materials",
2259        "meshes",
2260        "nodes",
2261        "samplers",
2262        "scene",
2263        "scenes",
2264        "skins",
2265        "textures",
2266    ];
2267    const ASSET: &[&str] = &[
2268        "copyright",
2269        "extensions",
2270        "extras",
2271        "generator",
2272        "minVersion",
2273        "version",
2274    ];
2275    const ACCESSOR: &[&str] = &[
2276        "bufferView",
2277        "byteOffset",
2278        "componentType",
2279        "count",
2280        "extensions",
2281        "extras",
2282        "max",
2283        "min",
2284        "name",
2285        "normalized",
2286        "sparse",
2287        "type",
2288    ];
2289    const BUFFER: &[&str] = &["byteLength", "extensions", "extras", "name", "uri"];
2290    const VIEW: &[&str] = &[
2291        "buffer",
2292        "byteLength",
2293        "byteOffset",
2294        "byteStride",
2295        "extensions",
2296        "extras",
2297        "name",
2298        "target",
2299    ];
2300    const NODE: &[&str] = &[
2301        "camera",
2302        "children",
2303        "extensions",
2304        "extras",
2305        "matrix",
2306        "mesh",
2307        "name",
2308        "rotation",
2309        "scale",
2310        "skin",
2311        "translation",
2312        "weights",
2313    ];
2314    const MESH: &[&str] = &["extensions", "extras", "name", "primitives", "weights"];
2315    const PRIMITIVE: &[&str] = &[
2316        "attributes",
2317        "extensions",
2318        "extras",
2319        "indices",
2320        "material",
2321        "mode",
2322        "targets",
2323    ];
2324    const ANIMATION: &[&str] = &["channels", "extensions", "extras", "name", "samplers"];
2325    const CHANNEL: &[&str] = &["extensions", "extras", "sampler", "target"];
2326    const TARGET: &[&str] = &["extensions", "extras", "node", "path"];
2327    const ANIM_SAMPLER: &[&str] = &["extensions", "extras", "input", "interpolation", "output"];
2328    const SKIN: &[&str] = &[
2329        "extensions",
2330        "extras",
2331        "inverseBindMatrices",
2332        "joints",
2333        "name",
2334        "skeleton",
2335    ];
2336    const SCENE: &[&str] = &["extensions", "extras", "name", "nodes"];
2337    const IMAGE: &[&str] = &[
2338        "bufferView",
2339        "extensions",
2340        "extras",
2341        "mimeType",
2342        "name",
2343        "uri",
2344    ];
2345    const TEXTURE: &[&str] = &["extensions", "extras", "name", "sampler", "source"];
2346    const SAMPLER: &[&str] = &[
2347        "extensions",
2348        "extras",
2349        "magFilter",
2350        "minFilter",
2351        "name",
2352        "wrapS",
2353        "wrapT",
2354    ];
2355    const CAMERA: &[&str] = &[
2356        "extensions",
2357        "extras",
2358        "name",
2359        "orthographic",
2360        "perspective",
2361        "type",
2362    ];
2363    const MATERIAL: &[&str] = &[
2364        "alphaCutoff",
2365        "alphaMode",
2366        "doubleSided",
2367        "emissiveFactor",
2368        "emissiveTexture",
2369        "extensions",
2370        "extras",
2371        "name",
2372        "normalTexture",
2373        "occlusionTexture",
2374        "pbrMetallicRoughness",
2375    ];
2376    const PBR: &[&str] = &[
2377        "baseColorFactor",
2378        "baseColorTexture",
2379        "extensions",
2380        "extras",
2381        "metallicFactor",
2382        "metallicRoughnessTexture",
2383        "roughnessFactor",
2384    ];
2385    const TEXTURE_INFO: &[&str] = &["extensions", "extras", "index", "texCoord"];
2386    const NORMAL_TEXTURE_INFO: &[&str] = &["extensions", "extras", "index", "scale", "texCoord"];
2387    const OCCLUSION_TEXTURE_INFO: &[&str] =
2388        &["extensions", "extras", "index", "strength", "texCoord"];
2389    const PERSPECTIVE: &[&str] = &[
2390        "aspectRatio",
2391        "extensions",
2392        "extras",
2393        "yfov",
2394        "zfar",
2395        "znear",
2396    ];
2397    const ORTHOGRAPHIC: &[&str] = &["extensions", "extras", "xmag", "ymag", "zfar", "znear"];
2398    const SPARSE: &[&str] = &["count", "extensions", "extras", "indices", "values"];
2399    const SPARSE_INDICES: &[&str] = &[
2400        "bufferView",
2401        "byteOffset",
2402        "componentType",
2403        "extensions",
2404        "extras",
2405    ];
2406    const SPARSE_VALUES: &[&str] = &["bufferView", "byteOffset", "extensions", "extras"];
2407    if pointer.is_empty() {
2408        Some(ROOT)
2409    } else if pointer == "/asset" {
2410        Some(ASSET)
2411    } else if indexed_member(pointer, "/accessors/") {
2412        Some(ACCESSOR)
2413    } else if indexed_member(pointer, "/buffers/") {
2414        Some(BUFFER)
2415    } else if indexed_member(pointer, "/bufferViews/") {
2416        Some(VIEW)
2417    } else if indexed_member(pointer, "/nodes/") {
2418        Some(NODE)
2419    } else if indexed_member(pointer, "/meshes/") {
2420        Some(MESH)
2421    } else if indexed_nested_member(pointer, "/meshes/", "/primitives/") {
2422        Some(PRIMITIVE)
2423    } else if indexed_member(pointer, "/animations/") {
2424        Some(ANIMATION)
2425    } else if indexed_nested_member(pointer, "/animations/", "/channels/") {
2426        Some(CHANNEL)
2427    } else if pointer.contains("/animations/") && pointer.ends_with("/target") {
2428        Some(TARGET)
2429    } else if indexed_nested_member(pointer, "/animations/", "/samplers/") {
2430        Some(ANIM_SAMPLER)
2431    } else if indexed_member(pointer, "/skins/") {
2432        Some(SKIN)
2433    } else if indexed_member(pointer, "/scenes/") {
2434        Some(SCENE)
2435    } else if indexed_member(pointer, "/images/") {
2436        Some(IMAGE)
2437    } else if indexed_member(pointer, "/textures/") {
2438        Some(TEXTURE)
2439    } else if indexed_member(pointer, "/samplers/") {
2440        Some(SAMPLER)
2441    } else if indexed_member(pointer, "/cameras/") {
2442        Some(CAMERA)
2443    } else if indexed_member(pointer, "/materials/") {
2444        Some(MATERIAL)
2445    } else if pointer.contains("/materials/") && pointer.ends_with("/pbrMetallicRoughness") {
2446        Some(PBR)
2447    } else if pointer.contains("/materials/")
2448        && (pointer.ends_with("/baseColorTexture")
2449            || pointer.ends_with("/metallicRoughnessTexture")
2450            || pointer.ends_with("/emissiveTexture"))
2451    {
2452        Some(TEXTURE_INFO)
2453    } else if pointer.contains("/materials/") && pointer.ends_with("/normalTexture") {
2454        Some(NORMAL_TEXTURE_INFO)
2455    } else if pointer.contains("/materials/") && pointer.ends_with("/occlusionTexture") {
2456        Some(OCCLUSION_TEXTURE_INFO)
2457    } else if pointer.contains("/cameras/") && pointer.ends_with("/perspective") {
2458        Some(PERSPECTIVE)
2459    } else if pointer.contains("/cameras/") && pointer.ends_with("/orthographic") {
2460        Some(ORTHOGRAPHIC)
2461    } else if pointer.contains("/accessors/") && pointer.ends_with("/sparse") {
2462        Some(SPARSE)
2463    } else if pointer.contains("/accessors/") && pointer.ends_with("/sparse/indices") {
2464        Some(SPARSE_INDICES)
2465    } else if pointer.contains("/accessors/") && pointer.ends_with("/sparse/values") {
2466        Some(SPARSE_VALUES)
2467    } else {
2468        None
2469    }
2470}
2471
2472fn indexed_member(pointer: &str, prefix: &str) -> bool {
2473    pointer
2474        .strip_prefix(prefix)
2475        .is_some_and(|suffix| !suffix.is_empty() && !suffix.contains('/'))
2476}
2477
2478fn indexed_nested_member(pointer: &str, prefix: &str, nested: &str) -> bool {
2479    let Some(suffix) = pointer.strip_prefix(prefix) else {
2480        return false;
2481    };
2482    let Some((outer, inner)) = suffix.split_once(nested) else {
2483        return false;
2484    };
2485    !outer.is_empty() && !outer.contains('/') && !inner.is_empty() && !inner.contains('/')
2486}
2487
2488#[cfg(test)]
2489mod tests {
2490    use super::*;
2491    use serde_json::json;
2492
2493    #[test]
2494    fn accessor_use_inventory_claims_orphan_sampler_fields_without_self_conflicting_channels() {
2495        let root = json!({
2496            "animations": [{
2497                "samplers": [
2498                    { "input": 1, "output": 2 },
2499                    { "input": 3, "output": 4 }
2500                ],
2501                "channels": [{
2502                    "sampler": 0,
2503                    "target": { "node": 0, "path": "translation" }
2504                }]
2505            }]
2506        });
2507        let uses = collect_accessor_uses(root.as_object().expect("root"));
2508        assert_eq!(uses[&1], BTreeSet::from([AccessorUse::Dimensionless]));
2509        assert_eq!(uses[&2], BTreeSet::from([AccessorUse::ScaleBearing]));
2510        assert_eq!(uses[&3], BTreeSet::from([AccessorUse::Dimensionless]));
2511        assert_eq!(uses[&4], BTreeSet::from([AccessorUse::Dimensionless]));
2512    }
2513}