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, measure_linear_transform,
22 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 = 6;
33pub const OUTPUT_SCHEMA_ID: &str = "urn:animsmith:schema:output:6";
35pub const MEASUREMENTS_SCHEMA_VERSION: u32 = 12;
37pub const MEASUREMENTS_SCHEMA_ID: &str = "urn:animsmith:schema:measurements:12";
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 accessor 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 accessor 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 accessor 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 accessor 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 declared inverse-bind accessor retains its count but cannot serialize raw 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 joint_bind_path =
948 format!("skins[{offset}].joints[{joint_offset}].joint_bind_to_mesh");
949 validate_derived_matrix(
950 &joint.joint_bind_to_mesh,
951 &joint_bind_path,
952 &finite_matrix,
953 invalid,
954 )?;
955 validate_derived_reason_compatibility(
956 &joint.joint_bind_to_mesh,
957 skin.inverse_bind_accessor.status,
958 skin.inverse_bind_accessor.matrices.len(),
959 joint_offset,
960 &joint_bind_path,
961 DerivedMatrixDomain::JointBindToMesh,
962 invalid,
963 )?;
964
965 let mesh_bind_path = format!("skins[{offset}].joints[{joint_offset}].mesh_bind_world");
966 validate_derived_matrix(
967 &joint.mesh_bind_world,
968 &mesh_bind_path,
969 &finite_matrix,
970 invalid,
971 )?;
972 validate_derived_reason_compatibility(
973 &joint.mesh_bind_world,
974 skin.inverse_bind_accessor.status,
975 skin.inverse_bind_accessor.matrices.len(),
976 joint_offset,
977 &mesh_bind_path,
978 DerivedMatrixDomain::MeshBindWorld,
979 invalid,
980 )?;
981 let joint_rest_world_available = assets
982 .skeleton_nodes
983 .get(joint.node_index)
984 .ok_or_else(|| {
985 invalid(
986 format!("skins[{offset}].joints[{joint_offset}].node_index"),
987 "joint node_index must reference a skeleton node",
988 )
989 })?
990 .rest_world_matrix
991 .is_some();
992 validate_mesh_bind_world_reason_compatibility(
993 &joint.mesh_bind_world,
994 joint_rest_world_available,
995 &mesh_bind_path,
996 invalid,
997 )?;
998 }
999 if let Some(scale) = skin.joint_bind_linear_summary.consistent_uniform_scale
1000 && !scale.is_finite()
1001 {
1002 return Err(MeasurementContractError::NonFiniteValue {
1003 path: format!("skins[{offset}].joint_bind_linear_summary.consistent_uniform_scale"),
1004 });
1005 }
1006 let expected_summary = summarize_skin_bind_linear(&skin.joints);
1007 if skin.joint_bind_linear_summary != expected_summary {
1008 return Err(invalid(
1009 format!("skins[{offset}].joint_bind_linear_summary"),
1010 "joint-bind linear summary must match the skin joint observations",
1011 ));
1012 }
1013 let mut previous_attachment_node = None;
1014 for (attachment_offset, attachment) in skin.attachments.iter().enumerate() {
1015 if attachment.node_index >= assets.skeleton_nodes.len() {
1016 return Err(invalid(
1017 format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1018 "attachment node_index must reference a skeleton node",
1019 ));
1020 }
1021 if previous_attachment_node.is_some_and(|previous| previous >= attachment.node_index) {
1022 return Err(invalid(
1023 format!("skins[{offset}].attachments[{attachment_offset}].node_index"),
1024 "attachment node_index values must be strictly increasing and unique",
1025 ));
1026 }
1027 previous_attachment_node = Some(attachment.node_index);
1028 }
1029 }
1030 Ok(())
1031}
1032
1033fn validate_derived_reason_compatibility(
1034 matrix: &SkinDerivedMatrixMeasurements,
1035 status: SourceInverseBindAccessorStatus,
1036 readable_matrix_count: usize,
1037 joint_index: usize,
1038 path: &str,
1039 domain: DerivedMatrixDomain,
1040 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1041) -> Result<(), MeasurementContractError> {
1042 let requires_accessor_reason = match status {
1043 SourceInverseBindAccessorStatus::Absent => {
1044 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1045 }
1046 SourceInverseBindAccessorStatus::EmptyAccessor => {
1047 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1048 }
1049 SourceInverseBindAccessorStatus::Unreadable => {
1050 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1051 }
1052 SourceInverseBindAccessorStatus::CountMismatch if joint_index >= readable_matrix_count => {
1053 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
1054 }
1055 SourceInverseBindAccessorStatus::Available
1056 | SourceInverseBindAccessorStatus::CountMismatch => None,
1057 };
1058 if let Some(expected) = requires_accessor_reason {
1059 if matrix.matrix.is_some() || matrix.unavailable_reason != Some(expected) {
1060 return Err(invalid(
1061 path.into(),
1062 "derived matrices without a usable inverse bind must carry the matching accessor reason",
1063 ));
1064 }
1065 } else {
1066 match (domain, matrix.unavailable_reason) {
1067 (
1068 _,
1069 Some(
1070 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent
1071 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty
1072 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch
1073 | SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable,
1074 ),
1075 ) => {
1076 return Err(invalid(
1077 format!("{path}.unavailable_reason"),
1078 "a usable inverse-bind matrix cannot be reported as accessor-unavailable",
1079 ));
1080 }
1081 (
1082 DerivedMatrixDomain::JointBindToMesh,
1083 Some(
1084 SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable
1085 | SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix,
1086 ),
1087 ) => {
1088 return Err(invalid(
1089 format!("{path}.unavailable_reason"),
1090 "joint_bind_to_mesh cannot use a joint-rest-world unavailable reason",
1091 ));
1092 }
1093 (
1094 DerivedMatrixDomain::MeshBindWorld,
1095 Some(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible),
1096 ) => {
1097 return Err(invalid(
1098 format!("{path}.unavailable_reason"),
1099 "mesh_bind_world does not require an invertible inverse-bind matrix",
1100 ));
1101 }
1102 _ => {}
1103 }
1104 }
1105 Ok(())
1106}
1107
1108fn validate_mesh_bind_world_reason_compatibility(
1109 matrix: &SkinDerivedMatrixMeasurements,
1110 joint_rest_world_available: bool,
1111 path: &str,
1112 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1113) -> Result<(), MeasurementContractError> {
1114 match matrix.unavailable_reason {
1115 Some(SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable)
1116 if joint_rest_world_available =>
1117 {
1118 Err(invalid(
1119 format!("{path}.unavailable_reason"),
1120 "an available joint rest-world matrix cannot be reported as unavailable",
1121 ))
1122 }
1123 Some(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix)
1124 if !joint_rest_world_available =>
1125 {
1126 Err(invalid(
1127 format!("{path}.unavailable_reason"),
1128 "a non-finite mesh-bind-world result requires an available joint rest-world matrix",
1129 ))
1130 }
1131 _ => Ok(()),
1132 }
1133}
1134
1135#[derive(Clone, Copy, PartialEq, Eq)]
1136enum ParentVisit {
1137 Unvisited,
1138 Visiting,
1139 Done,
1140}
1141
1142#[derive(Clone, Copy)]
1143enum DerivedMatrixDomain {
1144 JointBindToMesh,
1145 MeshBindWorld,
1146}
1147
1148fn validate_derived_matrix(
1149 matrix: &SkinDerivedMatrixMeasurements,
1150 path: &str,
1151 finite_matrix: &impl Fn(&[f32; 16], &str) -> Result<(), MeasurementContractError>,
1152 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1153) -> Result<(), MeasurementContractError> {
1154 match (
1155 &matrix.matrix,
1156 matrix.linear.as_ref(),
1157 matrix.unavailable_reason,
1158 ) {
1159 (Some(matrix), Some(linear), None) => {
1160 finite_matrix(matrix, &format!("{path}.matrix"))?;
1161 validate_linear_transform_fields(linear, &format!("{path}.linear"), invalid)?;
1162 if *linear != measure_linear_transform(Mat4::from_cols_array(matrix)) {
1163 return Err(invalid(
1164 format!("{path}.linear"),
1165 "linear facts must be derived from the available matrix",
1166 ));
1167 }
1168 }
1169 (None, None, Some(_)) => {}
1170 (Some(_), Some(_), Some(_)) => {
1171 return Err(invalid(
1172 path.into(),
1173 "an available derived matrix cannot have an unavailable reason",
1174 ));
1175 }
1176 _ => {
1177 return Err(invalid(
1178 path.into(),
1179 "derived matrix, linear facts, and unavailable reason fields are inconsistent",
1180 ));
1181 }
1182 }
1183 Ok(())
1184}
1185
1186fn validate_material_resources(
1187 assets: &AssetMeasurements,
1188 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1189) -> Result<(), MeasurementContractError> {
1190 let absent = assets.material_definitions.is_empty()
1191 && assets.textures.is_empty()
1192 && assets.images.is_empty();
1193 if assets.material_resource_coverage == MaterialResourceCoverage::Unavailable && !absent {
1194 return Err(invalid(
1195 "material_resource_coverage".into(),
1196 "unavailable resource coverage requires empty material, texture, and image arrays",
1197 ));
1198 }
1199
1200 for (offset, material) in assets.material_definitions.iter().enumerate() {
1201 if material.material_index != offset {
1202 return Err(invalid(
1203 format!("material_definitions[{offset}].material_index"),
1204 "material_index must be contiguous and match source order",
1205 ));
1206 }
1207 let mut previous_slot = None;
1208 for (binding_offset, binding) in material.texture_bindings.iter().enumerate() {
1209 if binding.texture_index >= assets.textures.len() {
1210 return Err(invalid(
1211 format!(
1212 "material_definitions[{offset}].texture_bindings[{binding_offset}].texture_index"
1213 ),
1214 "texture_index must reference a source texture",
1215 ));
1216 }
1217 if previous_slot.is_some_and(|previous| previous >= binding.slot) {
1218 return Err(invalid(
1219 format!(
1220 "material_definitions[{offset}].texture_bindings[{binding_offset}].slot"
1221 ),
1222 "texture bindings must be strictly ordered by slot and unique",
1223 ));
1224 }
1225 previous_slot = Some(binding.slot);
1226 }
1227 }
1228 for (offset, texture) in assets.textures.iter().enumerate() {
1229 if texture.texture_index != offset {
1230 return Err(invalid(
1231 format!("textures[{offset}].texture_index"),
1232 "texture_index must be contiguous and match source order",
1233 ));
1234 }
1235 if texture.image_index >= assets.images.len() {
1236 return Err(invalid(
1237 format!("textures[{offset}].image_index"),
1238 "image_index must reference a source image",
1239 ));
1240 }
1241 }
1242 for (offset, image) in assets.images.iter().enumerate() {
1243 validate_image_measurement(image, offset, invalid)?;
1244 }
1245 Ok(())
1246}
1247
1248fn validate_image_measurement(
1249 image: &ImageMeasurements,
1250 offset: usize,
1251 invalid: &impl Fn(String, &str) -> MeasurementContractError,
1252) -> Result<(), MeasurementContractError> {
1253 if image.image_index != offset {
1254 return Err(invalid(
1255 format!("images[{offset}].image_index"),
1256 "image_index must be contiguous and match source order",
1257 ));
1258 }
1259 let available = [
1260 image.width.is_some(),
1261 image.height.is_some(),
1262 image.channel_count.is_some(),
1263 image.decoded_color_type.is_some(),
1264 ];
1265 match (
1266 available.into_iter().all(|value| value),
1267 image.unavailable_reason,
1268 ) {
1269 (true, None) => {
1270 let (Some(width), Some(height), Some(channel_count), Some(decoded_color_type)) = (
1271 image.width,
1272 image.height,
1273 image.channel_count,
1274 image.decoded_color_type,
1275 ) else {
1276 return Err(invalid(
1277 format!("images[{offset}]"),
1278 "available image metadata must include width, height, channel_count, and decoded_color_type",
1279 ));
1280 };
1281 if width == 0 || height == 0 {
1282 return Err(invalid(
1283 format!("images[{offset}]"),
1284 "available image dimensions must be greater than zero",
1285 ));
1286 }
1287 if channel_count != color_type_channel_count(decoded_color_type) {
1288 return Err(invalid(
1289 format!("images[{offset}].channel_count"),
1290 "channel_count must match decoded_color_type",
1291 ));
1292 }
1293 if image.detected_container.is_none() {
1294 return Err(invalid(
1295 format!("images[{offset}].detected_container"),
1296 "available image metadata requires a detected_container",
1297 ));
1298 }
1299 }
1300 (false, Some(_)) if available.into_iter().all(|value| !value) => {}
1301 (true, Some(_)) => {
1302 return Err(invalid(
1303 format!("images[{offset}]"),
1304 "available image metadata cannot have an unavailable_reason",
1305 ));
1306 }
1307 (false, None) if available.into_iter().all(|value| !value) => {
1308 return Err(invalid(
1309 format!("images[{offset}]"),
1310 "missing image metadata requires an unavailable_reason",
1311 ));
1312 }
1313 (false, _) => {
1314 return Err(invalid(
1315 format!("images[{offset}]"),
1316 "available image metadata must include width, height, channel_count, and decoded_color_type",
1317 ));
1318 }
1319 }
1320 match image.unavailable_reason {
1321 Some(crate::model::ImageUnavailableReason::DecodeFailed)
1322 if image.detected_container.is_none() =>
1323 {
1324 return Err(invalid(
1325 format!("images[{offset}].detected_container"),
1326 "decode_failed requires a detected_container",
1327 ));
1328 }
1329 Some(
1330 crate::model::ImageUnavailableReason::SourceUnavailable
1331 | crate::model::ImageUnavailableReason::InvalidDataUri
1332 | crate::model::ImageUnavailableReason::UnsupportedContainer,
1333 ) if image.detected_container.is_some() => {
1334 return Err(invalid(
1335 format!("images[{offset}].detected_container"),
1336 "this unavailable_reason cannot have a detected_container",
1337 ));
1338 }
1339 _ => {}
1340 }
1341 Ok(())
1342}
1343
1344fn color_type_channel_count(color_type: DecodedImageColorType) -> u8 {
1345 match color_type {
1346 DecodedImageColorType::L8 | DecodedImageColorType::L16 => 1,
1347 DecodedImageColorType::La8 | DecodedImageColorType::La16 => 2,
1348 DecodedImageColorType::Rgb8 | DecodedImageColorType::Rgb16 => 3,
1349 DecodedImageColorType::Rgba8 | DecodedImageColorType::Rgba16 => 4,
1350 }
1351}
1352
1353#[derive(Debug, Deserialize)]
1361pub struct MeasurementReportInput {
1362 schema_version: Option<u32>,
1363 schema: Option<String>,
1364 command: Option<String>,
1365 files: Option<Vec<MeasurementFileInput>>,
1366}
1367
1368#[derive(Debug, Deserialize)]
1369struct MeasurementFileInput {
1370 path: Option<String>,
1371 input: Option<InputIdentityInput>,
1372 measurements: Option<MeasurementPayloadInput>,
1373}
1374
1375#[derive(Debug, Deserialize)]
1376struct InputIdentityInput {
1377 sha256: Option<String>,
1378 bytes: Option<u64>,
1379}
1380
1381#[derive(Debug, Deserialize)]
1382#[serde(untagged)]
1383enum SkeletonNodeMeasurementInput {
1384 Current(Box<crate::measure::SkeletonNodeMeasurements>),
1385 Earlier {
1386 #[serde(rename = "node_index")]
1387 _node_index: usize,
1388 },
1389}
1390
1391#[derive(Debug, Deserialize)]
1392#[serde(untagged)]
1393enum SkinMeasurementInput {
1394 Current(Box<crate::measure::SkinMeasurements>),
1395 Earlier {
1396 #[serde(rename = "skin_index")]
1397 _skin_index: usize,
1398 },
1399}
1400
1401#[derive(Debug, Deserialize)]
1402struct MeasurementPayloadInput {
1403 schema_version: Option<u32>,
1404 schema: Option<String>,
1405 clips: Option<BTreeMap<String, ClipMeasurements>>,
1406 material_resource_coverage: Option<MaterialResourceCoverage>,
1407 material_definitions: Option<Vec<MaterialDefinitionMeasurements>>,
1408 textures: Option<Vec<TextureMeasurements>>,
1409 images: Option<Vec<ImageMeasurements>>,
1410 skeleton_source_coverage: Option<SourceSkeletonCoverage>,
1411 skeleton_nodes: Option<Vec<SkeletonNodeMeasurementInput>>,
1412 skins: Option<Vec<SkinMeasurementInput>>,
1413 mesh_definitions: Option<Vec<crate::measure::MeshDefinitionMeasurements>>,
1414 node_instances: Option<Vec<crate::measure::NodeInstanceMeasurements>>,
1415 scenes: Option<Vec<crate::measure::SceneMeasurements>>,
1416 default_scene_index: Option<usize>,
1417}
1418
1419#[derive(Debug, Clone)]
1425pub struct MeasurementReportFile {
1426 path: String,
1427 input: InputIdentity,
1428 measurements: MeasurementContract,
1429}
1430
1431impl MeasurementReportFile {
1432 pub fn path(&self) -> &str {
1434 &self.path
1435 }
1436
1437 pub fn input(&self) -> &InputIdentity {
1439 &self.input
1440 }
1441
1442 pub fn measurements(&self) -> &MeasurementContract {
1444 &self.measurements
1445 }
1446
1447 pub fn into_measurements(self) -> MeasurementContract {
1449 self.measurements
1450 }
1451}
1452
1453#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1455#[non_exhaustive]
1456pub enum MeasurementReportError {
1457 #[error("report envelope has no `schema_version`")]
1459 MissingOutputVersion,
1460 #[error("has schema_version {found}; this build reads schema_version {OUTPUT_SCHEMA_VERSION}")]
1462 UnsupportedOutputVersion {
1463 found: u32,
1465 },
1466 #[error("report envelope does not identify output contract {OUTPUT_SCHEMA_ID}")]
1468 WrongOutputIdentity,
1469 #[error("report envelope has no `command`")]
1471 MissingCommand,
1472 #[error("report command {command:?} does not carry measurement file records")]
1474 UnsupportedCommand {
1475 command: String,
1477 },
1478 #[error("report envelope has no `files` array")]
1480 MissingFiles,
1481 #[error("files[{file_index}] {source}")]
1483 File {
1484 file_index: usize,
1486 #[source]
1488 source: MeasurementFileError,
1489 },
1490}
1491
1492#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1494#[non_exhaustive]
1495pub enum MeasurementFileError {
1496 #[error("has no `path`")]
1498 MissingPath,
1499 #[error("has no `input`")]
1501 MissingInput,
1502 #[error("input has no `sha256`")]
1504 MissingSha256,
1505 #[error("input `sha256` must be 64 lowercase hexadecimal characters")]
1507 InvalidSha256,
1508 #[error("input has no `bytes`")]
1510 MissingBytes,
1511 #[error("has no measurements")]
1513 MissingMeasurements,
1514 #[error("has no versioned measurement contract")]
1516 MissingMeasurementVersion,
1517 #[error(
1519 "has measurement schema_version {found}; this build reads measurement schema_version {MEASUREMENTS_SCHEMA_VERSION}"
1520 )]
1521 UnsupportedMeasurementVersion {
1522 found: u32,
1524 },
1525 #[error("does not identify measurement contract {MEASUREMENTS_SCHEMA_ID}")]
1527 WrongMeasurementIdentity,
1528 #[error("measurement contract has no `clips` map")]
1530 MissingClips,
1531 #[error("measurement contract has no `material_resource_coverage`")]
1533 MissingMaterialResourceCoverage,
1534 #[error("measurement contract has no `material_definitions` array")]
1536 MissingMaterialDefinitions,
1537 #[error("measurement contract has no `textures` array")]
1539 MissingTextures,
1540 #[error("measurement contract has no `images` array")]
1542 MissingImages,
1543 #[error("measurement contract has no `skeleton_source_coverage`")]
1545 MissingSkeletonSourceCoverage,
1546 #[error("measurement contract has no `skeleton_nodes` array")]
1548 MissingSkeletonNodes,
1549 #[error("measurement contract has no `skins` array")]
1551 MissingSkins,
1552 #[error("measurement contract has no `mesh_definitions` array")]
1554 MissingMeshDefinitions,
1555 #[error("measurement contract has no `node_instances` array")]
1557 MissingNodeInstances,
1558 #[error("measurement contract has no `scenes` array")]
1560 MissingScenes,
1561 #[error("has invalid measurements: {source}")]
1563 InvalidMeasurements {
1564 #[source]
1566 source: MeasurementContractError,
1567 },
1568}
1569
1570impl MeasurementReportError {
1571 pub fn file_index(&self) -> Option<usize> {
1575 match self {
1576 Self::File { file_index, .. } => Some(*file_index),
1577 _ => None,
1578 }
1579 }
1580
1581 fn file(file_index: usize, source: MeasurementFileError) -> Self {
1582 Self::File { file_index, source }
1583 }
1584}
1585
1586impl MeasurementReportInput {
1587 pub fn file_count(&self) -> Option<usize> {
1593 self.files.as_ref().map(Vec::len)
1594 }
1595
1596 pub fn into_files(self) -> Result<Vec<MeasurementReportFile>, MeasurementReportError> {
1607 match self.schema_version {
1608 Some(OUTPUT_SCHEMA_VERSION) => {}
1609 Some(found) => {
1610 return Err(MeasurementReportError::UnsupportedOutputVersion { found });
1611 }
1612 None => return Err(MeasurementReportError::MissingOutputVersion),
1613 }
1614 if self.schema.as_deref() != Some(OUTPUT_SCHEMA_ID) {
1615 return Err(MeasurementReportError::WrongOutputIdentity);
1616 }
1617 match self.command.as_deref() {
1618 Some("measure" | "lint") => {}
1619 Some(command) => {
1620 return Err(MeasurementReportError::UnsupportedCommand {
1621 command: command.to_owned(),
1622 });
1623 }
1624 None => return Err(MeasurementReportError::MissingCommand),
1625 }
1626 let files = self.files.ok_or(MeasurementReportError::MissingFiles)?;
1627 files
1628 .into_iter()
1629 .enumerate()
1630 .map(|(file_index, file)| {
1631 let path = file.path.ok_or_else(|| {
1632 MeasurementReportError::file(file_index, MeasurementFileError::MissingPath)
1633 })?;
1634 let input = file.input.ok_or_else(|| {
1635 MeasurementReportError::file(file_index, MeasurementFileError::MissingInput)
1636 })?;
1637 let sha256 = input.sha256.ok_or_else(|| {
1638 MeasurementReportError::file(file_index, MeasurementFileError::MissingSha256)
1639 })?;
1640 if sha256.len() != 64
1641 || !sha256
1642 .bytes()
1643 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1644 {
1645 return Err(MeasurementReportError::file(
1646 file_index,
1647 MeasurementFileError::InvalidSha256,
1648 ));
1649 }
1650 let bytes = input.bytes.ok_or_else(|| {
1651 MeasurementReportError::file(file_index, MeasurementFileError::MissingBytes)
1652 })?;
1653 let measurements = file.measurements.ok_or_else(|| {
1654 MeasurementReportError::file(
1655 file_index,
1656 MeasurementFileError::MissingMeasurements,
1657 )
1658 })?;
1659 match measurements.schema_version {
1660 Some(MEASUREMENTS_SCHEMA_VERSION) => {}
1661 Some(found) => {
1662 return Err(MeasurementReportError::file(
1663 file_index,
1664 MeasurementFileError::UnsupportedMeasurementVersion { found },
1665 ));
1666 }
1667 None => {
1668 return Err(MeasurementReportError::file(
1669 file_index,
1670 MeasurementFileError::MissingMeasurementVersion,
1671 ));
1672 }
1673 }
1674 if measurements.schema.as_deref() != Some(MEASUREMENTS_SCHEMA_ID) {
1675 return Err(MeasurementReportError::file(
1676 file_index,
1677 MeasurementFileError::WrongMeasurementIdentity,
1678 ));
1679 }
1680 let clips = measurements.clips.ok_or_else(|| {
1681 MeasurementReportError::file(file_index, MeasurementFileError::MissingClips)
1682 })?;
1683 let material_resource_coverage =
1684 measurements.material_resource_coverage.ok_or_else(|| {
1685 MeasurementReportError::file(
1686 file_index,
1687 MeasurementFileError::MissingMaterialResourceCoverage,
1688 )
1689 })?;
1690 let material_definitions = measurements.material_definitions.ok_or_else(|| {
1691 MeasurementReportError::file(
1692 file_index,
1693 MeasurementFileError::MissingMaterialDefinitions,
1694 )
1695 })?;
1696 let textures = measurements.textures.ok_or_else(|| {
1697 MeasurementReportError::file(file_index, MeasurementFileError::MissingTextures)
1698 })?;
1699 let images = measurements.images.ok_or_else(|| {
1700 MeasurementReportError::file(file_index, MeasurementFileError::MissingImages)
1701 })?;
1702 let skeleton_source_coverage =
1703 measurements.skeleton_source_coverage.ok_or_else(|| {
1704 MeasurementReportError::file(
1705 file_index,
1706 MeasurementFileError::MissingSkeletonSourceCoverage,
1707 )
1708 })?;
1709 let skeleton_nodes = measurements.skeleton_nodes.ok_or_else(|| {
1710 MeasurementReportError::file(
1711 file_index,
1712 MeasurementFileError::MissingSkeletonNodes,
1713 )
1714 })?;
1715 let skeleton_nodes = skeleton_nodes
1716 .into_iter()
1717 .enumerate()
1718 .map(|(offset, node)| match node {
1719 SkeletonNodeMeasurementInput::Current(node) => Ok(*node),
1720 SkeletonNodeMeasurementInput::Earlier { .. } => {
1721 Err(MeasurementReportError::file(
1722 file_index,
1723 MeasurementFileError::InvalidMeasurements {
1724 source: MeasurementContractError::InvalidStructure {
1725 path: format!("skeleton_nodes[{offset}]"),
1726 reason: "uses a shape from an earlier measurement contract"
1727 .into(),
1728 },
1729 },
1730 ))
1731 }
1732 })
1733 .collect::<Result<Vec<_>, _>>()?;
1734 let skins = measurements.skins.ok_or_else(|| {
1735 MeasurementReportError::file(file_index, MeasurementFileError::MissingSkins)
1736 })?;
1737 let skins = skins
1738 .into_iter()
1739 .enumerate()
1740 .map(|(offset, skin)| match skin {
1741 SkinMeasurementInput::Current(skin) => Ok(*skin),
1742 SkinMeasurementInput::Earlier { .. } => Err(MeasurementReportError::file(
1743 file_index,
1744 MeasurementFileError::InvalidMeasurements {
1745 source: MeasurementContractError::InvalidStructure {
1746 path: format!("skins[{offset}]"),
1747 reason: "uses a shape from an earlier measurement contract"
1748 .into(),
1749 },
1750 },
1751 )),
1752 })
1753 .collect::<Result<Vec<_>, _>>()?;
1754 let mesh_definitions = measurements.mesh_definitions.ok_or_else(|| {
1755 MeasurementReportError::file(
1756 file_index,
1757 MeasurementFileError::MissingMeshDefinitions,
1758 )
1759 })?;
1760 let node_instances = measurements.node_instances.ok_or_else(|| {
1761 MeasurementReportError::file(
1762 file_index,
1763 MeasurementFileError::MissingNodeInstances,
1764 )
1765 })?;
1766 let scenes = measurements.scenes.ok_or_else(|| {
1767 MeasurementReportError::file(file_index, MeasurementFileError::MissingScenes)
1768 })?;
1769 let assets = AssetMeasurements {
1770 material_resource_coverage,
1771 material_definitions,
1772 textures,
1773 images,
1774 skeleton_source_coverage,
1775 skeleton_nodes,
1776 skins,
1777 mesh_definitions,
1778 node_instances,
1779 scenes,
1780 default_scene_index: measurements.default_scene_index,
1781 };
1782 let measurements = MeasurementContract::new(clips, assets).map_err(|source| {
1783 MeasurementReportError::file(
1784 file_index,
1785 MeasurementFileError::InvalidMeasurements { source },
1786 )
1787 })?;
1788 Ok(MeasurementReportFile {
1789 path,
1790 input: InputIdentity { sha256, bytes },
1791 measurements,
1792 })
1793 })
1794 .collect()
1795 }
1796}
1797
1798#[cfg(test)]
1799mod measurement_report_input_tests {
1800 use super::*;
1801
1802 #[test]
1803 fn v11_nested_version_is_rejected_before_current_shape_decode() {
1804 let report: MeasurementReportInput = serde_json::from_value(serde_json::json!({
1805 "schema_version": OUTPUT_SCHEMA_VERSION,
1806 "schema": OUTPUT_SCHEMA_ID,
1807 "command": "measure",
1808 "files": [{
1809 "path": "measurements-v11.json",
1810 "input": { "sha256": "0".repeat(64), "bytes": 0 },
1811 "measurements": {
1812 "schema_version": 11,
1813 "schema": "urn:animsmith:schema:measurements:11",
1814 "skeleton_nodes": [{
1815 "node_index": 0,
1816 "scene_root_indices": [],
1817 "local_rest": {
1818 "kind": "trs",
1819 "translation_m": [0.0, 0.0, 0.0],
1820 "rotation_xyzw": [0.0, 0.0, 0.0, 1.0],
1821 "scale": [1.0, 1.0, 1.0]
1822 },
1823 "rest_world_matrix": [
1824 1.0, 0.0, 0.0, 0.0,
1825 0.0, 1.0, 0.0, 0.0,
1826 0.0, 0.0, 1.0, 0.0,
1827 0.0, 0.0, 0.0, 1.0
1828 ]
1829 }],
1830 "skins": [{ "skin_index": 0 }]
1831 }
1832 }]
1833 }))
1834 .expect("unsupported payload shapes remain decodable for version rejection");
1835
1836 assert!(matches!(
1837 report.into_files(),
1838 Err(MeasurementReportError::File {
1839 file_index: 0,
1840 source: MeasurementFileError::UnsupportedMeasurementVersion { found: 11 },
1841 })
1842 ));
1843 }
1844
1845 #[test]
1846 fn recovered_payloads_run_measurement_contract_validation() {
1847 let file =
1853 |path: &str,
1854 clips: BTreeMap<String, ClipMeasurements>,
1855 mesh_definitions: Vec<crate::measure::MeshDefinitionMeasurements>| {
1856 MeasurementFileInput {
1857 path: Some(path.into()),
1858 input: Some(InputIdentityInput {
1859 sha256: Some("0".repeat(64)),
1860 bytes: Some(0),
1861 }),
1862 measurements: Some(MeasurementPayloadInput {
1863 schema_version: Some(MEASUREMENTS_SCHEMA_VERSION),
1864 schema: Some(MEASUREMENTS_SCHEMA_ID.into()),
1865 clips: Some(clips),
1866 material_resource_coverage: Some(MaterialResourceCoverage::Unavailable),
1867 material_definitions: Some(Vec::new()),
1868 textures: Some(Vec::new()),
1869 images: Some(Vec::new()),
1870 skeleton_source_coverage: Some(SourceSkeletonCoverage::Unavailable),
1871 skeleton_nodes: Some(Vec::new()),
1872 skins: Some(Vec::new()),
1873 mesh_definitions: Some(mesh_definitions),
1874 node_instances: Some(Vec::new()),
1875 scenes: Some(Vec::new()),
1876 default_scene_index: None,
1877 }),
1878 }
1879 };
1880 let report = |files| MeasurementReportInput {
1881 schema_version: Some(OUTPUT_SCHEMA_VERSION),
1882 schema: Some(OUTPUT_SCHEMA_ID.into()),
1883 command: Some("measure".into()),
1884 files: Some(files),
1885 };
1886 let invalid_clip = || ClipMeasurements {
1887 duration_s: f64::NAN,
1888 frame_count: 1,
1889 animated_bones: Vec::new(),
1890 bone_rotation_range_deg: BTreeMap::new(),
1891 loop_continuity: None,
1892 loop_endpoint_mode: None,
1893 frame_grid: None,
1894 loop_seam_ratio: None,
1895 gait: None,
1896 speed_mps: None,
1897 };
1898 let invalid_mesh = || crate::measure::MeshDefinitionMeasurements {
1899 mesh_index: 0,
1900 name: "mesh".into(),
1901 vertex_count: 1,
1902 geometry_aabb: None,
1903 geometry_centroid: None,
1904 max_joints_per_vertex: 1,
1905 weight_sum_min: Some(f64::NAN),
1906 weight_sum_max: Some(1.0),
1907 additional_influence_sets: Vec::new(),
1908 };
1909 let valid = || file("valid.glb", BTreeMap::new(), Vec::new());
1910 let cases = [
1911 (
1912 report(vec![file(
1913 "invalid-clip.glb",
1914 BTreeMap::from([("walk".into(), invalid_clip())]),
1915 Vec::new(),
1916 )]),
1917 MeasurementReportError::File {
1918 file_index: 0,
1919 source: MeasurementFileError::InvalidMeasurements {
1920 source: MeasurementContractError::NonFiniteValue {
1921 path: "clips[\"walk\"].duration_s".into(),
1922 },
1923 },
1924 },
1925 "files[0] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
1926 0,
1927 ),
1928 (
1929 report(vec![file(
1930 "invalid-mesh.glb",
1931 BTreeMap::new(),
1932 vec![invalid_mesh()],
1933 )]),
1934 MeasurementReportError::File {
1935 file_index: 0,
1936 source: MeasurementFileError::InvalidMeasurements {
1937 source: MeasurementContractError::NonFiniteValue {
1938 path: "mesh_definitions[0].weight_sum_min".into(),
1939 },
1940 },
1941 },
1942 "files[0] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
1943 0,
1944 ),
1945 (
1946 report(vec![
1947 valid(),
1948 file(
1949 "invalid-clip.glb",
1950 BTreeMap::from([("walk".into(), invalid_clip())]),
1951 Vec::new(),
1952 ),
1953 ]),
1954 MeasurementReportError::File {
1955 file_index: 1,
1956 source: MeasurementFileError::InvalidMeasurements {
1957 source: MeasurementContractError::NonFiniteValue {
1958 path: "clips[\"walk\"].duration_s".into(),
1959 },
1960 },
1961 },
1962 "files[1] has invalid measurements: measurement value clips[\"walk\"].duration_s must be finite",
1963 1,
1964 ),
1965 (
1966 report(vec![
1967 valid(),
1968 file("invalid-mesh.glb", BTreeMap::new(), vec![invalid_mesh()]),
1969 ]),
1970 MeasurementReportError::File {
1971 file_index: 1,
1972 source: MeasurementFileError::InvalidMeasurements {
1973 source: MeasurementContractError::NonFiniteValue {
1974 path: "mesh_definitions[0].weight_sum_min".into(),
1975 },
1976 },
1977 },
1978 "files[1] has invalid measurements: measurement value mesh_definitions[0].weight_sum_min must be finite",
1979 1,
1980 ),
1981 ];
1982
1983 for (input, expected, expected_display, expected_file_index) in cases {
1984 let error = input
1985 .into_files()
1986 .expect_err("recovered evidence must be validated");
1987 assert_eq!(error, expected);
1988 assert_eq!(error.file_index(), Some(expected_file_index));
1989 assert_eq!(error.to_string(), expected_display);
1990 }
1991 }
1992}
1993
1994#[derive(Debug, Clone, Serialize)]
1995struct FileEvidence {
1996 path: String,
1997 input: InputIdentity,
1998 rig: RigInfo,
1999 measurements: MeasurementContract,
2000}
2001
2002impl FileEvidence {
2003 fn new(
2004 path: impl Into<String>,
2005 input: InputIdentity,
2006 rig: RigInfo,
2007 measurements: MeasurementContract,
2008 ) -> Self {
2009 Self {
2010 path: path.into(),
2011 input,
2012 rig,
2013 measurements,
2014 }
2015 }
2016}
2017
2018#[derive(Debug, Clone, Serialize)]
2020pub struct MeasureFileReport {
2021 #[serde(flatten)]
2022 evidence: FileEvidence,
2023}
2024
2025impl MeasureFileReport {
2026 pub fn new(
2028 path: impl Into<String>,
2029 input: InputIdentity,
2030 rig: RigInfo,
2031 measurements: MeasurementContract,
2032 ) -> Self {
2033 Self {
2034 evidence: FileEvidence::new(path, input, rig, measurements),
2035 }
2036 }
2037
2038 pub fn path(&self) -> &str {
2040 &self.evidence.path
2041 }
2042
2043 pub fn input(&self) -> &InputIdentity {
2045 &self.evidence.input
2046 }
2047
2048 pub fn measurements(&self) -> &MeasurementContract {
2050 &self.evidence.measurements
2051 }
2052}
2053
2054#[derive(Debug, Clone, Serialize)]
2056pub struct LintFileReport {
2057 #[serde(flatten)]
2058 evidence: FileEvidence,
2059 checks: Vec<CheckEvaluation>,
2060}
2061
2062impl LintFileReport {
2063 pub fn new(
2065 path: impl Into<String>,
2066 input: InputIdentity,
2067 rig: RigInfo,
2068 checks: Vec<CheckEvaluation>,
2069 measurements: MeasurementContract,
2070 ) -> Self {
2071 Self {
2072 evidence: FileEvidence::new(path, input, rig, measurements),
2073 checks,
2074 }
2075 }
2076
2077 pub fn path(&self) -> &str {
2079 &self.evidence.path
2080 }
2081
2082 pub fn input(&self) -> &InputIdentity {
2084 &self.evidence.input
2085 }
2086
2087 pub fn checks(&self) -> &[CheckEvaluation] {
2089 &self.checks
2090 }
2091
2092 pub fn measurements(&self) -> &MeasurementContract {
2094 &self.evidence.measurements
2095 }
2096}
2097
2098#[derive(Debug, Clone, Serialize)]
2099struct EnvelopeHeader {
2100 schema_version: u32,
2101 schema: &'static str,
2102 tool: ToolInfo,
2103 command: &'static str,
2104}
2105
2106impl EnvelopeHeader {
2107 fn new(tool: ToolInfo, command: &'static str) -> Self {
2108 Self {
2109 schema_version: OUTPUT_SCHEMA_VERSION,
2110 schema: OUTPUT_SCHEMA_ID,
2111 tool,
2112 command,
2113 }
2114 }
2115}
2116
2117#[derive(Debug, Clone, Serialize)]
2118struct MeasureSummary {
2119 files: usize,
2120}
2121
2122#[derive(Debug, Clone, Default, Serialize)]
2123struct FindingSummary {
2124 error: usize,
2125 warning: usize,
2126 note: usize,
2127}
2128
2129impl FindingSummary {
2130 fn add(&mut self, severity: Severity) {
2131 match severity {
2132 Severity::Error => self.error += 1,
2133 Severity::Warning => self.warning += 1,
2134 Severity::Note => self.note += 1,
2135 }
2136 }
2137}
2138
2139#[derive(Debug, Clone, Default, Serialize)]
2140struct SelectionSummary {
2141 selected: usize,
2142 unselected: usize,
2143}
2144
2145#[derive(Debug, Clone, Default, Serialize)]
2146struct ConfigurationSummary {
2147 enabled: usize,
2148 disabled: usize,
2149}
2150
2151#[derive(Debug, Clone, Default, Serialize)]
2152struct ApplicabilitySummary {
2153 applicable: usize,
2154 not_applicable: usize,
2155}
2156
2157#[derive(Debug, Clone, Default, Serialize)]
2158struct EvaluationStateSummary {
2159 complete: usize,
2160 partial: usize,
2161 not_evaluated: usize,
2162}
2163
2164#[derive(Debug, Clone, Default, Serialize)]
2165struct CheckSummary {
2166 total: usize,
2167 selection: SelectionSummary,
2168 configuration: ConfigurationSummary,
2169 applicability: ApplicabilitySummary,
2170 evaluation: EvaluationStateSummary,
2171 gaps: usize,
2172}
2173
2174#[derive(Debug, Clone, Serialize)]
2175struct LintSummary {
2176 files: usize,
2177 findings: FindingSummary,
2178 checks: CheckSummary,
2179}
2180
2181#[derive(Debug, Clone, Serialize)]
2183pub struct MeasureEnvelope {
2184 #[serde(flatten)]
2185 header: EnvelopeHeader,
2186 summary: MeasureSummary,
2187 files: Vec<MeasureFileReport>,
2188}
2189
2190impl MeasureEnvelope {
2191 pub fn new(tool: ToolInfo, files: Vec<MeasureFileReport>) -> Self {
2193 Self {
2194 header: EnvelopeHeader::new(tool, "measure"),
2195 summary: MeasureSummary { files: files.len() },
2196 files,
2197 }
2198 }
2199}
2200
2201#[derive(Debug, Clone, Serialize)]
2203pub struct LintEnvelope {
2204 #[serde(flatten)]
2205 header: EnvelopeHeader,
2206 summary: LintSummary,
2207 files: Vec<LintFileReport>,
2208}
2209
2210impl LintEnvelope {
2211 pub fn new(tool: ToolInfo, files: Vec<LintFileReport>) -> Self {
2214 let mut findings = FindingSummary::default();
2215 let mut checks = CheckSummary::default();
2216 for file in &files {
2217 for check in file.checks() {
2218 checks.total += 1;
2219 for finding in check.findings() {
2220 findings.add(finding.severity);
2221 }
2222 match check.selection() {
2223 SelectionState::Selected => checks.selection.selected += 1,
2224 SelectionState::Unselected => checks.selection.unselected += 1,
2225 }
2226 match check.configuration() {
2227 ConfigurationState::Enabled => checks.configuration.enabled += 1,
2228 ConfigurationState::Disabled => checks.configuration.disabled += 1,
2229 }
2230 match check.applicability() {
2231 Applicability::Applicable => checks.applicability.applicable += 1,
2232 Applicability::NotApplicable => checks.applicability.not_applicable += 1,
2233 }
2234 match check.evaluation() {
2235 EvaluationState::Complete => checks.evaluation.complete += 1,
2236 EvaluationState::Partial => checks.evaluation.partial += 1,
2237 EvaluationState::NotEvaluated => checks.evaluation.not_evaluated += 1,
2238 }
2239 checks.gaps += check.gaps().len();
2240 }
2241 }
2242 Self {
2243 header: EnvelopeHeader::new(tool, "lint"),
2244 summary: LintSummary {
2245 files: files.len(),
2246 findings,
2247 checks,
2248 },
2249 files,
2250 }
2251 }
2252}
2253
2254#[derive(Debug, Clone, Serialize)]
2255struct DiffInputs {
2256 before: String,
2257 after: String,
2258}
2259
2260#[derive(Debug, Clone, Serialize)]
2261struct DiffSummary {
2262 deltas: usize,
2263}
2264
2265#[derive(Debug, Serialize)]
2267pub struct DiffEnvelope {
2268 #[serde(flatten)]
2269 header: EnvelopeHeader,
2270 inputs: DiffInputs,
2271 summary: DiffSummary,
2272 deltas: Vec<MetricDelta>,
2273}
2274
2275impl DiffEnvelope {
2276 pub fn new(
2278 tool: ToolInfo,
2279 before: impl Into<String>,
2280 after: impl Into<String>,
2281 deltas: Vec<MetricDelta>,
2282 ) -> Self {
2283 Self {
2284 header: EnvelopeHeader::new(tool, "diff"),
2285 inputs: DiffInputs {
2286 before: before.into(),
2287 after: after.into(),
2288 },
2289 summary: DiffSummary {
2290 deltas: deltas.len(),
2291 },
2292 deltas,
2293 }
2294 }
2295}