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