1use std::collections::{BTreeMap, BTreeSet};
8
9use glam::Mat4;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::diff::MetricDelta;
14use crate::evaluation::{
15 Applicability, CheckEvaluation, ConfigurationState, EvaluationState, SelectionState,
16};
17use crate::measure::{
18 Aabb, AssetMeasurements, ClipMeasurements, ImageMeasurements, LinearTransformClassification,
19 LinearTransformMeasurements, MaterialDefinitionMeasurements, SkeletonNodeLocalRestMeasurements,
20 SkeletonRestWorldMatrixUnavailableReason, SkinDerivedMatrixMeasurements,
21 SkinDerivedMatrixUnavailableReason, TextureMeasurements, assess_inverse_bind,
22 measure_linear_transform, summarize_skin_bind_linear,
23};
24use crate::model::{
25 DecodedImageColorType, MaterialResourceCoverage, SourceInverseBindAccessorStatus,
26 SourceSkeletonCoverage,
27};
28use crate::profile::ResolvedRoles;
29use crate::{Document, Severity};
30
31pub const OUTPUT_SCHEMA_VERSION: u32 = 7;
33pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:7";
35pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 13;
37pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:13";
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct ToolSource {
43 revision: Option<String>,
44 dirty: Option<bool>,
45}
46
47impl ToolSource {
48 pub fn new(revision: Option<String>, dirty: Option<bool>) -> Self {
55 let revision = revision.filter(|revision| {
56 revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit())
57 });
58 Self { revision, dirty }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct ToolInfo {
65 name: &'static str,
66 version: &'static str,
67 source: ToolSource,
68}
69
70impl ToolInfo {
71 pub fn animsmith(source: ToolSource) -> Self {
74 Self {
75 name: "animsmith",
76 version: env!("CARGO_PKG_VERSION"),
77 source,
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct InputIdentity {
88 sha256: String,
89 bytes: u64,
90}
91
92impl InputIdentity {
93 pub fn from_bytes(bytes: &[u8]) -> Self {
95 Self {
96 sha256: format!("{:x}", Sha256::digest(bytes)),
97 bytes: bytes.len() as u64,
98 }
99 }
100
101 pub fn sha256(&self) -> &str {
103 &self.sha256
104 }
105
106 pub fn bytes(&self) -> u64 {
108 self.bytes
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct RigInfo {
115 profile: String,
116 resolved_roles: BTreeMap<&'static str, String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121#[non_exhaustive]
122pub enum RigInfoError {
123 #[error(
125 "resolved role {role:?} references bone {bone}, but the document has {bone_count} bones"
126 )]
127 InvalidBoneId {
128 role: &'static str,
130 bone: usize,
132 bone_count: usize,
134 },
135 #[error(
137 "resolved role {role:?} expected bone {bone} to be {expected:?}, but the document names it {found:?}"
138 )]
139 BoneNameMismatch {
140 role: &'static str,
142 bone: usize,
144 expected: String,
146 found: String,
148 },
149}
150
151impl RigInfo {
152 pub fn from_resolved(doc: &Document, roles: &ResolvedRoles) -> Result<Self, RigInfoError> {
161 let resolved_roles = roles
162 .iter_with_names()
163 .map(|(role, bone, expected_name)| {
164 let name = doc
165 .skeleton
166 .bones
167 .get(bone)
168 .ok_or(RigInfoError::InvalidBoneId {
169 role: role.as_str(),
170 bone,
171 bone_count: doc.skeleton.bones.len(),
172 })?;
173 if name.name != expected_name {
174 return Err(RigInfoError::BoneNameMismatch {
175 role: role.as_str(),
176 bone,
177 expected: expected_name.to_owned(),
178 found: name.name.clone(),
179 });
180 }
181 Ok((role.as_str(), name.name.clone()))
182 })
183 .collect::<Result<_, _>>()?;
184 Ok(Self {
185 profile: roles.profile.clone(),
186 resolved_roles,
187 })
188 }
189}
190
191#[derive(Debug, Clone, Serialize)]
194pub struct MeasurementContract {
195 schema_version: u32,
196 schema: &'static str,
197 clips: BTreeMap<String, ClipMeasurements>,
198 #[serde(flatten)]
199 assets: AssetMeasurements,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
204#[non_exhaustive]
205pub enum MeasurementContractError {
206 #[error("measurement value {path} must be finite")]
208 NonFiniteValue {
209 path: String,
211 },
212 #[error("measurement structure {path} is invalid: {reason}")]
214 InvalidStructure {
215 path: String,
217 reason: String,
219 },
220}
221
222impl MeasurementContract {
223 pub fn new(
230 clips: BTreeMap<String, ClipMeasurements>,
231 assets: AssetMeasurements,
232 ) -> Result<Self, MeasurementContractError> {
233 validate_measurements(&clips, &assets)?;
234 Ok(Self {
235 schema_version: MEASUREMENTS_SCHEMA_VERSION,
236 schema: MEASUREMENTS_SCHEMA_ID,
237 clips,
238 assets,
239 })
240 }
241
242 pub fn clips(&self) -> &BTreeMap<String, ClipMeasurements> {
244 &self.clips
245 }
246
247 pub fn assets(&self) -> &AssetMeasurements {
249 &self.assets
250 }
251
252 pub fn into_parts(self) -> (BTreeMap<String, ClipMeasurements>, AssetMeasurements) {
254 (self.clips, self.assets)
255 }
256}
257
258fn validate_measurements(
259 clips: &BTreeMap<String, ClipMeasurements>,
260 assets: &AssetMeasurements,
261) -> Result<(), MeasurementContractError> {
262 let finite = |value: f64, path: String| {
263 value
264 .is_finite()
265 .then_some(())
266 .ok_or(MeasurementContractError::NonFiniteValue { path })
267 };
268 for (clip_name, clip) in clips {
269 finite(clip.duration_s, format!("clips[{clip_name:?}].duration_s"))?;
270 for (bone, value) in &clip.bone_rotation_range_deg {
271 finite(
272 *value,
273 format!("clips[{clip_name:?}].bone_rotation_range_deg[{bone:?}]"),
274 )?;
275 }
276 if let Some(loop_continuity) = &clip.loop_continuity {
277 if loop_continuity.bones.is_empty() {
278 return Err(MeasurementContractError::InvalidStructure {
279 path: format!("clips[{clip_name:?}].loop_continuity.bones"),
280 reason: "present loop-continuity evidence must contain at least one bone"
281 .into(),
282 });
283 }
284 for (expected_index, bone) in loop_continuity.bones.iter().enumerate() {
285 let path = format!("clips[{clip_name:?}].loop_continuity.bones[{expected_index}]");
286 if usize::try_from(bone.bone_index) != Ok(expected_index) {
287 return Err(MeasurementContractError::InvalidStructure {
288 path: format!("{path}.bone_index"),
289 reason: format!(
290 "expected skeleton-order index {expected_index}, found {}",
291 bone.bone_index
292 ),
293 });
294 }
295 for (field, value) in [
296 ("position_delta_m", bone.position_delta_m),
297 ("rotation_delta_deg", bone.rotation_delta_deg),
298 ("seam_velocity_delta_mps", bone.seam_velocity_delta_mps),
299 (
300 "seam_angular_velocity_delta_degps",
301 bone.seam_angular_velocity_delta_degps,
302 ),
303 ] {
304 finite(value, format!("{path}.{field}"))?;
305 if value < 0.0 {
306 return Err(MeasurementContractError::InvalidStructure {
307 path: format!("{path}.{field}"),
308 reason: "loop-continuity deltas must be non-negative".into(),
309 });
310 }
311 }
312 }
313 }
314 if let Some(frame_grid) = clip.frame_grid {
315 let path = format!("clips[{clip_name:?}].frame_grid");
316 finite(frame_grid.fps, format!("{path}.fps"))?;
317 if frame_grid.fps <= 0.0 {
318 return Err(MeasurementContractError::InvalidStructure {
319 path: format!("{path}.fps"),
320 reason: "declared frame-grid FPS must be positive".into(),
321 });
322 }
323 if frame_grid.frame_intervals == 0 {
324 return Err(MeasurementContractError::InvalidStructure {
325 path: format!("{path}.frame_intervals"),
326 reason: "declared frame-grid evidence must contain at least one interval"
327 .into(),
328 });
329 }
330 }
331 if let Some(value) = clip.loop_seam_ratio {
332 finite(value, format!("clips[{clip_name:?}].loop_seam_ratio"))?;
333 }
334 if let Some(gait) = &clip.gait {
335 if let Some(value) = gait.phase {
336 finite(value, format!("clips[{clip_name:?}].gait.phase"))?;
337 }
338 finite(
339 gait.lr_amplitude_m,
340 format!("clips[{clip_name:?}].gait.lr_amplitude_m"),
341 )?;
342 }
343 if let Some(value) = clip.speed_mps {
344 finite(value, format!("clips[{clip_name:?}].speed_mps"))?;
345 }
346 }
347 let invalid = |path: String, reason: &str| MeasurementContractError::InvalidStructure {
348 path,
349 reason: reason.to_owned(),
350 };
351 let finite_aabb = |aabb: &Aabb, path: &str| {
352 for (corner, values) in [("min", aabb.min), ("max", aabb.max)] {
353 for (axis, value) in values.into_iter().enumerate() {
354 finite(f64::from(value), format!("{path}.{corner}[{axis}]"))?;
355 }
356 }
357 for (axis, (min, max)) in aabb.min.into_iter().zip(aabb.max).enumerate() {
358 if min > max {
359 return Err(invalid(
360 format!("{path}.min[{axis}]"),
361 "AABB minimum cannot exceed maximum",
362 ));
363 }
364 }
365 Ok(())
366 };
367
368 let mut mesh_indices = BTreeSet::new();
369 for (index, mesh) in assets.mesh_definitions.iter().enumerate() {
370 if !mesh_indices.insert(mesh.mesh_index) {
371 return Err(invalid(
372 format!("mesh_definitions[{index}].mesh_index"),
373 "mesh_index must be unique",
374 ));
375 }
376 if let Some(aabb) = &mesh.geometry_aabb {
377 finite_aabb(aabb, &format!("mesh_definitions[{index}].geometry_aabb"))?;
378 }
379 if let Some(centroid) = mesh.geometry_centroid {
380 for (axis, value) in centroid.into_iter().enumerate() {
381 finite(
382 f64::from(value),
383 format!("mesh_definitions[{index}].geometry_centroid[{axis}]"),
384 )?;
385 }
386 }
387 if let Some(value) = mesh.weight_sum_min {
388 finite(value, format!("mesh_definitions[{index}].weight_sum_min"))?;
389 }
390 if let Some(value) = mesh.weight_sum_max {
391 finite(value, format!("mesh_definitions[{index}].weight_sum_max"))?;
392 }
393 let mut previous_set_index = None;
394 for (set_offset, set) in mesh.additional_influence_sets.iter().enumerate() {
395 let path = format!(
396 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].set_index"
397 );
398 if set.set_index == 0 {
399 return Err(invalid(path, "set_index must be at least 1"));
400 }
401 if !set.joints_present && !set.weights_present {
402 return Err(invalid(
403 format!("mesh_definitions[{index}].additional_influence_sets[{set_offset}]"),
404 "an additional influence set must declare joints, weights, or both",
405 ));
406 }
407 if set.joints_without_weights_present && !set.joints_present {
408 return Err(invalid(
409 format!(
410 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
411 ),
412 "joints_without_weights_present requires joints_present",
413 ));
414 }
415 if set.weights_without_joints_present && !set.weights_present {
416 return Err(invalid(
417 format!(
418 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
419 ),
420 "weights_without_joints_present requires weights_present",
421 ));
422 }
423 if set.joints_present && !set.weights_present && !set.joints_without_weights_present {
424 return Err(invalid(
425 format!(
426 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].joints_without_weights_present"
427 ),
428 "joints_without_weights_present is required when weights_present is false",
429 ));
430 }
431 if set.weights_present && !set.joints_present && !set.weights_without_joints_present {
432 return Err(invalid(
433 format!(
434 "mesh_definitions[{index}].additional_influence_sets[{set_offset}].weights_without_joints_present"
435 ),
436 "weights_without_joints_present is required when joints_present is false",
437 ));
438 }
439 if previous_set_index.is_some_and(|previous| previous >= set.set_index) {
440 return Err(invalid(
441 path,
442 "set_index values must be strictly increasing and unique",
443 ));
444 }
445 previous_set_index = Some(set.set_index);
446 }
447 }
448
449 let mut node_indices = BTreeSet::new();
450 for (index, instance) in assets.node_instances.iter().enumerate() {
451 if !node_indices.insert(instance.node_index) {
452 return Err(invalid(
453 format!("node_instances[{index}].node_index"),
454 "node_index must be unique",
455 ));
456 }
457 if !mesh_indices.contains(&instance.mesh_index) {
458 return Err(invalid(
459 format!("node_instances[{index}].mesh_index"),
460 "mesh_index must reference a mesh definition",
461 ));
462 }
463 match (
464 instance.static_node_world_aabb.as_ref(),
465 instance.static_node_world_aabb_unavailable_reason,
466 ) {
467 (Some(aabb), None) => finite_aabb(
468 aabb,
469 &format!("node_instances[{index}].static_node_world_aabb"),
470 )?,
471 (None, Some(_)) => {}
472 (Some(_), Some(_)) => {
473 return Err(invalid(
474 format!("node_instances[{index}]"),
475 "an available static node AABB cannot have an unavailable reason",
476 ));
477 }
478 (None, None) => {
479 return Err(invalid(
480 format!("node_instances[{index}]"),
481 "a missing static node AABB requires an unavailable reason",
482 ));
483 }
484 }
485 }
486
487 let mut scene_indices = BTreeSet::new();
488 for (index, scene) in assets.scenes.iter().enumerate() {
489 if !scene_indices.insert(scene.scene_index) {
490 return Err(invalid(
491 format!("scenes[{index}].scene_index"),
492 "scene_index must be unique",
493 ));
494 }
495 if scene.excluded_instance_count > scene.instance_count {
496 return Err(invalid(
497 format!("scenes[{index}].excluded_instance_count"),
498 "excluded_instance_count cannot exceed instance_count",
499 ));
500 }
501 let available = scene.instance_count - scene.excluded_instance_count;
502 match (&scene.static_scene_world_aabb, available) {
503 (Some(aabb), 1..) => {
504 finite_aabb(aabb, &format!("scenes[{index}].static_scene_world_aabb"))?
505 }
506 (None, 0) => {}
507 (Some(_), 0) => {
508 return Err(invalid(
509 format!("scenes[{index}].static_scene_world_aabb"),
510 "a scene with no available instances cannot have an AABB",
511 ));
512 }
513 (None, _) => {
514 return Err(invalid(
515 format!("scenes[{index}].static_scene_world_aabb"),
516 "a scene with available instances requires an AABB",
517 ));
518 }
519 }
520 }
521 if let Some(default_scene_index) = assets.default_scene_index
522 && !scene_indices.contains(&default_scene_index)
523 {
524 return Err(invalid(
525 "default_scene_index".into(),
526 "default_scene_index must reference a declared scene",
527 ));
528 }
529 validate_skeleton_measurements(assets, &invalid)?;
530 validate_material_resources(assets, &invalid)?;
531 Ok(())
532}
533
534fn validate_linear_transform_fields(
535 linear: &LinearTransformMeasurements,
536 path: &str,
537 invalid: &impl Fn(String, &str) -> MeasurementContractError,
538) -> Result<(), MeasurementContractError> {
539 let numeric_fields_present = linear.axis_lengths.is_some()
540 && linear.determinant.is_some()
541 && linear.orientation.is_some();
542 if linear.classification == LinearTransformClassification::NonFinite {
543 if linear.axis_lengths.is_some()
544 || linear.determinant.is_some()
545 || linear.orientation.is_some()
546 || linear.uniform_scale.is_some()
547 {
548 return Err(invalid(
549 path.into(),
550 "a non_finite classification cannot carry numeric linear-transform facts",
551 ));
552 }
553 return Ok(());
554 }
555 if !numeric_fields_present {
556 return Err(invalid(
557 path.into(),
558 "a finite classification requires axis_lengths, determinant, and orientation",
559 ));
560 }
561 for (axis, value) in linear
562 .axis_lengths
563 .expect("presence checked")
564 .into_iter()
565 .enumerate()
566 {
567 if !value.is_finite() {
568 return Err(MeasurementContractError::NonFiniteValue {
569 path: format!("{path}.axis_lengths[{axis}]"),
570 });
571 }
572 if value < 0.0 {
573 return Err(invalid(
574 format!("{path}.axis_lengths[{axis}]"),
575 "axis lengths must be non-negative",
576 ));
577 }
578 }
579 if !linear.determinant.expect("presence checked").is_finite() {
580 return Err(MeasurementContractError::NonFiniteValue {
581 path: format!("{path}.determinant"),
582 });
583 }
584 if let Some(scale) = linear.uniform_scale {
585 if !scale.is_finite() {
586 return Err(MeasurementContractError::NonFiniteValue {
587 path: format!("{path}.uniform_scale"),
588 });
589 }
590 if scale < 0.0 {
591 return Err(invalid(
592 format!("{path}.uniform_scale"),
593 "uniform scale must be non-negative",
594 ));
595 }
596 }
597 Ok(())
598}
599
600fn validate_skeleton_measurements(
601 assets: &AssetMeasurements,
602 invalid: &impl Fn(String, &str) -> MeasurementContractError,
603) -> Result<(), MeasurementContractError> {
604 if assets.skeleton_source_coverage == SourceSkeletonCoverage::Unavailable {
605 if !assets.skeleton_nodes.is_empty() || !assets.skins.is_empty() {
606 return Err(invalid(
607 "skeleton_source_coverage".into(),
608 "unavailable skeleton source coverage requires empty skeleton_nodes and skins arrays",
609 ));
610 }
611 return Ok(());
612 }
613
614 let finite_matrix = |matrix: &[f32; 16], path: &str| {
615 for (component, value) in matrix.iter().enumerate() {
616 if !value.is_finite() {
617 return Err(MeasurementContractError::NonFiniteValue {
618 path: format!("{path}[{component}]"),
619 });
620 }
621 }
622 Ok(())
623 };
624 for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
625 if node.node_index != offset {
626 return Err(invalid(
627 format!("skeleton_nodes[{offset}].node_index"),
628 "node_index must be contiguous and match source order",
629 ));
630 }
631 match &node.local_rest {
632 SkeletonNodeLocalRestMeasurements::Trs {
633 translation_parent_space_m,
634 rotation_xyzw,
635 scale,
636 } => {
637 for (field, values) in [
638 (
639 "translation_parent_space_m",
640 translation_parent_space_m.as_slice(),
641 ),
642 ("rotation_xyzw", rotation_xyzw.as_slice()),
643 ("scale", scale.as_slice()),
644 ] {
645 for (component, value) in values.iter().enumerate() {
646 if !value.is_finite() {
647 return Err(MeasurementContractError::NonFiniteValue {
648 path: format!(
649 "skeleton_nodes[{offset}].local_rest.{field}[{component}]"
650 ),
651 });
652 }
653 }
654 }
655 }
656 SkeletonNodeLocalRestMeasurements::Matrix { matrix } => finite_matrix(
657 matrix,
658 &format!("skeleton_nodes[{offset}].local_rest.matrix"),
659 )?,
660 SkeletonNodeLocalRestMeasurements::Unavailable { .. } => {}
661 }
662 let node_path = format!("skeleton_nodes[{offset}]");
663 validate_linear_transform_fields(
664 &node.rest_world_linear,
665 &format!("{node_path}.rest_world_linear"),
666 invalid,
667 )?;
668 match (
669 node.rest_world_matrix.as_ref(),
670 node.rest_world_translation_m.as_ref(),
671 node.rest_world_matrix_unavailable_reason,
672 ) {
673 (Some(matrix), Some(translation), None) => {
674 finite_matrix(matrix, &format!("{node_path}.rest_world_matrix"))?;
675 for (component, value) in translation.iter().enumerate() {
676 if !value.is_finite() {
677 return Err(MeasurementContractError::NonFiniteValue {
678 path: format!("{node_path}.rest_world_translation_m[{component}]"),
679 });
680 }
681 }
682 let expected_translation = [matrix[12], matrix[13], matrix[14]];
683 if *translation != expected_translation {
684 return Err(invalid(
685 format!("{node_path}.rest_world_translation_m"),
686 "rest_world_translation_m must equal the rest-world matrix translation column",
687 ));
688 }
689 let expected_linear = measure_linear_transform(Mat4::from_cols_array(matrix));
690 if node.rest_world_linear != expected_linear {
691 return Err(invalid(
692 format!("{node_path}.rest_world_linear"),
693 "rest_world_linear must be derived from rest_world_matrix",
694 ));
695 }
696 }
697 (None, None, Some(_)) => {
698 if node.rest_world_linear.classification != LinearTransformClassification::NonFinite
699 {
700 return Err(invalid(
701 format!("{node_path}.rest_world_linear"),
702 "an unavailable rest-world matrix requires a non_finite linear classification",
703 ));
704 }
705 }
706 (Some(_), Some(_), Some(_)) => {
707 return Err(invalid(
708 node_path,
709 "an available rest_world_matrix cannot have an unavailable reason",
710 ));
711 }
712 _ => {
713 return Err(invalid(
714 node_path,
715 "rest-world matrix, translation, and unavailable reason fields are inconsistent",
716 ));
717 }
718 }
719 }
720 for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
721 if let Some(parent) = node.parent_node_index
722 && parent >= assets.skeleton_nodes.len()
723 {
724 return Err(invalid(
725 format!("skeleton_nodes[{offset}].parent_node_index"),
726 "parent_node_index must reference a skeleton node",
727 ));
728 }
729 let mut previous_scene = None;
730 for (scene_offset, scene_index) in node.scene_root_indices.iter().enumerate() {
731 if !assets
732 .scenes
733 .iter()
734 .any(|scene| scene.scene_index == *scene_index)
735 {
736 return Err(invalid(
737 format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
738 "scene_root_indices values must reference declared scenes",
739 ));
740 }
741 if previous_scene.is_some_and(|previous| previous >= *scene_index) {
742 return Err(invalid(
743 format!("skeleton_nodes[{offset}].scene_root_indices[{scene_offset}]"),
744 "scene_root_indices values must be strictly increasing and unique",
745 ));
746 }
747 previous_scene = Some(*scene_index);
748 }
749 }
750 let mut visits = vec![ParentVisit::Unvisited; assets.skeleton_nodes.len()];
751 for start in 0..assets.skeleton_nodes.len() {
752 if visits.get(start) != Some(&ParentVisit::Unvisited) {
753 continue;
754 }
755 let mut path = Vec::new();
756 let mut current = start;
757 loop {
758 match visits.get(current).copied().ok_or_else(|| {
759 invalid(
760 format!("skeleton_nodes[{current}].parent_node_index"),
761 "parent_node_index must reference a skeleton node",
762 )
763 })? {
764 ParentVisit::Done => break,
765 ParentVisit::Visiting => {
766 return Err(invalid(
767 format!("skeleton_nodes[{current}].parent_node_index"),
768 "source node parent graph must be acyclic",
769 ));
770 }
771 ParentVisit::Unvisited => {
772 *visits.get_mut(current).ok_or_else(|| {
773 invalid(
774 format!("skeleton_nodes[{current}].parent_node_index"),
775 "parent_node_index must reference a skeleton node",
776 )
777 })? = ParentVisit::Visiting;
778 path.push(current);
779 match assets
780 .skeleton_nodes
781 .get(current)
782 .ok_or_else(|| {
783 invalid(
784 format!("skeleton_nodes[{current}].parent_node_index"),
785 "parent_node_index must reference a skeleton node",
786 )
787 })?
788 .parent_node_index
789 {
790 Some(parent) => current = parent,
791 None => break,
792 }
793 }
794 }
795 }
796 for node_index in path {
797 *visits.get_mut(node_index).ok_or_else(|| {
798 invalid(
799 format!("skeleton_nodes[{node_index}].parent_node_index"),
800 "parent_node_index must reference a skeleton node",
801 )
802 })? = ParentVisit::Done;
803 }
804 }
805
806 for (offset, node) in assets.skeleton_nodes.iter().enumerate() {
807 let local_rest_available = !matches!(
808 node.local_rest,
809 SkeletonNodeLocalRestMeasurements::Unavailable { .. }
810 );
811 let path = format!("skeleton_nodes[{offset}]");
812 if !local_rest_available {
813 if node.rest_world_matrix.is_some()
814 || node.rest_world_matrix_unavailable_reason
815 != Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
816 {
817 return Err(invalid(
818 path,
819 "an unavailable local_rest requires a non_finite_local_rest rest-world result",
820 ));
821 }
822 continue;
823 }
824
825 let expected_unavailable_reason = if let Some(parent_index) = node.parent_node_index {
826 let parent = assets.skeleton_nodes.get(parent_index).ok_or_else(|| {
827 invalid(
828 format!("skeleton_nodes[{offset}].parent_node_index"),
829 "parent_node_index must reference a skeleton node",
830 )
831 })?;
832 if parent.rest_world_matrix.is_none() {
833 Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable)
834 } else {
835 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)
836 }
837 } else {
838 None
839 };
840 match (
841 node.rest_world_matrix.is_some(),
842 expected_unavailable_reason,
843 ) {
844 (true, None | Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix)) => {
845 }
846 (false, Some(expected))
847 if node.rest_world_matrix_unavailable_reason == Some(expected) => {}
848 _ => {
849 return Err(invalid(
850 path,
851 "rest-world availability must agree with local rest and parent rest-world evidence",
852 ));
853 }
854 }
855 }
856
857 for (offset, skin) in assets.skins.iter().enumerate() {
858 if skin.skin_index != offset {
859 return Err(invalid(
860 format!("skins[{offset}].skin_index"),
861 "skin_index must be contiguous and match source order",
862 ));
863 }
864 if let Some(root) = skin.skeleton_root_node_index
865 && root >= assets.skeleton_nodes.len()
866 {
867 return Err(invalid(
868 format!("skins[{offset}].skeleton_root_node_index"),
869 "skeleton_root_node_index must reference a skeleton node",
870 ));
871 }
872 for (joint_offset, joint) in skin.joints.iter().enumerate() {
873 if joint.joint_index != joint_offset {
874 return Err(invalid(
875 format!("skins[{offset}].joints[{joint_offset}].joint_index"),
876 "joint_index must be contiguous and match declared skin order",
877 ));
878 }
879 if joint.node_index >= assets.skeleton_nodes.len() {
880 return Err(invalid(
881 format!("skins[{offset}].joints[{joint_offset}].node_index"),
882 "joint node_index must reference a skeleton node",
883 ));
884 }
885 }
886 match skin.inverse_bind_accessor.status {
887 SourceInverseBindAccessorStatus::Absent => {
888 if skin.inverse_bind_accessor.declared_count.is_some()
889 || !skin.inverse_bind_accessor.matrices.is_empty()
890 {
891 return Err(invalid(
892 format!("skins[{offset}].inverse_bind_accessor"),
893 "an absent inverse-bind declaration has no declared count or matrices",
894 ));
895 }
896 }
897 SourceInverseBindAccessorStatus::EmptyAccessor => {
898 if skin.inverse_bind_accessor.declared_count != Some(0)
899 || !skin.inverse_bind_accessor.matrices.is_empty()
900 {
901 return Err(invalid(
902 format!("skins[{offset}].inverse_bind_accessor"),
903 "an empty inverse-bind declaration has declared_count 0 and no matrices",
904 ));
905 }
906 }
907 SourceInverseBindAccessorStatus::Available => {
908 if skin.inverse_bind_accessor.declared_count
909 != Some(skin.inverse_bind_accessor.matrices.len())
910 || skin.inverse_bind_accessor.matrices.len() < skin.joints.len()
911 {
912 return Err(invalid(
913 format!("skins[{offset}].inverse_bind_accessor"),
914 "an available inverse-bind declaration must retain its declared finite matrices and cover every joint",
915 ));
916 }
917 }
918 SourceInverseBindAccessorStatus::CountMismatch => {
919 if skin.inverse_bind_accessor.declared_count
920 != Some(skin.inverse_bind_accessor.matrices.len())
921 || skin.inverse_bind_accessor.matrices.len() >= skin.joints.len()
922 {
923 return Err(invalid(
924 format!("skins[{offset}].inverse_bind_accessor"),
925 "a count-mismatched inverse-bind declaration retains fewer matrices than joints",
926 ));
927 }
928 }
929 SourceInverseBindAccessorStatus::Unreadable => {
930 if skin.inverse_bind_accessor.declared_count.is_none()
931 || !skin.inverse_bind_accessor.matrices.is_empty()
932 {
933 return Err(invalid(
934 format!("skins[{offset}].inverse_bind_accessor"),
935 "an unreadable inverse-bind declaration retains its count but cannot serialize matrices",
936 ));
937 }
938 }
939 }
940 for (matrix_offset, matrix) in skin.inverse_bind_accessor.matrices.iter().enumerate() {
941 finite_matrix(
942 matrix,
943 &format!("skins[{offset}].inverse_bind_accessor.matrices[{matrix_offset}]"),
944 )?;
945 }
946 for (joint_offset, joint) in skin.joints.iter().enumerate() {
947 let expected_source = skin.inverse_bind_accessor.matrices.get(joint_offset);
948 let joint_bind_path =
949 format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
950 validate_derived_matrix(
951 &joint.joint_bind_to_mesh,
952 &joint_bind_path,
953 &finite_matrix,
954 invalid,
955 )?;
956 validate_derived_reason_compatibility(
957 &joint.joint_bind_to_mesh,
958 skin.inverse_bind_accessor.status,
959 skin.inverse_bind_accessor.matrices.len(),
960 joint_offset,
961 &joint_bind_path,
962 DerivedMatrixDomain::JointBindToMesh,
963 invalid,
964 )?;
965 validate_derived_source(
966 &joint.joint_bind_to_mesh,
967 expected_source,
968 None,
969 &joint_bind_path,
970 DerivedMatrixDomain::JointBindToMesh,
971 invalid,
972 )?;
973
974 let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
975 validate_derived_matrix(
976 &joint.mesh_bind_world,
977 &mesh_bind_path,
978 &finite_matrix,
979 invalid,
980 )?;
981 validate_derived_reason_compatibility(
982 &joint.mesh_bind_world,
983 skin.inverse_bind_accessor.status,
984 skin.inverse_bind_accessor.matrices.len(),
985 joint_offset,
986 &mesh_bind_path,
987 DerivedMatrixDomain::MeshBindWorld,
988 invalid,
989 )?;
990 let joint_rest_world_available = assets
991 .skeleton_nodes
992 .get(joint.node_index)
993 .ok_or_else(|| {
994 invalid(
995 format!("skins[{offset}].joints[{joint_offset}].node_index"),
996 "joint node_index must reference a skeleton node",
997 )
998 })?
999 .rest_world_matrix
1000 .is_some();
1001 let joint_rest_world = assets.skeleton_nodes[joint.node_index]
1002 .rest_world_matrix
1003 .as_ref();
1004 validate_mesh_bind_world_reason_compatibility(
1005 &joint.mesh_bind_world,
1006 joint_rest_world_available,
1007 &mesh_bind_path,
1008 invalid,
1009 )?;
1010 validate_derived_source(
1011 &joint.mesh_bind_world,
1012 expected_source,
1013 joint_rest_world,
1014 &mesh_bind_path,
1015 DerivedMatrixDomain::MeshBindWorld,
1016 invalid,
1017 )?;
1018 }
1019 if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1020 && !scale.is_finite()
1021 {
1022 return Err(MeasurementContractError::NonFiniteValue {
1023 path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1024 });
1025 }
1026 let expected_summary = summarize_skin_bind_linear(&skin.joints);
1027 if skin.joint_bind_linear_summary != expected_summary {
1028 return Err(invalid(
1029 format!("skins[{offset}].joint_bind_linear_summary"),
1030 "joint-bind linear summary must match the skin joint observations",
1031 ));
1032 }
1033 let mut previous_attachment_node = None;
1034 for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1035 if attachment.node_index >= assets.skeleton_nodes.len() {
1036 return Err(invalid(
1037 format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1038 "attachment node_index must reference a skeleton node",
1039 ));
1040 }
1041 if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1042 return Err(invalid(
1043 format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1044 "attachment node_index values must be strictly increasing and unique",
1045 ));
1046 }
1047 previous_attachment_node = Some(attachment.node_index);
1048 }
1049 }
1050 Ok(())
1051}
1052
1053fn validate_derived_reason_compatibility(
1054 matrix: &SkinDerivedMatrixMeasurements,
1055 status: SourceInverseBindAccessorStatus,
1056 readable_matrix_count: usize,
1057 joint_index: usize,
1058 path: &str,
1059 domain: DerivedMatrixDomain,
1060 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1061) -> Result<(), MeasurementContractError> {
1062 let requires_accessor_reason = match status {
1063 SourceInverseBindAccessorStatus::Absent => {
1064 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1065 }
1066 SourceInverseBindAccessorStatus::EmptyAccessor => {
1067 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1068 }
1069 SourceInverseBindAccessorStatus::Unreadable => {
1070 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1071 }
1072 SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1073 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1074 }
1075 SourceInverseBindAccessorStatus::Available
1076 | SourceInverseBindAccessorStatus::CountMismatch => None,
1077 };
1078 if let Some(expected) = requires_accessor_reason {
1079 if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1080 return Err(invalid(
1081 path.into(),
1082 "derived matrices without a usable inverse bind must carry the matching accessor reason",
1083 ));
1084 }
1085 } else {
1086 match (domain, matrix.unavailable_reason) {
1087 (
1088 _,
1089 Some(
1090 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1091 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1092 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1093 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1094 ),
1095 ) => {
1096 return Err(invalid(
1097 format!("{path}.unavailable_reason"),
1098 "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1099 ));
1100 }
1101 (
1102 DerivedMatrixDomain::JointBindToMesh,
1103 Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable),
1104 ) => {
1105 return Err(invalid(
1106 format!("{path}.unavailable_reason"),
1107 "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1108 ));
1109 }
1110 (
1111 DerivedMatrixDomain::MeshBindWorld,
1112 Some(
1113 SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible
1114 | SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine
1115 | SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned,
1116 ),
1117 ) => {
1118 return Err(invalid(
1119 format!("{path}.unavailable_reason"),
1120 "mesh_bind_world does not require an invertible inverse-bind matrix",
1121 ));
1122 }
1123 _ => {}
1124 }
1125 }
1126 Ok(())
1127}
1128
1129fn validate_mesh_bind_world_reason_compatibility(
1130 matrix: &SkinDerivedMatrixMeasurements,
1131 joint_rest_world_available: bool,
1132 path: &str,
1133 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1134) -> Result<(), MeasurementContractError> {
1135 match matrix.unavailable_reason {
1136 Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1137 if joint_rest_world_available =>
1138 {
1139 Err(invalid(
1140 format!("{path}.unavailable_reason"),
1141 "an available joint rest-world matrix cannot be reported as unavailable",
1142 ))
1143 }
1144 Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1145 if !joint_rest_world_available =>
1146 {
1147 Err(invalid(
1148 format!("{path}.unavailable_reason"),
1149 "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1150 ))
1151 }
1152 _ => Ok(()),
1153 }
1154}
1155
1156#[derive(Clone, Copy, PartialEq, Eq)]
1157enum ParentVisit {
1158 Unvisited,
1159 Visiting,
1160 Done,
1161}
1162
1163#[derive(Clone, Copy)]
1164enum DerivedMatrixDomain {
1165 JointBindToMesh,
1166 MeshBindWorld,
1167}
1168
1169fn validate_derived_source(
1170 measurements: &SkinDerivedMatrixMeasurements,
1171 expected_source: Option<&[f32; 16]>,
1172 joint_rest_world: Option<&[f32; 16]>,
1173 path: &str,
1174 domain: DerivedMatrixDomain,
1175 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1176) -> Result<(), MeasurementContractError> {
1177 if measurements.source_inverse_bind_matrix.as_ref() != expected_source {
1178 return Err(invalid(
1179 format!("{path}.source_inverse_bind_matrix"),
1180 "source_inverse_bind_matrix must equal the retained declaration slot exactly",
1181 ));
1182 }
1183 let Some(source) = expected_source else {
1184 if measurements.inversion_quality.is_some() {
1185 return Err(invalid(
1186 format!("{path}.inversion_quality"),
1187 "inversion quality requires a readable source inverse-bind matrix",
1188 ));
1189 }
1190 return Ok(());
1191 };
1192 let raw = Mat4::from_cols_array(source);
1193 match domain {
1194 DerivedMatrixDomain::JointBindToMesh => {
1195 let assessment = assess_inverse_bind(raw);
1196 if measurements.inversion_quality != assessment.quality {
1197 return Err(invalid(
1198 format!("{path}.inversion_quality"),
1199 "inversion quality must be derived from the source linear 3x3",
1200 ));
1201 }
1202 match assessment.inverse {
1203 Ok(inverse) => {
1204 if measurements.matrix != Some(inverse.to_cols_array())
1205 || measurements.unavailable_reason.is_some()
1206 {
1207 return Err(invalid(
1208 path.into(),
1209 "a trustworthy source inverse-bind matrix requires its exact inverse",
1210 ));
1211 }
1212 }
1213 Err(reason) => {
1214 if measurements.matrix.is_some()
1215 || measurements.unavailable_reason != Some(reason)
1216 {
1217 return Err(invalid(
1218 path.into(),
1219 "an untrustworthy source inverse-bind matrix requires its derived reason",
1220 ));
1221 }
1222 }
1223 }
1224 }
1225 DerivedMatrixDomain::MeshBindWorld => {
1226 if measurements.inversion_quality.is_some() {
1227 return Err(invalid(
1228 format!("{path}.inversion_quality"),
1229 "mesh_bind_world does not invert its source matrix",
1230 ));
1231 }
1232 if let Some(world) = joint_rest_world {
1233 let expected = Mat4::from_cols_array(world) * raw;
1234 if expected.to_cols_array().into_iter().all(f32::is_finite) {
1235 if measurements.matrix != Some(expected.to_cols_array())
1236 || measurements.unavailable_reason.is_some()
1237 {
1238 return Err(invalid(
1239 path.into(),
1240 "mesh_bind_world must equal joint_rest_world times the source inverse bind",
1241 ));
1242 }
1243 } else if measurements.unavailable_reason
1244 != Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1245 {
1246 return Err(invalid(
1247 format!("{path}.unavailable_reason"),
1248 "a non-finite mesh-bind product requires its typed unavailable reason",
1249 ));
1250 }
1251 }
1252 }
1253 }
1254 Ok(())
1255}
1256
1257fn validate_derived_matrix(
1258 matrix: &SkinDerivedMatrixMeasurements,
1259 path: &str,
1260 finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1261 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1262) -> Result<(), MeasurementContractError> {
1263 if let Some(source) = &matrix.source_inverse_bind_matrix {
1264 finite_matrix(source, &format!("{path}.source_inverse_bind_matrix"))?;
1265 }
1266 if let Some(quality) = matrix.inversion_quality {
1267 let value = quality.reciprocal_condition_number_inf;
1268 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1269 return Err(invalid(
1270 format!("{path}.inversion_quality.reciprocal_condition_number_inf"),
1271 "reciprocal condition number must be finite and between zero and one",
1272 ));
1273 }
1274 }
1275 match (
1276 &matrix.matrix,
1277 matrix.linear.as_ref(),
1278 matrix.unavailable_reason,
1279 ) {
1280 (Some(matrix), Some(linear), None) => {
1281 finite_matrix(matrix, &format!("{path}.matrix"))?;
1282 validate_linear_transform_fields(linear, &format!("{path}.linear"), invalid)?;
1283 if *linear != measure_linear_transform(Mat4::from_cols_array(matrix)) {
1284 return Err(invalid(
1285 format!("{path}.linear"),
1286 "linear facts must be derived from the available matrix",
1287 ));
1288 }
1289 }
1290 (None, None, Some(_)) => {}
1291 (Some(_), Some(_), Some(_)) => {
1292 return Err(invalid(
1293 path.into(),
1294 "an available derived matrix cannot have an unavailable reason",
1295 ));
1296 }
1297 _ => {
1298 return Err(invalid(
1299 path.into(),
1300 "derived matrix, linear facts, and unavailable reason fields are inconsistent",
1301 ));
1302 }
1303 }
1304 Ok(())
1305}
1306
1307fn validate_material_resources(
1308 assets: &AssetMeasurements,
1309 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1310) -> Result<(), MeasurementContractError> {
1311 let absent = assets.material_definitions.is_empty()
1312 && assets.textures.is_empty()
1313 && assets.images.is_empty();
1314 if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
1315 return Err(invalid(
1316 "material_resource_coverage".into(),
1317 "unavailable resource coverage requires empty material, texture, and image arrays",
1318 ));
1319 }
1320
1321 for (offset, material) in assets.material_definitions.iter().enumerate() {
1322 if material.material_index != offset {
1323 return Err(invalid(
1324 format!("material_definitions[{offset}].material_index"),
1325 "material_index must be contiguous and match source order",
1326 ));
1327 }
1328 let mut previous_slot = None;
1329 for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
1330 if binding.texture_index >= assets.textures.len() {
1331 return Err(invalid(
1332 format!(
1333 "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
1334 ),
1335 "texture_index must reference a source texture",
1336 ));
1337 }
1338 if previous_slot.is_some_and(|previous| previous >= binding.slot) {
1339 return Err(invalid(
1340 format!(
1341 "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
1342 ),
1343 "texture bindings must be strictly ordered by slot and unique",
1344 ));
1345 }
1346 previous_slot = Some(binding.slot);
1347 }
1348 }
1349 for (offset, texture) in assets.textures.iter().enumerate() {
1350 if texture.texture_index != offset {
1351 return Err(invalid(
1352 format!("textures[{offset}].texture_index"),
1353 "texture_index must be contiguous and match source order",
1354 ));
1355 }
1356 if texture.image_index >= assets.images.len() {
1357 return Err(invalid(
1358 format!("textures[{offset}].image_index"),
1359 "image_index must reference a source image",
1360 ));
1361 }
1362 }
1363 for (offset, image) in assets.images.iter().enumerate() {
1364 validate_image_measurement(image, offset, invalid)?;
1365 }
1366 Ok(())
1367}
1368
1369fn validate_image_measurement(
1370 image: &ImageMeasurements,
1371 offset: usize,
1372 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1373) -> Result<(), MeasurementContractError> {
1374 if image.image_index != offset {
1375 return Err(invalid(
1376 format!("images[{offset}].image_index"),
1377 "image_index must be contiguous and match source order",
1378 ));
1379 }
1380 let available = [
1381 image.width.is_some(),
1382 image.height.is_some(),
1383 image.channel_count.is_some(),
1384 image.decoded_color_type.is_some(),
1385 ];
1386 match (
1387 available.into_iter().all(|value| value),
1388 image.unavailable_reason,
1389 ) {
1390 (true, None) => {
1391 let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
1392 image.width,
1393 image.height,
1394 image.channel_count,
1395 image.decoded_color_type,
1396 ) else {
1397 return Err(invalid(
1398 format!("images[{offset}]"),
1399 "available image metadata must include width, height, channel_count, and decoded_color_type",
1400 ));
1401 };
1402 if width == 0 || height == 0 {
1403 return Err(invalid(
1404 format!("images[{offset}]"),
1405 "available image dimensions must be greater than zero",
1406 ));
1407 }
1408 if channel_count != color_type_channel_count(decoded_color_type) {
1409 return Err(invalid(
1410 format!("images[{offset}].channel_count"),
1411 "channel_count must match decoded_color_type",
1412 ));
1413 }
1414 if image.detected_container.is_none() {
1415 return Err(invalid(
1416 format!("images[{offset}].detected_container"),
1417 "available image metadata requires a detected_container",
1418 ));
1419 }
1420 }
1421 (false, Some(_)) if available.into_iter().all(|value| !value) => {}
1422 (true, Some(_)) => {
1423 return Err(invalid(
1424 format!("images[{offset}]"),
1425 "available image metadata cannot have an unavailable_reason",
1426 ));
1427 }
1428 (false, None) if available.into_iter().all(|value| !value) => {
1429 return Err(invalid(
1430 format!("images[{offset}]"),
1431 "missing image metadata requires an unavailable_reason",
1432 ));
1433 }
1434 (false, _) => {
1435 return Err(invalid(
1436 format!("images[{offset}]"),
1437 "available image metadata must include width, height, channel_count, and decoded_color_type",
1438 ));
1439 }
1440 }
1441 match image.unavailable_reason {
1442 Some(crate::model::ImageUnavailableReason::DecodeFailed)
1443 if image.detected_container.is_none() =>
1444 {
1445 return Err(invalid(
1446 format!("images[{offset}].detected_container"),
1447 "decode_failed requires a detected_container",
1448 ));
1449 }
1450 Some(
1451 crate::model::ImageUnavailableReason::SourceUnavailable
1452 | crate::model::ImageUnavailableReason::InvalidDataUri
1453 | crate::model::ImageUnavailableReason::UnsupportedContainer,
1454 ) if image.detected_container.is_some() => {
1455 return Err(invalid(
1456 format!("images[{offset}].detected_container"),
1457 "this unavailable_reason cannot have a detected_container",
1458 ));
1459 }
1460 _ => {}
1461 }
1462 Ok(())
1463}
1464
1465fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
1466 match color_type {
1467 DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
1468 DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
1469 DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
1470 DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
1471 }
1472}
1473
1474#[derive(Debug, Deserialize)]
1482pub struct MeasurementReportInput {
1483 schema_version: Option<u32>,
1484 schema: Option<String>,
1485 command: Option<String>,
1486 files: Option<Vec<MeasurementFileInput>>,
1487}
1488
1489#[derive(Debug, Deserialize)]
1490struct MeasurementFileInput {
1491 path: Option<String>,
1492 input: Option<InputIdentityInput>,
1493 measurements: Option<MeasurementPayloadInput>,
1494}
1495
1496#[derive(Debug, Deserialize)]
1497struct InputIdentityInput {
1498 sha256: Option<String>,
1499 bytes: Option<u64>,
1500}
1501
1502#[derive(Debug, Deserialize)]
1503#[serde(untagged)]
1504enum SkeletonNodeMeasurementInput {
1505 Current(Box<crate::measure::SkeletonNodeMeasurements>),
1506 Earlier {
1507 #[serde(rename = "node_index")]
1508 _node_index: usize,
1509 },
1510}
1511
1512#[derive(Debug, Deserialize)]
1513#[serde(untagged)]
1514enum SkinMeasurementInput {
1515 Current(Box<crate::measure::SkinMeasurements>),
1516 Earlier {
1517 #[serde(rename = "skin_index")]
1518 _skin_index: usize,
1519 },
1520}
1521
1522#[derive(Debug, Deserialize)]
1523struct MeasurementPayloadInput {
1524 schema_version: Option<u32>,
1525 schema: Option<String>,
1526 clips: Option<BTreeMap<String, ClipMeasurements>>,
1527 material_resource_coverage: Option<MaterialResourceCoverage>,
1528 material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
1529 textures: Option<Vec<TextureMeasurements>>,
1530 images: Option<Vec<ImageMeasurements>>,
1531 skeleton_source_coverage: Option<SourceSkeletonCoverage>,
1532 skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
1533 skins: Option<Vec<SkinMeasurementInput>>,
1534 mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
1535 node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
1536 scenes: Option<Vec<crate::measure::SceneMeasurements>>,
1537 default_scene_index: Option<usize>,
1538}
1539
1540#[derive(Debug, Clone)]
1546pub struct MeasurementReportFile {
1547 path: String,
1548 input: InputIdentity,
1549 measurements: MeasurementContract,
1550}
1551
1552impl MeasurementReportFile {
1553 pub fn path(&self) -> &str {
1555 &self.path
1556 }
1557
1558 pub fn input(&self) -> &InputIdentity {
1560 &self.input
1561 }
1562
1563 pub fn measurements(&self) -> &MeasurementContract {
1565 &self.measurements
1566 }
1567
1568 pub fn into_measurements(self) -> MeasurementContract {
1570 self.measurements
1571 }
1572}
1573
1574#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1576#[non_exhaustive]
1577pub enum MeasurementReportError {
1578 #[error("report envelope has no `schema_version`")]
1580 MissingOutputVersion,
1581 #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
1583 UnsupportedOutputVersion {
1584 found: u32,
1586 },
1587 #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
1589 WrongOutputIdentity,
1590 #[error("report envelope has no `command`")]
1592 MissingCommand,
1593 #[error("report command {command:?} does not carry measurement file records")]
1595 UnsupportedCommand {
1596 command: String,
1598 },
1599 #[error("report envelope has no `files` array")]
1601 MissingFiles,
1602 #[error("files[{file_index}] {source}")]
1604 File {
1605 file_index: usize,
1607 #[source]
1609 source: MeasurementFileError,
1610 },
1611}
1612
1613#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1615#[non_exhaustive]
1616pub enum MeasurementFileError {
1617 #[error("has no `path`")]
1619 MissingPath,
1620 #[error("has no `input`")]
1622 MissingInput,
1623 #[error("input has no `sha256`")]
1625 MissingSha256,
1626 #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
1628 InvalidSha256,
1629 #[error("input has no `bytes`")]
1631 MissingBytes,
1632 #[error("has no measurements")]
1634 MissingMeasurements,
1635 #[error("has no versioned measurement contract")]
1637 MissingMeasurementVersion,
1638 #[error(
1640 "has measurement schema_version {found}; this build reads measurement schema_version {MEASUREMENTS_SCHEMA_VERSION}"
1641 )]
1642 UnsupportedMeasurementVersion {
1643 found: u32,
1645 },
1646 #[error("does not identify measurement contract {MEASUREMENTS_SCHEMA_ID}")]
1648 WrongMeasurementIdentity,
1649 #[error("measurement contract has no `clips` map")]
1651 MissingClips,
1652 #[error("measurement contract has no `material_resource_coverage`")]
1654 MissingMaterialResourceCoverage,
1655 #[error("measurement contract has no `material_definitions` array")]
1657 MissingMaterialDefinitions,
1658 #[error("measurement contract has no `textures` array")]
1660 MissingTextures,
1661 #[error("measurement contract has no `images` array")]
1663 MissingImages,
1664 #[error("measurement contract has no `skeleton_source_coverage`")]
1666 MissingSkeletonSourceCoverage,
1667 #[error("measurement contract has no `skeleton_nodes` array")]
1669 MissingSkeletonNodes,
1670 #[error("measurement contract has no `skins` array")]
1672 MissingSkins,
1673 #[error("measurement contract has no `mesh_definitions` array")]
1675 MissingMeshDefinitions,
1676 #[error("measurement contract has no `node_instances` array")]
1678 MissingNodeInstances,
1679 #[error("measurement contract has no `scenes` array")]
1681 MissingScenes,
1682 #[error("has invalid measurements: {source}")]
1684 InvalidMeasurements {
1685 #[source]
1687 source: MeasurementContractError,
1688 },
1689}
1690
1691impl MeasurementReportError {
1692 pub fn file_index(&self) -> Option<usize> {
1696 match self {
1697 Self::File { file_index, .. } => Some(*file_index),
1698 _ => None,
1699 }
1700 }
1701
1702 fn file(file_index: usize, source: MeasurementFileError) -> Self {
1703 Self::File { file_index, source }
1704 }
1705}
1706
1707impl MeasurementReportInput {
1708 pub fn file_count(&self) -> Option<usize> {
1714 self.files.as_ref().map(Vec::len)
1715 }
1716
1717 pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
1728 match self.schema_version {
1729 Some(OUTPUT_SCHEMA_VERSION) => {}
1730 Some(found) => {
1731 return Err(MeasurementReportError::UnsupportedOutputVersion { found });
1732 }
1733 None => return Err(MeasurementReportError::MissingOutputVersion),
1734 }
1735 if self.schema.as_deref() != Some(OUTPUT_SCHEMA_ID) {
1736 return Err(MeasurementReportError::WrongOutputIdentity);
1737 }
1738 match self.command.as_deref() {
1739 Some("measure" | "lint") => {}
1740 Some(command) => {
1741 return Err(MeasurementReportError::UnsupportedCommand {
1742 command: command.to_owned(),
1743 });
1744 }
1745 None => return Err(MeasurementReportError::MissingCommand),
1746 }
1747 let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
1748 files
1749 .into_iter()
1750 .enumerate()
1751 .map(|(file_index, file)| {
1752 let path = file.path.ok_or_else(|| {
1753 MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
1754 })?;
1755 let input = file.input.ok_or_else(|| {
1756 MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
1757 })?;
1758 let sha256 = input.sha256.ok_or_else(|| {
1759 MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
1760 })?;
1761 if sha256.len() != 64
1762 || !sha256
1763 .bytes()
1764 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1765 {
1766 return Err(MeasurementReportError::file(
1767 file_index,
1768 MeasurementFileError::InvalidSha256,
1769 ));
1770 }
1771 let bytes = input.bytes.ok_or_else(|| {
1772 MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
1773 })?;
1774 let measurements = file.measurements.ok_or_else(|| {
1775 MeasurementReportError::file(
1776 file_index,
1777 MeasurementFileError::MissingMeasurements,
1778 )
1779 })?;
1780 match measurements.schema_version {
1781 Some(MEASUREMENTS_SCHEMA_VERSION) => {}
1782 Some(found) => {
1783 return Err(MeasurementReportError::file(
1784 file_index,
1785 MeasurementFileError::UnsupportedMeasurementVersion { found },
1786 ));
1787 }
1788 None => {
1789 return Err(MeasurementReportError::file(
1790 file_index,
1791 MeasurementFileError::MissingMeasurementVersion,
1792 ));
1793 }
1794 }
1795 if measurements.schema.as_deref() != Some(MEASUREMENTS_SCHEMA_ID) {
1796 return Err(MeasurementReportError::file(
1797 file_index,
1798 MeasurementFileError::WrongMeasurementIdentity,
1799 ));
1800 }
1801 let clips = measurements.clips.ok_or_else(|| {
1802 MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
1803 })?;
1804 let material_resource_coverage =
1805 measurements.material_resource_coverage.ok_or_else(|| {
1806 MeasurementReportError::file(
1807 file_index,
1808 MeasurementFileError::MissingMaterialResourceCoverage,
1809 )
1810 })?;
1811 let material_definitions = measurements.material_definitions.ok_or_else(|| {
1812 MeasurementReportError::file(
1813 file_index,
1814 MeasurementFileError::MissingMaterialDefinitions,
1815 )
1816 })?;
1817 let textures = measurements.textures.ok_or_else(|| {
1818 MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
1819 })?;
1820 let images = measurements.images.ok_or_else(|| {
1821 MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
1822 })?;
1823 let skeleton_source_coverage =
1824 measurements.skeleton_source_coverage.ok_or_else(|| {
1825 MeasurementReportError::file(
1826 file_index,
1827 MeasurementFileError::MissingSkeletonSourceCoverage,
1828 )
1829 })?;
1830 let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
1831 MeasurementReportError::file(
1832 file_index,
1833 MeasurementFileError::MissingSkeletonNodes,
1834 )
1835 })?;
1836 let skeleton_nodes = skeleton_nodes
1837 .into_iter()
1838 .enumerate()
1839 .map(|(offset, node)| match node {
1840 SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
1841 SkeletonNodeMeasurementInput::Earlier { .. } => {
1842 Err(MeasurementReportError::file(
1843 file_index,
1844 MeasurementFileError::InvalidMeasurements {
1845 source: MeasurementContractError::InvalidStructure {
1846 path: format!("skeleton_nodes[{offset}]"),
1847 reason: "uses a shape from an earlier measurement contract"
1848 .into(),
1849 },
1850 },
1851 ))
1852 }
1853 })
1854 .collect::<Result<Vec<_>, _>>()?;
1855 let skins = measurements.skins.ok_or_else(|| {
1856 MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
1857 })?;
1858 let skins = skins
1859 .into_iter()
1860 .enumerate()
1861 .map(|(offset, skin)| match skin {
1862 SkinMeasurementInput::Current(skin) => Ok(*skin),
1863 SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
1864 file_index,
1865 MeasurementFileError::InvalidMeasurements {
1866 source: MeasurementContractError::InvalidStructure {
1867 path: format!("skins[{offset}]"),
1868 reason: "uses a shape from an earlier measurement contract"
1869 .into(),
1870 },
1871 },
1872 )),
1873 })
1874 .collect::<Result<Vec<_>, _>>()?;
1875 let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
1876 MeasurementReportError::file(
1877 file_index,
1878 MeasurementFileError::MissingMeshDefinitions,
1879 )
1880 })?;
1881 let node_instances = measurements.node_instances.ok_or_else(|| {
1882 MeasurementReportError::file(
1883 file_index,
1884 MeasurementFileError::MissingNodeInstances,
1885 )
1886 })?;
1887 let scenes = measurements.scenes.ok_or_else(|| {
1888 MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
1889 })?;
1890 let assets = AssetMeasurements {
1891 material_resource_coverage,
1892 material_definitions,
1893 textures,
1894 images,
1895 skeleton_source_coverage,
1896 skeleton_nodes,
1897 skins,
1898 mesh_definitions,
1899 node_instances,
1900 scenes,
1901 default_scene_index: measurements.default_scene_index,
1902 };
1903 let measurements = MeasurementContract::new(clips, assets).map_err(|source| {
1904 MeasurementReportError::file(
1905 file_index,
1906 MeasurementFileError::InvalidMeasurements { source },
1907 )
1908 })?;
1909 Ok(MeasurementReportFile {
1910 path,
1911 input: InputIdentity { sha256, bytes },
1912 measurements,
1913 })
1914 })
1915 .collect()
1916 }
1917}
1918
1919#[cfg(test)]
1920mod measurement_report_input_tests {
1921 use super::*;
1922
1923 #[test]
1924 fn v11_nested_version_is_rejected_before_current_shape_decode() {
1925 let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
1926 "schema_version": OUTPUT_SCHEMA_VERSION,
1927 "schema": OUTPUT_SCHEMA_ID,
1928 "command": "measure",
1929 "files": [{
1930 "path": "measurements-v11.json",
1931 "input": { "sha256": "0".repeat(64), "bytes": 0 },
1932 "measurements": {
1933 "schema_version": 11,
1934 "schema": "urn:animsmith:schema:measurements:11",
1935 "skeleton_nodes": [{
1936 "node_index": 0,
1937 "scene_root_indices": [],
1938 "local_rest": {
1939 "kind": "trs",
1940 "translation_m": [0.0, 0.0, 0.0],
1941 "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
1942 "scale": [1.0, 1.0, 1.0]
1943 },
1944 "rest_world_matrix": [
1945 1.0, 0.0, 0.0, 0.0,
1946 0.0, 1.0, 0.0, 0.0,
1947 0.0, 0.0, 1.0, 0.0,
1948 0.0, 0.0, 0.0, 1.0
1949 ]
1950 }],
1951 "skins": [{ "skin_index": 0 }]
1952 }
1953 }]
1954 }))
1955 .expect("unsupported payload shapes remain decodable for version rejection");
1956
1957 assert!(matches!(
1958 report.into_files(),
1959 Err(MeasurementReportError::File {
1960 file_index: 0,
1961 source: MeasurementFileError::UnsupportedMeasurementVersion { found: 11 },
1962 })
1963 ));
1964 }
1965
1966 #[test]
1967 fn recovered_payloads_run_measurement_contract_validation() {
1968 let file =
1974 |path: &str,
1975 clips: BTreeMap<String, ClipMeasurements>,
1976 mesh_definitions: Vec<crate::measure::MeshDefinitionMeasurements>| {
1977 MeasurementFileInput {
1978 path: Some(path.into()),
1979 input: Some(InputIdentityInput {
1980 sha256: Some("0".repeat(64)),
1981 bytes: Some(0),
1982 }),
1983 measurements: Some(MeasurementPayloadInput {
1984 schema_version: Some(MEASUREMENTS_SCHEMA_VERSION),
1985 schema: Some(MEASUREMENTS_SCHEMA_ID.into()),
1986 clips: Some(clips),
1987 material_resource_coverage: Some(MaterialResourceCoverage::Unavailable),
1988 material_definitions: Some(Vec::new()),
1989 textures: Some(Vec::new()),
1990 images: Some(Vec::new()),
1991 skeleton_source_coverage: Some(SourceSkeletonCoverage::Unavailable),
1992 skeleton_nodes: Some(Vec::new()),
1993 skins: Some(Vec::new()),
1994 mesh_definitions: Some(mesh_definitions),
1995 node_instances: Some(Vec::new()),
1996 scenes: Some(Vec::new()),
1997 default_scene_index: None,
1998 }),
1999 }
2000 };
2001 let report = |files| MeasurementReportInput {
2002 schema_version: Some(OUTPUT_SCHEMA_VERSION),
2003 schema: Some(OUTPUT_SCHEMA_ID.into()),
2004 command: Some("measure".into()),
2005 files: Some(files),
2006 };
2007 let invalid_clip = || ClipMeasurements {
2008 duration_s: f64::NAN,
2009 frame_count: 1,
2010 animated_bones: Vec::new(),
2011 bone_rotation_range_deg: BTreeMap::new(),
2012 loop_continuity: None,
2013 loop_endpoint_mode: None,
2014 frame_grid: None,
2015 loop_seam_ratio: None,
2016 gait: None,
2017 speed_mps: None,
2018 };
2019 let invalid_mesh = || crate::measure::MeshDefinitionMeasurements {
2020 mesh_index: 0,
2021 name: "mesh".into(),
2022 vertex_count: 1,
2023 geometry_aabb: None,
2024 geometry_centroid: None,
2025 max_joints_per_vertex: 1,
2026 weight_sum_min: Some(f64::NAN),
2027 weight_sum_max: Some(1.0),
2028 additional_influence_sets: Vec::new(),
2029 };
2030 let valid = || file("valid.glb", BTreeMap::new(), Vec::new());
2031 let cases = [
2032 (
2033 report(vec![file(
2034 "invalid-clip.glb",
2035 BTreeMap::from([("walk".into(), invalid_clip())]),
2036 Vec::new(),
2037 )]),
2038 MeasurementReportError::File {
2039 file_index: 0,
2040 source: MeasurementFileError::InvalidMeasurements {
2041 source: MeasurementContractError::NonFiniteValue {
2042 path: "clips[\"walk\"].duration_s".into(),
2043 },
2044 },
2045 },
2046 "files[0] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
2047 0,
2048 ),
2049 (
2050 report(vec![file(
2051 "invalid-mesh.glb",
2052 BTreeMap::new(),
2053 vec![invalid_mesh()],
2054 )]),
2055 MeasurementReportError::File {
2056 file_index: 0,
2057 source: MeasurementFileError::InvalidMeasurements {
2058 source: MeasurementContractError::NonFiniteValue {
2059 path: "mesh_definitions[0].weight_sum_min".into(),
2060 },
2061 },
2062 },
2063 "files[0] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
2064 0,
2065 ),
2066 (
2067 report(vec![
2068 valid(),
2069 file(
2070 "invalid-clip.glb",
2071 BTreeMap::from([("walk".into(), invalid_clip())]),
2072 Vec::new(),
2073 ),
2074 ]),
2075 MeasurementReportError::File {
2076 file_index: 1,
2077 source: MeasurementFileError::InvalidMeasurements {
2078 source: MeasurementContractError::NonFiniteValue {
2079 path: "clips[\"walk\"].duration_s".into(),
2080 },
2081 },
2082 },
2083 "files[1] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
2084 1,
2085 ),
2086 (
2087 report(vec![
2088 valid(),
2089 file("invalid-mesh.glb", BTreeMap::new(), vec![invalid_mesh()]),
2090 ]),
2091 MeasurementReportError::File {
2092 file_index: 1,
2093 source: MeasurementFileError::InvalidMeasurements {
2094 source: MeasurementContractError::NonFiniteValue {
2095 path: "mesh_definitions[0].weight_sum_min".into(),
2096 },
2097 },
2098 },
2099 "files[1] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
2100 1,
2101 ),
2102 ];
2103
2104 for (input, expected, expected_display, expected_file_index) in cases {
2105 let error = input
2106 .into_files()
2107 .expect_err("recovered evidence must be validated");
2108 assert_eq!(error, expected);
2109 assert_eq!(error.file_index(), Some(expected_file_index));
2110 assert_eq!(error.to_string(), expected_display);
2111 }
2112 }
2113}
2114
2115#[derive(Debug, Clone, Serialize)]
2116struct FileEvidence {
2117 path: String,
2118 input: InputIdentity,
2119 rig: RigInfo,
2120 measurements: MeasurementContract,
2121}
2122
2123impl FileEvidence {
2124 fn new(
2125 path: impl Into<String>,
2126 input: InputIdentity,
2127 rig: RigInfo,
2128 measurements: MeasurementContract,
2129 ) -> Self {
2130 Self {
2131 path: path.into(),
2132 input,
2133 rig,
2134 measurements,
2135 }
2136 }
2137}
2138
2139#[derive(Debug, Clone, Serialize)]
2141pub struct MeasureFileReport {
2142 #[serde(flatten)]
2143 evidence: FileEvidence,
2144}
2145
2146impl MeasureFileReport {
2147 pub fn new(
2149 path: impl Into<String>,
2150 input: InputIdentity,
2151 rig: RigInfo,
2152 measurements: MeasurementContract,
2153 ) -> Self {
2154 Self {
2155 evidence: FileEvidence::new(path, input, rig, measurements),
2156 }
2157 }
2158
2159 pub fn path(&self) -> &str {
2161 &self.evidence.path
2162 }
2163
2164 pub fn input(&self) -> &InputIdentity {
2166 &self.evidence.input
2167 }
2168
2169 pub fn measurements(&self) -> &MeasurementContract {
2171 &self.evidence.measurements
2172 }
2173}
2174
2175#[derive(Debug, Clone, Serialize)]
2177pub struct LintFileReport {
2178 #[serde(flatten)]
2179 evidence: FileEvidence,
2180 checks: Vec<CheckEvaluation>,
2181}
2182
2183impl LintFileReport {
2184 pub fn new(
2186 path: impl Into<String>,
2187 input: InputIdentity,
2188 rig: RigInfo,
2189 checks: Vec<CheckEvaluation>,
2190 measurements: MeasurementContract,
2191 ) -> Self {
2192 Self {
2193 evidence: FileEvidence::new(path, input, rig, measurements),
2194 checks,
2195 }
2196 }
2197
2198 pub fn path(&self) -> &str {
2200 &self.evidence.path
2201 }
2202
2203 pub fn input(&self) -> &InputIdentity {
2205 &self.evidence.input
2206 }
2207
2208 pub fn checks(&self) -> &[CheckEvaluation] {
2210 &self.checks
2211 }
2212
2213 pub fn measurements(&self) -> &MeasurementContract {
2215 &self.evidence.measurements
2216 }
2217}
2218
2219#[derive(Debug, Clone, Serialize)]
2220struct EnvelopeHeader {
2221 schema_version: u32,
2222 schema: &'static str,
2223 tool: ToolInfo,
2224 command: &'static str,
2225}
2226
2227impl EnvelopeHeader {
2228 fn new(tool: ToolInfo, command: &'static str) -> Self {
2229 Self {
2230 schema_version: OUTPUT_SCHEMA_VERSION,
2231 schema: OUTPUT_SCHEMA_ID,
2232 tool,
2233 command,
2234 }
2235 }
2236}
2237
2238#[derive(Debug, Clone, Serialize)]
2239struct MeasureSummary {
2240 files: usize,
2241}
2242
2243#[derive(Debug, Clone, Default, Serialize)]
2244struct FindingSummary {
2245 error: usize,
2246 warning: usize,
2247 note: usize,
2248}
2249
2250impl FindingSummary {
2251 fn add(&mut self, severity: Severity) {
2252 match severity {
2253 Severity::Error => self.error += 1,
2254 Severity::Warning => self.warning += 1,
2255 Severity::Note => self.note += 1,
2256 }
2257 }
2258}
2259
2260#[derive(Debug, Clone, Default, Serialize)]
2261struct SelectionSummary {
2262 selected: usize,
2263 unselected: usize,
2264}
2265
2266#[derive(Debug, Clone, Default, Serialize)]
2267struct ConfigurationSummary {
2268 enabled: usize,
2269 disabled: usize,
2270}
2271
2272#[derive(Debug, Clone, Default, Serialize)]
2273struct ApplicabilitySummary {
2274 applicable: usize,
2275 not_applicable: usize,
2276}
2277
2278#[derive(Debug, Clone, Default, Serialize)]
2279struct EvaluationStateSummary {
2280 complete: usize,
2281 partial: usize,
2282 not_evaluated: usize,
2283}
2284
2285#[derive(Debug, Clone, Default, Serialize)]
2286struct CheckSummary {
2287 total: usize,
2288 selection: SelectionSummary,
2289 configuration: ConfigurationSummary,
2290 applicability: ApplicabilitySummary,
2291 evaluation: EvaluationStateSummary,
2292 gaps: usize,
2293}
2294
2295#[derive(Debug, Clone, Serialize)]
2296struct LintSummary {
2297 files: usize,
2298 findings: FindingSummary,
2299 checks: CheckSummary,
2300}
2301
2302#[derive(Debug, Clone, Serialize)]
2304pub struct MeasureEnvelope {
2305 #[serde(flatten)]
2306 header: EnvelopeHeader,
2307 summary: MeasureSummary,
2308 files: Vec<MeasureFileReport>,
2309}
2310
2311impl MeasureEnvelope {
2312 pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Self {
2314 Self {
2315 header: EnvelopeHeader::new(tool, "measure"),
2316 summary: MeasureSummary { files: files.len() },
2317 files,
2318 }
2319 }
2320}
2321
2322#[derive(Debug, Clone, Serialize)]
2324pub struct LintEnvelope {
2325 #[serde(flatten)]
2326 header: EnvelopeHeader,
2327 summary: LintSummary,
2328 files: Vec<LintFileReport>,
2329}
2330
2331impl LintEnvelope {
2332 pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Self {
2335 let mut findings = FindingSummary::default();
2336 let mut checks = CheckSummary::default();
2337 for file in &files {
2338 for check in file.checks() {
2339 checks.total += 1;
2340 for finding in check.findings() {
2341 findings.add(finding.severity);
2342 }
2343 match check.selection() {
2344 SelectionState::Selected => checks.selection.selected += 1,
2345 SelectionState::Unselected => checks.selection.unselected += 1,
2346 }
2347 match check.configuration() {
2348 ConfigurationState::Enabled => checks.configuration.enabled += 1,
2349 ConfigurationState::Disabled => checks.configuration.disabled += 1,
2350 }
2351 match check.applicability() {
2352 Applicability::Applicable => checks.applicability.applicable += 1,
2353 Applicability::NotApplicable => checks.applicability.not_applicable += 1,
2354 }
2355 match check.evaluation() {
2356 EvaluationState::Complete => checks.evaluation.complete += 1,
2357 EvaluationState::Partial => checks.evaluation.partial += 1,
2358 EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
2359 }
2360 checks.gaps += check.gaps().len();
2361 }
2362 }
2363 Self {
2364 header: EnvelopeHeader::new(tool, "lint"),
2365 summary: LintSummary {
2366 files: files.len(),
2367 findings,
2368 checks,
2369 },
2370 files,
2371 }
2372 }
2373}
2374
2375#[derive(Debug, Clone, Serialize)]
2376struct DiffInputs {
2377 before: String,
2378 after: String,
2379}
2380
2381#[derive(Debug, Clone, Serialize)]
2382struct DiffSummary {
2383 deltas: usize,
2384}
2385
2386#[derive(Debug, Serialize)]
2388pub struct DiffEnvelope {
2389 #[serde(flatten)]
2390 header: EnvelopeHeader,
2391 inputs: DiffInputs,
2392 summary: DiffSummary,
2393 deltas: Vec<MetricDelta>,
2394}
2395
2396impl DiffEnvelope {
2397 pub fn new(
2399 tool: ToolInfo,
2400 before: impl Into<String>,
2401 after: impl Into<String>,
2402 deltas: Vec<MetricDelta>,
2403 ) -> Self {
2404 Self {
2405 header: EnvelopeHeader::new(tool, "diff"),
2406 inputs: DiffInputs {
2407 before: before.into(),
2408 after: after.into(),
2409 },
2410 summary: DiffSummary {
2411 deltas: deltas.len(),
2412 },
2413 deltas,
2414 }
2415 }
2416}