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