Skip to main content

animsmith_gltf/
scale.rs

1//! Preservation-safe whole-document linear-unit rewriting of raw glTF/GLB
2//! bytes (DESIGN.md Appendix D §D.2 and ownership boundaries in §D.8).
3//!
4//! [`rewrite_linear_units`] converts every length in a captured
5//! [`GltfScaleSource`] by a caller-declared finite `factor > 0`. It operates
6//! on the source's own JSON tree and its own resolved buffer bytes, and it
7//! **never routes through [`crate::write`]**: that path rebuilds a normalized
8//! [`animsmith_core::Document`] and would silently drop every source payload
9//! the conversion exists to preserve. The factor is never inferred — not from
10//! bounds, character height, names, inverse binds, or asset category.
11//!
12//! # Composition
13//!
14//! ```no_run
15//! # fn convert(path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
16//! use animsmith_core::scale::{
17//!     ScaleCandidate, ScaleOperation, ScaleRequest, plan_scale, prove_scale,
18//! };
19//! use animsmith_gltf::{
20//!     capability_facts_for_source, load_bytes, preflight_scale_source,
21//!     prove_rewritten_artifact, rewrite_linear_units,
22//! };
23//!
24//! let factor = 0.01;
25//! let source = preflight_scale_source(path)?;
26//! let facts = capability_facts_for_source(&source);
27//! let plan = plan_scale(&ScaleRequest {
28//!     operation: ScaleOperation::WholeDocumentLinearUnits { factor },
29//!     document: source.document(),
30//!     capability: &facts,
31//! })?;
32//! let artifact = rewrite_linear_units(&source, factor)?;
33//! let reloaded = load_bytes(path, artifact.bytes())?;
34//! let core = prove_scale(
35//!     source.document(),
36//!     &ScaleCandidate::from_document(reloaded),
37//!     &plan,
38//! )?;
39//! let artifact_proof = prove_rewritten_artifact(&source, &artifact, &plan)?;
40//! # let _ = (core, artifact_proof);
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! # What is preserved, and what is not
46//!
47//! Buffer bytes outside the converted accessor ranges are preserved exactly.
48//! Every array index, every index-valued field, every material, image,
49//! texture, sampler, name, and `asset` member is preserved exactly. No byte
50//! length changes, because the conversion is an in-place `f32` mapping:
51//! `/buffers/*/byteLength`, every `bufferViews` field, and every accessor
52//! `count`/`byteOffset`/`componentType`/`type` are untouched.
53//!
54//! **JSON key order and float spelling are not preserved.** `serde_json` is
55//! built here without `preserve_order`, so its object map is a `BTreeMap` and
56//! re-serializing sorts members lexically and re-renders floats through
57//! `ryu` (`1.0000000` becomes `1.0`). Numeric *values* survive exactly — an
58//! integer `2` stays `2`. This is an accepted tradeoff: the output is
59//! deterministic, which is the criterion, though it is not a minimal textual
60//! diff. Enabling `preserve_order` is deliberately not done — it would pull
61//! `indexmap` into the dependency graph and reorder [`crate::write`]'s output,
62//! moving existing golden tests for an unrelated reason.
63//!
64//! Correctly rounded JSON parsing is part of this preservation boundary:
65//! `serde_json`'s `float_roundtrip` feature guarantees that `ryu`'s shortest
66//! finite `f64` spelling reparses to the same value. Converted numbers are
67//! narrowed to `f32` exactly once and written back as the shortest decimal
68//! that round-trips that `f32`, so the JSON and buffer payloads live in the
69//! same numeric regime the glTF schema declares. Writing the `f32`'s full
70//! `f64` widening would be longer without carrying more model information.
71
72mod bytes;
73mod container;
74mod plan;
75mod proof;
76mod rest_bind;
77mod rest_bind_proof;
78mod rules;
79
80use crate::capability::{
81    GltfCapabilityManifest, GltfCapabilityViolation, GltfCapabilityViolationKind,
82    GltfContainerKind, GltfScaleSource, NodeTransformFault, node_transform_faults,
83};
84use crate::{LoadError, WriteError};
85use animsmith_core::scale::{
86    ScaleCapabilityCoverage, ScaleCapabilityFacts, ScaleError, ScaleOperation, ScaleRequest,
87    plan_scale,
88};
89use animsmith_core::{
90    SourceConstructKindV1, SourceFactsViewV1, SourceResourceLocatorV1, SourceSetCoverageStateV1,
91};
92use bytes::{AccessorSpan, ComponentExtrema};
93use rules::{AccessorRule, JsonArrayRule};
94use serde_json::{Map, Value};
95use std::collections::{BTreeMap, BTreeSet};
96
97pub use proof::{GltfScaleArtifactProof, prove_rewritten_artifact};
98pub use rest_bind::rewrite_rest_bind;
99pub use rest_bind_proof::prove_rewritten_rest_bind;
100
101// --- Capability projection --------------------------------------------------
102
103/// Project a raw glTF capability manifest onto the format-neutral
104/// [`ScaleCapabilityFacts`] that [`animsmith_core::scale::plan_scale`]
105/// consumes.
106///
107/// Every flag is derived by re-deriving the #280 violations the manifest
108/// itself evidences and then folding them through one exhaustive
109/// [`GltfCapabilityViolationKind`] map, so a new violation kind is a
110/// compile error here rather than a silently unmapped domain.
111///
112/// # Coverage
113///
114/// `coverage` is [`ScaleCapabilityCoverage::Complete`] exactly when no source
115/// buffer is external. That is not a proxy: `preflight_scale_source_bytes`
116/// inventories the complete raw JSON unconditionally, and the single
117/// inspection it skips — accessor layout, which needs resolved buffer bytes —
118/// is skipped exactly when a buffer is external. Such a manifest also carries
119/// `external_resources_present`, so it fails
120/// [`ScaleCapabilityFacts::is_supported`] twice over.
121///
122/// # Authority
123///
124/// Four preflight violation kinds are **not** re-derivable here at all,
125/// because the manifest records neither resolved byte ranges, nor images, nor
126/// what a node authored beyond its rest kind:
127/// `OverlappingAccessorRanges`, `ImagePayloadOverlap`,
128/// `ConflictingNodeTransform` and `NonAffineNodeMatrix`. A manifest evidencing
129/// one of those can still project to supported facts. That is safe rather than
130/// merely tolerated: a [`GltfScaleSource`] exists only where
131/// [`crate::preflight_scale_source_bytes`] found no violation at all, and
132/// [`rewrite_linear_units`] re-checks each of the four itself. The preflight's
133/// own violation list remains the authority, and [`rewrite_linear_units`]
134/// independently re-resolves every accessor it touches rather than trusting
135/// these flags.
136pub fn capability_facts(manifest: &GltfCapabilityManifest) -> ScaleCapabilityFacts {
137    let violations = manifest_violations(manifest);
138    capability_facts_from_violations(manifest, &violations)
139}
140
141/// Project scale capability facts from one immutable captured source.
142///
143/// The operation manifest remains authoritative for located rewrite details,
144/// while the shared raw-source projection supplies construct presence,
145/// resource presence, and the coverage boundary. Producer evidence reads the
146/// wrapper's already-established primary identity directly.
147/// Any partial shared construct/resource projection fails closed.
148pub fn capability_facts_for_source(source: &GltfScaleSource) -> ScaleCapabilityFacts {
149    join_source_facts(source, capability_facts(source.manifest()))
150}
151
152fn join_source_facts(
153    source: &GltfScaleSource,
154    mut facts: ScaleCapabilityFacts,
155) -> ScaleCapabilityFacts {
156    let source_facts = source.source_facts();
157    if relevant_source_coverage_incomplete(source_facts) {
158        facts.coverage = ScaleCapabilityCoverage::Unavailable;
159    }
160    for row in source_facts.constructs().rows() {
161        if row.kind() != SourceConstructKindV1::Extension {
162            continue;
163        }
164        match row.name().as_str() {
165            "KHR_lights_punctual" => facts.lights_present = true,
166            "EXT_mesh_gpu_instancing" => facts.instancing_present = true,
167            _ => facts.unregistered_extensions_present = true,
168        }
169    }
170    if source_facts
171        .resources()
172        .rows()
173        .iter()
174        .any(|row| resource_is_external(row.locator()))
175    {
176        facts.external_resources_present = true;
177    }
178    facts
179}
180
181fn relevant_source_coverage_incomplete(source: SourceFactsViewV1<'_>) -> bool {
182    [
183        source.constructs().coverage().state(),
184        source.resources().coverage().state(),
185    ]
186    .into_iter()
187    .any(|state| state != SourceSetCoverageStateV1::Complete)
188}
189
190fn resource_is_external(locator: &SourceResourceLocatorV1) -> bool {
191    !matches!(
192        locator,
193        SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
194    )
195}
196
197fn capability_facts_from_violations(
198    manifest: &GltfCapabilityManifest,
199    violations: &[GltfCapabilityViolation],
200) -> ScaleCapabilityFacts {
201    let mut facts = ScaleCapabilityFacts::default();
202    facts.coverage = ScaleCapabilityCoverage::Complete;
203    if manifest
204        .buffers
205        .iter()
206        .any(|buffer| buffer.source_kind == crate::capability::GltfBufferSourceKind::External)
207    {
208        facts.coverage = ScaleCapabilityCoverage::Unavailable;
209    }
210    for violation in violations {
211        record_violation(&mut facts, violation.kind);
212    }
213    facts.morphs_present = manifest
214        .primitives
215        .iter()
216        .any(|primitive| primitive.morph_target_count > 0);
217    facts.morph_weights_present = !manifest.morph_weight_locations.is_empty();
218    facts.whole_document_morphs_preservable = (facts.morphs_present || facts.morph_weights_present)
219        && manifest
220            .primitives
221            .iter()
222            .all(|primitive| primitive.unsupported_morph_locations.is_empty());
223    facts
224}
225
226/// The one exhaustive violation-kind to capability-flag map.
227///
228/// Deliberately written without a wildcard arm: [`GltfCapabilityViolationKind`]
229/// is `#[non_exhaustive]` only for downstream crates, so a kind added here
230/// fails to compile until it is classified.
231fn record_violation(facts: &mut ScaleCapabilityFacts, kind: GltfCapabilityViolationKind) {
232    use GltfCapabilityViolationKind as Kind;
233    match kind {
234        Kind::ExternalResource => facts.external_resources_present = true,
235        Kind::MorphTarget => facts.morphs_present = true,
236        Kind::MorphWeights => facts.morph_weights_present = true,
237        Kind::Camera => facts.cameras_present = true,
238        Kind::Light => facts.lights_present = true,
239        Kind::Instancing => facts.instancing_present = true,
240        Kind::ExtensionDeclaration | Kind::ExtensionPayload => {
241            facts.unregistered_extensions_present = true;
242        }
243        Kind::Extras => facts.extras_present = true,
244        Kind::UnknownJsonMember => facts.unknown_source_members_present = true,
245        Kind::NonTrianglePrimitive => facts.non_triangle_primitives_present = true,
246        Kind::UnsupportedVertexAttribute => facts.unsupported_vertex_attributes_present = true,
247        Kind::SecondarySkinInfluences => facts.secondary_skin_influences_present = true,
248        Kind::MissingInverseBinds
249        | Kind::EmptyInverseBindAccessor
250        | Kind::InverseBindCountMismatch
251        | Kind::UnreadableInverseBinds => facts.inverse_bind_issues_present = true,
252        Kind::UnsafeAccessorLayout
253        | Kind::ConflictingAccessorUse
254        | Kind::OverlappingAccessorRanges
255        | Kind::ImagePayloadOverlap => facts.unsafe_accessor_layout_present = true,
256        // The typed glTF parse honours `matrix` and silently ignores the TRS
257        // members beside it, and decomposes `matrix` to TRS while dropping a
258        // projective last row: in both shapes the source carries transform
259        // members the normalized model does not represent.
260        Kind::ConflictingNodeTransform | Kind::NonAffineNodeMatrix | Kind::AnimatedMatrixNode => {
261            facts.unknown_source_members_present = true;
262        }
263    }
264}
265
266/// Re-derive, from the manifest alone, the #280 violations it evidences.
267fn manifest_violations(manifest: &GltfCapabilityManifest) -> Vec<GltfCapabilityViolation> {
268    use GltfCapabilityViolationKind as Kind;
269    let mut out = Vec::new();
270    let mut add = |kind: Kind, location: String| {
271        out.push(GltfCapabilityViolation { kind, location });
272    };
273
274    for location in &manifest.external_resource_locations {
275        add(Kind::ExternalResource, location.clone());
276    }
277    for location in &manifest.extras_locations {
278        add(Kind::Extras, location.clone());
279    }
280    for location in &manifest.unknown_member_locations {
281        add(Kind::UnknownJsonMember, location.clone());
282    }
283    for name in &manifest.extensions {
284        add(
285            match name.as_str() {
286                "KHR_lights_punctual" => Kind::Light,
287                "EXT_mesh_gpu_instancing" => Kind::Instancing,
288                _ => Kind::ExtensionDeclaration,
289            },
290            format!("/extensionsUsed:{name}"),
291        );
292    }
293    for location in &manifest.extension_locations {
294        add(Kind::ExtensionPayload, location.clone());
295    }
296    if manifest.camera_count > 0 {
297        add(Kind::Camera, "/cameras".to_owned());
298    }
299    for instancing in &manifest.instancing {
300        add(
301            Kind::Instancing,
302            format!(
303                "/nodes/{}/extensions/EXT_mesh_gpu_instancing",
304                instancing.node_index
305            ),
306        );
307    }
308    // Animation and node counts are independently controlled by the source.
309    // Index matrix-authored nodes once instead of rescanning every node for
310    // every channel, which would make this untrusted-input pass quadratic.
311    let matrix_nodes = manifest
312        .nodes
313        .iter()
314        .filter_map(|node| {
315            (node.rest_kind == crate::capability::GltfNodeRestKind::Matrix)
316                .then_some(node.node_index)
317        })
318        .collect::<BTreeSet<_>>();
319    for channel in &manifest.animation_channels {
320        if matrix_nodes.contains(&channel.target_node_index) {
321            add(
322                Kind::AnimatedMatrixNode,
323                format!(
324                    "/animations/{}/channels/{}/target",
325                    channel.animation_index, channel.channel_index
326                ),
327            );
328        }
329    }
330    for primitive in &manifest.primitives {
331        let base = format!(
332            "/meshes/{}/primitives/{}",
333            primitive.mesh_index, primitive.primitive_index
334        );
335        for location in &primitive.unsupported_morph_locations {
336            add(Kind::MorphTarget, location.clone());
337        }
338        if primitive.mode != 4 {
339            add(Kind::NonTrianglePrimitive, format!("{base}/mode"));
340        }
341        for attribute in &primitive.attributes {
342            let semantic = attribute.semantic.as_str();
343            let location = format!("{base}/attributes/{semantic}");
344            if is_secondary_influence(semantic) {
345                add(Kind::SecondarySkinInfluences, location);
346            } else if !matches!(
347                semantic,
348                "POSITION" | "NORMAL" | "TEXCOORD_0" | "JOINTS_0" | "WEIGHTS_0"
349            ) {
350                add(Kind::UnsupportedVertexAttribute, location);
351            }
352        }
353    }
354    for skin in &manifest.skins {
355        let location = format!("/skins/{}/inverseBindMatrices", skin.skin_index);
356        let accessor = skin
357            .inverse_bind_accessor_index
358            .and_then(|index| manifest.accessors.get(index));
359        match (skin.inverse_bind_accessor_index, skin.inverse_bind_count) {
360            (None, _) => add(Kind::MissingInverseBinds, location),
361            (Some(_), Some(0)) => add(Kind::EmptyInverseBindAccessor, location),
362            (Some(_), Some(count)) if count != skin.joint_count as u64 => {
363                add(Kind::InverseBindCountMismatch, location);
364            }
365            (Some(_), _)
366                if !accessor.is_some_and(|accessor| {
367                    accessor.buffer_view_index.is_some()
368                        && accessor.component_type == 5126
369                        && accessor.accessor_type == "MAT4"
370                        && !accessor.sparse
371                }) =>
372            {
373                add(Kind::UnreadableInverseBinds, location);
374            }
375            _ => {}
376        }
377    }
378    for accessor_index in scale_bearing_accessors(manifest) {
379        let Some(accessor) = manifest.accessors.get(accessor_index) else {
380            add(
381                Kind::UnsafeAccessorLayout,
382                format!("/accessors/{accessor_index}"),
383            );
384            continue;
385        };
386        let element_size =
387            rules::components_per_element(&accessor.accessor_type).map(|components| components * 4);
388        let stride = accessor
389            .buffer_view_index
390            .and_then(|index| manifest.buffer_views.get(index))
391            .and_then(|view| view.byte_stride);
392        if accessor.sparse
393            || accessor.normalized
394            || accessor.component_type != 5126
395            || accessor.buffer_view_index.is_none()
396            || accessor.count == 0
397            || element_size.is_none()
398            || stride.is_some_and(|stride| Some(stride as usize) != element_size)
399        {
400            add(
401                Kind::UnsafeAccessorLayout,
402                format!("/accessors/{accessor_index}"),
403            );
404        }
405    }
406    out
407}
408
409/// Validate a raw glTF capability manifest for one selected scale operation.
410///
411/// This operation-aware gate preserves the format frontend's complete,
412/// located violation inventory. Producers that already hold a captured
413/// [`GltfScaleSource`] call it before format-neutral planning so a rest/bind
414/// morph refusal cannot collapse into core's coarser incomplete-capability
415/// error with no source locations.
416///
417/// # Errors
418///
419/// Returns [`GltfScaleRewriteError::Capability`] with deterministic located
420/// violations when the selected operation cannot preserve the source.
421pub fn operation_capability_facts(
422    manifest: &GltfCapabilityManifest,
423    operation: ScaleOperation,
424) -> Result<ScaleCapabilityFacts, GltfScaleRewriteError> {
425    let mut violations = manifest_violations(manifest);
426    let facts = capability_facts_from_violations(manifest, &violations);
427    if facts.is_supported_for(operation) {
428        return Ok(facts);
429    }
430    if matches!(operation, ScaleOperation::RestBindUniformScale { .. }) {
431        for primitive in &manifest.primitives {
432            if primitive.morph_target_count > 0 {
433                violations.push(GltfCapabilityViolation {
434                    kind: GltfCapabilityViolationKind::MorphTarget,
435                    location: format!(
436                        "/meshes/{}/primitives/{}/targets",
437                        primitive.mesh_index, primitive.primitive_index
438                    ),
439                });
440            }
441        }
442        violations.extend(
443            manifest
444                .morph_weight_locations
445                .iter()
446                .cloned()
447                .map(|location| GltfCapabilityViolation {
448                    kind: GltfCapabilityViolationKind::MorphWeights,
449                    location,
450                }),
451        );
452        violations.sort_by(|left, right| {
453            (left.kind, left.location.as_str()).cmp(&(right.kind, right.location.as_str()))
454        });
455        violations.dedup();
456    }
457    let count = violations.len();
458    Err(GltfScaleRewriteError::Capability { violations, count })
459}
460
461/// Validate one captured glTF source for a selected scale operation.
462///
463/// This is the source-bearing production adapter. It joins the shared V1 raw
464/// facts with the richer operation manifest without widening either frozen
465/// evidence shape.
466///
467/// # Errors
468///
469/// Returns the existing located capability error for manifest violations, or
470/// [`ScaleError::IncompleteCapability`] when shared source-fact coverage or an
471/// overlapping shared domain fails closed.
472pub fn operation_capability_facts_for_source(
473    source: &GltfScaleSource,
474    operation: ScaleOperation,
475) -> Result<ScaleCapabilityFacts, GltfScaleRewriteError> {
476    if relevant_source_coverage_incomplete(source.source_facts()) {
477        return Err(ScaleError::IncompleteCapability.into());
478    }
479    let facts = join_source_facts(
480        source,
481        operation_capability_facts(source.manifest(), operation)?,
482    );
483    if facts.is_supported_for(operation) {
484        Ok(facts)
485    } else {
486        Err(ScaleError::IncompleteCapability.into())
487    }
488}
489
490fn is_secondary_influence(semantic: &str) -> bool {
491    semantic
492        .strip_prefix("JOINTS_")
493        .or_else(|| semantic.strip_prefix("WEIGHTS_"))
494        .and_then(|index| index.parse::<u32>().ok())
495        .is_some_and(|index| index >= 1)
496}
497
498/// Accessor indices the conversion would have to rewrite, from the manifest.
499fn scale_bearing_accessors(manifest: &GltfCapabilityManifest) -> BTreeSet<usize> {
500    let mut out = BTreeSet::new();
501    for primitive in &manifest.primitives {
502        for attribute in &primitive.attributes {
503            if attribute.semantic == "POSITION" {
504                out.insert(attribute.accessor_index);
505            }
506        }
507        out.extend(primitive.morph_position_accessors.iter().copied());
508    }
509    for skin in &manifest.skins {
510        out.extend(skin.inverse_bind_accessor_index);
511    }
512    for channel in &manifest.animation_channels {
513        if channel.target_path == "translation" {
514            out.insert(channel.output_accessor_index);
515        }
516    }
517    out
518}
519
520// --- Artifact ---------------------------------------------------------------
521
522/// A rewritten glTF/GLB container and the exact locations that changed.
523#[derive(Debug, Clone)]
524#[non_exhaustive]
525pub struct GltfScaleArtifact {
526    container: GltfContainerKind,
527    bytes: Vec<u8>,
528    rewritten_accessors: Vec<usize>,
529    rewritten_json_pointers: Vec<String>,
530    reencoded_buffers: Vec<usize>,
531    affected_source_nodes: Vec<usize>,
532    affected_source_skins: Vec<usize>,
533    declared_factor: f64,
534    operation: ScaleOperation,
535}
536
537impl GltfScaleArtifact {
538    /// The rewritten container bytes.
539    pub fn bytes(&self) -> &[u8] {
540        &self.bytes
541    }
542
543    /// The container kind, unchanged from the source.
544    pub fn container(&self) -> GltfContainerKind {
545        self.container
546    }
547
548    /// Source accessor indices whose payload was rewritten, ascending and
549    /// without repeats. One entry per **unique** accessor index, however many
550    /// logical uses reach it.
551    pub fn rewritten_accessors(&self) -> &[usize] {
552        &self.rewritten_accessors
553    }
554
555    /// JSON pointers whose value was rewritten, in lexical order.
556    ///
557    /// Buffer URIs re-encoded during container reassembly are not domain
558    /// rewrites and are reported by [`Self::reencoded_buffers`] instead.
559    ///
560    /// [`prove_rewritten_artifact`] checks this list against its own
561    /// independent scan, so it is evidence rather than an unverified label.
562    pub fn rewritten_json_pointers(&self) -> &[String] {
563        &self.rewritten_json_pointers
564    }
565
566    /// Buffer indices whose data URI was re-encoded, ascending. Empty for a
567    /// GLB whose only buffer is the BIN chunk.
568    pub fn reencoded_buffers(&self) -> &[usize] {
569        &self.reencoded_buffers
570    }
571
572    /// The affected closure as **source-node array indices**, ascending.
573    ///
574    /// Reported in the raw glTF index space the operation's own selectors use
575    /// — `/nodes/{i}` — not as normalized [`animsmith_core::BoneId`]s. A
576    /// producer recording which identities an artifact affected must name
577    /// them in the space the request named, and a consumer holding the
578    /// original file can resolve these directly against its `nodes` array.
579    /// [`animsmith_core::scale::ScalePlan::affected_nodes`] reports the same
580    /// closure in the normalized space; the frontend already proves the two
581    /// describe one tree before it writes a byte.
582    ///
583    /// For [`ScaleOperation::RestBindUniformScale`] this is the closed
584    /// connected hierarchy of DESIGN.md Appendix D §D.2. For
585    /// [`ScaleOperation::WholeDocumentLinearUnits`] it is every node the
586    /// source declares, that operation's closure being the whole document.
587    pub fn affected_source_nodes(&self) -> &[usize] {
588        &self.affected_source_nodes
589    }
590
591    /// The affected skins as **source-skin array indices**, ascending, in the
592    /// same raw index space as [`Self::affected_source_nodes`].
593    ///
594    /// For [`ScaleOperation::RestBindUniformScale`] a skin is affected when at
595    /// least one of its joints lies inside the closure — which is exactly the
596    /// condition under which its `inverseBindMatrices` accessor is rebased in
597    /// at least one slot. A skin straddling the closure boundary is listed:
598    /// it *is* affected, in the slots whose joints are. For
599    /// [`ScaleOperation::WholeDocumentLinearUnits`] it is every skin the
600    /// source declares.
601    pub fn affected_source_skins(&self) -> &[usize] {
602        &self.affected_source_skins
603    }
604
605    /// The factor the caller declared: the conversion factor `q` for a
606    /// whole-document conversion, the expected common factor `s` for a
607    /// rest/bind reparameterization.
608    pub fn declared_factor(&self) -> f64 {
609        self.declared_factor
610    }
611
612    /// The operation that produced this artifact, echoed with the selectors
613    /// the caller declared.
614    ///
615    /// Reported so a proof can refuse to check a rest/bind artifact against a
616    /// whole-document plan, or the reverse: the two operations write
617    /// different domains, and a factor alone does not distinguish them.
618    pub fn operation(&self) -> ScaleOperation {
619        self.operation
620    }
621}
622
623// --- Errors -----------------------------------------------------------------
624
625/// The structural relationship of one raw JSON difference to the source.
626///
627/// Values are intentionally not retained: proof diagnostics identify where
628/// preservation failed without copying potentially sensitive source payloads
629/// into logs or machine-readable output.
630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
631#[non_exhaustive]
632pub enum GltfRawJsonDifferenceKind {
633    /// The artifact declares a member the source did not.
634    ArtifactAdded,
635    /// The source declares a member the artifact removed.
636    ArtifactRemoved,
637    /// Both sides declare the location, but its value or shape changed.
638    ValueChanged,
639}
640
641impl std::fmt::Display for GltfRawJsonDifferenceKind {
642    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
643        formatter.write_str(match self {
644            Self::ArtifactAdded => "artifact-added",
645            Self::ArtifactRemoved => "artifact-removed",
646            Self::ValueChanged => "value-changed",
647        })
648    }
649}
650
651/// One value-free raw JSON difference found by an artifact preservation proof.
652#[derive(Debug, Clone, PartialEq, Eq)]
653pub struct GltfRawJsonDifference {
654    /// RFC 6901 JSON pointer of a deterministic difference root.
655    ///
656    /// Object members and equal-length array elements are walked recursively.
657    /// Unequal arrays are reported once at their array root because their
658    /// element identities no longer pair one-to-one.
659    pub pointer: String,
660    /// How the artifact differs from the source at [`Self::pointer`].
661    pub kind: GltfRawJsonDifferenceKind,
662}
663
664/// Bounded raw JSON diagnostics for an artifact preservation proof failure.
665///
666/// [`Self::differences`] contains at most sixteen entries. The full count is
667/// the retained length plus [`Self::omitted`], the exact number not retained.
668#[derive(Debug, Clone, PartialEq, Eq)]
669pub struct GltfRawJsonDifferenceSummary {
670    /// Deterministic prefix of differences, ordered by the JSON tree walk.
671    pub differences: Vec<GltfRawJsonDifference>,
672    /// Exact number of differences not retained in [`Self::differences`].
673    pub omitted: usize,
674}
675
676struct RawJsonDifferenceSuffix<'a>(Option<&'a GltfRawJsonDifferenceSummary>);
677
678impl std::fmt::Display for RawJsonDifferenceSuffix<'_> {
679    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
680        let Some(summary) = self.0 else {
681            return Ok(());
682        };
683        formatter.write_str("; raw JSON differences: ")?;
684        for (index, difference) in summary.differences.iter().enumerate() {
685            if index > 0 {
686                formatter.write_str(", ")?;
687            }
688            write!(formatter, "{} ({})", difference.pointer, difference.kind)?;
689        }
690        if summary.omitted > 0 {
691            write!(formatter, "; {} omitted", summary.omitted)?;
692        }
693        Ok(())
694    }
695}
696
697/// Typed, fail-closed rejection from [`rewrite_linear_units`] or
698/// [`prove_rewritten_artifact`].
699#[derive(Debug, thiserror::Error)]
700#[non_exhaustive]
701pub enum GltfScaleRewriteError {
702    /// The source's raw capability manifest declares a domain the rewrite
703    /// cannot preserve or convert.
704    #[error("glTF scale rewrite rejected {count} unsupported source domain(s)")]
705    Capability {
706        /// Deterministically ordered typed violations.
707        violations: Vec<GltfCapabilityViolation>,
708        /// Number of violations, repeated for stable error rendering.
709        count: usize,
710    },
711    /// Shared core planning or proof rejected the request.
712    #[error(transparent)]
713    Plan(#[from] ScaleError),
714    /// The source or the rewritten artifact could not be read.
715    #[error(transparent)]
716    Load(#[from] LoadError),
717    /// The rewritten container could not be emitted.
718    #[error(transparent)]
719    Write(#[from] WriteError),
720    /// A length-bearing member has no registered field handler, so its value
721    /// would be left in the source unit while everything around it moved.
722    #[error("no registered length-field handler for {location}")]
723    UnhandledLengthField {
724        /// JSON pointer of the unconvertible member.
725        location: String,
726    },
727    /// Two logical uses of one accessor disagree on how it converts.
728    #[error("accessor {accessor_index} is used with two disagreeing rewrite rules")]
729    ConflictingRewriteRule {
730        /// The contested accessor index.
731        accessor_index: usize,
732    },
733    /// An accessor selected for rewriting is not the dense `f32` layout the
734    /// preflight vouched for.
735    #[error("accessor {accessor_index} at {location} is not a rewritable dense f32 accessor")]
736    UnrewritableAccessor {
737        /// The accessor index.
738        accessor_index: usize,
739        /// JSON pointer of the accessor.
740        location: String,
741    },
742    /// A node declares `matrix` alongside a TRS member. glTF 2.0 forbids the
743    /// combination, and the `gltf` crate accepts it, so the conversion would
744    /// otherwise emit two rewrites for one node's single transform.
745    #[error("{location} declares a TRS member alongside matrix")]
746    ConflictingNodeTransform {
747        /// JSON pointer of the TRS member that conflicts with `matrix`.
748        location: String,
749    },
750    /// A node `matrix` is not TRS-decomposable: its last row is not
751    /// `(0, 0, 0, 1)`, so it carries a projective component that transforms
752    /// as `1/q` rather than staying dimensionless under `U M U^-1`.
753    #[error("{location} is {value}, so the node matrix is not TRS-decomposable")]
754    NonAffineNodeMatrix {
755        /// JSON pointer of the offending matrix entry.
756        location: String,
757        /// The authored value.
758        value: f64,
759        /// The only value glTF 2.0 permits there.
760        expected: f64,
761    },
762    /// An `image` reads a buffer view overlapping an accessor the conversion
763    /// rewrites, so converting would corrupt the image payload.
764    ///
765    /// #280's `inspect_accessor_layouts` ranges *accessors* only, so an image
766    /// buffer view is invisible to its disjointness proof.
767    #[error("{location} reads bytes that overlap rewritten accessor {accessor_index}")]
768    ImagePayloadOverlap {
769        /// JSON pointer of the offending image.
770        location: String,
771        /// The accessor whose rewritten range it overlaps.
772        accessor_index: usize,
773    },
774    /// The source container cannot be reassembled without inventing a
775    /// buffer-to-chunk mapping.
776    #[error("source container cannot be reassembled: {reason}")]
777    UnreassemblableContainer {
778        /// Stable machine-readable reason.
779        reason: &'static str,
780    },
781    /// One converted element has no usable `f32` image: the product is not
782    /// finite, or a nonzero product flushed to zero.
783    #[error("converted value {value} at {location} is not representable as f32")]
784    ValueNotRepresentable {
785        /// Located JSON pointer or accessor element identity.
786        location: String,
787        /// The `f64` product that could not be narrowed.
788        value: f64,
789    },
790    /// Two logical uses of one accessor demand different rest/bind factors,
791    /// so no single rewrite of that accessor can satisfy both.
792    ///
793    /// This is a fail-closed domain #280 does not produce. Its aliasing guard
794    /// is a two-value classification — scale-bearing versus dimensionless —
795    /// and fires only on the cross; the scale-bearing/scale-bearing cross is
796    /// *accepted*, correctly, because a whole-document conversion multiplies
797    /// every such use by the same `q`. Under a rest/bind reparameterization
798    /// the multiplier differs per node and per skin slot, so the same sharing
799    /// makes "rebase affected translation animation values" and "preserve
800    /// declared unaffected payloads" simultaneously unsatisfiable. The
801    /// manifest is clean and the file is valid glTF; it is the *plan* that
802    /// makes the sharing unsatisfiable.
803    ///
804    /// Splitting the accessor is not the remedy: it would change the
805    /// `accessors` and `bufferViews` array lengths and destroy the array
806    /// identities the artifact proof pins. Both claimants are named so the
807    /// source can be fixed instead.
808    #[error(
809        "accessor {accessor_index} element {element} must scale by {first_factor} for {first_location} and by {second_factor} for {second_location}"
810    )]
811    ConflictingRestBindFactor {
812        /// The contested accessor index.
813        accessor_index: usize,
814        /// First element index at which the two claims disagree.
815        element: usize,
816        /// JSON pointer of the first use to claim this accessor.
817        first_location: String,
818        /// The factor that use demands at `element`.
819        first_factor: f64,
820        /// JSON pointer of the use that disagreed.
821        second_location: String,
822        /// The factor that use demands at `element`.
823        second_factor: f64,
824    },
825    /// The affected closure derived from the raw node hierarchy is not the
826    /// closure [`animsmith_core::scale::plan_scale`] planned.
827    ///
828    /// The plan walks `SourceNodeAsset::parent_source_node_index`; this crate
829    /// walks `/nodes/*/children`. `animsmith_core` requires the projection to
830    /// agree with the normalized skeleton, but it never sees the raw child
831    /// arrays, so a projection that contradicts the JSON it was derived from
832    /// plans, builds and proves cleanly there.
833    #[error(
834        "the plan's affected closure {planned:?} is not the closure {derived:?} derived from the raw node hierarchy"
835    )]
836    ClosureMismatch {
837        /// Plan closure, as source-node indices in ascending order.
838        planned: Vec<usize>,
839        /// Raw-hierarchy closure, as source-node indices in ascending order.
840        derived: Vec<usize>,
841    },
842    /// A node's parent in the normalized skeleton is not its parent in the
843    /// raw node hierarchy, so the two disagree about which nodes inherit the
844    /// factor being removed.
845    #[error(
846        "source node {source_node_index} has a different parent in the skeleton than in the raw hierarchy"
847    )]
848    ParentChainDisagreement {
849        /// The source node whose two parent links disagree.
850        source_node_index: usize,
851    },
852    /// Two source nodes claim the same normalized [`animsmith_core::BoneId`],
853    /// so the plan's bone-keyed closure cannot be resolved back to a unique
854    /// source node to rewrite.
855    #[error("two source nodes both normalized to bone {bone}")]
856    AmbiguousSourceNodeProjection {
857        /// The contested bone.
858        bone: animsmith_core::BoneId,
859    },
860    /// The raw node hierarchy cannot support the requested closure.
861    #[error("source node hierarchy is unusable: {reason}")]
862    UnusableSourceHierarchy {
863        /// Stable machine-readable reason.
864        reason: &'static str,
865    },
866    /// An artifact-level proof claim failed.
867    #[error(
868        "artifact proof claim {claim:?} observed {observed}, tolerance {tolerance}{diagnostics}",
869        diagnostics = RawJsonDifferenceSuffix(.raw_json_differences.as_ref())
870    )]
871    ArtifactProofFailed {
872        /// Stable machine-readable claim identity.
873        claim: &'static str,
874        /// Observed residual, count, or difference.
875        observed: f64,
876        /// The bound it exceeded.
877        tolerance: f64,
878        /// Bounded, value-free locations for a raw JSON preservation failure.
879        ///
880        /// Every other artifact proof claim carries `None` because its
881        /// existing typed fields already identify the failed obligation.
882        raw_json_differences: Option<GltfRawJsonDifferenceSummary>,
883    },
884}
885
886// --- Rewrite ----------------------------------------------------------------
887
888/// Rewrite `source`'s linear units by the caller-declared finite
889/// `factor > 0`.
890///
891/// The factor is validated through
892/// [`animsmith_core::scale::plan_scale`], so it carries exactly the shared
893/// contract's `InvalidFactor` / `FactorNotRepresentable` boundary, and the
894/// source document's shape is validated before a byte is written. The plan
895/// drives the byte rewrite's semantic membership. The glTF adapter separately
896/// validates raw topology, aliases, ranges, container fields, and payloads the
897/// normalized document does not model, then maps the typed rows onto them.
898///
899/// # Errors
900///
901/// Returns [`GltfScaleRewriteError::Capability`] for a manifest declaring an
902/// unpreservable domain, [`GltfScaleRewriteError::Plan`] for an invalid or
903/// unrepresentable factor or a malformed source document,
904/// [`GltfScaleRewriteError::UnhandledLengthField`] for a length field with no
905/// registered handler, [`GltfScaleRewriteError::ConflictingNodeTransform`] for
906/// a node declaring `matrix` alongside a TRS member,
907/// [`GltfScaleRewriteError::NonAffineNodeMatrix`] for a node `matrix` that is
908/// not TRS-decomposable, [`GltfScaleRewriteError::ConflictingRewriteRule`] when
909/// one accessor is reached by two disagreeing rules,
910/// [`GltfScaleRewriteError::UnrewritableAccessor`] for an accessor outside the
911/// dense `f32` layout, [`GltfScaleRewriteError::ImagePayloadOverlap`] when an
912/// image payload shares bytes with a converted accessor,
913/// [`GltfScaleRewriteError::ValueNotRepresentable`] for an element whose
914/// converted value has no `f32` image, and
915/// [`GltfScaleRewriteError::Write`] when a GLB length field would overflow.
916pub fn rewrite_linear_units(
917    source: &GltfScaleSource,
918    factor: f64,
919) -> Result<GltfScaleArtifact, GltfScaleRewriteError> {
920    let operation = ScaleOperation::WholeDocumentLinearUnits { factor };
921    let facts = operation_capability_facts_for_source(source, operation)?;
922    let plan = plan_scale(&ScaleRequest {
923        operation,
924        document: source.document(),
925        capability: &facts,
926    })?;
927
928    rewrite_linear_units_plan(source, &plan)
929}
930
931/// Apply one already-compiled core scale plan to the raw glTF source.
932///
933/// This is the shared writer boundary used when a caller will prove the
934/// artifact with the same immutable plan. The operation-specific public
935/// convenience functions remain available and compile a plan before
936/// delegating here.
937///
938/// # Errors
939///
940/// Returns the same capability, plan-replay, raw-layout, aliasing,
941/// representability, and container-write errors as the corresponding
942/// operation-specific writer. A plan for another document or a plan whose
943/// operation cannot be represented by this glTF boundary is rejected before
944/// a byte is written.
945pub fn rewrite_scale_plan(
946    source: &GltfScaleSource,
947    plan: &animsmith_core::scale::ScalePlan,
948) -> Result<GltfScaleArtifact, GltfScaleRewriteError> {
949    operation_capability_facts_for_source(source, plan.operation())?;
950    match plan.operation() {
951        ScaleOperation::WholeDocumentLinearUnits { .. } => rewrite_linear_units_plan(source, plan),
952        ScaleOperation::RestBindUniformScale { .. } => {
953            rest_bind::rewrite_rest_bind_plan(source, plan)
954        }
955        _ => Err(plan::plan_mismatch("gltf_operation_plan_mismatch")),
956    }
957}
958
959fn rewrite_linear_units_plan(
960    source: &GltfScaleSource,
961    plan: &animsmith_core::scale::ScalePlan,
962) -> Result<GltfScaleArtifact, GltfScaleRewriteError> {
963    let manifest = source.manifest();
964    let ScaleOperation::WholeDocumentLinearUnits { factor } = plan.operation() else {
965        return Err(plan::plan_mismatch("gltf_operation_plan_mismatch"));
966    };
967    let gltf_plan = plan::GltfScalePlan::new(source, plan)?;
968
969    let root = source
970        .raw_json()
971        .as_object()
972        .ok_or_else(|| LoadError::Malformed("top-level glTF JSON is not an object".into()))?;
973    if let Some(location) = rules::unhandled_length_fields(source.raw_json())
974        .into_iter()
975        .next()
976    {
977        return Err(GltfScaleRewriteError::UnhandledLengthField { location });
978    }
979    reject_out_of_contract_nodes(root)?;
980
981    let accessor_rules = rules::collect_accessor_rules(&gltf_plan, factor != 1.0)?;
982    let mut spans = Vec::with_capacity(accessor_rules.len());
983    for (&accessor_index, &rule) in &accessor_rules {
984        spans.push((
985            bytes::accessor_span(root, source.resolved_buffers(), accessor_index, rule)?,
986            rule,
987        ));
988    }
989    reject_image_payload_overlap(root, manifest, &spans)?;
990
991    let mut buffers = source.resolved_buffers().to_vec();
992    let mut extrema: BTreeMap<usize, ComponentExtrema> = BTreeMap::new();
993    let mut modified: BTreeSet<usize> = BTreeSet::new();
994    for &(span, rule) in &spans {
995        extrema.insert(
996            span.accessor_index,
997            bytes::scale_span(&mut buffers, span, rule, factor)?,
998        );
999        modified.insert(span.buffer);
1000    }
1001
1002    let mut json = source.raw_json().clone();
1003    let mut rewritten_json_pointers = Vec::new();
1004    for (pointer, rule) in rules::collect_json_rewrites(&gltf_plan, factor != 1.0)? {
1005        rewrite_json_array(&mut json, &pointer, rule, factor)?;
1006        rewritten_json_pointers.push(pointer);
1007    }
1008    for (&accessor_index, &rule) in &accessor_rules {
1009        let observed = &extrema[&accessor_index];
1010        rewritten_json_pointers.extend(rewrite_accessor_bounds(
1011            &mut json,
1012            accessor_index,
1013            rule,
1014            factor,
1015            observed,
1016        )?);
1017    }
1018    rewritten_json_pointers.sort();
1019
1020    // A buffer whose bytes never changed keeps its authored data URI, so the
1021    // only re-encoded buffers are the ones a rewritten accessor lives in.
1022    let reencoded_buffers = modified
1023        .iter()
1024        .copied()
1025        .filter(|&buffer_index| {
1026            manifest.buffers.get(buffer_index).is_some_and(|buffer| {
1027                buffer.source_kind == crate::capability::GltfBufferSourceKind::DataUri
1028            })
1029        })
1030        .collect();
1031    let out = container::assemble(manifest, &json, &buffers, &modified)?;
1032    Ok(GltfScaleArtifact {
1033        container: manifest.container,
1034        bytes: out,
1035        rewritten_accessors: accessor_rules.keys().copied().collect(),
1036        rewritten_json_pointers,
1037        reencoded_buffers,
1038        // This operation's closure is the whole document, so every declared
1039        // node and skin is affected. Counted from the raw arrays rather than
1040        // from the manifest's own vectors: the manifest inventories the same
1041        // arrays, but the closure is a claim about the source JSON and is
1042        // read from it.
1043        affected_source_nodes: gltf_plan.affected_source_nodes(false),
1044        affected_source_skins: (0..raw_array_len(root, "skins")).collect(),
1045        declared_factor: factor,
1046        operation: plan.operation(),
1047    })
1048}
1049
1050/// Length of a top-level glTF array member, zero when it is absent or is not
1051/// an array.
1052fn raw_array_len(root: &Map<String, Value>, key: &str) -> usize {
1053    root.get(key).and_then(Value::as_array).map_or(0, Vec::len)
1054}
1055
1056/// Reject a node whose transform is outside the glTF 2.0 contract.
1057///
1058/// The classification itself lives in
1059/// [`crate::capability::node_transform_faults`], which is also what #280's
1060/// preflight reports, so the gate and this guard cannot disagree about which
1061/// nodes are out of contract. This guard is kept as defence in depth: it is
1062/// the layer that must hold if the preflight is ever relaxed, and it names
1063/// the consequence for *this* operation, which is that
1064/// [`rules::collect_json_rewrites`] would emit one rewrite for `matrix` and
1065/// one for the conflicting TRS member — converting a single node's transform
1066/// twice under two different rules — and that `M' = U M U^-1` leaves the last
1067/// row alone, which is only the converted transform when that row is affine.
1068///
1069/// Its call site above is test-protected: `rewrite_linear_units` is driven
1070/// past a synthetic gate by `capability::scale_source_past_the_gate`, so
1071/// deleting the call fails a test rather than passing silently.
1072fn reject_out_of_contract_nodes(root: &Map<String, Value>) -> Result<(), GltfScaleRewriteError> {
1073    let Some(nodes) = root.get("nodes").and_then(Value::as_array) else {
1074        return Ok(());
1075    };
1076    let Some(fault) = node_transform_faults(nodes).into_iter().next() else {
1077        return Ok(());
1078    };
1079    let location = fault.location();
1080    Err(match fault {
1081        NodeTransformFault::TrsBesideMatrix { .. } => {
1082            GltfScaleRewriteError::ConflictingNodeTransform { location }
1083        }
1084        NodeTransformFault::ProjectiveMatrixEntry {
1085            value, expected, ..
1086        } => GltfScaleRewriteError::NonAffineNodeMatrix {
1087            location,
1088            value,
1089            expected,
1090        },
1091        // A `matrix` entry that is not a number fails the typed glTF parse
1092        // before a source reaches here, so shape errors keep one owner.
1093        NodeTransformFault::UnreadableMatrixEntry { .. } => {
1094            LoadError::Malformed(format!("{location} is not a number")).into()
1095        }
1096    })
1097}
1098
1099/// Reject an `image` whose buffer view shares bytes with a converted accessor.
1100///
1101/// An empty image view is skipped, matching
1102/// [`crate::capability::image_payload_ranges`], which admits a range only when
1103/// `start < end`. Both walkers compare half-open ranges, under which an empty
1104/// range shares no byte with anything — including a range it sits inside — so
1105/// skipping it is the same answer the overlap test would give, not a
1106/// relaxation of it. Without the skip the predicate below degenerates for
1107/// `start == end` into "the view's offset lies strictly inside the span", and
1108/// the gate would accept a source this guard refuses. (`byteLength: 0` is
1109/// schema-invalid — glTF 2.0 gives `bufferView.byteLength` `minimum: 1` — so
1110/// this decides an unreachable case rather than a supported one; it is pinned
1111/// because the two walkers must not drift, not because the shape is expected.)
1112///
1113/// Its call site above is test-protected the same way
1114/// [`reject_out_of_contract_nodes`] is.
1115fn reject_image_payload_overlap(
1116    root: &Map<String, Value>,
1117    manifest: &GltfCapabilityManifest,
1118    spans: &[(AccessorSpan, AccessorRule)],
1119) -> Result<(), GltfScaleRewriteError> {
1120    reject_image_payload_overlap_spans(root, manifest, spans.iter().map(|(span, _)| *span))
1121}
1122
1123/// [`reject_image_payload_overlap`] over a bare span sequence, so the
1124/// rest/bind rewrite — whose spans carry per-slot claims rather than
1125/// [`AccessorRule`]s — shares one implementation with the whole-document
1126/// conversion instead of growing a second one.
1127fn reject_image_payload_overlap_spans(
1128    root: &Map<String, Value>,
1129    manifest: &GltfCapabilityManifest,
1130    spans: impl Iterator<Item = AccessorSpan> + Clone,
1131) -> Result<(), GltfScaleRewriteError> {
1132    let Some(images) = root.get("images").and_then(Value::as_array) else {
1133        return Ok(());
1134    };
1135    for (image_index, image) in images.iter().enumerate() {
1136        let Some(view_index) = image
1137            .get("bufferView")
1138            .and_then(Value::as_u64)
1139            .and_then(|index| usize::try_from(index).ok())
1140        else {
1141            continue;
1142        };
1143        let Some(view) = manifest.buffer_views.get(view_index) else {
1144            continue;
1145        };
1146        let start = view.byte_offset as usize;
1147        let end = start.saturating_add(view.byte_length as usize);
1148        if start >= end {
1149            continue;
1150        }
1151        for span in spans.clone() {
1152            if span.buffer == view.buffer_index && start < span.end && span.start < end {
1153                return Err(GltfScaleRewriteError::ImagePayloadOverlap {
1154                    location: format!("/images/{image_index}/bufferView"),
1155                    accessor_index: span.accessor_index,
1156                });
1157            }
1158        }
1159    }
1160    Ok(())
1161}
1162
1163/// Multiply the selected entries of a JSON numeric array in place.
1164fn rewrite_json_array(
1165    json: &mut Value,
1166    pointer: &str,
1167    rule: JsonArrayRule,
1168    factor: f64,
1169) -> Result<(), GltfScaleRewriteError> {
1170    let target = json
1171        .pointer_mut(pointer)
1172        .and_then(Value::as_array_mut)
1173        .filter(|values| values.len() == rule.expected_len())
1174        .ok_or_else(|| {
1175            LoadError::Malformed(format!(
1176                "{pointer} is not an array of {} numbers",
1177                rule.expected_len()
1178            ))
1179        })?;
1180    for (component, entry) in target.iter_mut().enumerate() {
1181        if !rule.scales_component(component) {
1182            continue;
1183        }
1184        let location = format!("{pointer}/{component}");
1185        let before = entry
1186            .as_f64()
1187            .ok_or_else(|| LoadError::Malformed(format!("{location} is not a number")))?;
1188        *entry = number(bytes::narrow(before * factor, &location)?, &location)?;
1189    }
1190    Ok(())
1191}
1192
1193/// Convert an accessor's authored `min`/`max`, then reconcile each converted
1194/// bound against the bytes that were actually written.
1195///
1196/// Multiplying an authored bound in `f64` and narrowing can round `min`
1197/// *up* past the true scaled minimum (or `max` down below the true maximum),
1198/// which trips a glTF validator's bound check on a document that is otherwise
1199/// correct. Rather than nudge blindly, the observed per-component extrema of
1200/// the rewritten payload are folded in: a converted bound that still bounds
1201/// the data is kept exactly, and one that does not is widened to the observed
1202/// extreme. That is deterministic, always sufficient (a one-ULP nudge is not),
1203/// and minimal — it never tightens an authored bound that was already loose.
1204fn rewrite_accessor_bounds(
1205    json: &mut Value,
1206    accessor_index: usize,
1207    rule: AccessorRule,
1208    factor: f64,
1209    observed: &ComponentExtrema,
1210) -> Result<Vec<String>, GltfScaleRewriteError> {
1211    rewrite_accessor_bounds_with(
1212        json,
1213        accessor_index,
1214        &|component| rule.scales_component(component),
1215        Some(factor),
1216        observed,
1217    )
1218}
1219
1220/// [`rewrite_accessor_bounds`] for a rewrite whose per-element factors need
1221/// not agree.
1222///
1223/// `factor` is the single multiplier every element of this accessor shares,
1224/// when there is one. `None` means the accessor's elements were rebased by
1225/// *different* factors — one `inverseBindMatrices` accessor whose joints
1226/// straddle the affected closure — and an authored bound then has no single
1227/// conversion at all. In that case the emitted bound is the observed extremum
1228/// of the rewritten payload: still deterministic, still sufficient, and the
1229/// only choice that cannot claim a bound the data does not satisfy. It can
1230/// tighten a loose authored bound, which is a fact about a document that
1231/// declares `min`/`max` on a partially-rebased matrix accessor, not a general
1232/// behaviour — for a single shared factor the authored bound is preserved
1233/// exactly as before.
1234fn rewrite_accessor_bounds_with(
1235    json: &mut Value,
1236    accessor_index: usize,
1237    scales_component: &dyn Fn(usize) -> bool,
1238    factor: Option<f64>,
1239    observed: &ComponentExtrema,
1240) -> Result<Vec<String>, GltfScaleRewriteError> {
1241    let mut rewritten = Vec::new();
1242    for (member, is_min) in [("min", true), ("max", false)] {
1243        let pointer = format!("/accessors/{accessor_index}/{member}");
1244        let Some(bounds) = json.pointer_mut(&pointer).and_then(Value::as_array_mut) else {
1245            continue;
1246        };
1247        if bounds.len() != observed.min.len() {
1248            return Err(LoadError::Malformed(format!(
1249                "{pointer} declares {} entries but the accessor has {} components",
1250                bounds.len(),
1251                observed.min.len()
1252            ))
1253            .into());
1254        }
1255        for (component, entry) in bounds.iter_mut().enumerate() {
1256            if !scales_component(component) {
1257                continue;
1258            }
1259            let location = format!("{pointer}/{component}");
1260            let before = entry
1261                .as_f64()
1262                .ok_or_else(|| LoadError::Malformed(format!("{location} is not a number")))?;
1263            let converted = match factor {
1264                Some(factor) => bytes::narrow(before * factor, &location)?,
1265                None if is_min => observed.min[component],
1266                None => observed.max[component],
1267            };
1268            let reconciled = if is_min {
1269                converted.min(observed.min[component])
1270            } else {
1271                converted.max(observed.max[component])
1272            };
1273            *entry = number(reconciled, &location)?;
1274        }
1275        rewritten.push(pointer);
1276    }
1277    Ok(rewritten)
1278}
1279
1280/// Render one converted `f32` as the shortest decimal that round-trips it.
1281fn number(value: f32, location: &str) -> Result<Value, GltfScaleRewriteError> {
1282    value
1283        .to_string()
1284        .parse::<f64>()
1285        .ok()
1286        .and_then(serde_json::Number::from_f64)
1287        .map(Value::Number)
1288        .ok_or_else(|| GltfScaleRewriteError::ValueNotRepresentable {
1289            location: location.to_owned(),
1290            value: f64::from(value),
1291        })
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297    use crate::capability::{
1298        GltfAccessorCapability, GltfAttributeCapability, GltfBufferCapability,
1299        GltfBufferSourceKind, GltfBufferViewCapability, GltfPrimitiveCapability,
1300        GltfSkinCapability,
1301    };
1302
1303    fn manifest() -> GltfCapabilityManifest {
1304        GltfCapabilityManifest {
1305            container: GltfContainerKind::Gltf,
1306            buffers: vec![GltfBufferCapability {
1307                buffer_index: 0,
1308                source_kind: GltfBufferSourceKind::DataUri,
1309                declared_byte_length: 36,
1310            }],
1311            buffer_views: vec![GltfBufferViewCapability {
1312                buffer_view_index: 0,
1313                buffer_index: 0,
1314                byte_offset: 0,
1315                byte_length: 36,
1316                byte_stride: None,
1317            }],
1318            accessors: vec![GltfAccessorCapability {
1319                accessor_index: 0,
1320                buffer_view_index: Some(0),
1321                byte_offset: 0,
1322                component_type: 5126,
1323                accessor_type: "VEC3".to_owned(),
1324                count: 3,
1325                normalized: false,
1326                sparse: false,
1327            }],
1328            nodes: Vec::new(),
1329            animation_channels: Vec::new(),
1330            primitives: vec![GltfPrimitiveCapability {
1331                mesh_index: 0,
1332                primitive_index: 0,
1333                mode: 4,
1334                attributes: vec![GltfAttributeCapability {
1335                    semantic: "POSITION".to_owned(),
1336                    accessor_index: 0,
1337                }],
1338                morph_target_count: 0,
1339                morph_position_accessors: Vec::new(),
1340                unsupported_morph_locations: Vec::new(),
1341            }],
1342            morph_weight_locations: Vec::new(),
1343            instancing: Vec::new(),
1344            skins: Vec::new(),
1345            camera_count: 0,
1346            extensions: Vec::new(),
1347            extension_locations: Vec::new(),
1348            external_resource_locations: Vec::new(),
1349            extras_locations: Vec::new(),
1350            unknown_member_locations: Vec::new(),
1351        }
1352    }
1353
1354    #[test]
1355    fn a_clean_manifest_projects_to_complete_supported_facts() {
1356        let facts = capability_facts(&manifest());
1357        assert_eq!(facts.coverage, ScaleCapabilityCoverage::Complete);
1358        assert!(facts.is_supported());
1359    }
1360
1361    #[test]
1362    fn shared_extension_presence_preserves_manifest_semantic_classification() {
1363        for (name, lights, instancing, unregistered) in [
1364            ("KHR_lights_punctual", true, false, false),
1365            ("EXT_mesh_gpu_instancing", false, true, false),
1366            ("ACME_opaque", false, false, true),
1367        ] {
1368            let source = past_the_gate(
1369                "shared-extension.gltf",
1370                serde_json::json!({
1371                    "asset": { "version": "2.0" },
1372                    "extensionsUsed": [name]
1373                }),
1374            );
1375            let facts = capability_facts_for_source(&source);
1376            assert_eq!(facts.lights_present, lights, "{name}");
1377            assert_eq!(facts.instancing_present, instancing, "{name}");
1378            assert_eq!(
1379                facts.unregistered_extensions_present, unregistered,
1380                "{name}"
1381            );
1382        }
1383    }
1384
1385    #[test]
1386    fn an_external_buffer_makes_coverage_unavailable_as_well_as_unsupported() {
1387        let mut manifest = manifest();
1388        manifest.buffers[0].source_kind = GltfBufferSourceKind::External;
1389        manifest.external_resource_locations = vec!["/buffers/0/uri".to_owned()];
1390        let facts = capability_facts(&manifest);
1391        assert_eq!(facts.coverage, ScaleCapabilityCoverage::Unavailable);
1392        assert!(facts.external_resources_present);
1393        assert!(!facts.is_supported());
1394    }
1395
1396    #[test]
1397    fn every_unsupported_domain_sets_exactly_its_own_flag() {
1398        type Case = (
1399            &'static str,
1400            Box<dyn Fn(&mut GltfCapabilityManifest)>,
1401            fn(&ScaleCapabilityFacts) -> bool,
1402        );
1403        let cases: Vec<Case> = vec![
1404            (
1405                "morph targets",
1406                Box::new(|m| m.primitives[0].morph_target_count = 2),
1407                |f| f.morphs_present,
1408            ),
1409            ("camera", Box::new(|m| m.camera_count = 1), |f| {
1410                f.cameras_present
1411            }),
1412            (
1413                "extension",
1414                Box::new(|m| m.extensions = vec!["ACME_opaque".to_owned()]),
1415                |f| f.unregistered_extensions_present,
1416            ),
1417            (
1418                "punctual light",
1419                Box::new(|m| m.extensions = vec!["KHR_lights_punctual".to_owned()]),
1420                |f| f.lights_present,
1421            ),
1422            (
1423                "extras",
1424                Box::new(|m| m.extras_locations = vec!["/extras".to_owned()]),
1425                |f| f.extras_present,
1426            ),
1427            (
1428                "unknown member",
1429                Box::new(|m| m.unknown_member_locations = vec!["/nope".to_owned()]),
1430                |f| f.unknown_source_members_present,
1431            ),
1432            (
1433                "non-triangle mode",
1434                Box::new(|m| m.primitives[0].mode = 1),
1435                |f| f.non_triangle_primitives_present,
1436            ),
1437            (
1438                "unmodeled attribute",
1439                Box::new(|m| {
1440                    m.primitives[0].attributes.push(GltfAttributeCapability {
1441                        semantic: "TANGENT".to_owned(),
1442                        accessor_index: 0,
1443                    })
1444                }),
1445                |f| f.unsupported_vertex_attributes_present,
1446            ),
1447            (
1448                "secondary influences",
1449                Box::new(|m| {
1450                    m.primitives[0].attributes.push(GltfAttributeCapability {
1451                        semantic: "JOINTS_1".to_owned(),
1452                        accessor_index: 0,
1453                    })
1454                }),
1455                |f| f.secondary_skin_influences_present,
1456            ),
1457            (
1458                "missing inverse binds",
1459                Box::new(|m| {
1460                    m.skins = vec![GltfSkinCapability {
1461                        skin_index: 0,
1462                        joint_count: 1,
1463                        inverse_bind_accessor_index: None,
1464                        inverse_bind_count: None,
1465                    }]
1466                }),
1467                |f| f.inverse_bind_issues_present,
1468            ),
1469            (
1470                "interleaved POSITION",
1471                Box::new(|m| m.buffer_views[0].byte_stride = Some(16)),
1472                |f| f.unsafe_accessor_layout_present,
1473            ),
1474            (
1475                "normalized POSITION",
1476                Box::new(|m| m.accessors[0].normalized = true),
1477                |f| f.unsafe_accessor_layout_present,
1478            ),
1479            (
1480                "sparse POSITION",
1481                Box::new(|m| m.accessors[0].sparse = true),
1482                |f| f.unsafe_accessor_layout_present,
1483            ),
1484        ];
1485        for (name, mutate, flag) in cases {
1486            let mut manifest = manifest();
1487            mutate(&mut manifest);
1488            let facts = capability_facts(&manifest);
1489            assert!(flag(&facts), "{name} did not set its capability flag");
1490            assert!(!facts.is_supported(), "{name} was still reported supported");
1491        }
1492    }
1493
1494    // --- Defence in depth -------------------------------------------------
1495    //
1496    // #280's preflight now refuses both out-of-contract node transforms
1497    // (#301) and image payloads aliasing a converted accessor (#300), so no
1498    // `GltfScaleSource` carrying either can be built through the public API
1499    // and these guards are unreachable from an integration test. They are
1500    // deliberately kept — they are the layer that must hold if the gate is
1501    // relaxed — so they are exercised directly here instead.
1502    //
1503    // Two things need proving, and they are not the same thing. That each
1504    // guard *classifies* correctly is proved by calling it directly. That
1505    // each guard is still *wired into* `rewrite_linear_units` is proved by
1506    // `capability::scale_source_past_the_gate`, the `cfg(test)`-only seam
1507    // that builds a `GltfScaleSource` from bytes the gate would refuse —
1508    // the synthetic relaxation the guards exist for. Without it, deleting a
1509    // guard's call site changes no observable behaviour and no test fails.
1510
1511    /// The identity node `matrix`, column-major.
1512    const IDENTITY_MATRIX: [f64; 16] = [
1513        1.0, 0.0, 0.0, 0.0, //
1514        0.0, 1.0, 0.0, 0.0, //
1515        0.0, 0.0, 1.0, 0.0, //
1516        0.0, 0.0, 0.0, 1.0,
1517    ];
1518
1519    fn nodes_root(nodes: Value) -> Map<String, Value> {
1520        serde_json::json!({ "nodes": nodes })
1521            .as_object()
1522            .expect("literal JSON object")
1523            .clone()
1524    }
1525
1526    #[test]
1527    fn the_rewriter_guard_still_refuses_a_matrix_beside_a_trs_member() {
1528        for (member, member_value) in [
1529            ("translation", serde_json::json!([1.5, -2.0, 0.25])),
1530            ("rotation", serde_json::json!([0.0, 0.0, 0.0, 1.0])),
1531            ("scale", serde_json::json!([2.0, 2.0, 2.0])),
1532        ] {
1533            let mut node = serde_json::json!({ "matrix": Vec::from(IDENTITY_MATRIX) });
1534            node[member] = member_value;
1535            match reject_out_of_contract_nodes(&nodes_root(serde_json::json!([node]))) {
1536                Err(GltfScaleRewriteError::ConflictingNodeTransform { location }) => {
1537                    assert_eq!(location, format!("/nodes/0/{member}"));
1538                }
1539                other => panic!("matrix + {member} must be refused, got {other:?}"),
1540            }
1541        }
1542    }
1543
1544    #[test]
1545    fn the_rewriter_guard_still_refuses_a_projective_node_matrix() {
1546        for (component, authored, expected) in [
1547            (3usize, 0.5f64, 0.0f64),
1548            (7, -1.0, 0.0),
1549            (11, 2.0, 0.0),
1550            (15, 2.0, 1.0),
1551        ] {
1552            let mut matrix = IDENTITY_MATRIX;
1553            matrix[component] = authored;
1554            let node = serde_json::json!({ "matrix": Vec::from(matrix) });
1555            match reject_out_of_contract_nodes(&nodes_root(serde_json::json!([node]))) {
1556                Err(GltfScaleRewriteError::NonAffineNodeMatrix {
1557                    location,
1558                    value,
1559                    expected: reported,
1560                }) => {
1561                    assert_eq!(location, format!("/nodes/0/matrix/{component}"));
1562                    assert_eq!(value, authored);
1563                    assert_eq!(reported, expected);
1564                }
1565                other => panic!("matrix[{component}] = {authored} must be refused, got {other:?}"),
1566            }
1567        }
1568    }
1569
1570    #[test]
1571    fn the_rewriter_guard_accepts_an_affine_matrix_with_a_translation_column() {
1572        let mut matrix = IDENTITY_MATRIX;
1573        matrix[12] = 1.5;
1574        matrix[13] = -2.0;
1575        matrix[14] = 0.25;
1576        let node = serde_json::json!({ "matrix": Vec::from(matrix) });
1577        reject_out_of_contract_nodes(&nodes_root(serde_json::json!([node])))
1578            .expect("an affine matrix with a translation column is in contract");
1579        reject_out_of_contract_nodes(&nodes_root(serde_json::json!([{
1580            "translation": [1.5, -2.0, 0.25],
1581            "rotation": [0.0, 0.0, 0.0, 1.0],
1582            "scale": [2.0, 2.0, 2.0]
1583        }])))
1584        .expect("TRS without matrix declares no conflict");
1585    }
1586
1587    #[test]
1588    fn a_non_numeric_matrix_entry_stays_a_malformed_source_rather_than_a_contract_fault() {
1589        let mut matrix: Vec<Value> = Vec::from(IDENTITY_MATRIX)
1590            .into_iter()
1591            .map(Value::from)
1592            .collect();
1593        matrix[15] = Value::from("1.0");
1594        let node = serde_json::json!({ "matrix": matrix });
1595        match reject_out_of_contract_nodes(&nodes_root(serde_json::json!([node]))) {
1596            Err(GltfScaleRewriteError::Load(_)) => {}
1597            other => panic!("a non-numeric matrix entry is malformed, got {other:?}"),
1598        }
1599    }
1600
1601    /// [`reject_image_payload_overlap`] for one image view and one converted
1602    /// accessor span, both in buffer 0.
1603    ///
1604    /// The image sits on `bufferView 2`, behind two decoy views that share no
1605    /// byte with any span, so a guard reading a fixed view rather than the
1606    /// indexed one answers from a range that is never the image's.
1607    fn image_overlap(image: (u64, u64), span: (usize, usize)) -> Result<(), GltfScaleRewriteError> {
1608        let root = serde_json::json!({ "images": [{ "bufferView": 2, "mimeType": "image/png" }] })
1609            .as_object()
1610            .expect("literal JSON object")
1611            .clone();
1612        let decoy = |buffer_view_index| GltfBufferViewCapability {
1613            buffer_view_index,
1614            buffer_index: 1,
1615            byte_offset: 0,
1616            byte_length: 4096,
1617            byte_stride: None,
1618        };
1619        let mut manifest = manifest();
1620        manifest.buffer_views = vec![
1621            decoy(0),
1622            decoy(1),
1623            GltfBufferViewCapability {
1624                buffer_view_index: 2,
1625                buffer_index: 0,
1626                byte_offset: image.0,
1627                byte_length: image.1,
1628                byte_stride: None,
1629            },
1630        ];
1631        let spans = vec![(
1632            AccessorSpan {
1633                accessor_index: 0,
1634                buffer: 0,
1635                start: span.0,
1636                end: span.1,
1637                components: 3,
1638            },
1639            AccessorRule::AllComponents,
1640        )];
1641        reject_image_payload_overlap(&root, &manifest, &spans)
1642    }
1643
1644    #[test]
1645    fn the_rewriter_guard_still_refuses_an_image_payload_over_a_converted_span() {
1646        for (name, image, span) in [
1647            (
1648                "image runs one byte into the span",
1649                (0u64, 13u64),
1650                (12usize, 48usize),
1651            ),
1652            ("span runs one byte into the image", (35, 13), (0, 36)),
1653        ] {
1654            match image_overlap(image, span) {
1655                Err(GltfScaleRewriteError::ImagePayloadOverlap {
1656                    location,
1657                    accessor_index,
1658                }) => {
1659                    assert_eq!(location, "/images/0/bufferView", "{name}");
1660                    assert_eq!(accessor_index, 0, "{name}");
1661                }
1662                other => panic!("{name}: expected ImagePayloadOverlap, got {other:?}"),
1663            }
1664        }
1665    }
1666
1667    #[test]
1668    fn the_rewriter_guard_accepts_an_image_payload_adjacent_to_a_converted_span() {
1669        // Both ranges are half-open, so touching endpoints share no byte.
1670        for (name, image, span) in [
1671            (
1672                "image ends where the span begins",
1673                (0u64, 12u64),
1674                (12usize, 48usize),
1675            ),
1676            ("image begins where the span ends", (36, 12), (0, 36)),
1677        ] {
1678            image_overlap(image, span)
1679                .unwrap_or_else(|error| panic!("{name}: adjacency is not an overlap: {error:?}"));
1680        }
1681    }
1682
1683    #[test]
1684    fn an_empty_image_view_inside_a_converted_span_is_not_an_overlap() {
1685        // A `byteLength: 0` view covers no byte, so under the half-open
1686        // comparison it aliases nothing — not even a span it sits inside.
1687        // `capability::image_payload_ranges` drops the same shape before
1688        // comparing, and the two walkers must give one answer: without the
1689        // skip this predicate degenerates for `start == end` into "the
1690        // offset lies strictly inside the span", and the gate would accept
1691        // what this guard refused.
1692        for (name, image, span) in [
1693            (
1694                "empty view inside the span",
1695                (12u64, 0u64),
1696                (0usize, 36usize),
1697            ),
1698            ("empty view at the span's start", (0, 0), (0, 36)),
1699            ("empty view at the span's end", (36, 0), (0, 36)),
1700        ] {
1701            image_overlap(image, span)
1702                .unwrap_or_else(|error| panic!("{name}: an empty view aliases nothing: {error:?}"));
1703        }
1704    }
1705
1706    // --- The guards are still wired into `rewrite_linear_units` -----------
1707
1708    /// A [`GltfScaleSource`] built from `value` past the preflight gate.
1709    fn past_the_gate(name: &str, value: Value) -> crate::GltfScaleSource {
1710        let bytes = serde_json::to_vec(&value).expect("literal JSON serializes");
1711        crate::capability::scale_source_past_the_gate(std::path::Path::new(name), &bytes)
1712            .unwrap_or_else(|error| panic!("{name} must still load past the gate: {error:?}"))
1713    }
1714
1715    /// One 96-byte buffer, a `POSITION` accessor on `bufferView 1`, and one
1716    /// image on `bufferView 2` at a caller-chosen range.
1717    ///
1718    /// The image is deliberately **not** on `bufferView 0`: a guard reading
1719    /// the first view instead of the indexed one would answer from the unused
1720    /// decoy view at index 0, which shares no byte with `POSITION`.
1721    fn image_and_position_document(image_offset: usize, image_length: usize) -> Value {
1722        use base64::{Engine as _, engine::general_purpose::STANDARD};
1723        serde_json::json!({
1724            "asset": { "version": "2.0" },
1725            "buffers": [{
1726                "uri": format!(
1727                    "data:application/octet-stream;base64,{}",
1728                    STANDARD.encode([0u8; 96])
1729                ),
1730                "byteLength": 96
1731            }],
1732            "bufferViews": [
1733                { "buffer": 0, "byteOffset": 48, "byteLength": 12 },
1734                { "buffer": 0, "byteOffset": 0, "byteLength": 36 },
1735                { "buffer": 0, "byteOffset": image_offset, "byteLength": image_length }
1736            ],
1737            "accessors": [{
1738                "bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3",
1739                "min": [0, 0, 0], "max": [0, 0, 0]
1740            }],
1741            "images": [{ "bufferView": 2, "mimeType": "image/png" }],
1742            "meshes": [{ "primitives": [{ "attributes": { "POSITION": 0 } }] }]
1743        })
1744    }
1745
1746    #[test]
1747    fn rewrite_linear_units_still_calls_the_node_transform_guard() {
1748        // Deleting `reject_out_of_contract_nodes(root)?` from
1749        // `rewrite_linear_units` must fail a test. It cannot fail one through
1750        // the public API, because the gate refuses every source that would
1751        // reach it; the seam supplies the relaxation the guard exists for.
1752        let mut node = serde_json::json!({ "matrix": Vec::from(IDENTITY_MATRIX) });
1753        node["translation"] = serde_json::json!([1.5, -2.0, 0.25]);
1754        let source = past_the_gate(
1755            "matrix-plus-trs.gltf",
1756            serde_json::json!({ "asset": { "version": "2.0" }, "nodes": [node] }),
1757        );
1758        match rewrite_linear_units(&source, 4.0) {
1759            Err(GltfScaleRewriteError::ConflictingNodeTransform { location }) => {
1760                assert_eq!(location, "/nodes/0/translation");
1761            }
1762            other => panic!("the wired guard must refuse matrix + translation, got {other:?}"),
1763        }
1764
1765        // The projective arm is reached through the same call site, and
1766        // without it `U M U^-1` would silently emit an unconverted last row.
1767        let mut matrix = IDENTITY_MATRIX;
1768        matrix[15] = 2.0;
1769        let source = past_the_gate(
1770            "projective-matrix.gltf",
1771            serde_json::json!({
1772                "asset": { "version": "2.0" },
1773                "nodes": [{ "matrix": Vec::from(matrix) }]
1774            }),
1775        );
1776        match rewrite_linear_units(&source, 4.0) {
1777            Err(GltfScaleRewriteError::NonAffineNodeMatrix {
1778                location,
1779                value,
1780                expected,
1781            }) => {
1782                assert_eq!(location, "/nodes/0/matrix/15");
1783                assert_eq!(value, 2.0);
1784                assert_eq!(expected, 1.0);
1785            }
1786            other => panic!("the wired guard must refuse a projective matrix, got {other:?}"),
1787        }
1788    }
1789
1790    #[test]
1791    fn rewrite_linear_units_still_calls_the_image_payload_guard() {
1792        // Deleting `reject_image_payload_overlap(root, manifest, &spans)?`
1793        // must fail a test. Without it the conversion runs to completion and
1794        // writes converted `f32`s over bytes the image reads.
1795        let source = past_the_gate(
1796            "image-overlap.gltf",
1797            // `POSITION` is [0, 36); the image view is [12, 24).
1798            image_and_position_document(12, 12),
1799        );
1800        match rewrite_linear_units(&source, 2.0) {
1801            Err(GltfScaleRewriteError::ImagePayloadOverlap {
1802                location,
1803                accessor_index,
1804            }) => {
1805                assert_eq!(location, "/images/0/bufferView");
1806                assert_eq!(accessor_index, 0);
1807            }
1808            other => panic!("the wired guard must refuse an aliased image, got {other:?}"),
1809        }
1810    }
1811
1812    #[test]
1813    fn the_wired_image_guard_still_accepts_a_disjoint_image_view() {
1814        // The seam must not make every source refusable: the same document
1815        // with the image moved clear of `POSITION` converts. This is what
1816        // keeps the two tests above from passing for the wrong reason.
1817        let source = past_the_gate(
1818            "image-disjoint.gltf",
1819            // `POSITION` is [0, 36); the image view is [36, 48).
1820            image_and_position_document(36, 12),
1821        );
1822        rewrite_linear_units(&source, 2.0)
1823            .expect("an image view disjoint from every converted span converts");
1824    }
1825
1826    #[test]
1827    fn an_animated_weights_channel_is_projected_as_morph_weights() {
1828        use crate::capability::GltfAnimationChannelCapability;
1829        let mut manifest = manifest();
1830        manifest.animation_channels = vec![GltfAnimationChannelCapability {
1831            animation_index: 0,
1832            channel_index: 0,
1833            target_node_index: 0,
1834            target_path: "weights".to_owned(),
1835            interpolation: "LINEAR".to_owned(),
1836            input_accessor_index: 1,
1837            output_accessor_index: 2,
1838        }];
1839        manifest.morph_weight_locations = vec!["/animations/0/channels/0/target/path".to_owned()];
1840        let facts = capability_facts(&manifest);
1841        assert!(facts.morph_weights_present);
1842        assert!(!facts.is_supported());
1843    }
1844
1845    #[test]
1846    fn an_animated_matrix_node_is_rederived_from_manifest_identity() {
1847        use crate::capability::{
1848            GltfAnimationChannelCapability, GltfNodeCapability, GltfNodeRestKind,
1849        };
1850        let mut manifest = manifest();
1851        manifest.nodes = vec![GltfNodeCapability {
1852            node_index: 9,
1853            rest_kind: GltfNodeRestKind::Matrix,
1854            mesh_index: None,
1855            skin_index: None,
1856        }];
1857        manifest.animation_channels = vec![GltfAnimationChannelCapability {
1858            animation_index: 3,
1859            channel_index: 4,
1860            target_node_index: 9,
1861            target_path: "scale".to_owned(),
1862            interpolation: "STEP".to_owned(),
1863            input_accessor_index: 5,
1864            output_accessor_index: 6,
1865        }];
1866        assert_eq!(
1867            manifest_violations(&manifest),
1868            vec![GltfCapabilityViolation {
1869                kind: GltfCapabilityViolationKind::AnimatedMatrixNode,
1870                location: "/animations/3/channels/4/target".to_owned(),
1871            }]
1872        );
1873        let facts = capability_facts(&manifest);
1874        assert!(facts.unknown_source_members_present);
1875        assert!(!facts.is_supported());
1876    }
1877}