1use super::bytes::{self, AccessorSpan};
36use super::plan::{GltfScalePlan, RawAccessorTarget, plan_mismatch};
37use super::{
38 GltfRawJsonDifference, GltfRawJsonDifferenceKind, GltfRawJsonDifferenceSummary,
39 GltfScaleArtifact, GltfScaleRewriteError,
40};
41use crate::capability::{GltfContainerKind, GltfScaleSource, raw_json_bytes};
42use crate::{LoadError, load_bytes, resolve_buffers};
43use animsmith_core::Property;
44use animsmith_core::scale::{
45 ScaleCandidate, ScaleFieldDisposition, ScaleOperation, ScalePlan, ScaleProof, ScaleRewriteRule,
46 ScaleSourceRestField, ScaleTolerancePolicy, prove_scale,
47};
48use serde_json::{Map, Value};
49use std::collections::{BTreeMap, BTreeSet};
50use std::path::Path;
51
52const GLB_JSON_CHUNK: u32 = 0x4e4f_534a;
53const GLB_BIN_CHUNK: u32 = 0x004e_4942;
54const MAX_RAW_JSON_DIFFERENCES: usize = 16;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum ProofAccessorRule {
58 AllComponents,
59 Mat4TranslationColumn,
60}
61
62impl ProofAccessorRule {
63 fn required_accessor_type(self) -> Option<&'static str> {
64 match self {
65 Self::AllComponents => None,
66 Self::Mat4TranslationColumn => Some("MAT4"),
67 }
68 }
69}
70
71fn proof_scales_component(rule: ProofAccessorRule, component: usize) -> bool {
72 match rule {
73 ProofAccessorRule::AllComponents => true,
74 ProofAccessorRule::Mat4TranslationColumn => matches!(component, 12..=14),
75 }
76}
77
78#[derive(Debug, Clone, PartialEq)]
81#[non_exhaustive]
82pub struct GltfScaleArtifactProof {
83 pub core: ScaleProof,
85 pub length_factor_residual: f64,
91 pub dimensionless_residual: f64,
98 pub preserved_byte_ranges: usize,
101 pub rewritten_accessor_count: usize,
103}
104
105pub fn prove_rewritten_artifact(
114 source: &GltfScaleSource,
115 artifact: &GltfScaleArtifact,
116 plan: &ScalePlan,
117) -> Result<GltfScaleArtifactProof, GltfScaleRewriteError> {
118 let tolerance = plan.tolerance_policy();
119 let ScaleOperation::WholeDocumentLinearUnits { factor } = plan.operation() else {
120 return Err(failed(
121 "plan declares a whole-document unit conversion",
122 1.0,
123 0.0,
124 ));
125 };
126 if factor != artifact.declared_factor() {
127 return Err(failed(
128 "plan factor equals the artifact's declared factor",
129 (factor - artifact.declared_factor()).abs(),
130 0.0,
131 ));
132 }
133
134 let reloaded = load_bytes(Path::new("scale-artifact"), artifact.bytes())?;
135 let core = prove_scale(
136 source.document(),
137 &ScaleCandidate::from_document(reloaded),
138 plan,
139 )
140 .map_err(GltfScaleRewriteError::Plan)?;
141
142 let (container, json_bytes) = raw_json_bytes(artifact.bytes())?;
143 if container != artifact.container() {
144 return Err(failed("artifact container kind is unchanged", 1.0, 0.0));
145 }
146 let artifact_json: Value = serde_json::from_slice(json_bytes)
147 .map_err(|error| LoadError::Malformed(format!("artifact JSON is invalid: {error}")))?;
148 let source_root = object(source.raw_json())?;
149 let artifact_root = object(&artifact_json)?;
150 let artifact_gltf = gltf::Gltf::from_slice(artifact.bytes()).map_err(LoadError::Gltf)?;
151 let artifact_buffers = resolve_buffers(&artifact_gltf, None)?;
152
153 check_container_integrity(artifact, artifact_root, &artifact_buffers)?;
154 check_array_identities(source_root, artifact_root)?;
155 let gltf_plan = GltfScalePlan::new(source, plan)?;
156
157 let mut proof = GltfScaleArtifactProof {
158 core,
159 length_factor_residual: 0.0,
160 dimensionless_residual: 0.0,
161 preserved_byte_ranges: 0,
162 rewritten_accessor_count: 0,
163 };
164
165 let mut converted_pointers = BTreeSet::new();
166 check_node_transforms(
167 source_root,
168 artifact_root,
169 &gltf_plan,
170 factor,
171 &tolerance,
172 &mut converted_pointers,
173 &mut proof,
174 )?;
175
176 let expected = scale_bearing_accessors(&gltf_plan, factor != 1.0)?;
177 if artifact.rewritten_accessors() != expected.keys().copied().collect::<Vec<_>>() {
178 return Err(failed(
179 "artifact reports exactly the accessors this proof independently derives",
180 artifact.rewritten_accessors().len() as f64,
181 expected.len() as f64,
182 ));
183 }
184 proof.rewritten_accessor_count = expected.len();
185
186 let mut spans = Vec::with_capacity(expected.len());
187 for (&accessor_index, &rule) in &expected {
188 let span = bytes::accessor_span_typed(
189 source_root,
190 source.resolved_buffers(),
191 accessor_index,
192 rule.required_accessor_type(),
193 )?;
194 let artifact_span = bytes::accessor_span_typed(
195 artifact_root,
196 &artifact_buffers,
197 accessor_index,
198 rule.required_accessor_type(),
199 )?;
200 if span != artifact_span {
201 return Err(failed(
202 "converted accessors keep their source byte layout",
203 artifact_span.start as f64,
204 span.start as f64,
205 ));
206 }
207 spans.push((span, rule));
208 }
209
210 check_converted_payloads(
211 source.resolved_buffers(),
212 &artifact_buffers,
213 &spans,
214 factor,
215 &tolerance,
216 &mut proof,
217 )?;
218 check_accessor_bounds(
219 source_root,
220 artifact_root,
221 &artifact_buffers,
222 &spans,
223 factor,
224 &tolerance,
225 &mut converted_pointers,
226 &mut proof,
227 )?;
228
229 if artifact.rewritten_json_pointers() != converted_pointers.iter().cloned().collect::<Vec<_>>()
234 {
235 return Err(failed(
236 "artifact reports exactly the JSON pointers this proof independently derives",
237 artifact.rewritten_json_pointers().len() as f64,
238 converted_pointers.len() as f64,
239 ));
240 }
241
242 let mut allowed = converted_pointers;
243 for &buffer_index in artifact.reencoded_buffers() {
244 allowed.insert(format!("/buffers/{buffer_index}/uri"));
245 }
246 check_preserved_json(
247 source.raw_json(),
248 &artifact_json,
249 &allowed,
250 "every raw JSON location outside the converted set is preserved exactly",
251 )?;
252
253 let converted_spans: Vec<AccessorSpan> = spans.iter().map(|(span, _)| *span).collect();
254 proof.preserved_byte_ranges = check_preserved_bytes(
255 source.resolved_buffers(),
256 &artifact_buffers,
257 &converted_spans,
258 )?;
259
260 let repeat = super::rewrite_scale_plan(source, plan)?;
261 if repeat.bytes() != artifact.bytes() {
262 return Err(failed(
263 "rewriting the same source twice yields identical bytes",
264 repeat.bytes().len() as f64,
265 artifact.bytes().len() as f64,
266 ));
267 }
268 Ok(proof)
269}
270
271pub(super) fn check_container_integrity(
275 artifact: &GltfScaleArtifact,
276 artifact_root: &Map<String, Value>,
277 artifact_buffers: &[Vec<u8>],
278) -> Result<(), GltfScaleRewriteError> {
279 if artifact.container() == GltfContainerKind::Glb {
280 let bytes = artifact.bytes();
281 let word = |offset: usize| -> Result<u32, GltfScaleRewriteError> {
282 bytes
283 .get(offset..offset + 4)
284 .and_then(|slice| slice.try_into().ok())
285 .map(u32::from_le_bytes)
286 .ok_or_else(|| failed("GLB container is long enough for its own framing", 0.0, 1.0))
287 };
288 if &bytes[0..4.min(bytes.len())] != b"glTF" || word(4)? != 2 {
289 return Err(failed("GLB declares magic 'glTF' and version 2", 0.0, 1.0));
290 }
291 if word(8)? as usize != bytes.len() {
292 return Err(failed(
293 "GLB total length equals the emitted byte count",
294 f64::from(word(8)?),
295 bytes.len() as f64,
296 ));
297 }
298 let json_len = word(12)? as usize;
299 if word(16)? != GLB_JSON_CHUNK || !json_len.is_multiple_of(4) {
300 return Err(failed(
301 "GLB JSON chunk is typed and 4-byte padded",
302 0.0,
303 1.0,
304 ));
305 }
306 let mut offset = 20 + json_len;
307 let mut framed = offset;
308 if offset < bytes.len() {
309 let bin_len = word(offset)? as usize;
310 if word(offset + 4)? != GLB_BIN_CHUNK || !bin_len.is_multiple_of(4) {
311 return Err(failed("GLB BIN chunk is typed and 4-byte padded", 0.0, 1.0));
312 }
313 offset += 8;
314 framed = offset + bin_len;
315 }
316 if framed != bytes.len() {
317 return Err(failed(
318 "GLB chunk lengths account for every emitted byte",
319 framed as f64,
320 bytes.len() as f64,
321 ));
322 }
323 }
324 let declared = artifact_root
325 .get("buffers")
326 .and_then(Value::as_array)
327 .map(Vec::as_slice)
328 .unwrap_or_default();
329 for (buffer_index, buffer) in declared.iter().enumerate() {
330 let byte_length = buffer
331 .get("byteLength")
332 .and_then(Value::as_u64)
333 .unwrap_or_default() as usize;
334 let resolved = artifact_buffers.get(buffer_index).map_or(0, Vec::len);
335 if resolved < byte_length {
336 return Err(failed(
337 "every declared buffer byteLength is backed by resolved bytes",
338 resolved as f64,
339 byte_length as f64,
340 ));
341 }
342 }
343 Ok(())
344}
345
346pub(super) fn check_array_identities(
349 source_root: &Map<String, Value>,
350 artifact_root: &Map<String, Value>,
351) -> Result<(), GltfScaleRewriteError> {
352 const ARRAYS: &[&str] = &[
353 "accessors",
354 "animations",
355 "bufferViews",
356 "buffers",
357 "cameras",
358 "images",
359 "materials",
360 "meshes",
361 "nodes",
362 "samplers",
363 "scenes",
364 "skins",
365 "textures",
366 ];
367 for key in ARRAYS {
368 let length =
369 |root: &Map<String, Value>| root.get(*key).and_then(Value::as_array).map(Vec::len);
370 if length(source_root) != length(artifact_root) {
371 return Err(failed(
372 "every top-level array keeps its source length",
373 length(artifact_root).unwrap_or_default() as f64,
374 length(source_root).unwrap_or_default() as f64,
375 ));
376 }
377 }
378 Ok(())
379}
380
381fn check_node_transforms(
384 source_root: &Map<String, Value>,
385 artifact_root: &Map<String, Value>,
386 plan: &GltfScalePlan,
387 factor: f64,
388 tolerance: &ScaleTolerancePolicy,
389 converted_pointers: &mut BTreeSet<String>,
390 proof: &mut GltfScaleArtifactProof,
391) -> Result<(), GltfScaleRewriteError> {
392 let source_nodes = source_root
393 .get("nodes")
394 .and_then(Value::as_array)
395 .map(Vec::as_slice)
396 .unwrap_or_default();
397 let artifact_nodes = artifact_root
398 .get("nodes")
399 .and_then(Value::as_array)
400 .map(Vec::as_slice)
401 .unwrap_or_default();
402 for binding in plan.node_bindings() {
403 let node_index = binding.source_node_index;
404 let before = source_nodes
405 .get(node_index)
406 .ok_or_else(|| plan_mismatch("source_node_payload_missing"))?;
407 let after = artifact_nodes
408 .get(node_index)
409 .ok_or_else(|| plan_mismatch("artifact_node_payload_missing"))?;
410 for (member, field, length, scales) in [
411 (
412 "translation",
413 ScaleSourceRestField::Translation,
414 3usize,
415 &[0usize, 1, 2] as &[usize],
416 ),
417 (
418 "matrix",
419 ScaleSourceRestField::MatrixTranslation,
420 16,
421 &[12, 13, 14],
422 ),
423 ] {
424 if (member == "translation" && !binding.translation_declared)
425 || (member == "matrix" && !binding.matrix_declared)
426 {
427 continue;
428 }
429 let Some(source_values) = before.get(member).and_then(Value::as_array) else {
430 continue;
431 };
432 let rewrites = validate_proof_whole_document_disposition(
433 plan.source_rest(node_index, field)?,
434 factor != 1.0,
435 )?;
436 if !rewrites {
437 continue;
438 }
439 let pointer = format!("/nodes/{node_index}/{member}");
440 let artifact_values = after
441 .get(member)
442 .and_then(Value::as_array)
443 .filter(|values| values.len() == length && source_values.len() == length)
444 .ok_or_else(|| {
445 failed(
446 "a converted node transform keeps its authored arity",
447 0.0,
448 length as f64,
449 )
450 })?;
451 for component in 0..length {
452 let before = numeric(&source_values[component], &pointer)?;
453 let after = numeric(&artifact_values[component], &pointer)?;
454 if scales.contains(&component) {
455 track_length(before, after, factor, tolerance, proof)?;
456 } else {
457 track_dimensionless(before, after, proof)?;
458 }
459 }
460 converted_pointers.insert(pointer);
461 }
462 }
463 Ok(())
464}
465
466fn check_converted_payloads(
469 source_buffers: &[Vec<u8>],
470 artifact_buffers: &[Vec<u8>],
471 spans: &[(AccessorSpan, ProofAccessorRule)],
472 factor: f64,
473 tolerance: &ScaleTolerancePolicy,
474 proof: &mut GltfScaleArtifactProof,
475) -> Result<(), GltfScaleRewriteError> {
476 for &(span, rule) in spans {
477 let before = bytes::read_span(source_buffers, span);
478 let after = bytes::read_span(artifact_buffers, span);
479 if before.len() != after.len() {
480 return Err(failed(
481 "a converted accessor keeps its element count",
482 after.len() as f64,
483 before.len() as f64,
484 ));
485 }
486 for (index, (&before, &after)) in before.iter().zip(&after).enumerate() {
487 if proof_scales_component(rule, index % span.components) {
488 let expected = f64::from(before) * factor;
489 if after.to_bits() != (expected as f32).to_bits() {
490 return Err(failed(
491 "every converted element is the single narrowing of before * q",
492 f64::from(after),
493 expected,
494 ));
495 }
496 track_length(
497 f64::from(before),
498 f64::from(after),
499 factor,
500 tolerance,
501 proof,
502 )?;
503 } else if after.to_bits() != before.to_bits() {
504 return Err(failed(
505 "a converted accessor's dimensionless components are bit-identical",
506 f64::from(after),
507 f64::from(before),
508 ));
509 }
510 }
511 }
512 Ok(())
513}
514
515#[allow(clippy::too_many_arguments)]
518fn check_accessor_bounds(
519 source_root: &Map<String, Value>,
520 artifact_root: &Map<String, Value>,
521 artifact_buffers: &[Vec<u8>],
522 spans: &[(AccessorSpan, ProofAccessorRule)],
523 factor: f64,
524 tolerance: &ScaleTolerancePolicy,
525 converted_pointers: &mut BTreeSet<String>,
526 proof: &mut GltfScaleArtifactProof,
527) -> Result<(), GltfScaleRewriteError> {
528 for &(span, rule) in spans {
529 let payload = bytes::read_span(artifact_buffers, span);
530 for (member, is_min) in [("min", true), ("max", false)] {
531 let pointer = format!("/accessors/{}/{member}", span.accessor_index);
532 let Some(source_bounds) = source_root
533 .get("accessors")
534 .and_then(Value::as_array)
535 .and_then(|accessors| accessors.get(span.accessor_index))
536 .and_then(|accessor| accessor.get(member))
537 .and_then(Value::as_array)
538 else {
539 continue;
540 };
541 let artifact_bounds = artifact_root
542 .get("accessors")
543 .and_then(Value::as_array)
544 .and_then(|accessors| accessors.get(span.accessor_index))
545 .and_then(|accessor| accessor.get(member))
546 .and_then(Value::as_array)
547 .filter(|bounds| bounds.len() == source_bounds.len())
548 .ok_or_else(|| {
549 failed(
550 "a converted accessor keeps its authored bound arity",
551 0.0,
552 source_bounds.len() as f64,
553 )
554 })?;
555 for component in 0..source_bounds.len() {
556 let before = numeric(&source_bounds[component], &pointer)?;
557 let after = numeric(&artifact_bounds[component], &pointer)?;
558 if !proof_scales_component(rule, component) {
559 track_dimensionless(before, after, proof)?;
560 continue;
561 }
562 track_length(before, after, factor, tolerance, proof)?;
569 let observed = payload
570 .iter()
571 .skip(component)
572 .step_by(span.components)
573 .copied()
574 .fold(
575 if is_min {
576 f32::INFINITY
577 } else {
578 f32::NEG_INFINITY
579 },
580 |accumulator, value| {
581 if is_min {
582 accumulator.min(value)
583 } else {
584 accumulator.max(value)
585 }
586 },
587 );
588 let declared = after as f32;
595 let bounds_data = if is_min {
596 declared <= observed
597 } else {
598 declared >= observed
599 };
600 if !bounds_data {
601 return Err(failed(
602 "a converted bound still bounds the converted payload",
603 f64::from(declared),
604 f64::from(observed),
605 ));
606 }
607 }
608 converted_pointers.insert(pointer);
609 }
610 }
611 Ok(())
612}
613
614pub(super) fn check_preserved_bytes(
617 source_buffers: &[Vec<u8>],
618 artifact_buffers: &[Vec<u8>],
619 spans: &[AccessorSpan],
620) -> Result<usize, GltfScaleRewriteError> {
621 if source_buffers.len() != artifact_buffers.len() {
622 return Err(failed(
623 "the artifact declares the same number of resolved buffers",
624 artifact_buffers.len() as f64,
625 source_buffers.len() as f64,
626 ));
627 }
628 let mut preserved = 0usize;
629 for (buffer_index, (before, after)) in source_buffers.iter().zip(artifact_buffers).enumerate() {
630 if before.len() != after.len() {
631 return Err(failed(
632 "every resolved buffer keeps its source byte length",
633 after.len() as f64,
634 before.len() as f64,
635 ));
636 }
637 let mut converted: Vec<(usize, usize)> = spans
638 .iter()
639 .filter(|span| span.buffer == buffer_index)
640 .map(|span| (span.start, span.end))
641 .collect();
642 converted.sort_unstable();
643 let mut cursor = 0usize;
644 for (start, end) in converted.into_iter().chain([(before.len(), before.len())]) {
645 if cursor < start {
646 if before[cursor..start] != after[cursor..start] {
647 return Err(failed(
648 "buffer bytes outside the converted ranges are preserved",
649 cursor as f64,
650 start as f64,
651 ));
652 }
653 preserved += 1;
654 }
655 cursor = cursor.max(end);
663 }
664 }
665 Ok(preserved)
666}
667
668#[derive(Debug, Default)]
669struct RawJsonDifferenceCollector {
670 differences: Vec<GltfRawJsonDifference>,
671 total: usize,
672}
673
674impl RawJsonDifferenceCollector {
675 fn record(&mut self, pointer: String, kind: GltfRawJsonDifferenceKind) {
676 self.total += 1;
677 if self.differences.len() < MAX_RAW_JSON_DIFFERENCES {
678 self.differences
679 .push(GltfRawJsonDifference { pointer, kind });
680 }
681 }
682
683 fn finish(self) -> GltfRawJsonDifferenceSummary {
684 let omitted = self.total - self.differences.len();
685 GltfRawJsonDifferenceSummary {
686 differences: self.differences,
687 omitted,
688 }
689 }
690}
691
692pub(super) fn check_preserved_json(
694 before: &Value,
695 after: &Value,
696 allowed: &BTreeSet<String>,
697 claim: &'static str,
698) -> Result<(), GltfScaleRewriteError> {
699 let mut collector = RawJsonDifferenceCollector::default();
700 collect_json_differences(before, after, "", allowed, &mut collector);
701 if collector.total == 0 {
702 return Ok(());
703 }
704 let total = collector.total;
705 let summary = collector.finish();
706 Err(GltfScaleRewriteError::ArtifactProofFailed {
707 claim,
708 observed: total as f64,
709 tolerance: 0.0,
710 raw_json_differences: Some(summary),
711 })
712}
713
714fn collect_json_differences(
717 before: &Value,
718 after: &Value,
719 pointer: &str,
720 allowed: &BTreeSet<String>,
721 out: &mut RawJsonDifferenceCollector,
722) {
723 if allowed.contains(pointer) {
724 return;
725 }
726 match (before, after) {
727 (Value::Object(before), Value::Object(after)) => {
728 let keys: BTreeSet<&String> = before.keys().chain(after.keys()).collect();
729 for key in keys {
730 let child = format!("{pointer}/{}", key.replace('~', "~0").replace('/', "~1"));
731 match (before.get(key), after.get(key)) {
732 (Some(before), Some(after)) => {
733 collect_json_differences(before, after, &child, allowed, out);
734 }
735 _ if allowed.contains(&child) => {}
747 (None, Some(_)) => {
748 out.record(child, GltfRawJsonDifferenceKind::ArtifactAdded);
749 }
750 (Some(_), None) => {
751 out.record(child, GltfRawJsonDifferenceKind::ArtifactRemoved);
752 }
753 (None, None) => unreachable!("a key came from at least one object"),
754 }
755 }
756 }
757 (Value::Array(before), Value::Array(after)) if before.len() == after.len() => {
758 for (index, (before, after)) in before.iter().zip(after).enumerate() {
759 collect_json_differences(
760 before,
761 after,
762 &format!("{pointer}/{index}"),
763 allowed,
764 out,
765 );
766 }
767 }
768 (Value::Number(before), Value::Number(after)) => {
769 if !json_numbers_have_identical_value_and_zero_sign(before, after) {
770 out.record(pointer.to_owned(), GltfRawJsonDifferenceKind::ValueChanged);
771 }
772 }
773 (before, after) if before == after => {}
774 _ => out.record(pointer.to_owned(), GltfRawJsonDifferenceKind::ValueChanged),
775 }
776}
777
778fn json_numbers_have_identical_value_and_zero_sign(
785 before: &serde_json::Number,
786 after: &serde_json::Number,
787) -> bool {
788 if before != after {
789 return false;
790 }
791 match (before.as_f64(), after.as_f64()) {
792 (Some(before), Some(after)) if before == 0.0 && after == 0.0 => {
793 before.to_bits() == after.to_bits()
794 }
795 _ => true,
796 }
797}
798
799fn scale_bearing_accessors(
806 plan: &GltfScalePlan,
807 factor_changes: bool,
808) -> Result<BTreeMap<usize, ProofAccessorRule>, GltfScaleRewriteError> {
809 let mut out = BTreeMap::new();
810 for binding in plan.accessor_bindings() {
811 let rule = match &binding.target {
812 RawAccessorTarget::MeshPositions { disposition } => {
813 validate_proof_whole_document_disposition(*disposition, factor_changes)?
814 .then_some(ProofAccessorRule::AllComponents)
815 }
816 RawAccessorTarget::MorphPositions => {
817 factor_changes.then_some(ProofAccessorRule::AllComponents)
818 }
819 RawAccessorTarget::InstanceInverseBind { source_skin_index } => {
820 let skin = plan.skin_binding(*source_skin_index)?;
821 let mut rewrite = None;
822 for slot in &skin.slots {
823 let slot_rewrite = validate_proof_whole_document_disposition(
824 slot.disposition
825 .ok_or_else(|| plan_mismatch("inverse_bind_disposition_missing"))?,
826 factor_changes,
827 )?;
828 match rewrite {
829 Some(previous) if previous != slot_rewrite => {
830 return Err(plan_mismatch("mixed_whole_document_accessor_disposition"));
831 }
832 Some(_) => {}
833 None => rewrite = Some(slot_rewrite),
834 }
835 }
836 rewrite
837 .unwrap_or(false)
838 .then_some(ProofAccessorRule::Mat4TranslationColumn)
839 }
840 RawAccessorTarget::Animation {
841 property: Property::Translation,
842 disposition,
843 ..
844 } => validate_proof_whole_document_disposition(*disposition, factor_changes)?
845 .then_some(ProofAccessorRule::AllComponents),
846 RawAccessorTarget::MeshNormals { .. } => None,
847 RawAccessorTarget::PreserveExact | RawAccessorTarget::Animation { .. } => None,
848 };
849 if let Some(rule) = rule {
850 out.insert(binding.accessor_index, rule);
851 }
852 }
853 Ok(out)
854}
855
856fn validate_proof_whole_document_disposition(
861 disposition: ScaleFieldDisposition,
862 factor_changes: bool,
863) -> Result<bool, GltfScaleRewriteError> {
864 match (factor_changes, disposition) {
865 (true, ScaleFieldDisposition::Rewrite(ScaleRewriteRule::WholeDocumentLength)) => Ok(true),
866 (false, ScaleFieldDisposition::PreserveExact) => Ok(false),
867 _ => Err(plan_mismatch("invalid_whole_document_field_disposition")),
868 }
869}
870
871pub(super) fn track_length(
874 before: f64,
875 after: f64,
876 factor: f64,
877 tolerance: &ScaleTolerancePolicy,
878 proof: &mut GltfScaleArtifactProof,
879) -> Result<(), GltfScaleRewriteError> {
880 let expected = before * factor;
881 let residual = (after - expected).abs();
882 proof.length_factor_residual = proof.length_factor_residual.max(residual);
883 let bound = tolerance.scalar_tolerance(expected, after);
884 if residual > bound {
885 return Err(failed(
886 "every converted length differs from the source by exactly the declared factor",
887 residual,
888 bound,
889 ));
890 }
891 Ok(())
892}
893
894pub(super) fn track_dimensionless(
895 before: f64,
896 after: f64,
897 proof: &mut GltfScaleArtifactProof,
898) -> Result<(), GltfScaleRewriteError> {
899 let residual = (after - before).abs();
900 proof.dimensionless_residual = proof.dimensionless_residual.max(residual);
901 if before.to_bits() != after.to_bits() {
902 return Err(failed(
903 "every dimensionless value inside a converted range is invariant",
904 residual,
905 0.0,
906 ));
907 }
908 Ok(())
909}
910
911pub(super) fn failed(claim: &'static str, observed: f64, tolerance: f64) -> GltfScaleRewriteError {
912 GltfScaleRewriteError::ArtifactProofFailed {
913 claim,
914 observed,
915 tolerance,
916 raw_json_differences: None,
917 }
918}
919
920pub(super) fn object(value: &Value) -> Result<&Map<String, Value>, GltfScaleRewriteError> {
921 value
922 .as_object()
923 .ok_or_else(|| LoadError::Malformed("top-level glTF JSON is not an object".into()).into())
924}
925
926pub(super) fn numeric(value: &Value, location: &str) -> Result<f64, GltfScaleRewriteError> {
927 value
928 .as_f64()
929 .ok_or_else(|| LoadError::Malformed(format!("{location} is not a number")).into())
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
950 use crate::preflight_scale_source_bytes;
951 use animsmith_core::scale::{ScaleOperation, ScaleRequest, plan_scale};
952 use base64::{Engine as _, engine::general_purpose::STANDARD};
953 use serde_json::json;
954
955 mod offsets {
957 pub const POSITION: usize = 0; pub const INVERSE_BIND: usize = 36; pub const JOINTS: usize = 100; pub const WEIGHTS: usize = 124; pub const SPARE: usize = 172; pub const LENGTH: usize = 176;
963 }
964
965 const FACTOR: f64 = 4.0;
966
967 const POSITIONS: [f32; 9] = [1.0, 2.0, -3.0, 0.5, -0.25, 4.0, 2.0, 0.0, 1.5];
968 const INVERSE_BIND: [f32; 16] = [
969 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, -1.0, 2.0, -0.5, 1.0,
973 ];
974
975 fn data_uri(bytes: &[u8]) -> String {
976 format!(
977 "data:application/octet-stream;base64,{}",
978 STANDARD.encode(bytes)
979 )
980 }
981
982 fn fixture_buffer() -> Vec<u8> {
983 let mut buffer = vec![0u8; offsets::LENGTH];
984 for (index, value) in POSITIONS.iter().enumerate() {
985 let at = offsets::POSITION + index * 4;
986 buffer[at..at + 4].copy_from_slice(&value.to_le_bytes());
987 }
988 for (index, value) in INVERSE_BIND.iter().enumerate() {
989 let at = offsets::INVERSE_BIND + index * 4;
990 buffer[at..at + 4].copy_from_slice(&value.to_le_bytes());
991 }
992 for vertex in 0..3 {
994 let at = offsets::WEIGHTS + vertex * 16;
995 buffer[at..at + 4].copy_from_slice(&1.0f32.to_le_bytes());
996 }
997 buffer
998 }
999
1000 fn fixture_json(buffer: &[u8]) -> Value {
1004 json!({
1005 "asset": { "version": "2.0" },
1006 "buffers": [{ "uri": data_uri(buffer), "byteLength": offsets::LENGTH }],
1007 "bufferViews": [
1008 { "buffer": 0, "byteOffset": offsets::POSITION, "byteLength": 36 },
1009 { "buffer": 0, "byteOffset": offsets::INVERSE_BIND, "byteLength": 64 },
1010 { "buffer": 0, "byteOffset": offsets::JOINTS, "byteLength": 24 },
1011 { "buffer": 0, "byteOffset": offsets::WEIGHTS, "byteLength": 48 }
1012 ],
1013 "accessors": [
1014 { "bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
1015 "min": [0.5, -0.25, -3.0], "max": [2.0, 2.0, 4.0] },
1016 { "bufferView": 1, "componentType": 5126, "count": 1, "type": "MAT4" },
1017 { "bufferView": 2, "componentType": 5123, "count": 3, "type": "VEC4" },
1018 { "bufferView": 3, "componentType": 5126, "count": 3, "type": "VEC4" }
1019 ],
1020 "materials": [{ "name": "surface" }],
1021 "meshes": [{ "primitives": [{
1022 "attributes": { "POSITION": 0, "JOINTS_0": 2, "WEIGHTS_0": 3 },
1023 "material": 0
1024 }] }],
1025 "nodes": [{ "name": "joint" }, { "name": "holder", "mesh": 0, "skin": 0 }],
1026 "scenes": [{ "nodes": [0, 1] }],
1027 "scene": 0,
1028 "skins": [{ "joints": [0], "skeleton": 0, "inverseBindMatrices": 1 }]
1029 })
1030 }
1031
1032 fn fixture() -> (GltfScaleSource, GltfScaleArtifact, ScalePlan) {
1033 let value = fixture_json(&fixture_buffer());
1034 fixture_from_value(&value)
1035 }
1036
1037 fn fixture_from_value(value: &Value) -> (GltfScaleSource, GltfScaleArtifact, ScalePlan) {
1038 fixture_from_value_with_factor(value, FACTOR)
1039 }
1040
1041 fn fixture_from_value_with_factor(
1042 value: &Value,
1043 factor: f64,
1044 ) -> (GltfScaleSource, GltfScaleArtifact, ScalePlan) {
1045 let bytes = serde_json::to_vec(&value).expect("fixture serializes");
1046 let source = preflight_scale_source_bytes(Path::new("proof-fixture.gltf"), &bytes)
1047 .expect("the fixture preflights cleanly");
1048 let plan = plan_scale(&ScaleRequest {
1049 operation: ScaleOperation::WholeDocumentLinearUnits { factor },
1050 document: source.document(),
1051 capability: &super::super::capability_facts(source.manifest()),
1052 })
1053 .expect("plan");
1054 let artifact = super::super::rewrite_linear_units(&source, factor).expect("rewrite");
1055 (source, artifact, plan)
1056 }
1057
1058 fn artifact_value(artifact: &GltfScaleArtifact) -> Value {
1060 serde_json::from_slice(artifact.bytes()).expect("a .gltf artifact is JSON")
1061 }
1062
1063 fn put_artifact_value(artifact: &mut GltfScaleArtifact, value: &Value) {
1064 artifact.bytes = serde_json::to_vec(value).expect("corrupted fixture serializes");
1065 }
1066
1067 fn artifact_buffer(value: &Value) -> Vec<u8> {
1068 let uri = value["buffers"][0]["uri"].as_str().expect("data URI");
1069 STANDARD
1070 .decode(uri.split_once("base64,").expect("base64 data URI").1)
1071 .expect("valid base64")
1072 }
1073
1074 fn put_artifact_buffer(value: &mut Value, bytes: &[u8]) {
1075 value["buffers"][0]["uri"] = json!(data_uri(bytes));
1076 }
1077
1078 fn expect_claim(
1080 source: &GltfScaleSource,
1081 artifact: &GltfScaleArtifact,
1082 plan: &ScalePlan,
1083 expected: &str,
1084 ) {
1085 match prove_rewritten_artifact(source, artifact, plan) {
1086 Err(GltfScaleRewriteError::ArtifactProofFailed {
1087 claim,
1088 raw_json_differences,
1089 ..
1090 }) => {
1091 assert_eq!(claim, expected);
1092 assert_eq!(
1093 raw_json_differences, None,
1094 "ordinary proof claims do not carry raw JSON diagnostics"
1095 );
1096 }
1097 other => panic!("expected the claim {expected:?} to fail, got {other:?}"),
1098 }
1099 }
1100
1101 #[test]
1102 fn the_uncorrupted_fixture_proves_and_reports_its_evidence() {
1103 let (source, artifact, plan) = fixture();
1107 let proof = prove_rewritten_artifact(&source, &artifact, &plan).expect("artifact proof");
1108 assert_eq!(proof.rewritten_accessor_count, 2, "POSITION and the IBM");
1109 assert_eq!(proof.length_factor_residual, 0.0);
1110 assert_eq!(proof.dimensionless_residual, 0.0);
1111 assert_eq!(proof.preserved_byte_ranges, 1);
1114 assert_eq!(artifact.rewritten_accessors(), [0, 1]);
1115 assert_eq!(
1116 artifact.rewritten_json_pointers(),
1117 ["/accessors/0/max", "/accessors/0/min"]
1118 );
1119 }
1120
1121 #[test]
1122 fn a_flipped_byte_outside_every_converted_range_fails_byte_preservation() {
1123 let (source, mut artifact, plan) = fixture();
1124 let mut value = artifact_value(&artifact);
1125 let mut buffer = artifact_buffer(&value);
1126 buffer[offsets::SPARE] ^= 0xff;
1127 put_artifact_buffer(&mut value, &buffer);
1128 put_artifact_value(&mut artifact, &value);
1129 expect_claim(
1130 &source,
1131 &artifact,
1132 &plan,
1133 "buffer bytes outside the converted ranges are preserved",
1134 );
1135 }
1136
1137 #[test]
1138 fn a_dimensionless_component_inside_a_converted_range_must_be_bit_identical() {
1139 let (source, mut artifact, plan) = fixture();
1143 let mut value = artifact_value(&artifact);
1144 let mut buffer = artifact_buffer(&value);
1145 const SIGN_BYTE: usize = offsets::INVERSE_BIND + 4 + 3;
1146 buffer[SIGN_BYTE] |= 0x80;
1147 put_artifact_buffer(&mut value, &buffer);
1148 put_artifact_value(&mut artifact, &value);
1149 expect_claim(
1150 &source,
1151 &artifact,
1152 &plan,
1153 "a converted accessor's dimensionless components are bit-identical",
1154 );
1155 }
1156
1157 #[test]
1158 fn a_dimensionless_node_matrix_component_must_be_exact_in_parsed_f64() {
1159 let mut value = fixture_json(&fixture_buffer());
1160 value["nodes"][0] = json!({
1161 "name": "joint",
1162 "matrix": [
1163 1.0, 0.0, 0.0, 0.0,
1164 0.0, 1.0, 0.0, 0.0,
1165 0.0, 0.0, 1.0, 0.0,
1166 0.0, 0.0, 0.0, 1.0
1167 ]
1168 });
1169 let (source, mut artifact, plan) = fixture_from_value(&value);
1170 let mut doctored = artifact_value(&artifact);
1171 let adjacent = f64::from_bits(1.0f64.to_bits() + 1);
1172 doctored["nodes"][0]["matrix"][0] = json!(adjacent);
1173 put_artifact_value(&mut artifact, &doctored);
1174 let error = prove_rewritten_artifact(&source, &artifact, &plan)
1175 .expect_err("an adjacent identity-multiplier matrix value must be refused");
1176 match error {
1177 GltfScaleRewriteError::ArtifactProofFailed {
1178 claim, observed, ..
1179 } => {
1180 assert_eq!(
1181 claim,
1182 "every dimensionless value inside a converted range is invariant"
1183 );
1184 assert_eq!(observed, adjacent - 1.0, "one adjacent matrix f64");
1185 }
1186 other => panic!("expected a dimensionless matrix residual, got {other:?}"),
1187 }
1188 }
1189
1190 #[test]
1191 fn a_factor_one_matrix_translation_is_preserved_by_the_public_artifact_proof() {
1192 let mut value = fixture_json(&fixture_buffer());
1193 let authored = f64::from_bits(1.0f64.to_bits() + 1);
1194 value["nodes"][1]["matrix"] = json!([
1195 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, authored, 0.0, 0.0, 1.0
1196 ]);
1197 let (source, mut artifact, plan) = fixture_from_value_with_factor(&value, 1.0);
1198 assert!(artifact.rewritten_accessors().is_empty());
1199 assert!(artifact.rewritten_json_pointers().is_empty());
1200 assert_eq!(
1201 artifact_value(&artifact)["nodes"][1]["matrix"][12]
1202 .as_f64()
1203 .expect("numeric matrix translation")
1204 .to_bits(),
1205 authored.to_bits()
1206 );
1207 let proof =
1208 prove_rewritten_artifact(&source, &artifact, &plan).expect("factor-one artifact proof");
1209 assert_eq!(proof.rewritten_accessor_count, 0);
1210
1211 let mut doctored = artifact_value(&artifact);
1212 let adjacent = f64::from_bits(authored.to_bits() + 1);
1213 doctored["nodes"][1]["matrix"][12] = json!(adjacent);
1214 put_artifact_value(&mut artifact, &doctored);
1215 let error = prove_rewritten_artifact(&source, &artifact, &plan)
1216 .expect_err("a changed factor-one matrix translation must be refused");
1217 match error {
1218 GltfScaleRewriteError::ArtifactProofFailed {
1219 claim,
1220 observed,
1221 raw_json_differences: Some(summary),
1222 ..
1223 } => {
1224 assert_eq!(
1225 claim,
1226 "every raw JSON location outside the converted set is preserved exactly"
1227 );
1228 assert_eq!(observed, 1.0);
1229 assert_eq!(summary.differences.len(), 1);
1230 assert_eq!(summary.differences[0].pointer, "/nodes/1/matrix/12");
1231 assert_eq!(
1232 summary.differences[0].kind,
1233 GltfRawJsonDifferenceKind::ValueChanged
1234 );
1235 }
1236 other => panic!("expected a located raw JSON difference, got {other:?}"),
1237 }
1238 }
1239
1240 #[test]
1241 fn an_under_reported_converted_accessor_fails_the_accessor_cross_check() {
1242 let (source, mut artifact, plan) = fixture();
1243 artifact.rewritten_accessors.pop();
1244 expect_claim(
1245 &source,
1246 &artifact,
1247 &plan,
1248 "artifact reports exactly the accessors this proof independently derives",
1249 );
1250 }
1251
1252 #[test]
1253 fn an_under_reported_rewritten_json_pointer_fails_the_pointer_cross_check() {
1254 let (source, mut artifact, plan) = fixture();
1255 artifact.rewritten_json_pointers.pop();
1256 expect_claim(
1257 &source,
1258 &artifact,
1259 &plan,
1260 "artifact reports exactly the JSON pointers this proof independently derives",
1261 );
1262 }
1263
1264 #[test]
1265 fn added_and_removed_preserved_json_locations_keep_whole_document_direction() {
1266 let (source, mut artifact, plan) = fixture();
1267 let mut value = artifact_value(&artifact);
1268 let material = value["materials"][0]
1269 .as_object_mut()
1270 .expect("fixture material is an object");
1271 material.remove("name");
1272 material.insert("replacement".into(), json!(true));
1273 put_artifact_value(&mut artifact, &value);
1274 let error = prove_rewritten_artifact(&source, &artifact, &plan)
1275 .expect_err("changed preserved JSON must fail");
1276 assert_eq!(
1277 error.to_string(),
1278 "artifact proof claim \"every raw JSON location outside the converted set is preserved exactly\" observed 2, tolerance 0; raw JSON differences: /materials/0/name (artifact-removed), /materials/0/replacement (artifact-added)"
1279 );
1280 match error {
1281 GltfScaleRewriteError::ArtifactProofFailed {
1282 claim,
1283 observed,
1284 tolerance,
1285 raw_json_differences: Some(summary),
1286 } => {
1287 assert_eq!(
1288 claim,
1289 "every raw JSON location outside the converted set is preserved exactly"
1290 );
1291 assert_eq!(observed, 2.0);
1292 assert_eq!(tolerance, 0.0);
1293 assert_eq!(
1294 summary,
1295 GltfRawJsonDifferenceSummary {
1296 differences: vec![
1297 GltfRawJsonDifference {
1298 pointer: "/materials/0/name".into(),
1299 kind: GltfRawJsonDifferenceKind::ArtifactRemoved,
1300 },
1301 GltfRawJsonDifference {
1302 pointer: "/materials/0/replacement".into(),
1303 kind: GltfRawJsonDifferenceKind::ArtifactAdded,
1304 },
1305 ],
1306 omitted: 0,
1307 }
1308 );
1309 }
1310 other => panic!("expected located JSON diagnostics, got {other:?}"),
1311 }
1312 }
1313
1314 #[test]
1315 fn json_difference_collection_is_typed_escaped_ordered_and_allowlisted() {
1316 let before = json!({
1317 "a~/b~/c": 1,
1318 "allowed": "source secret",
1319 "allowedly": 1,
1320 "removed": true,
1321 "value": 1
1322 });
1323 let after = json!({
1324 "a~/b~/c": 2,
1325 "added": true,
1326 "allowed": "artifact secret",
1327 "allowedly": 2,
1328 "value": 2
1329 });
1330 let allowed = BTreeSet::from(["/allowed".to_owned()]);
1331 let mut collector = RawJsonDifferenceCollector::default();
1332 collect_json_differences(&before, &after, "", &allowed, &mut collector);
1333 assert_eq!(
1334 collector.finish(),
1335 GltfRawJsonDifferenceSummary {
1336 differences: vec![
1337 GltfRawJsonDifference {
1338 pointer: "/added".into(),
1339 kind: GltfRawJsonDifferenceKind::ArtifactAdded,
1340 },
1341 GltfRawJsonDifference {
1342 pointer: "/allowedly".into(),
1343 kind: GltfRawJsonDifferenceKind::ValueChanged,
1344 },
1345 GltfRawJsonDifference {
1346 pointer: "/a~0~1b~0~1c".into(),
1347 kind: GltfRawJsonDifferenceKind::ValueChanged,
1348 },
1349 GltfRawJsonDifference {
1350 pointer: "/removed".into(),
1351 kind: GltfRawJsonDifferenceKind::ArtifactRemoved,
1352 },
1353 GltfRawJsonDifference {
1354 pointer: "/value".into(),
1355 kind: GltfRawJsonDifferenceKind::ValueChanged,
1356 },
1357 ],
1358 omitted: 0,
1359 }
1360 );
1361 }
1362
1363 #[test]
1364 fn json_difference_collection_preserves_zero_sign_and_quaternion_sign() {
1365 let before: Value = serde_json::from_str(
1366 r#"{"extras":{"authoredZero":-0.0},"nodes":[{"rotation":[0.0,0.0,0.0,-1.0]}]}"#,
1367 )
1368 .expect("source JSON");
1369 let after: Value = serde_json::from_str(
1370 r#"{"extras":{"authoredZero":0.0},"nodes":[{"rotation":[0.0,0.0,0.0,1.0]}]}"#,
1371 )
1372 .expect("artifact JSON");
1373 assert_eq!(
1374 before["extras"]["authoredZero"]
1375 .as_f64()
1376 .expect("source zero")
1377 .to_bits(),
1378 (-0.0f64).to_bits()
1379 );
1380 let mut collector = RawJsonDifferenceCollector::default();
1381 collect_json_differences(&before, &after, "", &BTreeSet::new(), &mut collector);
1382 assert_eq!(
1383 collector.finish(),
1384 GltfRawJsonDifferenceSummary {
1385 differences: vec![
1386 GltfRawJsonDifference {
1387 pointer: "/extras/authoredZero".into(),
1388 kind: GltfRawJsonDifferenceKind::ValueChanged,
1389 },
1390 GltfRawJsonDifference {
1391 pointer: "/nodes/0/rotation/3".into(),
1392 kind: GltfRawJsonDifferenceKind::ValueChanged,
1393 },
1394 ],
1395 omitted: 0,
1396 }
1397 );
1398 }
1399
1400 #[test]
1401 fn json_difference_collection_caps_storage_but_counts_every_difference() {
1402 let mut before = Map::new();
1403 let mut after = Map::new();
1404 for index in 0..20 {
1405 let key = format!("key-{index:02}");
1406 match index % 3 {
1407 0 => {
1408 before.insert(key.clone(), json!(0));
1409 after.insert(key, json!(1));
1410 }
1411 1 => {
1412 after.insert(key, json!(1));
1413 }
1414 _ => {
1415 before.insert(key, json!(0));
1416 }
1417 }
1418 }
1419 let error = check_preserved_json(
1420 &Value::Object(before),
1421 &Value::Object(after),
1422 &BTreeSet::new(),
1423 "the capped preservation fixture stays exact",
1424 )
1425 .expect_err("twenty preserved-location differences must fail");
1426 let GltfScaleRewriteError::ArtifactProofFailed {
1427 claim,
1428 observed,
1429 tolerance,
1430 raw_json_differences: Some(summary),
1431 } = error
1432 else {
1433 panic!("expected located JSON diagnostics, got {error:?}");
1434 };
1435 assert_eq!(claim, "the capped preservation fixture stays exact");
1436 assert_eq!(observed, 20.0, "observed retains the full count");
1437 assert_eq!(tolerance, 0.0);
1438 assert_eq!(summary.differences.len() + summary.omitted, 20);
1439 assert_eq!(summary.omitted, 4);
1440 assert_eq!(
1441 summary.differences,
1442 (0..MAX_RAW_JSON_DIFFERENCES)
1443 .map(|index| GltfRawJsonDifference {
1444 pointer: format!("/key-{index:02}"),
1445 kind: match index % 3 {
1446 0 => GltfRawJsonDifferenceKind::ValueChanged,
1447 1 => GltfRawJsonDifferenceKind::ArtifactAdded,
1448 _ => GltfRawJsonDifferenceKind::ArtifactRemoved,
1449 },
1450 })
1451 .collect::<Vec<_>>()
1452 );
1453 let display = super::super::RawJsonDifferenceSuffix(Some(&summary)).to_string();
1454 assert!(display.ends_with("; 4 omitted"));
1455 assert!(display.contains("/key-15 (value-changed)"));
1456 assert!(
1457 !display.contains("/key-16"),
1458 "omitted pointers stay omitted"
1459 );
1460 }
1461
1462 #[test]
1463 fn unequal_arrays_report_one_value_change_at_the_array_root() {
1464 for (before, after) in [
1465 (json!({ "nodes": [1] }), json!({ "nodes": [1, 2] })),
1466 (json!({ "nodes": [1, 2] }), json!({ "nodes": [1] })),
1467 ] {
1468 let mut collector = RawJsonDifferenceCollector::default();
1469 collect_json_differences(&before, &after, "", &BTreeSet::new(), &mut collector);
1470 assert_eq!(
1471 collector.finish(),
1472 GltfRawJsonDifferenceSummary {
1473 differences: vec![GltfRawJsonDifference {
1474 pointer: "/nodes".into(),
1475 kind: GltfRawJsonDifferenceKind::ValueChanged,
1476 }],
1477 omitted: 0,
1478 }
1479 );
1480 }
1481 }
1482
1483 #[test]
1484 fn a_changed_top_level_array_length_fails_array_identity() {
1485 let (source, mut artifact, plan) = fixture();
1486 let mut value = artifact_value(&artifact);
1487 value["materials"]
1488 .as_array_mut()
1489 .expect("materials array")
1490 .push(json!({ "name": "smuggled" }));
1491 put_artifact_value(&mut artifact, &value);
1492 expect_claim(
1493 &source,
1494 &artifact,
1495 &plan,
1496 "every top-level array keeps its source length",
1497 );
1498 }
1499
1500 #[test]
1501 fn a_resolved_buffer_that_grew_fails_the_buffer_length_claim() {
1502 let (source, mut artifact, plan) = fixture();
1505 let mut value = artifact_value(&artifact);
1506 let mut buffer = artifact_buffer(&value);
1507 buffer.extend_from_slice(&[0u8; 4]);
1508 put_artifact_buffer(&mut value, &buffer);
1509 put_artifact_value(&mut artifact, &value);
1510 expect_claim(
1511 &source,
1512 &artifact,
1513 &plan,
1514 "every resolved buffer keeps its source byte length",
1515 );
1516 }
1517
1518 #[test]
1519 fn a_declared_buffer_length_without_backing_bytes_fails_container_integrity() {
1520 let (source, mut artifact, plan) = fixture();
1521 let mut value = artifact_value(&artifact);
1522 value["buffers"][0]["byteLength"] = json!(offsets::LENGTH + 4);
1523 put_artifact_value(&mut artifact, &value);
1524 expect_claim(
1525 &source,
1526 &artifact,
1527 &plan,
1528 "every declared buffer byteLength is backed by resolved bytes",
1529 );
1530 }
1531
1532 #[test]
1533 fn a_flipped_container_kind_fails_the_container_claim() {
1534 let (source, mut artifact, plan) = fixture();
1535 artifact.container = GltfContainerKind::Glb;
1536 expect_claim(
1537 &source,
1538 &artifact,
1539 &plan,
1540 "artifact container kind is unchanged",
1541 );
1542 }
1543
1544 #[test]
1545 fn a_bound_that_no_longer_bounds_the_converted_payload_fails() {
1546 let (source, mut artifact, plan) = fixture();
1550 let mut value = artifact_value(&artifact);
1551 value["accessors"][0]["min"][0] = json!(2.0000002f32 as f64);
1552 put_artifact_value(&mut artifact, &value);
1553 expect_claim(
1554 &source,
1555 &artifact,
1556 &plan,
1557 "a converted bound still bounds the converted payload",
1558 );
1559 }
1560
1561 #[test]
1562 fn bytes_that_are_not_the_rewriters_own_output_fail_the_determinism_claim() {
1563 let (source, mut artifact, plan) = fixture();
1567 let value = artifact_value(&artifact);
1568 artifact.bytes = serde_json::to_vec_pretty(&value).expect("pretty serializes");
1569 expect_claim(
1570 &source,
1571 &artifact,
1572 &plan,
1573 "rewriting the same source twice yields identical bytes",
1574 );
1575 }
1576
1577 #[test]
1578 fn a_nested_converted_range_does_not_rewind_the_preserved_byte_cursor() {
1579 let mut before = vec![0u8; 20];
1587 let mut after = vec![0u8; 20];
1588 before[10] = 1;
1589 after[10] = 2;
1590 let span = |start: usize, end: usize| AccessorSpan {
1591 accessor_index: 0,
1592 buffer: 0,
1593 start,
1594 end,
1595 components: 1,
1596 };
1597 let spans = [span(0, 16), span(4, 8)];
1598 let preserved = check_preserved_bytes(&[before], &[after], &spans)
1599 .expect("only 16..20 lies outside the converted ranges");
1600 assert_eq!(preserved, 1);
1601 }
1602}