1mod 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
101pub fn capability_facts(manifest: &GltfCapabilityManifest) -> ScaleCapabilityFacts {
137 let violations = manifest_violations(manifest);
138 capability_facts_from_violations(manifest, &violations)
139}
140
141pub 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
226fn 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 Kind::ConflictingNodeTransform | Kind::NonAffineNodeMatrix | Kind::AnimatedMatrixNode => {
261 facts.unknown_source_members_present = true;
262 }
263 }
264}
265
266fn 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 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
409pub 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
461pub 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
498fn 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#[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 pub fn bytes(&self) -> &[u8] {
540 &self.bytes
541 }
542
543 pub fn container(&self) -> GltfContainerKind {
545 self.container
546 }
547
548 pub fn rewritten_accessors(&self) -> &[usize] {
552 &self.rewritten_accessors
553 }
554
555 pub fn rewritten_json_pointers(&self) -> &[String] {
563 &self.rewritten_json_pointers
564 }
565
566 pub fn reencoded_buffers(&self) -> &[usize] {
569 &self.reencoded_buffers
570 }
571
572 pub fn affected_source_nodes(&self) -> &[usize] {
588 &self.affected_source_nodes
589 }
590
591 pub fn affected_source_skins(&self) -> &[usize] {
602 &self.affected_source_skins
603 }
604
605 pub fn declared_factor(&self) -> f64 {
609 self.declared_factor
610 }
611
612 pub fn operation(&self) -> ScaleOperation {
619 self.operation
620 }
621}
622
623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
631#[non_exhaustive]
632pub enum GltfRawJsonDifferenceKind {
633 ArtifactAdded,
635 ArtifactRemoved,
637 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#[derive(Debug, Clone, PartialEq, Eq)]
653pub struct GltfRawJsonDifference {
654 pub pointer: String,
660 pub kind: GltfRawJsonDifferenceKind,
662}
663
664#[derive(Debug, Clone, PartialEq, Eq)]
669pub struct GltfRawJsonDifferenceSummary {
670 pub differences: Vec<GltfRawJsonDifference>,
672 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#[derive(Debug, thiserror::Error)]
700#[non_exhaustive]
701pub enum GltfScaleRewriteError {
702 #[error("glTF scale rewrite rejected {count} unsupported source domain(s)")]
705 Capability {
706 violations: Vec<GltfCapabilityViolation>,
708 count: usize,
710 },
711 #[error(transparent)]
713 Plan(#[from] ScaleError),
714 #[error(transparent)]
716 Load(#[from] LoadError),
717 #[error(transparent)]
719 Write(#[from] WriteError),
720 #[error("no registered length-field handler for {location}")]
723 UnhandledLengthField {
724 location: String,
726 },
727 #[error("accessor {accessor_index} is used with two disagreeing rewrite rules")]
729 ConflictingRewriteRule {
730 accessor_index: usize,
732 },
733 #[error("accessor {accessor_index} at {location} is not a rewritable dense f32 accessor")]
736 UnrewritableAccessor {
737 accessor_index: usize,
739 location: String,
741 },
742 #[error("{location} declares a TRS member alongside matrix")]
746 ConflictingNodeTransform {
747 location: String,
749 },
750 #[error("{location} is {value}, so the node matrix is not TRS-decomposable")]
754 NonAffineNodeMatrix {
755 location: String,
757 value: f64,
759 expected: f64,
761 },
762 #[error("{location} reads bytes that overlap rewritten accessor {accessor_index}")]
768 ImagePayloadOverlap {
769 location: String,
771 accessor_index: usize,
773 },
774 #[error("source container cannot be reassembled: {reason}")]
777 UnreassemblableContainer {
778 reason: &'static str,
780 },
781 #[error("converted value {value} at {location} is not representable as f32")]
784 ValueNotRepresentable {
785 location: String,
787 value: f64,
789 },
790 #[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 accessor_index: usize,
814 element: usize,
816 first_location: String,
818 first_factor: f64,
820 second_location: String,
822 second_factor: f64,
824 },
825 #[error(
834 "the plan's affected closure {planned:?} is not the closure {derived:?} derived from the raw node hierarchy"
835 )]
836 ClosureMismatch {
837 planned: Vec<usize>,
839 derived: Vec<usize>,
841 },
842 #[error(
846 "source node {source_node_index} has a different parent in the skeleton than in the raw hierarchy"
847 )]
848 ParentChainDisagreement {
849 source_node_index: usize,
851 },
852 #[error("two source nodes both normalized to bone {bone}")]
856 AmbiguousSourceNodeProjection {
857 bone: animsmith_core::BoneId,
859 },
860 #[error("source node hierarchy is unusable: {reason}")]
862 UnusableSourceHierarchy {
863 reason: &'static str,
865 },
866 #[error(
868 "artifact proof claim {claim:?} observed {observed}, tolerance {tolerance}{diagnostics}",
869 diagnostics = RawJsonDifferenceSuffix(.raw_json_differences.as_ref())
870 )]
871 ArtifactProofFailed {
872 claim: &'static str,
874 observed: f64,
876 tolerance: f64,
878 raw_json_differences: Option<GltfRawJsonDifferenceSummary>,
883 },
884}
885
886pub 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
931pub 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 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 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
1050fn 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
1056fn 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 NodeTransformFault::UnreadableMatrixEntry { .. } => {
1094 LoadError::Malformed(format!("{location} is not a number")).into()
1095 }
1096 })
1097}
1098
1099fn 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
1123fn 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
1163fn 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
1193fn 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
1220fn 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
1280fn 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 const IDENTITY_MATRIX: [f64; 16] = [
1513 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 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 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 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 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 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 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 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 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 let source = past_the_gate(
1796 "image-overlap.gltf",
1797 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 let source = past_the_gate(
1818 "image-disjoint.gltf",
1819 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}