1use crate::checks::exceeds_f32_cap;
6use crate::checks::fps::GRID_TOLERANCE_FRAMES;
7use crate::checks::loop_closure::effective_caps;
8use crate::config::Config;
9use crate::metrics::{
10 GaitPhaseOutcome, MetricGrids, RootYawHeadingAxis, foot_cycle_metrics, loop_continuity_metrics,
11 root_motion_speed_mps, root_trajectory_metrics, rotation_range_deg,
12};
13use crate::model::{
14 AffineGeometryFacts, DecodedImageColorType, Document, ImageContainerFormat, ImageSourceKind,
15 ImageUnavailableReason, MaterialResourceCoverage, MaterialTextureSlot, MeshAsset, Property,
16 SourceImageInspection, SourceInverseBindAccessorStatus, SourceNodeLocalRest,
17 SourceSkeletonCoverage, tolerant_world_rest_matrices, validate_track_shape,
18 values_equal_to_mean,
19};
20use crate::profile::{ResolvedRoles, Role};
21use crate::sample::PoseGrid;
22use crate::transform::analyze_duplicate_loop_endpoint;
23use glam::{DMat3, Mat3, Mat4, Vec3};
24use serde::ser::SerializeStruct;
25use serde::{Deserialize, Deserializer, Serialize, Serializer};
26use std::collections::{BTreeMap, BTreeSet};
27
28pub const MIN_RECORDED_ROTATION_DEG: f64 = 0.1;
31
32pub const LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE: f64 = 1.0e-5;
34pub const LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE: f64 = 1.0e-6;
36pub const INVERSE_BIND_AFFINE_TOLERANCE: f64 = 1.0e-6;
40pub const INVERSE_BIND_MIN_RECIPROCAL_CONDITION_INF: f64 = 1.0e-6;
45
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
49#[non_exhaustive]
50pub struct Aabb {
51 pub min: [f32; 3],
53 pub max: [f32; 3],
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
63#[non_exhaustive]
64pub struct PrimitiveMeasurements {
65 pub primitive_index: usize,
69 #[serde(deserialize_with = "deserialize_required_material_index")]
71 pub material_index: Option<usize>,
72 pub vertex_count: u64,
74 pub finite_vertex_count: u64,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub geometry_aabb: Option<Aabb>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub geometry_centroid: Option<[f32; 3]>,
82}
83
84fn deserialize_required_material_index<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
85where
86 D: Deserializer<'de>,
87{
88 Option::<usize>::deserialize(deserializer)
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
99#[non_exhaustive]
100pub struct MeshDefinitionMeasurements {
101 pub mesh_index: usize,
103 pub name: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub primitives: Option<Vec<PrimitiveMeasurements>>,
110 pub vertex_count: u64,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub geometry_aabb: Option<Aabb>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub geometry_centroid: Option<[f32; 3]>,
121 pub max_joints_per_vertex: u32,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub weight_sum_min: Option<f64>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub weight_sum_max: Option<f64>,
132 pub additional_influence_sets: Vec<AdditionalInfluenceSetMeasurements>,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[non_exhaustive]
145pub struct AdditionalInfluenceSetMeasurements {
146 pub set_index: u32,
148 pub joints_present: bool,
150 pub weights_present: bool,
152 pub joints_without_weights_present: bool,
155 pub weights_without_joints_present: bool,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163#[non_exhaustive]
164pub enum StaticNodeAabbUnavailableReason {
165 NoFinitePositions,
167 SkinnedDeformationExcluded,
170 NonFiniteTransform,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
176#[non_exhaustive]
177pub struct NodeInstanceMeasurements {
178 pub node_index: usize,
180 pub node_name: String,
182 pub mesh_index: usize,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub static_node_world_aabb: Option<Aabb>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub static_node_world_aabb_unavailable_reason: Option<StaticNodeAabbUnavailableReason>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196#[non_exhaustive]
197pub struct SceneMeasurements {
198 pub scene_index: usize,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub name: Option<String>,
203 pub instance_count: usize,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub static_scene_world_aabb: Option<Aabb>,
208 pub excluded_instance_count: usize,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[non_exhaustive]
216pub struct MaterialTextureBindingMeasurements {
217 pub slot: MaterialTextureSlot,
219 pub texture_index: usize,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225#[non_exhaustive]
226pub struct MaterialDefinitionMeasurements {
227 pub material_index: usize,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub name: Option<String>,
232 pub texture_bindings: Vec<MaterialTextureBindingMeasurements>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238#[non_exhaustive]
239pub struct TextureMeasurements {
240 pub texture_index: usize,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub name: Option<String>,
245 pub image_index: usize,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
252#[non_exhaustive]
253pub struct ImageMeasurements {
254 pub image_index: usize,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub name: Option<String>,
259 pub source_kind: ImageSourceKind,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub declared_mime_type: Option<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub detected_container: Option<ImageContainerFormat>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub leading_magic_hex: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub width: Option<u32>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub height: Option<u32>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub channel_count: Option<u8>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub decoded_color_type: Option<DecodedImageColorType>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub unavailable_reason: Option<ImageUnavailableReason>,
286}
287
288pub type SkeletonSourceCoverage = SourceSkeletonCoverage;
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
300#[serde(tag = "kind", rename_all = "snake_case")]
301#[non_exhaustive]
302pub enum SkeletonNodeLocalRestMeasurements {
303 Trs {
305 translation_parent_space_m: [f32; 3],
309 rotation_xyzw: [f32; 4],
311 scale: [f32; 3],
313 },
314 Matrix {
316 matrix: [f32; 16],
318 },
319 Unavailable {
321 reason: SkeletonNodeLocalRestUnavailableReason,
323 },
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332#[non_exhaustive]
333pub enum LinearTransformClassification {
334 UnitOrthonormal,
336 UniformScaled,
338 NonUniform,
340 Sheared,
342 Reflected,
345 Singular,
348 NonFinite,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "snake_case")]
356#[non_exhaustive]
357pub enum LinearTransformOrientation {
358 Positive,
360 Negative,
362 Zero,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
374#[non_exhaustive]
375pub struct LinearTransformMeasurements {
376 pub classification: LinearTransformClassification,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub axis_lengths: Option<[f64; 3]>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub determinant: Option<f64>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub orientation: Option<LinearTransformOrientation>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub rotation_xyzw: Option<[f32; 4]>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub uniform_scale: Option<f64>,
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(rename_all = "snake_case")]
400#[non_exhaustive]
401pub enum SkeletonNodeLocalRestUnavailableReason {
402 NonFiniteTransform,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(rename_all = "snake_case")]
409#[non_exhaustive]
410pub enum SkeletonRestWorldMatrixUnavailableReason {
411 NonFiniteLocalRest,
413 ParentRestWorldUnavailable,
415 NonFiniteWorldMatrix,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
421#[non_exhaustive]
422pub struct SkeletonNodeMeasurements {
423 pub node_index: usize,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub name: Option<String>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub parent_node_index: Option<usize>,
431 pub scene_root_indices: Vec<usize>,
435 pub local_rest: SkeletonNodeLocalRestMeasurements,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub rest_world_matrix: Option<[f32; 16]>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub rest_world_translation_m: Option<[f32; 3]>,
445 pub rest_world_linear: LinearTransformMeasurements,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub rest_world_matrix_unavailable_reason: Option<SkeletonRestWorldMatrixUnavailableReason>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455#[non_exhaustive]
456pub struct SkinInverseBindAccessorMeasurements {
457 pub status: SourceInverseBindAccessorStatus,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub declared_count: Option<usize>,
462 pub matrices: Vec<[f32; 16]>,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
470#[non_exhaustive]
471pub struct SkinJointMeasurements {
472 pub joint_index: usize,
474 pub node_index: usize,
476 pub joint_bind_to_mesh: SkinDerivedMatrixMeasurements,
478 pub mesh_bind_world: SkinDerivedMatrixMeasurements,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
486#[non_exhaustive]
487pub struct SkinAttachmentMeasurements {
488 pub node_index: usize,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub mesh_index: Option<usize>,
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
497#[serde(rename_all = "snake_case")]
498#[non_exhaustive]
499pub enum SkinDerivedMatrixUnavailableReason {
500 InverseBindAccessorAbsent,
502 InverseBindAccessorEmpty,
504 InverseBindAccessorCountMismatch,
506 InverseBindAccessorUnreadable,
509 JointRestWorldUnavailable,
511 InverseBindMatrixNonInvertible,
513 InverseBindMatrixNonAffine,
516 InverseBindMatrixIllConditioned,
519 NonFiniteDerivedMatrix,
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
530#[non_exhaustive]
531pub struct SkinMatrixInversionQuality {
532 pub reciprocal_condition_number_inf: f64,
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
540#[non_exhaustive]
541pub struct SkinDerivedMatrixMeasurements {
542 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub source_inverse_bind_matrix: Option<[f32; 16]>,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub inversion_quality: Option<SkinMatrixInversionQuality>,
555 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub matrix: Option<[f32; 16]>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub linear: Option<LinearTransformMeasurements>,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub unavailable_reason: Option<SkinDerivedMatrixUnavailableReason>,
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
569#[serde(rename_all = "snake_case")]
570#[non_exhaustive]
571pub enum SkinBindLinearSummaryClassification {
572 NoJoints,
574 Unavailable,
576 PartiallyUnavailable,
578 ConsistentUniform,
581 MixedUniform,
583 NonUniformOrSheared,
585 ReflectedOrSingular,
587 Mixed,
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
593#[non_exhaustive]
594pub struct SkinBindLinearSummaryMeasurements {
595 pub classification: SkinBindLinearSummaryClassification,
597 pub joint_count: usize,
599 pub available_joint_count: usize,
601 pub unavailable_joint_count: usize,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub consistent_uniform_scale: Option<f64>,
607}
608
609fn unavailable_linear_transform() -> LinearTransformMeasurements {
610 LinearTransformMeasurements {
611 classification: LinearTransformClassification::NonFinite,
612 axis_lengths: None,
613 determinant: None,
614 orientation: None,
615 rotation_xyzw: None,
616 uniform_scale: None,
617 }
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
622#[non_exhaustive]
623pub struct SkinMeasurements {
624 pub skin_index: usize,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub name: Option<String>,
629 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub skeleton_root_node_index: Option<usize>,
632 pub joints: Vec<SkinJointMeasurements>,
634 pub joint_bind_linear_summary: SkinBindLinearSummaryMeasurements,
636 pub inverse_bind_accessor: SkinInverseBindAccessorMeasurements,
638 pub attachments: Vec<SkinAttachmentMeasurements>,
640}
641
642#[derive(Debug, Clone, Default, Serialize, Deserialize)]
644#[non_exhaustive]
645pub struct AssetMeasurements {
646 pub material_resource_coverage: MaterialResourceCoverage,
648 pub material_definitions: Vec<MaterialDefinitionMeasurements>,
650 pub textures: Vec<TextureMeasurements>,
652 pub images: Vec<ImageMeasurements>,
654 #[serde(default)]
656 pub skeleton_source_coverage: SkeletonSourceCoverage,
657 #[serde(default)]
659 pub skeleton_nodes: Vec<SkeletonNodeMeasurements>,
660 #[serde(default)]
662 pub skins: Vec<SkinMeasurements>,
663 pub mesh_definitions: Vec<MeshDefinitionMeasurements>,
665 pub node_instances: Vec<NodeInstanceMeasurements>,
667 pub scenes: Vec<SceneMeasurements>,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub default_scene_index: Option<usize>,
672}
673
674#[derive(Debug, Clone, Copy)]
675struct Bounds {
676 min: [f32; 3],
677 max: [f32; 3],
678 any: bool,
679}
680
681impl Default for Bounds {
682 fn default() -> Self {
683 Self {
684 min: [f32::INFINITY; 3],
685 max: [f32::NEG_INFINITY; 3],
686 any: false,
687 }
688 }
689}
690
691impl Bounds {
692 fn include(&mut self, point: Vec3) -> bool {
693 let point = point.to_array();
694 if !point.iter().all(|value| value.is_finite()) {
695 return false;
696 }
697 self.any = true;
698 for ((min, max), value) in self.min.iter_mut().zip(&mut self.max).zip(point) {
699 *min = min.min(value);
700 *max = max.max(value);
701 }
702 true
703 }
704
705 fn include_aabb(&mut self, aabb: Aabb) {
706 self.any = true;
707 for ((min, max), (aabb_min, aabb_max)) in self
708 .min
709 .iter_mut()
710 .zip(&mut self.max)
711 .zip(aabb.min.into_iter().zip(aabb.max))
712 {
713 *min = min.min(aabb_min);
714 *max = max.max(aabb_max);
715 }
716 }
717
718 fn finish(self) -> Option<Aabb> {
719 self.any.then_some(Aabb {
720 min: self.min,
721 max: self.max,
722 })
723 }
724}
725
726#[derive(Default)]
731struct Centroid {
732 sum: [f64; 3],
733 count: u64,
734}
735
736impl Centroid {
737 fn include(&mut self, point: Vec3) {
738 let point = point.to_array();
739 for (sum, value) in self.sum.iter_mut().zip(point) {
740 *sum += f64::from(value);
741 }
742 self.count += 1;
743 }
744
745 fn include_published_mean(&mut self, mean: [f32; 3], count: u64) {
746 for (sum, value) in self.sum.iter_mut().zip(mean) {
747 *sum += f64::from(value) * count as f64;
748 }
749 self.count += count;
750 }
751
752 fn finish(self) -> Option<[f32; 3]> {
753 (self.count != 0).then(|| {
754 let count = self.count as f64;
755 self.sum.map(|sum| (sum / count) as f32)
756 })
757 }
758}
759
760fn measure_mesh_definition(mesh: &MeshAsset) -> MeshDefinitionMeasurements {
761 let mut vertex_count = 0u64;
762 let mut max_joints_per_vertex = 0u32;
763 let mut weight_sum_min = f64::INFINITY;
764 let mut weight_sum_max = f64::NEG_INFINITY;
765 let mut any_finite_weight = false;
766 let mut additional_influence_sets: BTreeMap<u32, AdditionalInfluenceSetMeasurements> =
767 BTreeMap::new();
768 let mut primitives = Vec::with_capacity(mesh.primitives.len());
769
770 for (retained_primitive_index, primitive) in mesh.primitives.iter().enumerate() {
771 let primitive_vertex_count = primitive.positions.len() as u64;
772 vertex_count = vertex_count.saturating_add(primitive_vertex_count);
773 let mut primitive_bounds = Bounds::default();
774 let mut primitive_centroid = Centroid::default();
775 let mut finite_vertex_count = 0u64;
776 for &position in &primitive.positions {
777 if primitive_bounds.include(position) {
780 finite_vertex_count = finite_vertex_count.saturating_add(1);
781 primitive_centroid.include(position);
782 }
783 }
784 primitives.push(PrimitiveMeasurements {
785 primitive_index: primitive
786 .source_primitive_index
787 .unwrap_or(retained_primitive_index),
788 material_index: primitive.material,
789 vertex_count: primitive_vertex_count,
790 finite_vertex_count,
791 geometry_aabb: primitive_bounds.finish(),
792 geometry_centroid: primitive_centroid.finish(),
793 });
794 for weights in &primitive.weights {
795 let influences = weights.iter().filter(|&&weight| weight > 0.0).count() as u32;
796 max_joints_per_vertex = max_joints_per_vertex.max(influences);
797 let sum: f64 = weights.iter().map(|&weight| f64::from(weight)).sum();
798 if sum.is_finite() {
799 any_finite_weight = true;
800 weight_sum_min = weight_sum_min.min(sum);
801 weight_sum_max = weight_sum_max.max(sum);
802 }
803 }
804 for set in &primitive.additional_influence_sets {
805 additional_influence_sets
806 .entry(set.set_index)
807 .and_modify(|entry| {
808 entry.joints_present |= set.joints_present;
809 entry.weights_present |= set.weights_present;
810 entry.joints_without_weights_present |=
811 set.joints_present && !set.weights_present;
812 entry.weights_without_joints_present |=
813 set.weights_present && !set.joints_present;
814 })
815 .or_insert(AdditionalInfluenceSetMeasurements {
816 set_index: set.set_index,
817 joints_present: set.joints_present,
818 weights_present: set.weights_present,
819 joints_without_weights_present: set.joints_present && !set.weights_present,
820 weights_without_joints_present: set.weights_present && !set.joints_present,
821 });
822 }
823 }
824
825 let mut bounds = Bounds::default();
829 let mut centroid = Centroid::default();
830 for primitive in &primitives {
831 if let Some(aabb) = primitive.geometry_aabb {
832 bounds.include_aabb(aabb);
833 }
834 if let Some(mean) = primitive.geometry_centroid {
835 centroid.include_published_mean(mean, primitive.finite_vertex_count);
836 }
837 }
838
839 MeshDefinitionMeasurements {
840 mesh_index: mesh.source_mesh_index,
841 name: mesh.name.clone(),
842 primitives: Some(primitives),
843 vertex_count,
844 geometry_aabb: bounds.finish(),
845 geometry_centroid: centroid.finish(),
846 max_joints_per_vertex,
847 weight_sum_min: any_finite_weight.then_some(weight_sum_min),
848 weight_sum_max: any_finite_weight.then_some(weight_sum_max),
849 additional_influence_sets: additional_influence_sets.into_values().collect(),
850 }
851}
852
853fn matrix_is_finite(matrix: Mat4) -> bool {
854 matrix
855 .to_cols_array()
856 .into_iter()
857 .all(|component| component.is_finite())
858}
859
860fn matrix_to_columns(matrix: Mat4) -> [f32; 16] {
861 matrix.to_cols_array()
862}
863
864fn vec3_is_finite(value: Vec3) -> bool {
865 value.to_array().into_iter().all(f32::is_finite)
866}
867
868fn quat_is_finite(value: glam::Quat) -> bool {
869 value.to_array().into_iter().all(f32::is_finite)
870}
871
872fn source_local_rest_measurement(
873 local_rest: &SourceNodeLocalRest,
874) -> (SkeletonNodeLocalRestMeasurements, Option<Mat4>) {
875 match local_rest {
876 SourceNodeLocalRest::Trs {
877 translation,
878 rotation,
879 scale,
880 } if vec3_is_finite(*translation)
881 && quat_is_finite(*rotation)
882 && vec3_is_finite(*scale) =>
883 {
884 let matrix = Mat4::from_scale_rotation_translation(*scale, *rotation, *translation);
885 if matrix_is_finite(matrix) {
886 (
887 SkeletonNodeLocalRestMeasurements::Trs {
888 translation_parent_space_m: translation.to_array(),
889 rotation_xyzw: rotation.to_array(),
890 scale: scale.to_array(),
891 },
892 Some(matrix),
893 )
894 } else {
895 (
896 SkeletonNodeLocalRestMeasurements::Unavailable {
897 reason: SkeletonNodeLocalRestUnavailableReason::NonFiniteTransform,
898 },
899 None,
900 )
901 }
902 }
903 SourceNodeLocalRest::Matrix(matrix) if matrix_is_finite(*matrix) => (
904 SkeletonNodeLocalRestMeasurements::Matrix {
905 matrix: matrix_to_columns(*matrix),
906 },
907 Some(*matrix),
908 ),
909 _ => (
910 SkeletonNodeLocalRestMeasurements::Unavailable {
911 reason: SkeletonNodeLocalRestUnavailableReason::NonFiniteTransform,
912 },
913 None,
914 ),
915 }
916}
917
918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
919enum RestWorldVisit {
920 Visiting,
921 Done,
922}
923
924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
925enum SourceRestWorldError {
926 NonFiniteLocalRest,
927 MissingParentNode,
928 ParentRestWorldUnavailable,
929 ParentCycle,
930 NonFiniteWorldMatrix,
931}
932
933fn source_rest_world(
934 node_index: usize,
935 source_nodes: &BTreeMap<usize, (&crate::model::SourceNodeAsset, Option<Mat4>)>,
936 visits: &mut BTreeMap<usize, RestWorldVisit>,
937 worlds: &mut BTreeMap<usize, Result<Mat4, SourceRestWorldError>>,
938) -> Result<Mat4, SourceRestWorldError> {
939 if let Some(result) = worlds.get(&node_index) {
940 return *result;
941 }
942 let mut path = Vec::new();
943 let mut current = node_index;
944 let mut parent_result = loop {
945 if let Some(result) = worlds.get(¤t) {
946 break *result;
947 }
948 if visits.get(¤t) == Some(&RestWorldVisit::Visiting) {
949 break Err(SourceRestWorldError::ParentCycle);
950 }
951 let Some((node, local)) = source_nodes.get(¤t) else {
952 if path.is_empty() {
953 return Err(SourceRestWorldError::MissingParentNode);
954 }
955 break Err(SourceRestWorldError::MissingParentNode);
956 };
957 let Some(local) = *local else {
958 let result = Err(SourceRestWorldError::NonFiniteLocalRest);
959 worlds.insert(current, result);
960 visits.insert(current, RestWorldVisit::Done);
961 break result;
962 };
963 visits.insert(current, RestWorldVisit::Visiting);
964 path.push((current, local));
965 match node.parent_source_node_index {
966 Some(parent) => current = parent,
967 None => {
968 let result = Ok(local);
969 worlds.insert(current, result);
970 visits.insert(current, RestWorldVisit::Done);
971 path.pop();
972 break result;
973 }
974 }
975 };
976
977 for (current, local) in path.into_iter().rev() {
978 parent_result = match parent_result {
979 Err(
980 error @ (SourceRestWorldError::MissingParentNode
981 | SourceRestWorldError::ParentCycle),
982 ) => Err(error),
983 Err(_) => Err(SourceRestWorldError::ParentRestWorldUnavailable),
984 Ok(parent_world) => {
985 let world = parent_world * local;
986 matrix_is_finite(world)
987 .then_some(world)
988 .ok_or(SourceRestWorldError::NonFiniteWorldMatrix)
989 }
990 };
991 visits.insert(current, RestWorldVisit::Done);
992 worlds.insert(current, parent_result);
993 }
994 parent_result
995}
996
997fn derived_accessor_global_unavailable_reason(
998 status: SourceInverseBindAccessorStatus,
999) -> Option<SkinDerivedMatrixUnavailableReason> {
1000 match status {
1001 SourceInverseBindAccessorStatus::Absent => {
1002 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
1003 }
1004 SourceInverseBindAccessorStatus::EmptyAccessor => {
1005 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
1006 }
1007 SourceInverseBindAccessorStatus::Unreadable => {
1008 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
1009 }
1010 SourceInverseBindAccessorStatus::Available
1011 | SourceInverseBindAccessorStatus::CountMismatch => None,
1012 }
1013}
1014
1015pub(crate) struct InverseBindAssessment {
1016 pub(crate) inverse: Result<Mat4, SkinDerivedMatrixUnavailableReason>,
1017 pub(crate) quality: Option<SkinMatrixInversionQuality>,
1018}
1019
1020pub(crate) fn assess_inverse_bind(matrix: Mat4) -> InverseBindAssessment {
1021 let values = matrix.to_cols_array();
1022 let affine = [values[3], values[7], values[11]]
1023 .into_iter()
1024 .all(|value| f64::from(value).abs() <= INVERSE_BIND_AFFINE_TOLERANCE)
1025 && (f64::from(values[15]) - 1.0).abs() <= INVERSE_BIND_AFFINE_TOLERANCE;
1026 if !affine {
1027 return InverseBindAssessment {
1028 inverse: Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine),
1029 quality: None,
1030 };
1031 }
1032
1033 let linear = [
1034 [
1035 f64::from(values[0]),
1036 f64::from(values[4]),
1037 f64::from(values[8]),
1038 ],
1039 [
1040 f64::from(values[1]),
1041 f64::from(values[5]),
1042 f64::from(values[9]),
1043 ],
1044 [
1045 f64::from(values[2]),
1046 f64::from(values[6]),
1047 f64::from(values[10]),
1048 ],
1049 ];
1050 let determinant = linear[0][0] * (linear[1][1] * linear[2][2] - linear[1][2] * linear[2][1])
1051 - linear[0][1] * (linear[1][0] * linear[2][2] - linear[1][2] * linear[2][0])
1052 + linear[0][2] * (linear[1][0] * linear[2][1] - linear[1][1] * linear[2][0]);
1053 let norm = linear
1054 .iter()
1055 .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
1056 .fold(0.0_f64, f64::max);
1057 if determinant == 0.0 || norm == 0.0 || !determinant.is_finite() || !norm.is_finite() {
1058 return InverseBindAssessment {
1059 inverse: Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible),
1060 quality: Some(SkinMatrixInversionQuality {
1061 reciprocal_condition_number_inf: 0.0,
1062 }),
1063 };
1064 }
1065 let inverse_linear = [
1066 [
1067 (linear[1][1] * linear[2][2] - linear[1][2] * linear[2][1]) / determinant,
1068 (linear[0][2] * linear[2][1] - linear[0][1] * linear[2][2]) / determinant,
1069 (linear[0][1] * linear[1][2] - linear[0][2] * linear[1][1]) / determinant,
1070 ],
1071 [
1072 (linear[1][2] * linear[2][0] - linear[1][0] * linear[2][2]) / determinant,
1073 (linear[0][0] * linear[2][2] - linear[0][2] * linear[2][0]) / determinant,
1074 (linear[0][2] * linear[1][0] - linear[0][0] * linear[1][2]) / determinant,
1075 ],
1076 [
1077 (linear[1][0] * linear[2][1] - linear[1][1] * linear[2][0]) / determinant,
1078 (linear[0][1] * linear[2][0] - linear[0][0] * linear[2][1]) / determinant,
1079 (linear[0][0] * linear[1][1] - linear[0][1] * linear[1][0]) / determinant,
1080 ],
1081 ];
1082 let inverse_norm = inverse_linear
1083 .iter()
1084 .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
1085 .fold(0.0_f64, f64::max);
1086 let reciprocal_condition_number_inf = (1.0 / (norm * inverse_norm)).clamp(0.0, 1.0);
1087 let quality = Some(SkinMatrixInversionQuality {
1088 reciprocal_condition_number_inf,
1089 });
1090 if !reciprocal_condition_number_inf.is_finite()
1091 || reciprocal_condition_number_inf <= INVERSE_BIND_MIN_RECIPROCAL_CONDITION_INF
1092 {
1093 return InverseBindAssessment {
1094 inverse: Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned),
1095 quality,
1096 };
1097 }
1098 let mut inverse = matrix.inverse();
1099 if !matrix_is_finite(inverse) {
1100 let translation = [
1101 f64::from(values[12]),
1102 f64::from(values[13]),
1103 f64::from(values[14]),
1104 ];
1105 let inverse_translation = [
1106 -inverse_linear[0]
1107 .iter()
1108 .zip(translation)
1109 .map(|(coefficient, value)| coefficient * value)
1110 .sum::<f64>(),
1111 -inverse_linear[1]
1112 .iter()
1113 .zip(translation)
1114 .map(|(coefficient, value)| coefficient * value)
1115 .sum::<f64>(),
1116 -inverse_linear[2]
1117 .iter()
1118 .zip(translation)
1119 .map(|(coefficient, value)| coefficient * value)
1120 .sum::<f64>(),
1121 ];
1122 let widened = [
1123 inverse_linear[0][0],
1124 inverse_linear[1][0],
1125 inverse_linear[2][0],
1126 0.0,
1127 inverse_linear[0][1],
1128 inverse_linear[1][1],
1129 inverse_linear[2][1],
1130 0.0,
1131 inverse_linear[0][2],
1132 inverse_linear[1][2],
1133 inverse_linear[2][2],
1134 0.0,
1135 inverse_translation[0],
1136 inverse_translation[1],
1137 inverse_translation[2],
1138 1.0,
1139 ];
1140 let narrowed = widened.map(|value| value as f32);
1141 inverse = Mat4::from_cols_array(&narrowed);
1142 }
1143 InverseBindAssessment {
1144 inverse: matrix_is_finite(inverse)
1145 .then_some(inverse)
1146 .ok_or(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix),
1147 quality,
1148 }
1149}
1150
1151pub fn measure_linear_transform(matrix: Mat4) -> LinearTransformMeasurements {
1162 if !matrix_is_finite(matrix) {
1163 return LinearTransformMeasurements {
1164 classification: LinearTransformClassification::NonFinite,
1165 axis_lengths: None,
1166 determinant: None,
1167 orientation: None,
1168 rotation_xyzw: None,
1169 uniform_scale: None,
1170 };
1171 }
1172
1173 let facts = match AffineGeometryFacts::from_linear(Mat3::from_mat4(matrix)) {
1178 Ok(facts) => facts,
1179 Err(_) => return unavailable_linear_transform(),
1180 };
1181
1182 let singular = facts.axis_length_product == 0.0
1183 || facts.determinant.abs()
1184 <= LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE * facts.axis_length_product;
1185 let orientation = if singular {
1186 LinearTransformOrientation::Zero
1187 } else if facts.determinant < 0.0 {
1188 LinearTransformOrientation::Negative
1189 } else {
1190 LinearTransformOrientation::Positive
1191 };
1192 let orthogonal = [(0usize, 1usize), (0, 2), (1, 2)]
1193 .into_iter()
1194 .zip(facts.cross_axis_dots)
1195 .all(|((left, right), dot)| {
1196 let length_product = facts.axis_lengths[left] * facts.axis_lengths[right];
1197 length_product == 0.0
1198 || dot.abs() <= LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE * length_product
1199 });
1200 let uniform = facts.has_equal_axis_lengths(LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE);
1201 let uniform_scale = (orthogonal && uniform).then_some(facts.mean_axis_length);
1202 let unit = uniform_scale
1203 .is_some_and(|scale| (scale - 1.0).abs() <= LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE);
1204 let classification = if singular {
1205 LinearTransformClassification::Singular
1206 } else if orientation == LinearTransformOrientation::Negative {
1207 LinearTransformClassification::Reflected
1208 } else if !orthogonal {
1209 LinearTransformClassification::Sheared
1210 } else if unit {
1211 LinearTransformClassification::UnitOrthonormal
1212 } else if uniform {
1213 LinearTransformClassification::UniformScaled
1214 } else {
1215 LinearTransformClassification::NonUniform
1216 };
1217 let rotation_xyzw = (classification == LinearTransformClassification::UnitOrthonormal)
1218 .then(|| canonical_rotation_xyzw(Mat3::from_mat4(matrix)));
1219
1220 LinearTransformMeasurements {
1221 classification,
1222 axis_lengths: Some(facts.axis_lengths),
1223 determinant: Some(facts.determinant),
1224 orientation: Some(orientation),
1225 rotation_xyzw,
1226 uniform_scale,
1227 }
1228}
1229
1230const POLAR_ROTATION_MAX_NEWTON_STEPS: usize = 8;
1231const POLAR_ROTATION_ORTHOGONALITY_TOLERANCE: f64 = 64.0 * f64::EPSILON;
1232
1233fn canonical_rotation_xyzw(matrix: Mat3) -> [f32; 4] {
1241 let polar = proper_orthogonal_polar_factor(matrix);
1242
1243 let m00 = polar.x_axis.x;
1244 let m01 = polar.y_axis.x;
1245 let m02 = polar.z_axis.x;
1246 let m10 = polar.x_axis.y;
1247 let m11 = polar.y_axis.y;
1248 let m12 = polar.z_axis.y;
1249 let m20 = polar.x_axis.z;
1250 let m21 = polar.y_axis.z;
1251 let m22 = polar.z_axis.z;
1252 let trace = m00 + m11 + m22;
1253
1254 let mut quaternion = if trace > 0.0 {
1255 let scale = 2.0 * (trace + 1.0).sqrt();
1256 [
1257 (m21 - m12) / scale,
1258 (m02 - m20) / scale,
1259 (m10 - m01) / scale,
1260 0.25 * scale,
1261 ]
1262 } else if m00 > m11 && m00 > m22 {
1263 let scale = 2.0 * (1.0 + m00 - m11 - m22).sqrt();
1264 [
1265 0.25 * scale,
1266 (m01 + m10) / scale,
1267 (m02 + m20) / scale,
1268 (m21 - m12) / scale,
1269 ]
1270 } else if m11 > m22 {
1271 let scale = 2.0 * (1.0 + m11 - m00 - m22).sqrt();
1272 [
1273 (m01 + m10) / scale,
1274 0.25 * scale,
1275 (m12 + m21) / scale,
1276 (m02 - m20) / scale,
1277 ]
1278 } else {
1279 let scale = 2.0 * (1.0 + m22 - m00 - m11).sqrt();
1280 [
1281 (m02 + m20) / scale,
1282 (m12 + m21) / scale,
1283 0.25 * scale,
1284 (m10 - m01) / scale,
1285 ]
1286 };
1287
1288 let norm = quaternion
1289 .iter()
1290 .map(|value| value * value)
1291 .sum::<f64>()
1292 .sqrt();
1293 for value in &mut quaternion {
1294 *value /= norm;
1295 }
1296 let mut quaternion = quaternion.map(|value| value as f32);
1300 let mut selected = 0usize;
1301 for index in 1..quaternion.len() {
1302 if quaternion[index].abs() > quaternion[selected].abs() {
1303 selected = index;
1304 }
1305 }
1306 if quaternion[selected] < 0.0 {
1307 for value in &mut quaternion {
1308 *value = -*value;
1309 }
1310 }
1311 quaternion
1312}
1313
1314fn proper_orthogonal_polar_factor(matrix: Mat3) -> DMat3 {
1317 let mut polar = DMat3::from_cols(
1324 matrix.x_axis.as_dvec3(),
1325 matrix.y_axis.as_dvec3(),
1326 matrix.z_axis.as_dvec3(),
1327 );
1328 for _ in 0..POLAR_ROTATION_MAX_NEWTON_STEPS {
1329 polar = (polar + polar.inverse().transpose()) * 0.5;
1330 if polar_orthogonality_residual(polar) <= POLAR_ROTATION_ORTHOGONALITY_TOLERANCE {
1331 break;
1332 }
1333 }
1334 polar
1335}
1336
1337fn polar_orthogonality_residual(matrix: DMat3) -> f64 {
1338 let residual = matrix.transpose() * matrix - DMat3::IDENTITY;
1339 residual
1340 .to_cols_array()
1341 .into_iter()
1342 .map(f64::abs)
1343 .fold(0.0, f64::max)
1344}
1345
1346pub(crate) fn summarize_skin_bind_linear(
1347 joints: &[SkinJointMeasurements],
1348) -> SkinBindLinearSummaryMeasurements {
1349 let joint_count = joints.len();
1350 let available: Vec<_> = joints
1351 .iter()
1352 .filter_map(|joint| joint.joint_bind_to_mesh.linear)
1353 .collect();
1354 let available_joint_count = available.len();
1355 let unavailable_joint_count = joint_count.saturating_sub(available_joint_count);
1356 let (classification, consistent_uniform_scale) = if joint_count == 0 {
1357 (SkinBindLinearSummaryClassification::NoJoints, None)
1358 } else if available_joint_count == 0 {
1359 (SkinBindLinearSummaryClassification::Unavailable, None)
1360 } else if unavailable_joint_count > 0 {
1361 (
1362 SkinBindLinearSummaryClassification::PartiallyUnavailable,
1363 None,
1364 )
1365 } else if available.iter().all(|linear| {
1366 matches!(
1367 linear.classification,
1368 LinearTransformClassification::UnitOrthonormal
1369 | LinearTransformClassification::UniformScaled
1370 )
1371 }) {
1372 let mut factors = available
1373 .iter()
1374 .map(|linear| {
1375 linear
1376 .uniform_scale
1377 .expect("uniform classifications carry a scale")
1378 })
1379 .collect::<Vec<_>>();
1380 factors.sort_by(f64::total_cmp);
1384 let mean = factors.iter().sum::<f64>() / factors.len() as f64;
1385 if values_equal_to_mean(&factors, mean, LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE) {
1386 (
1387 SkinBindLinearSummaryClassification::ConsistentUniform,
1388 Some(mean),
1389 )
1390 } else {
1391 (SkinBindLinearSummaryClassification::MixedUniform, None)
1392 }
1393 } else if available.iter().all(|linear| {
1394 matches!(
1395 linear.classification,
1396 LinearTransformClassification::NonUniform | LinearTransformClassification::Sheared
1397 )
1398 }) {
1399 (
1400 SkinBindLinearSummaryClassification::NonUniformOrSheared,
1401 None,
1402 )
1403 } else if available.iter().all(|linear| {
1404 matches!(
1405 linear.classification,
1406 LinearTransformClassification::Reflected | LinearTransformClassification::Singular
1407 )
1408 }) {
1409 (
1410 SkinBindLinearSummaryClassification::ReflectedOrSingular,
1411 None,
1412 )
1413 } else {
1414 (SkinBindLinearSummaryClassification::Mixed, None)
1415 };
1416 SkinBindLinearSummaryMeasurements {
1417 classification,
1418 joint_count,
1419 available_joint_count,
1420 unavailable_joint_count,
1421 consistent_uniform_scale,
1422 }
1423}
1424
1425fn unavailable_derived_matrix(
1426 reason: SkinDerivedMatrixUnavailableReason,
1427) -> SkinDerivedMatrixMeasurements {
1428 SkinDerivedMatrixMeasurements {
1429 source_inverse_bind_matrix: None,
1430 inversion_quality: None,
1431 matrix: None,
1432 linear: None,
1433 unavailable_reason: Some(reason),
1434 }
1435}
1436
1437fn available_derived_matrix(matrix: Mat4) -> SkinDerivedMatrixMeasurements {
1438 SkinDerivedMatrixMeasurements {
1439 source_inverse_bind_matrix: None,
1440 inversion_quality: None,
1441 matrix: Some(matrix_to_columns(matrix)),
1442 linear: Some(measure_linear_transform(matrix)),
1443 unavailable_reason: None,
1444 }
1445}
1446
1447fn with_inverse_bind_source(
1448 mut measurements: SkinDerivedMatrixMeasurements,
1449 raw: Mat4,
1450 quality: Option<SkinMatrixInversionQuality>,
1451) -> SkinDerivedMatrixMeasurements {
1452 measurements.source_inverse_bind_matrix = Some(matrix_to_columns(raw));
1453 measurements.inversion_quality = quality;
1454 measurements
1455}
1456
1457pub(crate) fn measure_source_skeleton(
1458 doc: &Document,
1459) -> (
1460 SkeletonSourceCoverage,
1461 Vec<SkeletonNodeMeasurements>,
1462 Vec<SkinMeasurements>,
1463) {
1464 let source = &doc.assets.source_skeleton;
1465 if source.coverage == SourceSkeletonCoverage::Unavailable {
1466 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1467 }
1468
1469 let mut source_nodes = BTreeMap::new();
1470 for node in &source.nodes {
1471 let (_, local) = source_local_rest_measurement(&node.local_rest);
1472 if source_nodes
1473 .insert(node.source_node_index, (node, local))
1474 .is_some()
1475 {
1476 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1477 }
1478 }
1479 for skin in &source.skins {
1480 if skin
1481 .joint_source_node_indices
1482 .iter()
1483 .any(|joint| !source_nodes.contains_key(joint))
1484 || skin
1485 .skeleton_root_source_node_index
1486 .is_some_and(|root| !source_nodes.contains_key(&root))
1487 || skin
1488 .attachments
1489 .iter()
1490 .any(|attachment| !source_nodes.contains_key(&attachment.source_node_index))
1491 {
1492 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1493 }
1494 }
1495
1496 let mut visits = BTreeMap::new();
1497 let mut worlds = BTreeMap::new();
1498 for node in &source.nodes {
1499 let _ = source_rest_world(
1500 node.source_node_index,
1501 &source_nodes,
1502 &mut visits,
1503 &mut worlds,
1504 );
1505 }
1506 let mut skeleton_nodes = Vec::with_capacity(source.nodes.len());
1507 for node in &source.nodes {
1508 let (local_rest, _) = source_local_rest_measurement(&node.local_rest);
1509 let Some(world) = worlds.get(&node.source_node_index).copied() else {
1510 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1511 };
1512 let (
1513 rest_world_matrix,
1514 rest_world_translation_m,
1515 rest_world_linear,
1516 rest_world_matrix_unavailable_reason,
1517 ) = match world {
1518 Ok(matrix) => (
1519 Some(matrix_to_columns(matrix)),
1520 Some(matrix.w_axis.truncate().to_array()),
1521 measure_linear_transform(matrix),
1522 None,
1523 ),
1524 Err(SourceRestWorldError::NonFiniteLocalRest) => (
1525 None,
1526 None,
1527 unavailable_linear_transform(),
1528 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest),
1529 ),
1530 Err(SourceRestWorldError::ParentRestWorldUnavailable) => (
1531 None,
1532 None,
1533 unavailable_linear_transform(),
1534 Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable),
1535 ),
1536 Err(SourceRestWorldError::NonFiniteWorldMatrix) => (
1537 None,
1538 None,
1539 unavailable_linear_transform(),
1540 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix),
1541 ),
1542 Err(SourceRestWorldError::MissingParentNode | SourceRestWorldError::ParentCycle) => {
1543 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1544 }
1545 };
1546 skeleton_nodes.push(SkeletonNodeMeasurements {
1547 node_index: node.source_node_index,
1548 name: node.name.clone(),
1549 parent_node_index: node.parent_source_node_index,
1550 scene_root_indices: node.scene_root_indices.clone(),
1551 local_rest,
1552 rest_world_matrix,
1553 rest_world_translation_m,
1554 rest_world_linear,
1555 rest_world_matrix_unavailable_reason,
1556 });
1557 }
1558
1559 let skins = source
1560 .skins
1561 .iter()
1562 .map(|skin| {
1563 let all_raw_finite = skin
1564 .inverse_bind_accessor
1565 .matrices
1566 .iter()
1567 .all(|matrix| matrix_is_finite(*matrix));
1568 let status = if all_raw_finite {
1569 skin.inverse_bind_accessor.status
1570 } else {
1571 SourceInverseBindAccessorStatus::Unreadable
1572 };
1573 let raw_matrices = if all_raw_finite {
1574 skin.inverse_bind_accessor
1575 .matrices
1576 .iter()
1577 .copied()
1578 .map(matrix_to_columns)
1579 .collect()
1580 } else {
1581 Vec::new()
1582 };
1583 let inverse_bind_accessor = SkinInverseBindAccessorMeasurements {
1584 status,
1585 declared_count: skin.inverse_bind_accessor.declared_count,
1586 matrices: raw_matrices,
1587 };
1588 let joints: Vec<_> = skin
1589 .joint_source_node_indices
1590 .iter()
1591 .enumerate()
1592 .map(|(joint_index, &node_index)| {
1593 let unavailable_joint = |reason| SkinJointMeasurements {
1594 joint_index,
1595 node_index,
1596 joint_bind_to_mesh: unavailable_derived_matrix(reason),
1597 mesh_bind_world: unavailable_derived_matrix(reason),
1598 };
1599 let raw = match derived_accessor_global_unavailable_reason(status) {
1600 Some(reason) => return unavailable_joint(reason),
1601 None => match skin.inverse_bind_accessor.matrices.get(joint_index).copied() {
1602 Some(raw) => raw,
1603 None => {
1604 let reason =
1605 SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch;
1606 return unavailable_joint(reason);
1607 }
1608 },
1609 };
1610 let assessment = assess_inverse_bind(raw);
1611 let joint_bind_to_mesh = with_inverse_bind_source(
1612 match assessment.inverse {
1613 Ok(inverse) => available_derived_matrix(inverse),
1614 Err(reason) => unavailable_derived_matrix(reason),
1615 },
1616 raw,
1617 assessment.quality,
1618 );
1619 let world = worlds
1620 .get(&node_index)
1621 .copied()
1622 .unwrap_or(Err(SourceRestWorldError::ParentRestWorldUnavailable));
1623 let mesh_bind_world = match world {
1624 Ok(world) => {
1625 let matrix = world * raw;
1626 with_inverse_bind_source(matrix_is_finite(matrix).then_some(()).map_or_else(
1627 || {
1628 unavailable_derived_matrix(
1629 SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix,
1630 )
1631 },
1632 |_| available_derived_matrix(matrix),
1633 ), raw, None)
1634 }
1635 Err(_) => with_inverse_bind_source(
1636 unavailable_derived_matrix(
1637 SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable,
1638 ),
1639 raw,
1640 None,
1641 ),
1642 };
1643 SkinJointMeasurements {
1644 joint_index,
1645 node_index,
1646 joint_bind_to_mesh,
1647 mesh_bind_world,
1648 }
1649 })
1650 .collect();
1651 let joint_bind_linear_summary = summarize_skin_bind_linear(&joints);
1652 SkinMeasurements {
1653 skin_index: skin.source_skin_index,
1654 name: skin.name.clone(),
1655 skeleton_root_node_index: skin.skeleton_root_source_node_index,
1656 joints,
1657 joint_bind_linear_summary,
1658 inverse_bind_accessor,
1659 attachments: skin
1660 .attachments
1661 .iter()
1662 .map(|attachment| SkinAttachmentMeasurements {
1663 node_index: attachment.source_node_index,
1664 mesh_index: attachment.source_mesh_index,
1665 })
1666 .collect(),
1667 }
1668 })
1669 .collect();
1670 (SourceSkeletonCoverage::Complete, skeleton_nodes, skins)
1671}
1672
1673fn transformed_definition_aabb(
1674 mesh: &MeshAsset,
1675 world: Mat4,
1676) -> Result<Aabb, StaticNodeAabbUnavailableReason> {
1677 let mut bounds = Bounds::default();
1678 let mut any_finite_source = false;
1679 for primitive in &mesh.primitives {
1680 for &position in &primitive.positions {
1681 if !position.is_finite() {
1682 continue;
1683 }
1684 any_finite_source = true;
1685 if !bounds.include(world.transform_point3(position)) {
1686 return Err(StaticNodeAabbUnavailableReason::NonFiniteTransform);
1687 }
1688 }
1689 }
1690 if !any_finite_source {
1691 return Err(StaticNodeAabbUnavailableReason::NoFinitePositions);
1692 }
1693 bounds
1694 .finish()
1695 .ok_or(StaticNodeAabbUnavailableReason::NonFiniteTransform)
1696}
1697
1698#[derive(Debug, Clone, Copy, Default)]
1699struct NodeAggregate {
1700 bounds: Bounds,
1701 instance_count: usize,
1702 excluded_instance_count: usize,
1703}
1704
1705impl NodeAggregate {
1706 fn include(&mut self, other: Self) {
1707 if let Some(aabb) = other.bounds.finish() {
1708 self.bounds.include_aabb(aabb);
1709 }
1710 self.instance_count = self.instance_count.saturating_add(other.instance_count);
1711 self.excluded_instance_count = self
1712 .excluded_instance_count
1713 .saturating_add(other.excluded_instance_count);
1714 }
1715}
1716
1717pub fn measure_assets(doc: &Document) -> AssetMeasurements {
1726 let (skeleton_source_coverage, skeleton_nodes, skins) = measure_source_skeleton(doc);
1727 let material_resource_coverage = doc.assets.material_resources.coverage;
1728 let material_definitions = doc
1729 .assets
1730 .material_resources
1731 .materials
1732 .iter()
1733 .map(|material| MaterialDefinitionMeasurements {
1734 material_index: material.material_index,
1735 name: material.name.clone(),
1736 texture_bindings: material
1737 .texture_bindings
1738 .iter()
1739 .map(|binding| MaterialTextureBindingMeasurements {
1740 slot: binding.slot,
1741 texture_index: binding.texture_index,
1742 })
1743 .collect(),
1744 })
1745 .collect();
1746 let textures = doc
1747 .assets
1748 .material_resources
1749 .textures
1750 .iter()
1751 .map(|texture| TextureMeasurements {
1752 texture_index: texture.texture_index,
1753 name: texture.name.clone(),
1754 image_index: texture.image_index,
1755 })
1756 .collect();
1757 let images = doc
1758 .assets
1759 .material_resources
1760 .images
1761 .iter()
1762 .map(|image| {
1763 let (width, height, channel_count, decoded_color_type, unavailable_reason) =
1764 match image.inspection {
1765 SourceImageInspection::Available {
1766 width,
1767 height,
1768 channel_count,
1769 color_type,
1770 } => (
1771 Some(width),
1772 Some(height),
1773 Some(channel_count),
1774 Some(color_type),
1775 None,
1776 ),
1777 SourceImageInspection::Unavailable { reason } => {
1778 (None, None, None, None, Some(reason))
1779 }
1780 };
1781 ImageMeasurements {
1782 image_index: image.image_index,
1783 name: image.name.clone(),
1784 source_kind: image.source_kind,
1785 declared_mime_type: image.declared_mime_type.clone(),
1786 detected_container: image.detected_container,
1787 leading_magic_hex: image.leading_magic_hex.clone(),
1788 width,
1789 height,
1790 channel_count,
1791 decoded_color_type,
1792 unavailable_reason,
1793 }
1794 })
1795 .collect();
1796 let mesh_definitions = doc
1797 .assets
1798 .meshes
1799 .iter()
1800 .map(measure_mesh_definition)
1801 .collect::<Vec<_>>();
1802 let worlds = tolerant_world_rest_matrices(&doc.skeleton);
1803 let mut node_aggregates = vec![NodeAggregate::default(); doc.skeleton.bones.len()];
1804 let mut node_instances = Vec::with_capacity(doc.assets.instances.len());
1805
1806 for instance in &doc.assets.instances {
1807 let Some(mesh) = doc.assets.meshes.get(instance.mesh) else {
1808 continue;
1809 };
1810 let bounds = if !instance.skin_joints.is_empty() {
1811 Err(StaticNodeAabbUnavailableReason::SkinnedDeformationExcluded)
1812 } else {
1813 match worlds.get(instance.node).copied().flatten() {
1814 Some(world) => transformed_definition_aabb(mesh, world),
1815 None => Err(StaticNodeAabbUnavailableReason::NonFiniteTransform),
1816 }
1817 };
1818 let (static_node_world_aabb, unavailable) = match bounds {
1819 Ok(aabb) => (Some(aabb), None),
1820 Err(reason) => (None, Some(reason)),
1821 };
1822 let node_name = doc
1823 .skeleton
1824 .bones
1825 .get(instance.node)
1826 .map(|bone| bone.name.clone())
1827 .unwrap_or_else(|| format!("node-{}", instance.source_node_index));
1828 let measurement = NodeInstanceMeasurements {
1829 node_index: instance.source_node_index,
1830 node_name,
1831 mesh_index: mesh.source_mesh_index,
1832 static_node_world_aabb,
1833 static_node_world_aabb_unavailable_reason: unavailable,
1834 };
1835 if let Some(aggregate) = node_aggregates.get_mut(instance.node) {
1836 aggregate.instance_count = aggregate.instance_count.saturating_add(1);
1837 match measurement.static_node_world_aabb {
1838 Some(aabb) => aggregate.bounds.include_aabb(aabb),
1839 None => {
1840 aggregate.excluded_instance_count =
1841 aggregate.excluded_instance_count.saturating_add(1);
1842 }
1843 }
1844 }
1845 node_instances.push(measurement);
1846 }
1847
1848 for node in (0..doc.skeleton.bones.len()).rev() {
1852 let Some(parent) = doc.skeleton.bones[node].parent else {
1853 continue;
1854 };
1855 let child = node_aggregates[node];
1856 if let Some(parent_aggregate) = node_aggregates.get_mut(parent) {
1857 parent_aggregate.include(child);
1858 }
1859 }
1860
1861 let scenes = doc
1862 .assets
1863 .scenes
1864 .iter()
1865 .map(|scene| {
1866 let mut aggregate = NodeAggregate::default();
1867 for &root in &scene.roots {
1868 if let Some(root_aggregate) = node_aggregates.get(root).copied() {
1869 aggregate.include(root_aggregate);
1870 }
1871 }
1872 SceneMeasurements {
1873 scene_index: scene.source_scene_index,
1874 name: scene.name.clone(),
1875 instance_count: aggregate.instance_count,
1876 static_scene_world_aabb: aggregate.bounds.finish(),
1877 excluded_instance_count: aggregate.excluded_instance_count,
1878 }
1879 })
1880 .collect();
1881
1882 AssetMeasurements {
1883 material_resource_coverage,
1884 material_definitions,
1885 textures,
1886 images,
1887 skeleton_source_coverage,
1888 skeleton_nodes,
1889 skins,
1890 mesh_definitions,
1891 node_instances,
1892 scenes,
1893 default_scene_index: doc.assets.default_scene,
1894 }
1895}
1896
1897#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1910#[serde(rename_all = "snake_case")]
1911#[non_exhaustive]
1912pub enum MeasurementAvailability {
1913 Measured,
1915 NotApplicable,
1917 Unavailable,
1919}
1920
1921#[derive(Debug, Clone, Serialize, Deserialize)]
1923#[non_exhaustive]
1924pub struct GaitMeasurement {
1925 #[serde(default, skip_serializing_if = "Option::is_none")]
1932 pub phase: Option<f64>,
1933 pub phase_availability: MeasurementAvailability,
1935 pub lr_amplitude_m: f64,
1938}
1939
1940#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1942#[serde(rename_all = "snake_case")]
1943#[non_exhaustive]
1944pub enum RootTrajectorySourceRole {
1945 Root,
1947 HipsFallback,
1949}
1950
1951impl RootTrajectorySourceRole {
1952 pub const fn as_str(self) -> &'static str {
1954 match self {
1955 Self::Root => "root",
1956 Self::HipsFallback => "hips_fallback",
1957 }
1958 }
1959}
1960
1961#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1963#[non_exhaustive]
1964pub struct RootYawMeasurement {
1965 pub heading_axis: RootYawHeadingAxis,
1967 pub net_yaw_deg: f64,
1971 pub unwrapped_yaw_deg: f64,
1973 pub yaw_travel_deg: f64,
1975}
1976
1977#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1979#[non_exhaustive]
1980pub struct RootTranslationMeasurement {
1981 pub horizontal_displacement_x_m: f64,
1983 pub horizontal_displacement_z_m: f64,
1985 pub horizontal_travel_m: f64,
1987 pub vertical_displacement_m: f64,
1989 pub vertical_min_displacement_m: f64,
1991 pub vertical_max_displacement_m: f64,
1993}
1994
1995#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1997#[non_exhaustive]
1998pub struct RootTrajectoryMeasurement {
1999 pub bone_index: u32,
2001 pub bone_name: String,
2003 pub source_role: RootTrajectorySourceRole,
2006 #[serde(default, skip_serializing_if = "Option::is_none")]
2009 pub translation: Option<RootTranslationMeasurement>,
2010 pub translation_availability: MeasurementAvailability,
2012 #[serde(default, skip_serializing_if = "Option::is_none")]
2014 pub yaw: Option<RootYawMeasurement>,
2015 pub yaw_availability: MeasurementAvailability,
2018}
2019
2020#[derive(Debug, Clone)]
2022#[non_exhaustive]
2023pub struct BoneLoopContinuityMeasurement {
2024 pub bone_index: u32,
2026 pub bone_name: String,
2029 pub availability: MeasurementAvailability,
2032 pub(crate) availability_was_present: bool,
2033 pub position_delta_m: Option<f64>,
2035 pub rotation_delta_deg: Option<f64>,
2037 pub seam_velocity_delta_mps: Option<f64>,
2040 pub seam_angular_velocity_delta_degps: Option<f64>,
2043}
2044
2045impl Serialize for BoneLoopContinuityMeasurement {
2046 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2047 where
2048 S: Serializer,
2049 {
2050 let mut wire = serializer.serialize_struct("BoneLoopContinuityMeasurement", 7)?;
2051 wire.serialize_field("bone_index", &self.bone_index)?;
2052 wire.serialize_field("bone_name", &self.bone_name)?;
2053 if self.availability_was_present {
2054 wire.serialize_field("availability", &self.availability)?;
2055 }
2056 if let Some(value) = self.position_delta_m {
2057 wire.serialize_field("position_delta_m", &value)?;
2058 }
2059 if let Some(value) = self.rotation_delta_deg {
2060 wire.serialize_field("rotation_delta_deg", &value)?;
2061 }
2062 if let Some(value) = self.seam_velocity_delta_mps {
2063 wire.serialize_field("seam_velocity_delta_mps", &value)?;
2064 }
2065 if let Some(value) = self.seam_angular_velocity_delta_degps {
2066 wire.serialize_field("seam_angular_velocity_delta_degps", &value)?;
2067 }
2068 wire.end()
2069 }
2070}
2071
2072impl<'de> Deserialize<'de> for BoneLoopContinuityMeasurement {
2073 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2074 where
2075 D: Deserializer<'de>,
2076 {
2077 #[derive(Default)]
2078 enum Presence<T> {
2079 #[default]
2080 Absent,
2081 Value(T),
2082 }
2083
2084 struct NonNullVisitor<T> {
2085 field: &'static str,
2086 marker: std::marker::PhantomData<T>,
2087 }
2088
2089 impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for NonNullVisitor<T> {
2090 type Value = T;
2091
2092 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2093 write!(formatter, "a non-null `{}` value", self.field)
2094 }
2095
2096 fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
2097 Err(E::custom(format_args!(
2098 "`{}` must be omitted rather than null",
2099 self.field
2100 )))
2101 }
2102
2103 fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
2104 self.visit_none()
2105 }
2106
2107 fn visit_some<D: Deserializer<'de>>(
2108 self,
2109 deserializer: D,
2110 ) -> Result<Self::Value, D::Error> {
2111 T::deserialize(deserializer)
2112 }
2113 }
2114
2115 fn deserialize_non_null<'de, D: Deserializer<'de>, T: Deserialize<'de>>(
2116 deserializer: D,
2117 field: &'static str,
2118 ) -> Result<Presence<T>, D::Error> {
2119 deserializer
2120 .deserialize_option(NonNullVisitor {
2121 field,
2122 marker: std::marker::PhantomData,
2123 })
2124 .map(Presence::Value)
2125 }
2126
2127 fn deserialize_availability<'de, D: Deserializer<'de>>(
2128 deserializer: D,
2129 ) -> Result<Presence<MeasurementAvailability>, D::Error> {
2130 deserialize_non_null(deserializer, "availability")
2131 }
2132
2133 fn deserialize_position<'de, D: Deserializer<'de>>(
2134 deserializer: D,
2135 ) -> Result<Presence<f64>, D::Error> {
2136 deserialize_non_null(deserializer, "position_delta_m")
2137 }
2138
2139 fn deserialize_rotation<'de, D: Deserializer<'de>>(
2140 deserializer: D,
2141 ) -> Result<Presence<f64>, D::Error> {
2142 deserialize_non_null(deserializer, "rotation_delta_deg")
2143 }
2144
2145 fn deserialize_velocity<'de, D: Deserializer<'de>>(
2146 deserializer: D,
2147 ) -> Result<Presence<f64>, D::Error> {
2148 deserialize_non_null(deserializer, "seam_velocity_delta_mps")
2149 }
2150
2151 fn deserialize_angular_velocity<'de, D: Deserializer<'de>>(
2152 deserializer: D,
2153 ) -> Result<Presence<f64>, D::Error> {
2154 deserialize_non_null(deserializer, "seam_angular_velocity_delta_degps")
2155 }
2156
2157 #[derive(Deserialize)]
2158 #[serde(deny_unknown_fields)]
2159 struct Wire {
2160 bone_index: u32,
2161 bone_name: String,
2162 #[serde(default, deserialize_with = "deserialize_availability")]
2163 availability: Presence<MeasurementAvailability>,
2164 #[serde(default, deserialize_with = "deserialize_position")]
2165 position_delta_m: Presence<f64>,
2166 #[serde(default, deserialize_with = "deserialize_rotation")]
2167 rotation_delta_deg: Presence<f64>,
2168 #[serde(default, deserialize_with = "deserialize_velocity")]
2169 seam_velocity_delta_mps: Presence<f64>,
2170 #[serde(default, deserialize_with = "deserialize_angular_velocity")]
2171 seam_angular_velocity_delta_degps: Presence<f64>,
2172 }
2173
2174 let wire = Wire::deserialize(deserializer)?;
2175 let (availability, availability_was_present) = match wire.availability {
2176 Presence::Absent => (MeasurementAvailability::Measured, false),
2177 Presence::Value(availability) => (availability, true),
2178 };
2179 let into_option = |value| match value {
2180 Presence::Absent => None,
2181 Presence::Value(value) => Some(value),
2182 };
2183 Ok(Self {
2184 bone_index: wire.bone_index,
2185 bone_name: wire.bone_name,
2186 availability,
2187 availability_was_present,
2188 position_delta_m: into_option(wire.position_delta_m),
2189 rotation_delta_deg: into_option(wire.rotation_delta_deg),
2190 seam_velocity_delta_mps: into_option(wire.seam_velocity_delta_mps),
2191 seam_angular_velocity_delta_degps: into_option(wire.seam_angular_velocity_delta_degps),
2192 })
2193 }
2194}
2195
2196#[derive(Debug, Clone, Serialize, Deserialize)]
2199#[non_exhaustive]
2200pub struct LoopContinuityMeasurement {
2201 pub bones: Vec<BoneLoopContinuityMeasurement>,
2203}
2204
2205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2212#[serde(rename_all = "snake_case")]
2213#[non_exhaustive]
2214pub enum LoopEndpointMode {
2215 UniqueCycle,
2218 DuplicateEndpoint,
2221 NonClosing,
2224}
2225
2226impl LoopEndpointMode {
2227 pub const fn as_str(self) -> &'static str {
2229 match self {
2230 Self::UniqueCycle => "unique_cycle",
2231 Self::DuplicateEndpoint => "duplicate_endpoint",
2232 Self::NonClosing => "non_closing",
2233 }
2234 }
2235}
2236
2237#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
2239#[non_exhaustive]
2240pub struct FrameGridMeasurement {
2241 pub fps: f64,
2243 pub frame_intervals: u32,
2245}
2246
2247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2255#[non_exhaustive]
2256pub struct BoneChannelCoverage {
2257 pub bone_index: u32,
2259 pub bone_name: String,
2262 pub properties: Vec<Property>,
2264}
2265
2266#[derive(Debug, Clone, Serialize, Deserialize)]
2268#[non_exhaustive]
2269pub struct ClipMeasurements {
2270 pub duration_s: f64,
2272 pub frame_count: u32,
2275 pub animated_bones: Vec<String>,
2277 pub bone_channels: Vec<BoneChannelCoverage>,
2281 pub bone_rotation_range_deg: BTreeMap<String, f64>,
2285 #[serde(default, skip_serializing_if = "Option::is_none")]
2291 pub loop_continuity: Option<LoopContinuityMeasurement>,
2292 pub loop_continuity_availability: MeasurementAvailability,
2294 #[serde(default, skip_serializing_if = "Option::is_none")]
2297 pub loop_endpoint_mode: Option<LoopEndpointMode>,
2298 pub loop_endpoint_mode_availability: MeasurementAvailability,
2300 #[serde(default, skip_serializing_if = "Option::is_none")]
2303 pub frame_grid: Option<FrameGridMeasurement>,
2304 pub frame_grid_availability: MeasurementAvailability,
2306 #[serde(default, skip_serializing_if = "Option::is_none")]
2309 pub loop_seam_ratio: Option<f64>,
2310 pub loop_seam_ratio_availability: MeasurementAvailability,
2318 #[serde(default, skip_serializing_if = "Option::is_none")]
2321 pub gait: Option<GaitMeasurement>,
2322 pub gait_availability: MeasurementAvailability,
2324 #[serde(default, skip_serializing_if = "Option::is_none")]
2328 pub root_trajectory: Option<RootTrajectoryMeasurement>,
2329 pub root_trajectory_availability: MeasurementAvailability,
2331 #[serde(default, skip_serializing_if = "Option::is_none")]
2334 pub speed_mps: Option<f64>,
2335 pub speed_mps_availability: MeasurementAvailability,
2337}
2338
2339pub fn measure_document(
2349 grids: &MetricGrids<'_>,
2350 roles: &ResolvedRoles,
2351 config: &Config,
2352) -> BTreeMap<String, ClipMeasurements> {
2353 grids
2354 .document()
2355 .clips
2356 .iter()
2357 .map(|clip| clip.name.clone())
2358 .zip(measure_document_indexed(grids, roles, config))
2359 .collect()
2360}
2361
2362pub fn measure_document_indexed(
2372 grids: &MetricGrids<'_>,
2373 roles: &ResolvedRoles,
2374 config: &Config,
2375) -> Vec<ClipMeasurements> {
2376 let doc = grids.document();
2377 let min_stride_step_m = config.loop_seam_min_stride_step_m();
2378 doc.clips
2379 .iter()
2380 .enumerate()
2381 .map(|(clip_index, clip)| {
2382 let mut animated: BTreeSet<String> = BTreeSet::new();
2383 let mut bone_channels: BTreeMap<usize, BTreeSet<Property>> = BTreeMap::new();
2384 let mut rotation_range: BTreeMap<String, f64> = BTreeMap::new();
2385 let mut frame_count = 0usize;
2386
2387 for track in &clip.tracks {
2388 let Some(bone) = doc.skeleton.bones.get(track.bone) else {
2389 continue;
2390 };
2391 if track.key_count() == 0 {
2392 continue;
2393 }
2394 let track_is_structurally_valid = validate_track_shape(clip_index, track).is_ok();
2395 if track_is_structurally_valid {
2396 animated.insert(bone.name.clone());
2397 bone_channels
2398 .entry(track.bone)
2399 .or_default()
2400 .insert(track.property);
2401
2402 if let Some(max_deg) = rotation_range_deg(track)
2403 && max_deg >= MIN_RECORDED_ROTATION_DEG
2404 {
2405 let entry = rotation_range.entry(bone.name.clone()).or_insert(0.0);
2406 *entry = entry.max(max_deg);
2407 }
2408 }
2409 frame_count = frame_count.max(track.key_count());
2410 }
2411
2412 let grid = grids.grid(clip_index);
2413 let cycle = grid
2414 .as_ref()
2415 .and_then(|g| foot_cycle_metrics(g, roles, min_stride_step_m));
2416 let gait_roles_applicable = roles.get(Role::Hips).is_some()
2417 && [
2418 Role::LeftFoot,
2419 Role::LeftToe,
2420 Role::RightFoot,
2421 Role::RightToe,
2422 ]
2423 .iter()
2424 .any(|&role| roles.get(role).is_some());
2425 let (loop_continuity, loop_continuity_availability) = if doc.skeleton.bones.is_empty() {
2426 (None, MeasurementAvailability::NotApplicable)
2427 } else {
2428 match grid.as_ref().and_then(|grid| loop_continuity_metrics(grid)) {
2429 Some(metrics) => (
2430 Some(LoopContinuityMeasurement {
2431 bones: metrics
2432 .into_iter()
2433 .enumerate()
2434 .map(|(bone_index, metrics)| {
2435 let availability = if metrics.is_some() {
2436 MeasurementAvailability::Measured
2437 } else {
2438 MeasurementAvailability::Unavailable
2439 };
2440 BoneLoopContinuityMeasurement {
2441 bone_index: bone_index as u32,
2442 bone_name: doc.skeleton.bones[bone_index].name.clone(),
2443 availability,
2444 availability_was_present: true,
2445 position_delta_m: metrics
2446 .as_ref()
2447 .map(|metrics| metrics.position_delta_m),
2448 rotation_delta_deg: metrics
2449 .as_ref()
2450 .map(|metrics| metrics.rotation_delta_deg),
2451 seam_velocity_delta_mps: metrics
2452 .as_ref()
2453 .map(|metrics| metrics.seam_velocity_delta_mps),
2454 seam_angular_velocity_delta_degps: metrics.as_ref().map(
2455 |metrics| metrics.seam_angular_velocity_delta_degps,
2456 ),
2457 }
2458 })
2459 .collect(),
2460 }),
2461 MeasurementAvailability::Measured,
2462 ),
2463 None => (None, MeasurementAvailability::Unavailable),
2464 }
2465 };
2466 let expectations = config.expectations_for(&clip.name);
2467 let (position_cap, rotation_cap) = effective_caps(config, &expectations);
2468 let (loop_endpoint_mode, loop_endpoint_mode_availability) = if expectations.looping
2469 == Some(true)
2470 {
2471 match measure_loop_endpoint_mode(clip, grid.as_deref(), position_cap, rotation_cap)
2472 {
2473 Some(mode) => (Some(mode), MeasurementAvailability::Measured),
2474 None => (None, MeasurementAvailability::Unavailable),
2475 }
2476 } else {
2477 (None, MeasurementAvailability::NotApplicable)
2478 };
2479 let (frame_grid, frame_grid_availability) = match expectations.fps {
2480 None => (None, MeasurementAvailability::NotApplicable),
2481 Some(_) => match measure_frame_grid(clip, expectations.fps) {
2482 Some(measurement) => (Some(measurement), MeasurementAvailability::Measured),
2483 None => (None, MeasurementAvailability::Unavailable),
2484 },
2485 };
2486 let (loop_seam_ratio, loop_seam_ratio_availability) = match &cycle {
2487 Some(metrics) => match metrics.loop_seam_ratio {
2499 Some(ratio) => (Some(ratio), MeasurementAvailability::Measured),
2500 None if !metrics.has_real_stride => {
2501 (None, MeasurementAvailability::NotApplicable)
2502 }
2503 None => (None, MeasurementAvailability::Unavailable),
2504 },
2505 None if !gait_roles_applicable => (None, MeasurementAvailability::NotApplicable),
2506 None => (None, MeasurementAvailability::Unavailable),
2507 };
2508 let (gait, gait_availability) = match &cycle {
2509 Some(metrics) => {
2510 let (phase, phase_availability) = match metrics.gait_phase_outcome(roles) {
2511 GaitPhaseOutcome::MissingBilateralFootRoles
2512 | GaitPhaseOutcome::NoFootHeightSwing => {
2513 (None, MeasurementAvailability::NotApplicable)
2514 }
2515 GaitPhaseOutcome::Measured(phase) => {
2516 (Some(phase), MeasurementAvailability::Measured)
2517 }
2518 GaitPhaseOutcome::Unavailable => {
2519 (None, MeasurementAvailability::Unavailable)
2520 }
2521 };
2522 (
2523 Some(GaitMeasurement {
2524 phase,
2525 phase_availability,
2526 lr_amplitude_m: metrics.lr_amplitude_m,
2527 }),
2528 MeasurementAvailability::Measured,
2529 )
2530 }
2531 None if !gait_roles_applicable => (None, MeasurementAvailability::NotApplicable),
2532 None => (None, MeasurementAvailability::Unavailable),
2533 };
2534 let root_selection = roles
2535 .get_with_name(Role::Root)
2536 .map(|(bone, name)| (bone, name, RootTrajectorySourceRole::Root))
2537 .or_else(|| {
2538 roles
2539 .get_with_name(Role::Hips)
2540 .map(|(bone, name)| (bone, name, RootTrajectorySourceRole::HipsFallback))
2541 });
2542 let root_roles_applicable = root_selection.is_some();
2543 let (root_trajectory, root_trajectory_availability) = match root_selection {
2544 None => (None, MeasurementAvailability::NotApplicable),
2545 Some((bone, resolved_name, source_role)) => match doc.skeleton.bones.get(bone) {
2546 Some(selected_bone) if selected_bone.name == resolved_name => {
2547 let trajectory = grid
2548 .as_ref()
2549 .and_then(|grid| root_trajectory_metrics(grid, bone));
2550 let (translation, translation_availability) = match trajectory
2551 .as_ref()
2552 .and_then(|trajectory| trajectory.translation)
2553 {
2554 Some(translation) => (
2555 Some(RootTranslationMeasurement {
2556 horizontal_displacement_x_m: translation
2557 .horizontal_displacement_x_m,
2558 horizontal_displacement_z_m: translation
2559 .horizontal_displacement_z_m,
2560 horizontal_travel_m: translation.horizontal_travel_m,
2561 vertical_displacement_m: translation.vertical_displacement_m,
2562 vertical_min_displacement_m: translation
2563 .vertical_min_displacement_m,
2564 vertical_max_displacement_m: translation
2565 .vertical_max_displacement_m,
2566 }),
2567 MeasurementAvailability::Measured,
2568 ),
2569 None => (None, MeasurementAvailability::Unavailable),
2570 };
2571 let (yaw, yaw_availability) =
2572 match trajectory.and_then(|trajectory| trajectory.yaw) {
2573 Some(yaw) => (
2574 Some(RootYawMeasurement {
2575 heading_axis: yaw.heading_axis,
2576 net_yaw_deg: yaw.net_yaw_deg,
2577 unwrapped_yaw_deg: yaw.unwrapped_yaw_deg,
2578 yaw_travel_deg: yaw.yaw_travel_deg,
2579 }),
2580 MeasurementAvailability::Measured,
2581 ),
2582 None => (None, MeasurementAvailability::Unavailable),
2583 };
2584 (
2585 Some(RootTrajectoryMeasurement {
2586 bone_index: bone as u32,
2587 bone_name: selected_bone.name.clone(),
2588 source_role,
2589 translation,
2590 translation_availability,
2591 yaw,
2592 yaw_availability,
2593 }),
2594 MeasurementAvailability::Measured,
2595 )
2596 }
2597 _ => (None, MeasurementAvailability::Unavailable),
2598 },
2599 };
2600 let (speed_mps, speed_mps_availability) = if !root_roles_applicable {
2601 (None, MeasurementAvailability::NotApplicable)
2602 } else if root_trajectory.is_none() {
2603 (None, MeasurementAvailability::Unavailable)
2604 } else {
2605 match grid.as_ref().and_then(|g| root_motion_speed_mps(g, roles)) {
2606 Some(speed) => (Some(speed), MeasurementAvailability::Measured),
2607 None => (None, MeasurementAvailability::Unavailable),
2608 }
2609 };
2610 let duration_s = if clip.duration_s.is_finite() {
2611 clip.duration_s
2612 } else {
2613 clip.tracks
2614 .iter()
2615 .flat_map(|track| track.times.iter().copied())
2616 .filter(|time| time.is_finite())
2617 .map(f64::from)
2618 .fold(0.0, f64::max)
2619 };
2620 let bone_channels = bone_channels
2621 .into_iter()
2622 .map(|(bone_index, properties)| BoneChannelCoverage {
2623 bone_index: bone_index as u32,
2624 bone_name: doc.skeleton.bones[bone_index].name.clone(),
2625 properties: properties.into_iter().collect(),
2626 })
2627 .collect();
2628
2629 ClipMeasurements {
2630 duration_s,
2631 frame_count: frame_count as u32,
2632 animated_bones: animated.into_iter().collect(),
2633 bone_channels,
2634 bone_rotation_range_deg: rotation_range,
2635 loop_continuity,
2636 loop_continuity_availability,
2637 loop_endpoint_mode,
2638 loop_endpoint_mode_availability,
2639 frame_grid,
2640 frame_grid_availability,
2641 loop_seam_ratio,
2642 loop_seam_ratio_availability,
2643 gait,
2644 gait_availability,
2645 root_trajectory,
2646 root_trajectory_availability,
2647 speed_mps,
2648 speed_mps_availability,
2649 }
2650 })
2651 .collect()
2652}
2653
2654pub(crate) fn measure_loop_endpoint_mode(
2657 clip: &crate::model::Clip,
2658 grid: Option<&PoseGrid>,
2659 max_position_delta_m: f64,
2660 max_rotation_delta_deg: f64,
2661) -> Option<LoopEndpointMode> {
2662 let duplicate_endpoint = analyze_duplicate_loop_endpoint(clip).ok()?;
2663 let continuity = loop_continuity_metrics(grid?)?;
2664 if continuity.iter().any(Option::is_none) {
2665 return None;
2666 }
2667 if duplicate_endpoint.is_some() {
2668 return Some(LoopEndpointMode::DuplicateEndpoint);
2669 }
2670 let closes = continuity.iter().flatten().all(|bone| {
2671 !exceeds_f32_cap(bone.position_delta_m, max_position_delta_m)
2672 && !exceeds_f32_cap(bone.rotation_delta_deg, max_rotation_delta_deg)
2673 });
2674 Some(if closes {
2675 LoopEndpointMode::UniqueCycle
2676 } else {
2677 LoopEndpointMode::NonClosing
2678 })
2679}
2680
2681pub(crate) fn measure_frame_grid(
2683 clip: &crate::model::Clip,
2684 declared_fps: Option<f64>,
2685) -> Option<FrameGridMeasurement> {
2686 let fps = declared_fps?;
2687 if !fps.is_finite() || fps <= 0.0 || !clip.duration_s.is_finite() || clip.duration_s <= 0.0 {
2688 return None;
2689 }
2690 let intervals = clip.duration_s * fps;
2691 if !intervals.is_finite() || (intervals - intervals.round()).abs() > GRID_TOLERANCE_FRAMES {
2692 return None;
2693 }
2694 let rounded = intervals.round();
2695 if !(0.0..=f64::from(u32::MAX)).contains(&rounded) {
2696 return None;
2697 }
2698 if clip
2699 .tracks
2700 .iter()
2701 .flat_map(|track| &track.times)
2702 .any(|&time| {
2703 let frames = f64::from(time) * fps;
2704 !frames.is_finite() || (frames - frames.round()).abs() > GRID_TOLERANCE_FRAMES
2705 })
2706 {
2707 return None;
2708 }
2709 Some(FrameGridMeasurement {
2710 fps,
2711 frame_intervals: rounded as u32,
2712 })
2713}
2714
2715#[cfg(test)]
2716mod tests {
2717 use super::*;
2718 use crate::config::CheckSettings;
2719 use crate::model::{
2720 AdditionalInfluenceSet, AffineDomainViolation, Bone, Clip, Document, Interpolation,
2721 MeshAsset, PositiveUniformAffineTolerance, Primitive, Property, SceneAsset, SceneAssets,
2722 Skeleton, SourceInverseBindAccessor, SourceInverseBindAccessorStatus, SourceNodeAsset,
2723 SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage, SourceSkinAsset,
2724 SourceSkinAttachment, Track, TrackValues, Transform, classify_positive_uniform_affine,
2725 };
2726 use crate::profile::Role;
2727 use glam::{Mat4, Quat, Vec3};
2728
2729 fn mesh(name: &str, primitives: Vec<Primitive>) -> MeshDefinitionMeasurements {
2730 let doc = Document {
2731 assets: SceneAssets {
2732 meshes: vec![MeshAsset {
2733 name: name.into(),
2734 source_mesh_index: 0,
2735 primitives,
2736 }],
2737 ..SceneAssets::default()
2738 },
2739 ..Document::default()
2740 };
2741 measure_assets(&doc).mesh_definitions.remove(0)
2742 }
2743
2744 fn channel_track(bone: usize, property: Property) -> Track {
2745 let values = match property {
2746 Property::Rotation => TrackValues::Quats(vec![Quat::IDENTITY]),
2747 Property::Translation | Property::Scale => TrackValues::Vec3s(vec![Vec3::ZERO]),
2748 };
2749 Track {
2750 bone,
2751 property,
2752 interpolation: Interpolation::Linear,
2753 times: vec![0.0],
2754 values,
2755 }
2756 }
2757
2758 #[test]
2759 fn bone_channel_coverage_is_a_canonical_artifact_set() {
2760 let document = Document {
2761 skeleton: Skeleton {
2762 bones: vec![
2763 Bone {
2764 name: "duplicate".into(),
2765 parent: None,
2766 rest: Transform::IDENTITY,
2767 inverse_bind: None,
2768 },
2769 Bone {
2770 name: "duplicate".into(),
2771 parent: Some(0),
2772 rest: Transform::IDENTITY,
2773 inverse_bind: None,
2774 },
2775 Bone {
2776 name: "empty".into(),
2777 parent: Some(1),
2778 rest: Transform::IDENTITY,
2779 inverse_bind: None,
2780 },
2781 ],
2782 },
2783 clips: vec![Clip {
2784 name: "coverage".into(),
2785 duration_s: 0.0,
2786 tracks: vec![
2787 channel_track(1, Property::Scale),
2788 channel_track(0, Property::Rotation),
2789 channel_track(99, Property::Translation),
2790 channel_track(0, Property::Translation),
2791 channel_track(0, Property::Translation),
2792 channel_track(1, Property::Rotation),
2793 Track {
2794 bone: 2,
2795 property: Property::Translation,
2796 interpolation: Interpolation::Linear,
2797 times: Vec::new(),
2798 values: TrackValues::Vec3s(Vec::new()),
2799 },
2800 Track {
2801 bone: 2,
2802 property: Property::Translation,
2803 interpolation: Interpolation::Linear,
2804 times: vec![0.0, 1.0],
2805 values: TrackValues::Vec3s(vec![Vec3::ZERO]),
2806 },
2807 Track {
2808 bone: 2,
2809 property: Property::Rotation,
2810 interpolation: Interpolation::Linear,
2811 times: vec![0.0],
2812 values: TrackValues::Vec3s(vec![Vec3::ZERO]),
2813 },
2814 Track {
2815 bone: 2,
2816 property: Property::Rotation,
2817 interpolation: Interpolation::Linear,
2818 times: vec![0.0, 0.0],
2819 values: TrackValues::Quats(vec![
2820 Quat::IDENTITY,
2821 Quat::from_rotation_y(std::f32::consts::FRAC_PI_2),
2822 ]),
2823 },
2824 Track {
2825 bone: 2,
2826 property: Property::Scale,
2827 interpolation: Interpolation::Linear,
2828 times: vec![f32::NAN],
2829 values: TrackValues::Vec3s(vec![Vec3::ONE]),
2830 },
2831 Track {
2832 bone: 2,
2833 property: Property::Scale,
2834 interpolation: Interpolation::Linear,
2835 times: vec![0.0],
2836 values: TrackValues::Vec3s(vec![Vec3::splat(f32::INFINITY)]),
2837 },
2838 ],
2839 }],
2840 ..Document::default()
2841 };
2842 let grids = MetricGrids::new(&document);
2843
2844 let measured =
2845 &measure_document(&grids, &ResolvedRoles::default(), &Config::default())["coverage"];
2846
2847 assert_eq!(measured.animated_bones, ["duplicate"]);
2848 assert_eq!(
2849 measured.bone_channels,
2850 [
2851 BoneChannelCoverage {
2852 bone_index: 0,
2853 bone_name: "duplicate".into(),
2854 properties: vec![Property::Translation, Property::Rotation],
2855 },
2856 BoneChannelCoverage {
2857 bone_index: 1,
2858 bone_name: "duplicate".into(),
2859 properties: vec![Property::Rotation, Property::Scale],
2860 },
2861 ]
2862 );
2863 assert!(
2864 measured.bone_rotation_range_deg.is_empty(),
2865 "a malformed rotation track cannot contribute a range fact"
2866 );
2867 }
2868
2869 #[test]
2870 fn root_trajectory_selection_is_root_first_with_typed_hips_fallback() {
2871 let skeleton = Skeleton {
2872 bones: vec![
2873 Bone {
2874 name: "root".into(),
2875 parent: None,
2876 rest: Transform::IDENTITY,
2877 inverse_bind: None,
2878 },
2879 Bone {
2880 name: "hips".into(),
2881 parent: Some(0),
2882 rest: Transform::IDENTITY,
2883 inverse_bind: None,
2884 },
2885 ],
2886 };
2887 let document = Document {
2888 skeleton: skeleton.clone(),
2889 clips: vec![Clip {
2890 name: "travel".into(),
2891 duration_s: 1.0,
2892 tracks: vec![
2893 Track {
2894 bone: 0,
2895 property: Property::Translation,
2896 interpolation: Interpolation::Linear,
2897 times: vec![0.0, 0.5, 1.0],
2898 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X * 0.5, Vec3::X]),
2899 },
2900 Track {
2901 bone: 1,
2902 property: Property::Translation,
2903 interpolation: Interpolation::Linear,
2904 times: vec![0.0, 0.5, 1.0],
2905 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::Z * 0.5, Vec3::Z]),
2906 },
2907 ],
2908 }],
2909 ..Document::default()
2910 };
2911 let both_roles = ResolvedRoles::from_names(
2912 &skeleton,
2913 [(Role::Root, "root".into()), (Role::Hips, "hips".into())],
2914 );
2915 let grids = MetricGrids::new(&document);
2916 let measured = &measure_document(&grids, &both_roles, &Config::default())["travel"];
2917 let trajectory = measured.root_trajectory.as_ref().expect("selected Root");
2918 assert_eq!(trajectory.bone_index, 0);
2919 assert_eq!(trajectory.source_role, RootTrajectorySourceRole::Root);
2920 assert_eq!(
2921 trajectory
2922 .translation
2923 .as_ref()
2924 .unwrap()
2925 .horizontal_displacement_x_m,
2926 1.0
2927 );
2928
2929 let hips_only = ResolvedRoles::from_names(&skeleton, [(Role::Hips, "hips".into())]);
2930 let measured = &measure_document(&grids, &hips_only, &Config::default())["travel"];
2931 let trajectory = measured.root_trajectory.as_ref().expect("Hips fallback");
2932 assert_eq!(trajectory.bone_index, 1);
2933 assert_eq!(
2934 trajectory.source_role,
2935 RootTrajectorySourceRole::HipsFallback
2936 );
2937 let translation = trajectory.translation.as_ref().unwrap();
2938 assert_eq!(translation.horizontal_displacement_x_m, 1.0);
2939 assert_eq!(translation.horizontal_displacement_z_m, 1.0);
2940
2941 let measured =
2942 &measure_document(&grids, &ResolvedRoles::default(), &Config::default())["travel"];
2943 assert!(measured.root_trajectory.is_none());
2944 assert_eq!(
2945 measured.root_trajectory_availability,
2946 MeasurementAvailability::NotApplicable
2947 );
2948
2949 let mut too_short = document.clone();
2950 for track in &mut too_short.clips[0].tracks {
2951 track.times.truncate(2);
2952 match &mut track.values {
2953 TrackValues::Vec3s(values) => values.truncate(2),
2954 TrackValues::Quats(values) => values.truncate(2),
2955 }
2956 }
2957 let too_short_grids = MetricGrids::new(&too_short);
2958 let measured =
2959 &measure_document(&too_short_grids, &both_roles, &Config::default())["travel"];
2960 let trajectory = measured
2961 .root_trajectory
2962 .as_ref()
2963 .expect("selection remains observable without a metric grid");
2964 assert_eq!(
2965 trajectory.translation_availability,
2966 MeasurementAvailability::Unavailable
2967 );
2968 assert_eq!(
2969 trajectory.yaw_availability,
2970 MeasurementAvailability::Unavailable
2971 );
2972
2973 let roles_from_larger_skeleton = ResolvedRoles::from_names(
2974 &Skeleton {
2975 bones: vec![
2976 Bone {
2977 name: "hips".into(),
2978 parent: None,
2979 rest: Transform::IDENTITY,
2980 inverse_bind: None,
2981 },
2982 Bone {
2983 name: "root".into(),
2984 parent: None,
2985 rest: Transform::IDENTITY,
2986 inverse_bind: None,
2987 },
2988 ],
2989 },
2990 [(Role::Root, "root".into()), (Role::Hips, "hips".into())],
2991 );
2992 let stale_role_document = Document {
2993 skeleton: Skeleton {
2994 bones: vec![Bone {
2995 name: "hips".into(),
2996 parent: None,
2997 rest: Transform::IDENTITY,
2998 inverse_bind: None,
2999 }],
3000 },
3001 clips: document.clips.clone(),
3002 ..Document::default()
3003 };
3004 let stale_role_grids = MetricGrids::new(&stale_role_document);
3005 let measured = &measure_document(
3006 &stale_role_grids,
3007 &roles_from_larger_skeleton,
3008 &Config::default(),
3009 )["travel"];
3010 assert!(
3011 measured.root_trajectory.is_none(),
3012 "invalid Root must not fall back to the valid Hips index"
3013 );
3014 assert_eq!(
3015 measured.root_trajectory_availability,
3016 MeasurementAvailability::Unavailable
3017 );
3018 assert!(measured.speed_mps.is_none());
3019 assert_eq!(
3020 measured.speed_mps_availability,
3021 MeasurementAvailability::Unavailable
3022 );
3023
3024 let mismatched_name_document = Document {
3025 skeleton: Skeleton {
3026 bones: vec![
3027 Bone {
3028 name: "hips".into(),
3029 parent: None,
3030 rest: Transform::IDENTITY,
3031 inverse_bind: None,
3032 },
3033 Bone {
3034 name: "other".into(),
3035 parent: None,
3036 rest: Transform::IDENTITY,
3037 inverse_bind: None,
3038 },
3039 ],
3040 },
3041 clips: document.clips.clone(),
3042 ..Document::default()
3043 };
3044 let mismatched_name_grids = MetricGrids::new(&mismatched_name_document);
3045 let measured = &measure_document(
3046 &mismatched_name_grids,
3047 &roles_from_larger_skeleton,
3048 &Config::default(),
3049 )["travel"];
3050 assert!(
3051 measured.root_trajectory.is_none(),
3052 "a stale Root name must not bind a different in-range bone or fall back"
3053 );
3054 assert_eq!(
3055 measured.root_trajectory_availability,
3056 MeasurementAvailability::Unavailable
3057 );
3058 assert!(measured.speed_mps.is_none());
3059 assert_eq!(
3060 measured.speed_mps_availability,
3061 MeasurementAvailability::Unavailable
3062 );
3063 }
3064
3065 #[test]
3066 fn resolved_root_derivation_failure_does_not_fall_back_to_measurable_hips() {
3067 let skeleton = Skeleton {
3068 bones: vec![
3069 Bone {
3070 name: "root".into(),
3071 parent: None,
3072 rest: Transform::IDENTITY,
3073 inverse_bind: None,
3074 },
3075 Bone {
3076 name: "hips".into(),
3077 parent: None,
3078 rest: Transform::IDENTITY,
3079 inverse_bind: None,
3080 },
3081 ],
3082 };
3083 let document = Document {
3084 skeleton: skeleton.clone(),
3085 clips: vec![Clip {
3086 name: "root_failure".into(),
3087 duration_s: 1.0,
3088 tracks: vec![
3089 Track {
3090 bone: 0,
3091 property: Property::Translation,
3092 interpolation: Interpolation::Linear,
3093 times: vec![0.0, 0.5, 1.0],
3094 values: TrackValues::Vec3s(vec![
3095 Vec3::ZERO,
3096 Vec3::new(f32::NAN, 0.0, 0.0),
3097 Vec3::ZERO,
3098 ]),
3099 },
3100 Track {
3101 bone: 0,
3102 property: Property::Rotation,
3103 interpolation: Interpolation::Linear,
3104 times: vec![0.0, 0.5, 1.0],
3105 values: TrackValues::Quats(vec![Quat::from_xyzw(0.0, 0.0, 0.0, 0.0); 3]),
3106 },
3107 Track {
3108 bone: 1,
3109 property: Property::Translation,
3110 interpolation: Interpolation::Linear,
3111 times: vec![0.0, 0.5, 1.0],
3112 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::Z, Vec3::Z * 2.0]),
3113 },
3114 Track {
3115 bone: 1,
3116 property: Property::Rotation,
3117 interpolation: Interpolation::Linear,
3118 times: vec![0.0, 0.5, 1.0],
3119 values: TrackValues::Quats(vec![Quat::IDENTITY; 3]),
3120 },
3121 ],
3122 }],
3123 ..Document::default()
3124 };
3125 let roles = ResolvedRoles::from_names(
3126 &skeleton,
3127 [(Role::Root, "root".into()), (Role::Hips, "hips".into())],
3128 );
3129 let grids = MetricGrids::new(&document);
3130 let grid = grids.grid(0).expect("shared metric grid");
3131 let hips = root_trajectory_metrics(&grid, 1).expect("separate Hips evidence");
3132 let hips_translation = hips.translation.expect("Hips translation is measurable");
3133 assert_eq!(hips_translation.horizontal_displacement_x_m, 0.0);
3134 assert_eq!(hips_translation.horizontal_displacement_z_m, 2.0);
3135 assert_eq!(hips_translation.horizontal_travel_m, 2.0);
3136 assert!(hips.yaw.is_some(), "Hips yaw is independently measurable");
3137
3138 let measured = &measure_document(&grids, &roles, &Config::default())["root_failure"];
3139 let trajectory = measured
3140 .root_trajectory
3141 .as_ref()
3142 .expect("valid resolved Root identity remains observable");
3143 assert_eq!(trajectory.bone_index, 0);
3144 assert_eq!(trajectory.bone_name, "root");
3145 assert_eq!(trajectory.source_role, RootTrajectorySourceRole::Root);
3146 assert!(trajectory.translation.is_none());
3147 assert_eq!(
3148 trajectory.translation_availability,
3149 MeasurementAvailability::Unavailable
3150 );
3151 assert!(trajectory.yaw.is_none());
3152 assert_eq!(
3153 trajectory.yaw_availability,
3154 MeasurementAvailability::Unavailable
3155 );
3156 assert_eq!(
3157 measured.root_trajectory_availability,
3158 MeasurementAvailability::Measured
3159 );
3160 }
3161
3162 #[test]
3163 fn only_globally_unavailable_inverse_bind_accessors_have_a_derived_reason() {
3164 assert_eq!(
3165 derived_accessor_global_unavailable_reason(SourceInverseBindAccessorStatus::Absent),
3166 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
3167 );
3168 assert_eq!(
3169 derived_accessor_global_unavailable_reason(
3170 SourceInverseBindAccessorStatus::EmptyAccessor
3171 ),
3172 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
3173 );
3174 assert_eq!(
3175 derived_accessor_global_unavailable_reason(SourceInverseBindAccessorStatus::Unreadable),
3176 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
3177 );
3178 assert_eq!(
3179 derived_accessor_global_unavailable_reason(SourceInverseBindAccessorStatus::Available),
3180 None
3181 );
3182 assert_eq!(
3183 derived_accessor_global_unavailable_reason(
3184 SourceInverseBindAccessorStatus::CountMismatch
3185 ),
3186 None,
3187 "a readable count-mismatched accessor can still supply earlier slots"
3188 );
3189 }
3190
3191 #[test]
3192 fn linear_transform_measurements_classify_affine_shape_and_orientation() {
3193 let cases = [
3194 (
3195 Mat4::IDENTITY,
3196 LinearTransformClassification::UnitOrthonormal,
3197 Some(LinearTransformOrientation::Positive),
3198 Some(1.0),
3199 ),
3200 (
3201 Mat4::from_scale(Vec3::splat(0.01)),
3202 LinearTransformClassification::UniformScaled,
3203 Some(LinearTransformOrientation::Positive),
3204 Some(f64::from(0.01f32)),
3205 ),
3206 (
3207 Mat4::from_scale(Vec3::new(2.0, 3.0, 4.0)),
3208 LinearTransformClassification::NonUniform,
3209 Some(LinearTransformOrientation::Positive),
3210 None,
3211 ),
3212 (
3213 Mat4::from_cols_array(&[
3214 1.0, 0.0, 0.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
3215 ]),
3216 LinearTransformClassification::Sheared,
3217 Some(LinearTransformOrientation::Positive),
3218 None,
3219 ),
3220 (
3221 Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)),
3222 LinearTransformClassification::Reflected,
3223 Some(LinearTransformOrientation::Negative),
3224 Some(1.0),
3225 ),
3226 (
3227 Mat4::from_scale(Vec3::new(1.0, 0.0, 1.0)),
3228 LinearTransformClassification::Singular,
3229 Some(LinearTransformOrientation::Zero),
3230 None,
3231 ),
3232 ];
3233 for (matrix, classification, orientation, uniform_scale) in cases {
3234 let measured = measure_linear_transform(matrix);
3235 assert_eq!(measured.classification, classification);
3236 assert_eq!(measured.orientation, orientation);
3237 assert_eq!(measured.uniform_scale, uniform_scale);
3238 assert!(measured.axis_lengths.is_some());
3239 assert!(measured.determinant.is_some());
3240 }
3241
3242 let non_finite = measure_linear_transform(Mat4::from_cols_array(&[f32::NAN; 16]));
3243 assert_eq!(
3244 non_finite,
3245 LinearTransformMeasurements {
3246 classification: LinearTransformClassification::NonFinite,
3247 axis_lengths: None,
3248 determinant: None,
3249 orientation: None,
3250 rotation_xyzw: None,
3251 uniform_scale: None,
3252 }
3253 );
3254
3255 for scale in [1.0e-30f32, 1.0e-16, 1.0e13, 1.0e30] {
3256 let measured = measure_linear_transform(Mat4::from_scale(Vec3::splat(scale)));
3257 assert_eq!(
3258 measured.classification,
3259 LinearTransformClassification::UniformScaled,
3260 "finite uniform scale {scale:e}"
3261 );
3262 assert_eq!(measured.uniform_scale, Some(f64::from(scale)));
3263 assert!(measured.determinant.is_some_and(f64::is_finite));
3264 assert_ne!(measured.determinant, Some(0.0));
3265 }
3266 }
3267
3268 #[test]
3269 fn linear_transform_rotation_is_recomposable_canonical_and_unit_only() {
3270 let exact_x_half_turn = Mat4::from_cols_array(&[
3274 1.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
3275 ]);
3276 let exact_equal_xy_half_turn = Mat4::from_cols_array(&[
3277 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
3278 ]);
3279 let exact_equal_yz_half_turn = Mat4::from_cols_array(&[
3280 -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0,
3281 ]);
3282 let exact_equal_zw = Mat4::from_cols_array(&[
3285 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
3286 ]);
3287 let exact_x = measure_linear_transform(exact_x_half_turn)
3288 .rotation_xyzw
3289 .expect("exact unit half turn");
3290 assert_eq!(exact_x, [1.0, 0.0, 0.0, 0.0]);
3291 let exact_equal_xy = measure_linear_transform(exact_equal_xy_half_turn)
3292 .rotation_xyzw
3293 .expect("exact unit half turn");
3294 assert_eq!(exact_equal_xy[3], 0.0, "exact half turn has w == 0");
3295 assert!(
3296 exact_equal_xy[0] > 0.0 && exact_equal_xy[1] < 0.0,
3297 "equal |x|/|y| must choose X first, then make X non-negative: {exact_equal_xy:?}"
3298 );
3299 assert!((exact_equal_xy[0].abs() - exact_equal_xy[1].abs()).abs() <= f32::EPSILON);
3300 let exact_equal_yz = measure_linear_transform(exact_equal_yz_half_turn)
3301 .rotation_xyzw
3302 .expect("exact unit half turn");
3303 assert_eq!(exact_equal_yz[0], 0.0);
3304 assert_eq!(exact_equal_yz[3], 0.0);
3305 assert!(
3306 exact_equal_yz[1] > 0.0 && exact_equal_yz[2] < 0.0,
3307 "equal |y|/|z| must choose Y first: {exact_equal_yz:?}"
3308 );
3309 assert!((exact_equal_yz[1].abs() - exact_equal_yz[2].abs()).abs() <= f32::EPSILON);
3310 let exact_equal_zw = measure_linear_transform(exact_equal_zw)
3311 .rotation_xyzw
3312 .expect("exact quarter turn");
3313 assert!(
3314 exact_equal_zw[2] > 0.0 && exact_equal_zw[3] < 0.0,
3315 "equal |z|/|w| must choose Z first: {exact_equal_zw:?}"
3316 );
3317 assert!((exact_equal_zw[2].abs() - exact_equal_zw[3].abs()).abs() <= f32::EPSILON);
3318
3319 let rotations = [
3320 Quat::from_rotation_y(core::f32::consts::PI - 1.0e-4),
3321 Quat::from_euler(glam::EulerRot::XYZ, 0.4, -1.1, 2.2),
3322 ];
3323 for rotation in rotations {
3324 let matrix = Mat4::from_quat(rotation);
3325 let measured = measure_linear_transform(matrix);
3326 assert_eq!(
3327 measured.classification,
3328 LinearTransformClassification::UnitOrthonormal
3329 );
3330 let reported = Quat::from_array(measured.rotation_xyzw.expect("unit rotation"));
3331 let recomposed = Mat3::from_quat(reported);
3332 for (expected, actual) in Mat3::from_mat4(matrix)
3333 .to_cols_array()
3334 .into_iter()
3335 .zip(recomposed.to_cols_array())
3336 {
3337 assert!(
3338 (expected - actual).abs() <= LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE as f32,
3339 "recomposed {actual} differs from {expected}"
3340 );
3341 }
3342
3343 let inverse_sign = Quat::from_xyzw(-rotation.x, -rotation.y, -rotation.z, -rotation.w);
3344 assert_eq!(
3345 measured.rotation_xyzw,
3346 measure_linear_transform(Mat4::from_quat(inverse_sign)).rotation_xyzw,
3347 "q and -q canonicalize identically"
3348 );
3349 let values = measured.rotation_xyzw.expect("unit rotation");
3350 let selected = values
3351 .iter()
3352 .enumerate()
3353 .max_by(|(left_index, left), (right_index, right)| {
3354 left.abs()
3355 .partial_cmp(&right.abs())
3356 .unwrap()
3357 .then_with(|| right_index.cmp(left_index))
3358 })
3359 .expect("quaternion components");
3360 assert!(
3361 *selected.1 >= 0.0,
3362 "largest XYZW-tie-broken component is non-negative"
3363 );
3364 }
3365
3366 let diagonal_boundary = Mat3::from_diagonal(Vec3::new(1.000_019, 1.000_004_5, 1.000_004_5));
3372 let diagonal_admitted = measure_linear_transform(Mat4::from_mat3(diagonal_boundary));
3373 assert_eq!(
3374 diagonal_admitted.classification,
3375 LinearTransformClassification::UnitOrthonormal
3376 );
3377 assert!(diagonal_boundary.x_axis.x - 1.0 > LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE as f32);
3378 assert_eq!(
3379 diagonal_admitted.rotation_xyzw,
3380 Some([0.0, 0.0, 0.0, 1.0]),
3381 "the polar factor of a positive diagonal matrix is identity"
3382 );
3383
3384 let known_rotation = Quat::from_euler(glam::EulerRot::XYZ, 0.4, -1.1, 2.2);
3388 let symmetric_positive_definite = Mat3::from_cols(
3389 Vec3::new(1.000_006, 3.0e-6, 0.0),
3390 Vec3::new(3.0e-6, 0.999_996, -2.0e-6),
3391 Vec3::new(0.0, -2.0e-6, 1.000_002),
3392 );
3393 let tolerance_admitted =
3394 Mat4::from_mat3(Mat3::from_quat(known_rotation) * symmetric_positive_definite);
3395 let admitted = measure_linear_transform(tolerance_admitted);
3396 assert_eq!(
3397 admitted.classification,
3398 LinearTransformClassification::UnitOrthonormal
3399 );
3400 let recomposed = Mat3::from_quat(Quat::from_array(
3401 admitted
3402 .rotation_xyzw
3403 .expect("tolerance-admitted polar rotation"),
3404 ));
3405 for (expected, actual) in Mat3::from_quat(known_rotation)
3406 .to_cols_array()
3407 .into_iter()
3408 .zip(recomposed.to_cols_array())
3409 {
3410 assert!(
3411 (expected - actual).abs() <= LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE as f32,
3412 "polar rotation {actual} differs from known factor {expected}"
3413 );
3414 }
3415
3416 let sheared = Mat4::from_cols_array(&[
3417 1.0, 0.0, 0.0, 0.0, 0.25, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
3418 ]);
3419 for matrix in [
3420 sheared,
3421 Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)),
3422 Mat4::from_scale(Vec3::new(2.0, 3.0, 4.0)),
3423 Mat4::from_scale(Vec3::splat(2.0)),
3424 ] {
3425 assert!(measure_linear_transform(matrix).rotation_xyzw.is_none());
3426 }
3427 }
3428
3429 #[test]
3430 fn unit_linear_polar_factor_is_orthogonal_and_analytic_at_classifier_boundaries() {
3431 let factors = [
3432 Mat3::from_diagonal(Vec3::new(1.000_019, 1.000_004_5, 1.000_004_5)),
3433 Mat3::from_cols(
3434 Vec3::new(1.000_006, 3.0e-6, 0.0),
3435 Vec3::new(3.0e-6, 0.999_996, -2.0e-6),
3436 Vec3::new(0.0, -2.0e-6, 1.000_002),
3437 ),
3438 Mat3::from_cols(
3439 Vec3::new(0.999_994, -3.5e-6, 1.5e-6),
3440 Vec3::new(-3.5e-6, 1.000_005, 2.5e-6),
3441 Vec3::new(1.5e-6, 2.5e-6, 1.000_004),
3442 ),
3443 ];
3444 let rotations = [
3445 Quat::from_euler(glam::EulerRot::XYZ, 0.4, -1.1, 2.2),
3446 Quat::from_rotation_y(core::f32::consts::PI - 1.0e-4),
3447 ];
3448 let max_abs = |matrix: DMat3| {
3449 matrix
3450 .to_cols_array()
3451 .into_iter()
3452 .map(f64::abs)
3453 .fold(0.0, f64::max)
3454 };
3455
3456 for (factor_index, factor) in factors.into_iter().enumerate() {
3457 for (rotation_index, rotation) in rotations.into_iter().enumerate() {
3458 let linear = Mat3::from_quat(rotation) * factor;
3463 assert_eq!(
3464 measure_linear_transform(Mat4::from_mat3(linear)).classification,
3465 LinearTransformClassification::UnitOrthonormal,
3466 "admitted factor {factor_index}, rotation {rotation_index}"
3467 );
3468 let polar = proper_orthogonal_polar_factor(linear);
3469 assert!(
3470 polar_orthogonality_residual(polar) <= POLAR_ROTATION_ORTHOGONALITY_TOLERANCE,
3471 "polar factor {factor_index}/{rotation_index} did not converge"
3472 );
3473 assert!(
3474 (polar.determinant() - 1.0).abs() <= POLAR_ROTATION_ORTHOGONALITY_TOLERANCE
3475 );
3476
3477 let source = DMat3::from_cols(
3478 linear.x_axis.as_dvec3(),
3479 linear.y_axis.as_dvec3(),
3480 linear.z_axis.as_dvec3(),
3481 );
3482 let polar_residual = polar.transpose() * source;
3483 assert!(
3484 max_abs(polar_residual - polar_residual.transpose())
3485 <= POLAR_ROTATION_ORTHOGONALITY_TOLERANCE,
3486 "polar residual {factor_index}/{rotation_index} must be symmetric"
3487 );
3488
3489 let reported = Mat3::from_quat(Quat::from_array(
3490 measure_linear_transform(Mat4::from_mat3(linear))
3491 .rotation_xyzw
3492 .expect("unit transform publishes a polar rotation"),
3493 ));
3494 let expected = Mat3::from_quat(rotation);
3495 for (actual, expected) in reported
3496 .to_cols_array()
3497 .into_iter()
3498 .zip(expected.to_cols_array())
3499 {
3500 assert!(
3501 (actual - expected).abs() <= 1.0e-6,
3502 "reported polar factor {factor_index}/{rotation_index} differs by {}",
3503 (actual - expected).abs()
3504 );
3505 }
3506 }
3507 }
3508 }
3509
3510 #[test]
3511 fn linear_measurement_reconciles_equal_axis_fixtures_in_every_axis_order() {
3512 let permutations = |[x, y, z]: [f32; 3]| {
3513 [
3514 Vec3::new(x, y, z),
3515 Vec3::new(x, z, y),
3516 Vec3::new(y, x, z),
3517 Vec3::new(y, z, x),
3518 Vec3::new(z, x, y),
3519 Vec3::new(z, y, x),
3520 ]
3521 };
3522 let policy = PositiveUniformAffineTolerance {
3523 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3524 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3525 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
3526 };
3527
3528 for diagonal in permutations([1.0, 1.0, 1.000_012]) {
3529 let measured = measure_linear_transform(Mat4::from_scale(diagonal));
3530 assert_eq!(
3531 measured.classification,
3532 LinearTransformClassification::UnitOrthonormal,
3533 "issue fixture {diagonal:?}"
3534 );
3535 assert_eq!(
3536 classify_positive_uniform_affine(Mat3::from_diagonal(diagonal), policy),
3537 measured
3538 .uniform_scale
3539 .ok_or(AffineDomainViolation::NonFinite),
3540 "measurement and Appendix D share the equal-axis decision"
3541 );
3542 }
3543
3544 let high = f32::from_bits(0x3f80_004b);
3548 let low = f32::from_bits(0x3f7f_ff69);
3549 for diagonal in permutations([1.0, high, low]) {
3550 assert_eq!(
3551 measure_linear_transform(Mat4::from_scale(diagonal)).classification,
3552 LinearTransformClassification::UnitOrthonormal,
3553 "axis-order counterexample {diagonal:?}"
3554 );
3555 }
3556 }
3557
3558 #[test]
3559 fn linear_measurement_uses_the_shared_canonical_mean_in_every_axis_order() {
3560 let columns = [
3566 Vec3::new(
3567 f32::from_bits(0x3f7f_fd59),
3568 f32::from_bits(0x3bd8_d637),
3569 0.0,
3570 ),
3571 Vec3::new(
3572 -f32::from_bits(0x3bd8_d69d),
3573 f32::from_bits(0x3f7f_fdd1),
3574 0.0,
3575 ),
3576 Vec3::Z,
3577 ];
3578 let permutations = [
3579 Mat3::from_cols(columns[0], columns[1], columns[2]),
3580 Mat3::from_cols(-columns[0], columns[2], columns[1]),
3581 Mat3::from_cols(-columns[1], columns[0], columns[2]),
3582 Mat3::from_cols(columns[1], columns[2], columns[0]),
3583 Mat3::from_cols(columns[2], columns[0], columns[1]),
3584 Mat3::from_cols(-columns[2], columns[1], columns[0]),
3585 ];
3586 let policy = PositiveUniformAffineTolerance {
3587 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3588 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3589 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
3590 };
3591 let expected_mean = f64::from_bits(0x3fef_ffeb_074a_771d);
3592
3593 for (index, linear) in permutations.into_iter().enumerate() {
3594 let measured = measure_linear_transform(Mat4::from_mat3(linear));
3595 assert_eq!(
3596 measured.classification,
3597 LinearTransformClassification::UnitOrthonormal,
3598 "canonical mean must give proper permutation {index} one stable class"
3599 );
3600 assert_eq!(
3601 measured.uniform_scale,
3602 Some(expected_mean),
3603 "measurement must publish the canonical mean for permutation {index}"
3604 );
3605 assert_eq!(
3606 classify_positive_uniform_affine(linear, policy),
3607 Ok(expected_mean),
3608 "the shared classifier must consume the same mean for permutation {index}"
3609 );
3610 }
3611 }
3612
3613 #[test]
3614 fn linear_measurement_reports_axis_lengths_in_xyz_column_order() {
3615 let measured = measure_linear_transform(Mat4::from_scale(Vec3::new(2.0, 3.0, 5.0)));
3616
3617 assert_eq!(measured.axis_lengths, Some([2.0, 3.0, 5.0]));
3618 }
3619
3620 #[test]
3621 fn affine_consumers_widen_each_pair_dot_before_comparison() {
3622 let x = Vec3::new(
3627 f32::from_bits(0x3fd8_2778),
3628 f32::from_bits(0x3fd9_ea4a),
3629 0.0,
3630 );
3631 let y = Vec3::new(
3632 f32::from_bits(0xbfd9_e92c),
3633 f32::from_bits(0x3fd8_2778),
3634 0.0,
3635 );
3636 let z = Vec3::new(0.0, 0.0, f32::from_bits(0x4019_77cc));
3637 let widened_dot = x.as_dvec3().dot(y.as_dvec3()).abs();
3638 let f32_first_dot = f64::from(x.dot(y).abs());
3639 let x_length = x.as_dvec3().length();
3640 let y_length = y.as_dvec3().length();
3641 let z_length = f64::from(z.z);
3642 let pair_tolerance = LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE * x_length * y_length;
3643 let mean = (x_length + y_length + z_length) / 3.0;
3644 let common_tolerance = LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE * mean * mean;
3645 let policy = PositiveUniformAffineTolerance {
3646 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3647 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3648 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
3649 };
3650
3651 assert!(f32_first_dot <= pair_tolerance && widened_dot > pair_tolerance);
3652 assert!(f32_first_dot <= common_tolerance && widened_dot > common_tolerance);
3653
3654 for (pair, linear) in [
3655 ("positive XY", Mat3::from_cols(x, y, z)),
3656 ("negative XY", Mat3::from_cols(x, -y, -z)),
3657 ("positive XZ", Mat3::from_cols(x, -z, y)),
3658 ("negative XZ", Mat3::from_cols(x, z, -y)),
3659 ("positive YZ", Mat3::from_cols(z, x, y)),
3660 ("negative YZ", Mat3::from_cols(-z, x, -y)),
3661 ] {
3662 let measured = measure_linear_transform(Mat4::from_mat3(linear));
3663 assert_eq!(
3664 measured.classification,
3665 LinearTransformClassification::Sheared,
3666 "measurement must compare the widened {pair} dot"
3667 );
3668 assert_eq!(
3669 classify_positive_uniform_affine(linear, policy),
3670 Err(AffineDomainViolation::Sheared),
3671 "the positive-uniform classifier must compare the same widened {pair} dot"
3672 );
3673 }
3674 }
3675
3676 #[test]
3677 fn linear_measurement_pins_equal_axis_boundaries_and_extreme_finite_scales() {
3678 let on_long_edge = Vec3::new(99_998.5, 99_998.5, 100_000.0);
3679 let measured = measure_linear_transform(Mat4::from_scale(on_long_edge));
3680 assert_eq!(
3681 measured.classification,
3682 LinearTransformClassification::UniformScaled
3683 );
3684 assert_eq!(measured.uniform_scale, Some(99_999.0));
3685
3686 let short = 99_998.5;
3687 let outside = 100_000.0 + 0.007_812_5;
3688 for diagonal in [
3689 Vec3::new(outside, short, short),
3690 Vec3::new(short, outside, short),
3691 Vec3::new(short, short, outside),
3692 ] {
3693 assert_eq!(
3694 measure_linear_transform(Mat4::from_scale(diagonal)).classification,
3695 LinearTransformClassification::NonUniform
3696 );
3697 }
3698
3699 for scale in [f32::from_bits(1), f32::MIN_POSITIVE, f32::MAX] {
3700 let measured = measure_linear_transform(Mat4::from_scale(Vec3::splat(scale)));
3701 assert_eq!(
3702 measured.classification,
3703 LinearTransformClassification::UniformScaled,
3704 "complete finite f32 scale range at {scale:e}"
3705 );
3706 assert_eq!(measured.uniform_scale, Some(f64::from(scale)));
3707 assert!(measured.determinant.is_some_and(f64::is_finite));
3708 }
3709 }
3710
3711 #[test]
3712 fn linear_measurement_pins_pair_normalization_and_public_precedence() {
3713 let pair_normalized_shear = Mat3::from_cols(
3714 Vec3::X,
3715 Vec3::new(3.0e-5, 2.0, 0.0),
3716 Vec3::new(0.0, 0.0, 3.0),
3717 );
3718 let facts = AffineGeometryFacts::from_linear(pair_normalized_shear).unwrap();
3719 assert!(
3720 facts.cross_axis_dots[0].abs()
3721 > LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE
3722 * facts.axis_lengths[0]
3723 * facts.axis_lengths[1]
3724 );
3725 assert!(
3726 facts.cross_axis_dots[0].abs()
3727 <= LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE
3728 * facts.mean_axis_length
3729 * facts.mean_axis_length,
3730 "measurement intentionally does not use the operation classifier's common-factor band"
3731 );
3732 let measured = measure_linear_transform(Mat4::from_mat3(pair_normalized_shear));
3733 assert_eq!(
3734 measured.classification,
3735 LinearTransformClassification::Sheared,
3736 "public measurement must use the XY pair product, not mean squared"
3737 );
3738 for shear in [3.0e-5, -3.0e-5] {
3739 let signed_shear = Mat3::from_cols(Vec3::X, Vec3::new(shear, 2.0, 0.0), Vec3::Z);
3740 assert_eq!(
3741 measure_linear_transform(Mat4::from_mat3(signed_shear)).classification,
3742 LinearTransformClassification::Sheared,
3743 "orthogonality is independent of the dot-product sign"
3744 );
3745 }
3746 for (pair, linear) in [
3747 (
3748 "XZ",
3749 Mat3::from_cols(
3750 Vec3::X,
3751 Vec3::new(0.0, 100.0, 0.0),
3752 Vec3::new(1.5e-5, 0.0, 1.0),
3753 ),
3754 ),
3755 (
3756 "negative XZ",
3757 Mat3::from_cols(
3758 Vec3::X,
3759 Vec3::new(0.0, 100.0, 0.0),
3760 Vec3::new(-1.5e-5, 0.0, 1.0),
3761 ),
3762 ),
3763 (
3764 "YZ",
3765 Mat3::from_cols(
3766 Vec3::new(100.0, 0.0, 0.0),
3767 Vec3::Y,
3768 Vec3::new(0.0, 1.5e-5, 1.0),
3769 ),
3770 ),
3771 (
3772 "negative YZ",
3773 Mat3::from_cols(
3774 Vec3::new(100.0, 0.0, 0.0),
3775 Vec3::Y,
3776 Vec3::new(0.0, -1.5e-5, 1.0),
3777 ),
3778 ),
3779 ] {
3780 assert_eq!(
3781 measure_linear_transform(Mat4::from_mat3(linear)).classification,
3782 LinearTransformClassification::Sheared,
3783 "{pair} dot must use that pair's own length product"
3784 );
3785 }
3786 assert_eq!(
3787 classify_positive_uniform_affine(
3788 pair_normalized_shear,
3789 PositiveUniformAffineTolerance {
3790 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3791 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3792 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
3793 },
3794 ),
3795 Err(AffineDomainViolation::NonUniformScale),
3796 "the positive-uniform operation classifier intentionally rejects shape before shear"
3797 );
3798
3799 let singular_reflected_shear = Mat4::from_cols(
3800 (-Vec3::X).extend(0.0),
3801 Vec3::new(0.5, 1.0e-8, 0.0).extend(0.0),
3802 Vec3::Z.extend(0.0),
3803 glam::Vec4::W,
3804 );
3805 let singular = measure_linear_transform(singular_reflected_shear);
3806 assert_eq!(
3807 singular.classification,
3808 LinearTransformClassification::Singular
3809 );
3810 assert_eq!(
3811 singular.orientation,
3812 Some(LinearTransformOrientation::Zero),
3813 "singularity owns the public orientation before determinant sign"
3814 );
3815 assert!(singular.determinant.is_some_and(|value| value < 0.0));
3816
3817 let reflected_shear = Mat4::from_cols(
3818 (-Vec3::X).extend(0.0),
3819 Vec3::new(0.5, 1.0, 0.0).extend(0.0),
3820 Vec3::Z.extend(0.0),
3821 glam::Vec4::W,
3822 );
3823 assert_eq!(
3824 measure_linear_transform(reflected_shear).classification,
3825 LinearTransformClassification::Reflected
3826 );
3827 }
3828
3829 #[test]
3830 fn linear_measurement_uses_axis_length_product_for_singularity() {
3831 let linear = Mat3::from_cols(
3832 Vec3::new(1.0, 0.0, 0.0),
3833 Vec3::new(0.0, 100.0, 0.0),
3834 Vec3::new(100.0, 0.0, 0.001),
3835 );
3836 let facts = AffineGeometryFacts::from_linear(linear).unwrap();
3837 let determinant = facts.determinant.abs();
3838 let product_threshold =
3839 LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE * facts.axis_length_product;
3840 let mean_cubed_threshold =
3841 LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE * facts.mean_axis_length.powi(3);
3842
3843 assert!(
3844 determinant > product_threshold,
3845 "the true axis-length-product threshold must not classify this matrix as singular"
3846 );
3847 assert!(
3848 determinant <= mean_cubed_threshold,
3849 "a mean-cubed threshold must disagree on this singularity boundary fixture"
3850 );
3851
3852 let measured = measure_linear_transform(Mat4::from_mat3(linear));
3853 assert_eq!(
3854 measured.classification,
3855 LinearTransformClassification::Sheared
3856 );
3857 assert_eq!(
3858 measured.orientation,
3859 Some(LinearTransformOrientation::Positive)
3860 );
3861 }
3862
3863 #[test]
3864 fn linear_measurement_is_atomic_for_non_finite_mat4_components() {
3865 for index in 0..16 {
3866 let mut columns = Mat4::IDENTITY.to_cols_array();
3867 columns[index] = f32::NAN;
3868 assert_eq!(
3869 measure_linear_transform(Mat4::from_cols_array(&columns)),
3870 unavailable_linear_transform(),
3871 "component {index} must make every numeric fact unavailable"
3872 );
3873 }
3874 }
3875
3876 #[test]
3877 fn linear_measurement_reports_the_canonical_widened_determinant() {
3878 let linear = Mat3::from_cols(
3879 Vec3::new(
3880 f32::from_bits(0x3ff3_5574),
3881 f32::from_bits(0x3f0e_fa3c),
3882 0.0,
3883 ),
3884 Vec3::new(
3885 f32::from_bits(0x3ff5_5e17),
3886 f32::from_bits(0x3f10_2c31),
3887 0.0,
3888 ),
3889 Vec3::Z,
3890 );
3891 let measured = measure_linear_transform(Mat4::from_mat3(linear));
3892 assert_eq!(
3893 measured.determinant.map(f64::to_bits),
3894 Some(0x3eb4_b98f_a000_0000)
3895 );
3896 assert_ne!(measured.determinant, Some(f64::from(linear.determinant())));
3897 }
3898
3899 #[test]
3900 fn skin_bind_summary_covers_every_stable_aggregate_class() {
3901 let available_joint = |joint_index, matrix| SkinJointMeasurements {
3902 joint_index,
3903 node_index: joint_index,
3904 joint_bind_to_mesh: available_derived_matrix(matrix),
3905 mesh_bind_world: available_derived_matrix(Mat4::IDENTITY),
3906 };
3907 let unavailable_joint = |joint_index| SkinJointMeasurements {
3908 joint_index,
3909 node_index: joint_index,
3910 joint_bind_to_mesh: unavailable_derived_matrix(
3911 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent,
3912 ),
3913 mesh_bind_world: unavailable_derived_matrix(
3914 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent,
3915 ),
3916 };
3917 let assert_summary = |joints: &[SkinJointMeasurements],
3918 classification,
3919 available_joint_count,
3920 unavailable_joint_count,
3921 consistent_uniform_scale| {
3922 assert_eq!(
3923 summarize_skin_bind_linear(joints),
3924 SkinBindLinearSummaryMeasurements {
3925 classification,
3926 joint_count: joints.len(),
3927 available_joint_count,
3928 unavailable_joint_count,
3929 consistent_uniform_scale,
3930 }
3931 );
3932 };
3933
3934 assert_summary(
3935 &[],
3936 SkinBindLinearSummaryClassification::NoJoints,
3937 0,
3938 0,
3939 None,
3940 );
3941 assert_summary(
3942 &[unavailable_joint(0)],
3943 SkinBindLinearSummaryClassification::Unavailable,
3944 0,
3945 1,
3946 None,
3947 );
3948 assert_summary(
3949 &[available_joint(0, Mat4::IDENTITY), unavailable_joint(1)],
3950 SkinBindLinearSummaryClassification::PartiallyUnavailable,
3951 1,
3952 1,
3953 None,
3954 );
3955 assert_summary(
3956 &[
3957 available_joint(0, Mat4::IDENTITY),
3958 available_joint(1, Mat4::IDENTITY),
3959 ],
3960 SkinBindLinearSummaryClassification::ConsistentUniform,
3961 2,
3962 0,
3963 Some(1.0),
3964 );
3965 assert_summary(
3966 &[
3967 available_joint(0, Mat4::IDENTITY),
3968 available_joint(1, Mat4::from_scale(Vec3::splat(2.0))),
3969 ],
3970 SkinBindLinearSummaryClassification::MixedUniform,
3971 2,
3972 0,
3973 None,
3974 );
3975 assert_summary(
3976 &[
3977 available_joint(0, Mat4::from_scale(Vec3::new(1.0, 2.0, 3.0))),
3978 available_joint(
3979 1,
3980 Mat4::from_cols_array(&[
3981 1.0, 0.0, 0.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0,
3982 1.0,
3983 ]),
3984 ),
3985 ],
3986 SkinBindLinearSummaryClassification::NonUniformOrSheared,
3987 2,
3988 0,
3989 None,
3990 );
3991 assert_summary(
3992 &[
3993 available_joint(0, Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0))),
3994 available_joint(
3995 1,
3996 Mat4::from_cols_array(&[
3997 1.0, 0.0, 0.0, 0.0, 1.0, 1.0e-8, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0,
3998 0.0, 1.0,
3999 ]),
4000 ),
4001 ],
4002 SkinBindLinearSummaryClassification::ReflectedOrSingular,
4003 2,
4004 0,
4005 None,
4006 );
4007 assert_summary(
4008 &[
4009 available_joint(0, Mat4::IDENTITY),
4010 available_joint(1, Mat4::from_scale(Vec3::new(1.0, 2.0, 3.0))),
4011 ],
4012 SkinBindLinearSummaryClassification::Mixed,
4013 2,
4014 0,
4015 None,
4016 );
4017 }
4018
4019 #[test]
4020 fn skin_bind_summary_is_joint_order_invariant_and_reports_the_mean() {
4021 let matrix_from_bits = |columns: [[u32; 4]; 4]| {
4022 Mat4::from_cols(
4023 glam::Vec4::from_array(columns[0].map(f32::from_bits)),
4024 glam::Vec4::from_array(columns[1].map(f32::from_bits)),
4025 glam::Vec4::from_array(columns[2].map(f32::from_bits)),
4026 glam::Vec4::from_array(columns[3].map(f32::from_bits)),
4027 )
4028 };
4029 let raw_inverse_binds = [
4030 matrix_from_bits([
4031 [0xbcde_4500, 0xbd7b_2918, 0x3f7f_6c80, 0],
4032 [0x3f40_907c, 0xbf28_9ba8, 0xbca4_0480, 0],
4033 [0x3f28_8afa, 0x3f3f_fdef, 0x3d83_0f78, 0],
4034 [0, 0, 0, 0x3f80_0000],
4035 ]),
4036 matrix_from_bits([
4037 [0x3da5_7c20, 0xbf7e_c9a2, 0xbd5d_55e0, 0],
4038 [0x3e48_71f6, 0xbd18_d560, 0x3f7a_dda0, 0],
4039 [0xbf7a_31a0, 0xbdb7_d42c, 0x3e44_6898, 0],
4040 [0, 0, 0, 0x3f80_0000],
4041 ]),
4042 matrix_from_bits([
4043 [0xbee1_b0e8, 0xbd50_c238, 0xbf65_6a79, 0],
4044 [0xbf62_2552, 0xbe1c_0be8, 0x3ee2_e94f, 0],
4045 [0xbe22_f8bc, 0x3f7c_ac66, 0x3cb5_7540, 0],
4046 [0, 0, 0, 0x3f80_0000],
4047 ]),
4048 ];
4049 let expected_factor_bits = [
4050 0x3ff0_0000_110e_4203,
4051 0x3ff0_0000_2d55_0083,
4052 0x3fef_ffff_b3bb_b2b8,
4053 ];
4054 let expected_mean = f64::from_bits(0x3ff0_0000_0815_b3f6);
4055 let permutations = [
4056 [0usize, 1usize, 2usize],
4057 [0, 2, 1],
4058 [1, 0, 2],
4059 [1, 2, 0],
4060 [2, 0, 1],
4061 [2, 1, 0],
4062 ];
4063
4064 for order in permutations {
4065 let doc = Document {
4066 assets: SceneAssets {
4067 source_skeleton: SourceSkeletonAssets {
4068 coverage: SourceSkeletonCoverage::Complete,
4069 nodes: (0..3)
4070 .map(|source_node_index| SourceNodeAsset {
4071 source_node_index,
4072 name: Some(format!("joint_{source_node_index}")),
4073 parent_source_node_index: None,
4074 scene_root_indices: vec![0],
4075 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
4076 bone: None,
4077 })
4078 .collect(),
4079 skins: vec![SourceSkinAsset {
4080 source_skin_index: 0,
4081 name: Some("order_invariant_uniform_bind_scale".into()),
4082 skeleton_root_source_node_index: Some(0),
4083 joint_source_node_indices: order.to_vec(),
4084 inverse_bind_accessor: SourceInverseBindAccessor {
4085 status: SourceInverseBindAccessorStatus::Available,
4086 declared_count: Some(3),
4087 matrices: order.map(|index| raw_inverse_binds[index]).to_vec(),
4088 },
4089 attachments: Vec::new(),
4090 }],
4091 },
4092 ..SceneAssets::default()
4093 },
4094 ..Document::default()
4095 };
4096
4097 let measured = measure_assets(&doc);
4098 let skin = &measured.skins[0];
4099 assert_eq!(
4100 skin.joints
4101 .iter()
4102 .map(|joint| {
4103 let linear = joint
4104 .joint_bind_to_mesh
4105 .linear
4106 .expect("finite invertible raw inverse binds are measurable");
4107 assert_eq!(
4108 linear.classification,
4109 LinearTransformClassification::UnitOrthonormal
4110 );
4111 linear
4112 .uniform_scale
4113 .expect("uniform joint binds carry their factor")
4114 .to_bits()
4115 })
4116 .collect::<Vec<_>>(),
4117 order.map(|index| expected_factor_bits[index]).to_vec(),
4118 "source joint order {order:?}"
4119 );
4120 assert_eq!(
4121 skin.joint_bind_linear_summary,
4122 SkinBindLinearSummaryMeasurements {
4123 classification: SkinBindLinearSummaryClassification::ConsistentUniform,
4124 joint_count: 3,
4125 available_joint_count: 3,
4126 unavailable_joint_count: 0,
4127 consistent_uniform_scale: Some(expected_mean),
4128 },
4129 "source joint order {order:?}"
4130 );
4131 }
4132 assert_ne!(
4133 expected_mean, 1.0,
4134 "the summary reports its mean, not joint 0"
4135 );
4136 }
4137
4138 #[test]
4139 fn skin_bind_summary_classification_is_mean_relative_in_every_joint_order() {
4140 let factors = [
4141 1.0_f32,
4142 f32::from_bits(0x3f80_004b),
4143 f32::from_bits(0x3f7f_ff69),
4144 ];
4145 let mut sorted_factors = factors.map(f64::from);
4146 sorted_factors.sort_by(f64::total_cmp);
4147 let expected_mean = sorted_factors.into_iter().sum::<f64>() / factors.len() as f64;
4148 let permutations = [
4149 [0usize, 1usize, 2usize],
4150 [0, 2, 1],
4151 [1, 0, 2],
4152 [1, 2, 0],
4153 [2, 0, 1],
4154 [2, 1, 0],
4155 ];
4156
4157 for order in permutations {
4158 let joints = order.map(|index| SkinJointMeasurements {
4159 joint_index: index,
4160 node_index: index,
4161 joint_bind_to_mesh: available_derived_matrix(Mat4::from_scale(Vec3::splat(
4162 factors[index],
4163 ))),
4164 mesh_bind_world: available_derived_matrix(Mat4::IDENTITY),
4165 });
4166 assert_eq!(
4167 summarize_skin_bind_linear(&joints),
4168 SkinBindLinearSummaryMeasurements {
4169 classification: SkinBindLinearSummaryClassification::ConsistentUniform,
4170 joint_count: 3,
4171 available_joint_count: 3,
4172 unavailable_joint_count: 0,
4173 consistent_uniform_scale: Some(expected_mean),
4174 },
4175 "high/low factors straddle the first-joint band in order {order:?}"
4176 );
4177 }
4178 }
4179
4180 #[test]
4181 fn source_measurement_reports_disagreeing_uniform_joint_bind_scales() {
4182 let doc = Document {
4183 assets: SceneAssets {
4184 source_skeleton: SourceSkeletonAssets {
4185 coverage: SourceSkeletonCoverage::Complete,
4186 nodes: (0..2)
4187 .map(|source_node_index| SourceNodeAsset {
4188 source_node_index,
4189 name: Some(format!("joint_{source_node_index}")),
4190 parent_source_node_index: None,
4191 scene_root_indices: vec![0],
4192 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
4193 bone: None,
4194 })
4195 .collect(),
4196 skins: vec![SourceSkinAsset {
4197 source_skin_index: 0,
4198 name: Some("mixed_uniform_bind_scale".into()),
4199 skeleton_root_source_node_index: Some(0),
4200 joint_source_node_indices: vec![0, 1],
4201 inverse_bind_accessor: SourceInverseBindAccessor {
4202 status: SourceInverseBindAccessorStatus::Available,
4203 declared_count: Some(2),
4204 matrices: vec![Mat4::IDENTITY, Mat4::from_scale(Vec3::splat(0.5))],
4205 },
4206 attachments: Vec::new(),
4207 }],
4208 },
4209 ..SceneAssets::default()
4210 },
4211 ..Document::default()
4212 };
4213
4214 let measured = measure_assets(&doc);
4215 let skin = &measured.skins[0];
4216 assert_eq!(
4217 skin.joints
4218 .iter()
4219 .map(|joint| {
4220 let linear = joint
4221 .joint_bind_to_mesh
4222 .linear
4223 .expect("finite invertible raw inverse binds are measurable");
4224 (linear.classification, linear.uniform_scale)
4225 })
4226 .collect::<Vec<_>>(),
4227 vec![
4228 (LinearTransformClassification::UnitOrthonormal, Some(1.0)),
4229 (LinearTransformClassification::UniformScaled, Some(2.0)),
4230 ]
4231 );
4232 assert_eq!(
4233 skin.joint_bind_linear_summary,
4234 SkinBindLinearSummaryMeasurements {
4235 classification: SkinBindLinearSummaryClassification::MixedUniform,
4236 joint_count: 2,
4237 available_joint_count: 2,
4238 unavailable_joint_count: 0,
4239 consistent_uniform_scale: None,
4240 }
4241 );
4242 }
4243
4244 #[test]
4245 fn non_finite_source_rest_is_explicit_in_matrix_and_linear_domains() {
4246 let doc = Document {
4247 assets: SceneAssets {
4248 source_skeleton: SourceSkeletonAssets {
4249 coverage: SourceSkeletonCoverage::Complete,
4250 nodes: vec![SourceNodeAsset {
4251 source_node_index: 0,
4252 name: None,
4253 parent_source_node_index: None,
4254 scene_root_indices: Vec::new(),
4255 local_rest: SourceNodeLocalRest::Matrix(Mat4::from_cols_array(
4256 &[f32::NAN; 16],
4257 )),
4258 bone: None,
4259 }],
4260 skins: Vec::new(),
4261 },
4262 ..SceneAssets::default()
4263 },
4264 ..Document::default()
4265 };
4266 let node = &measure_assets(&doc).skeleton_nodes[0];
4267 assert!(node.rest_world_matrix.is_none());
4268 assert!(node.rest_world_translation_m.is_none());
4269 assert_eq!(
4270 node.rest_world_matrix_unavailable_reason,
4271 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
4272 );
4273 assert_eq!(
4274 node.rest_world_linear.classification,
4275 LinearTransformClassification::NonFinite
4276 );
4277 assert!(node.rest_world_linear.axis_lengths.is_none());
4278 }
4279
4280 #[test]
4281 fn source_skeleton_measurement_preserves_source_order_and_bind_domains() {
4282 let skeleton = Skeleton {
4286 bones: vec![
4287 Bone {
4288 name: "root".into(),
4289 parent: None,
4290 rest: Transform {
4291 translation: Vec3::new(10.0, 0.0, 0.0),
4292 ..Transform::IDENTITY
4293 },
4294 inverse_bind: None,
4295 },
4296 Bone {
4297 name: "joint".into(),
4298 parent: Some(0),
4299 rest: Transform {
4300 translation: Vec3::new(2.0, 0.0, 0.0),
4301 ..Transform::IDENTITY
4302 },
4303 inverse_bind: None,
4304 },
4305 Bone {
4306 name: "mesh".into(),
4307 parent: Some(0),
4308 rest: Transform::IDENTITY,
4309 inverse_bind: None,
4310 },
4311 ],
4312 };
4313 let doc = Document {
4314 skeleton,
4315 assets: SceneAssets {
4316 scenes: vec![SceneAsset {
4317 source_scene_index: 4,
4318 name: None,
4319 roots: vec![0],
4320 }],
4321 source_skeleton: SourceSkeletonAssets {
4322 coverage: SourceSkeletonCoverage::Complete,
4323 nodes: vec![
4324 SourceNodeAsset {
4325 source_node_index: 0,
4326 name: Some("joint".into()),
4327 parent_source_node_index: Some(1),
4328 scene_root_indices: vec![],
4329 local_rest: SourceNodeLocalRest::Trs {
4330 translation: Vec3::new(2.0, 0.0, 0.0),
4331 rotation: Quat::IDENTITY,
4332 scale: Vec3::ONE,
4333 },
4334 bone: None,
4335 },
4336 SourceNodeAsset {
4337 source_node_index: 1,
4338 name: Some("root".into()),
4339 parent_source_node_index: None,
4340 scene_root_indices: vec![4],
4341 local_rest: SourceNodeLocalRest::Trs {
4342 translation: Vec3::new(10.0, 0.0, 0.0),
4343 rotation: Quat::IDENTITY,
4344 scale: Vec3::ONE,
4345 },
4346 bone: None,
4347 },
4348 SourceNodeAsset {
4349 source_node_index: 2,
4350 name: Some("mesh".into()),
4351 parent_source_node_index: Some(1),
4352 scene_root_indices: vec![],
4353 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
4354 bone: None,
4355 },
4356 ],
4357 skins: vec![SourceSkinAsset {
4358 source_skin_index: 0,
4359 name: Some("skin".into()),
4360 skeleton_root_source_node_index: Some(1),
4361 joint_source_node_indices: vec![0],
4362 inverse_bind_accessor: SourceInverseBindAccessor {
4363 status: SourceInverseBindAccessorStatus::Available,
4364 declared_count: Some(2),
4365 matrices: vec![
4366 Mat4::from_translation(Vec3::new(-12.0, 0.0, 0.0)),
4367 Mat4::IDENTITY,
4368 ],
4369 },
4370 attachments: vec![SourceSkinAttachment {
4371 source_node_index: 2,
4372 source_mesh_index: Some(7),
4373 }],
4374 }],
4375 },
4376 ..SceneAssets::default()
4377 },
4378 ..Document::default()
4379 };
4380
4381 let measured = measure_assets(&doc);
4382 assert_eq!(
4383 measured.skeleton_source_coverage,
4384 SourceSkeletonCoverage::Complete
4385 );
4386 assert_eq!(
4387 measured
4388 .skeleton_nodes
4389 .iter()
4390 .map(|node| node.node_index)
4391 .collect::<Vec<_>>(),
4392 vec![0, 1, 2]
4393 );
4394 assert_eq!(measured.skeleton_nodes[0].parent_node_index, Some(1));
4395 assert_eq!(measured.skeleton_nodes[1].scene_root_indices, vec![4]);
4396 assert_eq!(
4397 measured.skeleton_nodes[0]
4398 .rest_world_matrix
4399 .expect("finite child rest world")[12],
4400 12.0
4401 );
4402 let skin = &measured.skins[0];
4403 assert_eq!(skin.skeleton_root_node_index, Some(1));
4404 assert_eq!(
4405 skin.inverse_bind_accessor.matrices.len(),
4406 2,
4407 "extra raw IBM survives"
4408 );
4409 assert_eq!(skin.attachments[0].node_index, 2);
4410 assert_eq!(skin.attachments[0].mesh_index, Some(7));
4411 assert_eq!(skin.joints[0].joint_bind_to_mesh.matrix.unwrap()[12], 12.0);
4412 assert_eq!(
4413 skin.joints[0].mesh_bind_world.matrix.unwrap(),
4414 Mat4::IDENTITY.to_cols_array()
4415 );
4416 }
4417
4418 #[test]
4419 fn count_mismatched_inverse_bind_accessor_keeps_present_slots_and_marks_missing_ones() {
4420 let doc = Document {
4421 assets: SceneAssets {
4422 source_skeleton: SourceSkeletonAssets {
4423 coverage: SourceSkeletonCoverage::Complete,
4424 nodes: vec![SourceNodeAsset {
4425 source_node_index: 0,
4426 name: None,
4427 parent_source_node_index: None,
4428 scene_root_indices: vec![],
4429 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
4430 bone: None,
4431 }],
4432 skins: vec![SourceSkinAsset {
4433 source_skin_index: 0,
4434 name: None,
4435 skeleton_root_source_node_index: None,
4436 joint_source_node_indices: vec![0, 0],
4437 inverse_bind_accessor: SourceInverseBindAccessor {
4438 status: SourceInverseBindAccessorStatus::CountMismatch,
4439 declared_count: Some(1),
4440 matrices: vec![Mat4::IDENTITY],
4441 },
4442 attachments: vec![],
4443 }],
4444 },
4445 ..SceneAssets::default()
4446 },
4447 ..Document::default()
4448 };
4449
4450 let skin = &measure_assets(&doc).skins[0];
4451 assert_eq!(
4452 skin.joints[0].joint_bind_to_mesh.matrix,
4453 Some(Mat4::IDENTITY.to_cols_array())
4454 );
4455 assert_eq!(
4456 skin.joints[1].joint_bind_to_mesh.unavailable_reason,
4457 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
4458 );
4459 assert_eq!(
4460 skin.joints[1].mesh_bind_world.unavailable_reason,
4461 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
4462 );
4463 }
4464
4465 #[test]
4466 fn source_skeleton_measurement_preserves_full_matrix_domains() {
4467 let doc = Document {
4470 assets: SceneAssets {
4471 source_skeleton: SourceSkeletonAssets {
4472 coverage: SourceSkeletonCoverage::Complete,
4473 nodes: vec![SourceNodeAsset {
4474 source_node_index: 0,
4475 name: None,
4476 parent_source_node_index: None,
4477 scene_root_indices: vec![],
4478 local_rest: SourceNodeLocalRest::Matrix(Mat4::from_cols_array(&[
4479 2.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0, 4.0, 0.0, 10.0, 20.0,
4480 30.0, 1.0,
4481 ])),
4482 bone: None,
4483 }],
4484 skins: vec![SourceSkinAsset {
4485 source_skin_index: 0,
4486 name: None,
4487 skeleton_root_source_node_index: Some(0),
4488 joint_source_node_indices: vec![0],
4489 inverse_bind_accessor: SourceInverseBindAccessor {
4490 status: SourceInverseBindAccessorStatus::Available,
4491 declared_count: Some(1),
4492 matrices: vec![Mat4::from_cols_array(&[
4493 0.5, 0.0, 0.0, 0.0, 0.0, 0.25, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0,
4494 2.0, 3.0, 1.0,
4495 ])],
4496 },
4497 attachments: vec![],
4498 }],
4499 },
4500 ..SceneAssets::default()
4501 },
4502 ..Document::default()
4503 };
4504
4505 let joint = &measure_assets(&doc).skins[0].joints[0];
4506 assert_eq!(
4507 joint.joint_bind_to_mesh.matrix,
4508 Some([
4509 2.0, 0.0, 0.0, 0.0, 0.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, -2.0, -8.0, -1.5, 1.0,
4510 ])
4511 );
4512 assert_eq!(
4513 joint.mesh_bind_world.matrix,
4514 Some([
4515 1.0, 0.0, 0.0, 0.0, 0.0, 0.75, 0.0, 0.0, 0.0, 0.0, 8.0, 0.0, 12.0, 26.0, 42.0, 1.0,
4516 ])
4517 );
4518 }
4519
4520 #[test]
4521 fn source_skeleton_measurement_handles_a_deep_leaf_first_hierarchy() {
4522 const NODE_COUNT: usize = 16_384;
4523 let nodes = (0..NODE_COUNT)
4524 .map(|node_index| SourceNodeAsset {
4525 source_node_index: node_index,
4526 name: None,
4527 parent_source_node_index: (node_index + 1 < NODE_COUNT).then_some(node_index + 1),
4528 scene_root_indices: Vec::new(),
4529 local_rest: SourceNodeLocalRest::Matrix(if node_index + 1 == NODE_COUNT {
4530 Mat4::from_translation(Vec3::X)
4531 } else {
4532 Mat4::IDENTITY
4533 }),
4534 bone: None,
4535 })
4536 .collect();
4537 let doc = Document {
4538 assets: SceneAssets {
4539 source_skeleton: SourceSkeletonAssets {
4540 coverage: SourceSkeletonCoverage::Complete,
4541 nodes,
4542 skins: Vec::new(),
4543 },
4544 ..SceneAssets::default()
4545 },
4546 ..Document::default()
4547 };
4548
4549 let measured = measure_assets(&doc);
4550 assert_eq!(measured.skeleton_nodes.len(), NODE_COUNT);
4551 assert_eq!(
4552 measured.skeleton_nodes[0]
4553 .rest_world_matrix
4554 .expect("deep leaf rest world")[12],
4555 1.0
4556 );
4557 }
4558
4559 #[test]
4560 fn malformed_source_parent_graph_downgrades_source_coverage() {
4561 for parent_source_node_index in [Some(7), Some(0)] {
4562 let doc = Document {
4563 assets: SceneAssets {
4564 source_skeleton: SourceSkeletonAssets {
4565 coverage: SourceSkeletonCoverage::Complete,
4566 nodes: vec![SourceNodeAsset {
4567 source_node_index: 0,
4568 name: None,
4569 parent_source_node_index,
4570 scene_root_indices: Vec::new(),
4571 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
4572 bone: None,
4573 }],
4574 skins: Vec::new(),
4575 },
4576 ..SceneAssets::default()
4577 },
4578 ..Document::default()
4579 };
4580
4581 let measured = measure_assets(&doc);
4582 assert_eq!(
4583 measured.skeleton_source_coverage,
4584 SourceSkeletonCoverage::Unavailable
4585 );
4586 assert!(measured.skeleton_nodes.is_empty());
4587 assert!(measured.skins.is_empty());
4588 }
4589 }
4590
4591 #[test]
4592 fn skinned_mesh_measures_bbox_joints_and_weight_sums() {
4593 let prim = Primitive {
4595 positions: vec![
4596 Vec3::new(0.0, 0.0, 0.0),
4597 Vec3::new(2.0, 0.0, 0.0),
4598 Vec3::new(0.0, 3.0, 0.0),
4599 Vec3::new(0.0, 0.0, 4.0),
4600 ],
4601 weights: vec![
4604 [1.0, 0.0, 0.0, 0.0],
4605 [0.5, 0.5, 0.0, 0.0],
4606 [0.4, 0.3, 0.3, 0.0],
4607 [0.3, 0.3, 0.3, 0.0],
4608 ],
4609 joints: vec![[0, 0, 0, 0]; 4],
4610 ..Primitive::default()
4611 };
4612 let m = mesh("body", vec![prim]);
4613
4614 assert_eq!(m.name, "body");
4615 assert_eq!(m.vertex_count, 4);
4616 let aabb = m.geometry_aabb.as_ref().expect("positions present");
4617 assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
4618 assert_eq!(aabb.max, [2.0, 3.0, 4.0]);
4619 assert_eq!(m.geometry_centroid, Some([0.5, 0.75, 1.0]));
4620 assert_eq!(m.max_joints_per_vertex, 3);
4621 assert!((m.weight_sum_min.unwrap() - 0.9).abs() < 1e-6);
4623 assert!((m.weight_sum_max.unwrap() - 1.0).abs() < 1e-6);
4624 }
4625
4626 #[test]
4627 fn mesh_measurements_preserve_secondary_influence_set_mismatches_without_affecting_primary_stats()
4628 {
4629 let primary = Primitive {
4630 positions: vec![Vec3::ZERO],
4631 joints: vec![[0, 1, 0, 0]],
4632 weights: vec![[0.75, 0.25, 0.0, 0.0]],
4633 additional_influence_sets: vec![AdditionalInfluenceSet {
4634 set_index: 2,
4635 joints_present: true,
4636 weights_present: false,
4637 }],
4638 ..Primitive::default()
4639 };
4640 let secondary = Primitive {
4641 positions: vec![Vec3::ONE],
4642 additional_influence_sets: vec![
4643 AdditionalInfluenceSet {
4644 set_index: 1,
4645 joints_present: false,
4646 weights_present: true,
4647 },
4648 AdditionalInfluenceSet {
4649 set_index: 2,
4650 joints_present: false,
4651 weights_present: true,
4652 },
4653 ],
4654 ..Primitive::default()
4655 };
4656
4657 let measured = mesh("body", vec![primary, secondary]);
4658
4659 assert_eq!(measured.max_joints_per_vertex, 2);
4660 assert_eq!(measured.weight_sum_min, Some(1.0));
4661 assert_eq!(measured.weight_sum_max, Some(1.0));
4662 assert_eq!(
4663 measured.additional_influence_sets,
4664 vec![
4665 AdditionalInfluenceSetMeasurements {
4666 set_index: 1,
4667 joints_present: false,
4668 weights_present: true,
4669 joints_without_weights_present: false,
4670 weights_without_joints_present: true,
4671 },
4672 AdditionalInfluenceSetMeasurements {
4673 set_index: 2,
4674 joints_present: true,
4675 weights_present: true,
4676 joints_without_weights_present: true,
4677 weights_without_joints_present: true,
4678 },
4679 ]
4680 );
4681 }
4682
4683 #[test]
4684 fn unskinned_mesh_has_bbox_but_no_weight_stats() {
4685 let prim = Primitive {
4686 positions: vec![Vec3::new(-1.0, -2.0, -3.0), Vec3::new(1.0, 2.0, 3.0)],
4687 ..Primitive::default()
4688 };
4689 let m = mesh("prop", vec![prim]);
4690
4691 assert_eq!(m.vertex_count, 2);
4692 assert_eq!(m.geometry_aabb.as_ref().unwrap().min, [-1.0, -2.0, -3.0]);
4693 assert_eq!(m.geometry_centroid, Some([0.0, 0.0, 0.0]));
4694 assert_eq!(m.max_joints_per_vertex, 0);
4695 assert_eq!(m.weight_sum_min, None, "no skin ⇒ no weight-sum");
4696 assert_eq!(m.weight_sum_max, None);
4697 }
4698
4699 #[test]
4700 fn empty_mesh_reports_no_bbox() {
4701 let m = mesh("hollow", vec![Primitive::default()]);
4702 assert_eq!(m.vertex_count, 0);
4703 assert!(m.geometry_aabb.is_none(), "no positions ⇒ no bounding box");
4704 assert!(m.geometry_centroid.is_none(), "no positions ⇒ no centroid");
4705 }
4706
4707 #[test]
4708 fn non_finite_position_is_dropped_from_the_bbox() {
4709 let prim = Primitive {
4713 positions: vec![
4714 Vec3::new(0.0, 0.0, 0.0),
4715 Vec3::new(f32::NAN, 5.0, 0.0),
4716 Vec3::new(f32::INFINITY, 9.0, 0.0),
4717 Vec3::new(2.0, 3.0, 0.0),
4718 ],
4719 ..Primitive::default()
4720 };
4721 let m = mesh("nan", vec![prim]);
4722 let aabb = m.geometry_aabb.as_ref().unwrap();
4723 assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
4726 assert_eq!(aabb.max, [2.0, 3.0, 0.0]);
4727 assert_eq!(m.geometry_centroid, Some([1.0, 1.5, 0.0]));
4728 assert!(
4729 aabb.min.iter().chain(&aabb.max).all(|c| c.is_finite()),
4730 "no non-finite bound is ever emitted"
4731 );
4732 }
4733
4734 #[test]
4735 fn all_non_finite_positions_yield_no_bbox() {
4736 let prim = Primitive {
4739 positions: vec![Vec3::splat(f32::NAN), Vec3::splat(f32::INFINITY)],
4740 ..Primitive::default()
4741 };
4742 let m = mesh("allnan", vec![prim]);
4743 assert_eq!(m.vertex_count, 2, "count still reflects the vertices");
4744 assert!(
4745 m.geometry_aabb.is_none(),
4746 "no finite vertex ⇒ no box (never null bounds)"
4747 );
4748 assert!(
4749 m.geometry_centroid.is_none(),
4750 "no finite vertex ⇒ no centroid"
4751 );
4752 }
4753
4754 #[test]
4755 fn non_finite_weight_sum_is_omitted() {
4756 let prim = Primitive {
4759 positions: vec![Vec3::ZERO, Vec3::ONE],
4760 weights: vec![[0.5, 0.5, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 0.0]],
4761 ..Primitive::default()
4762 };
4763 let m = mesh("nanw", vec![prim]);
4764 assert_eq!(m.weight_sum_min, Some(1.0));
4766 assert_eq!(m.weight_sum_max, Some(1.0));
4767 }
4768
4769 #[test]
4770 fn all_non_finite_weight_sums_yield_no_weight_stats() {
4771 let prim = Primitive {
4774 positions: vec![Vec3::ZERO, Vec3::ONE],
4775 weights: vec![[f32::NAN, 0.0, 0.0, 0.0], [f32::INFINITY, 0.0, 0.0, 0.0]],
4776 ..Primitive::default()
4777 };
4778 let m = mesh("allnanw", vec![prim]);
4779 assert_eq!(m.weight_sum_min, None, "no finite weight sum ⇒ omitted");
4780 assert_eq!(m.weight_sum_max, None);
4781 assert_eq!(m.max_joints_per_vertex, 1);
4783 }
4784
4785 #[test]
4786 fn vertex_count_sums_across_primitives() {
4787 let a = Primitive {
4788 positions: vec![Vec3::ZERO; 3],
4789 ..Primitive::default()
4790 };
4791 let b = Primitive {
4792 positions: vec![Vec3::ONE; 5],
4793 ..Primitive::default()
4794 };
4795 let m = mesh("multi", vec![a, b]);
4796 assert_eq!(m.vertex_count, 8, "3 + 5 corners across two primitives");
4797 }
4798
4799 #[test]
4800 fn geometry_centroid_is_the_finite_position_mean_across_primitives() {
4801 let indexed = Primitive {
4805 positions: vec![
4806 Vec3::new(0.0, 0.0, 0.0),
4807 Vec3::new(6.0, 0.0, 0.0),
4808 Vec3::new(0.0, 3.0, 0.0),
4809 ],
4810 indices: vec![0, 1, 2, 0, 1, 2],
4811 ..Primitive::default()
4812 };
4813 let unindexed = Primitive {
4814 positions: vec![Vec3::new(0.0, 3.0, 0.0), Vec3::splat(f32::NAN)],
4815 ..Primitive::default()
4816 };
4817 let m = mesh("asymmetric", vec![indexed, unindexed]);
4818
4819 assert_eq!(m.vertex_count, 5, "all authored position rows count");
4820 assert_eq!(m.geometry_aabb.unwrap().max, [6.0, 3.0, 0.0]);
4821 assert_eq!(
4822 m.geometry_centroid,
4823 Some([1.5, 1.5, 0.0]),
4824 "four finite position rows, independent of six index references"
4825 );
4826 }
4827
4828 #[test]
4829 fn mesh_centroid_is_composed_from_published_primitive_centroids() {
4830 let first = Primitive {
4831 positions: vec![Vec3::new(-10.0, 0.0, 0.0); 3],
4832 ..Primitive::default()
4833 };
4834 let second = Primitive {
4835 positions: vec![Vec3::new(-10.0, 0.0, 0.0), Vec3::new(-9.7, 0.0, 0.0)],
4836 ..Primitive::default()
4837 };
4838 let measurements = mesh("rounded-centroids", vec![first, second]);
4839 let primitives = measurements.primitives.as_ref().unwrap();
4840 let first_mean = primitives[0].geometry_centroid.unwrap()[0];
4841 let second_mean = primitives[1].geometry_centroid.unwrap()[0];
4842 let expected = ((f64::from(first_mean) * 3.0 + f64::from(second_mean) * 2.0) / 5.0) as f32;
4843
4844 assert_eq!(measurements.geometry_centroid.unwrap()[0], expected);
4845 assert_ne!(
4846 expected,
4847 ((-10.0f64 * 4.0 + f64::from(-9.7f32)) / 5.0) as f32,
4848 "fixture must exercise the primitive-centroid rounding boundary"
4849 );
4850 }
4851
4852 #[test]
4853 fn primitive_measurements_preserve_source_slots_and_finite_geometry_domain() {
4854 let first = Primitive {
4855 source_primitive_index: Some(2),
4856 material: Some(7),
4857 positions: vec![Vec3::new(-2.0, 1.0, 0.0), Vec3::splat(f32::NAN)],
4858 indices: vec![0, 0, 0],
4859 ..Primitive::default()
4860 };
4861 let second = Primitive {
4862 source_primitive_index: Some(5),
4863 material: None,
4864 positions: vec![Vec3::new(4.0, 3.0, 0.0), Vec3::new(6.0, 3.0, 0.0)],
4865 indices: vec![0, 1, 1],
4866 ..Primitive::default()
4867 };
4868 let measurements = mesh("primitive-order", vec![first, second]);
4869
4870 assert_eq!(measurements.vertex_count, 4);
4871 let primitives = measurements.primitives.as_ref().unwrap();
4872 assert_eq!(primitives.len(), 2);
4873 assert_eq!(primitives[0].primitive_index, 2);
4874 assert_eq!(primitives[0].material_index, Some(7));
4875 assert_eq!(primitives[0].vertex_count, 2);
4876 assert_eq!(primitives[0].finite_vertex_count, 1);
4877 assert_eq!(primitives[0].geometry_aabb.unwrap().min, [-2.0, 1.0, 0.0]);
4878 assert_eq!(primitives[0].geometry_centroid, Some([-2.0, 1.0, 0.0]));
4879 assert_eq!(primitives[1].primitive_index, 5);
4880 assert_eq!(primitives[1].material_index, None);
4881 assert_eq!(primitives[1].vertex_count, 2);
4882 assert_eq!(primitives[1].finite_vertex_count, 2);
4883 assert_eq!(primitives[1].geometry_centroid, Some([5.0, 3.0, 0.0]));
4884 let mesh_aabb = measurements.geometry_aabb.as_ref().unwrap();
4885 assert_eq!(mesh_aabb.min, [-2.0, 1.0, 0.0]);
4886 assert_eq!(mesh_aabb.max, [6.0, 3.0, 0.0]);
4887 assert_eq!(
4888 measurements.geometry_centroid,
4889 Some([8.0 / 3.0, 7.0 / 3.0, 0.0])
4890 );
4891 }
4892
4893 #[test]
4894 fn primitive_measurements_fall_back_to_retained_order_without_source_slots() {
4895 let measurements = mesh("manual", vec![Primitive::default(), Primitive::default()]);
4896 let primitives = measurements.primitives.unwrap();
4897 assert_eq!(primitives[0].primitive_index, 0);
4898 assert_eq!(primitives[1].primitive_index, 1);
4899 }
4900
4901 #[test]
4902 fn non_finite_instance_transform_makes_scene_coverage_partial() {
4903 let doc = Document {
4904 skeleton: Skeleton {
4905 bones: vec![
4906 Bone {
4907 name: "finite".into(),
4908 parent: None,
4909 rest: Transform::IDENTITY,
4910 inverse_bind: None,
4911 },
4912 Bone {
4913 name: "overflow".into(),
4914 parent: Some(0),
4915 rest: Transform {
4916 scale: Vec3::splat(f32::MAX),
4917 ..Transform::IDENTITY
4918 },
4919 inverse_bind: None,
4920 },
4921 ],
4922 },
4923 assets: SceneAssets {
4924 meshes: vec![MeshAsset {
4925 name: "point".into(),
4926 source_mesh_index: 4,
4927 primitives: vec![Primitive {
4928 positions: vec![Vec3::new(2.0, 0.0, 0.0)],
4929 ..Primitive::default()
4930 }],
4931 }],
4932 instances: vec![
4933 crate::model::MeshInstance {
4934 source_node_index: 10,
4935 node: 0,
4936 mesh: 0,
4937 ..crate::model::MeshInstance::default()
4938 },
4939 crate::model::MeshInstance {
4940 source_node_index: 11,
4941 node: 1,
4942 mesh: 0,
4943 ..crate::model::MeshInstance::default()
4944 },
4945 ],
4946 scenes: vec![crate::model::SceneAsset {
4947 source_scene_index: 3,
4948 name: Some("partial".into()),
4949 roots: vec![0],
4950 }],
4951 default_scene: None,
4952 ..SceneAssets::default()
4953 },
4954 ..Document::default()
4955 };
4956
4957 let measured = measure_assets(&doc);
4958 assert_eq!(measured.default_scene_index, None, "no implicit scene zero");
4959 assert_eq!(measured.node_instances.len(), 2);
4960 assert_eq!(
4961 measured.node_instances[0].static_node_world_aabb,
4962 Some(Aabb {
4963 min: [2.0, 0.0, 0.0],
4964 max: [2.0, 0.0, 0.0],
4965 })
4966 );
4967 assert_eq!(
4968 measured.node_instances[1].static_node_world_aabb_unavailable_reason,
4969 Some(StaticNodeAabbUnavailableReason::NonFiniteTransform)
4970 );
4971 assert_eq!(measured.scenes[0].instance_count, 2);
4972 assert_eq!(measured.scenes[0].excluded_instance_count, 1);
4973 assert_eq!(
4974 measured.scenes[0].static_scene_world_aabb,
4975 measured.node_instances[0].static_node_world_aabb,
4976 "partial aggregate retains the finite instance"
4977 );
4978 }
4979
4980 #[test]
4981 fn malformed_skeleton_chain_does_not_hide_an_unrelated_instance() {
4982 let doc = Document {
4983 skeleton: Skeleton {
4984 bones: vec![
4985 Bone {
4986 name: "malformed".into(),
4987 parent: Some(1),
4988 rest: Transform::IDENTITY,
4989 inverse_bind: None,
4990 },
4991 Bone {
4992 name: "malformed_child".into(),
4993 parent: Some(0),
4994 rest: Transform::IDENTITY,
4995 inverse_bind: None,
4996 },
4997 Bone {
4998 name: "valid_root".into(),
4999 parent: None,
5000 rest: Transform {
5001 translation: Vec3::X,
5002 ..Transform::IDENTITY
5003 },
5004 inverse_bind: None,
5005 },
5006 Bone {
5007 name: "valid_instance".into(),
5008 parent: Some(2),
5009 rest: Transform {
5010 translation: Vec3::Y,
5011 ..Transform::IDENTITY
5012 },
5013 inverse_bind: None,
5014 },
5015 ],
5016 },
5017 assets: SceneAssets {
5018 meshes: vec![MeshAsset {
5019 name: "point".into(),
5020 source_mesh_index: 0,
5021 primitives: vec![Primitive {
5022 positions: vec![Vec3::X],
5023 ..Primitive::default()
5024 }],
5025 }],
5026 instances: vec![
5027 crate::model::MeshInstance {
5028 source_node_index: 10,
5029 node: 0,
5030 mesh: 0,
5031 ..crate::model::MeshInstance::default()
5032 },
5033 crate::model::MeshInstance {
5034 source_node_index: 11,
5035 node: 3,
5036 mesh: 0,
5037 ..crate::model::MeshInstance::default()
5038 },
5039 ],
5040 scenes: vec![SceneAsset {
5041 source_scene_index: 0,
5042 name: None,
5043 roots: vec![0, 2],
5044 }],
5045 ..SceneAssets::default()
5046 },
5047 ..Document::default()
5048 };
5049
5050 let measured = measure_assets(&doc);
5051 assert_eq!(
5052 measured.node_instances[0].static_node_world_aabb_unavailable_reason,
5053 Some(StaticNodeAabbUnavailableReason::NonFiniteTransform)
5054 );
5055 assert_eq!(
5056 measured.node_instances[1].static_node_world_aabb,
5057 Some(Aabb {
5058 min: [2.0, 1.0, 0.0],
5059 max: [2.0, 1.0, 0.0],
5060 })
5061 );
5062 assert_eq!(measured.scenes[0].excluded_instance_count, 1);
5063 assert_eq!(
5064 measured.scenes[0].static_scene_world_aabb,
5065 measured.node_instances[1].static_node_world_aabb
5066 );
5067 }
5068
5069 #[test]
5070 fn later_duplicate_clip_name_replaces_earlier_measurement() {
5071 let earlier = Clip {
5072 name: "duplicate".into(),
5073 duration_s: 1.0,
5074 tracks: vec![
5075 Track {
5076 bone: 0,
5077 property: Property::Rotation,
5078 interpolation: Interpolation::Linear,
5079 times: vec![0.0, 0.5, 1.0],
5080 values: TrackValues::Quats(vec![
5081 Quat::IDENTITY,
5082 Quat::from_rotation_x(0.25),
5083 Quat::from_rotation_x(0.5),
5084 ]),
5085 },
5086 Track {
5087 bone: 0,
5088 property: Property::Translation,
5089 interpolation: Interpolation::Linear,
5090 times: vec![0.0, 0.5, 1.0],
5091 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::Z * 0.5, Vec3::Z]),
5092 },
5093 Track {
5094 bone: 1,
5095 property: Property::Translation,
5096 interpolation: Interpolation::Linear,
5097 times: vec![0.0, 0.5, 1.0],
5098 values: TrackValues::Vec3s(vec![
5099 Vec3::new(-0.1, -1.0, 0.0),
5100 Vec3::new(-0.1, -0.9, 0.15),
5101 Vec3::new(-0.1, -1.0, 0.0),
5102 ]),
5103 },
5104 Track {
5105 bone: 2,
5106 property: Property::Translation,
5107 interpolation: Interpolation::Linear,
5108 times: vec![0.0, 0.5, 1.0],
5109 values: TrackValues::Vec3s(vec![
5110 Vec3::new(0.1, -1.0, 0.0),
5111 Vec3::new(0.1, -1.1, -0.15),
5112 Vec3::new(0.1, -1.0, 0.0),
5113 ]),
5114 },
5115 ],
5116 };
5117 let later = Clip {
5118 name: "duplicate".into(),
5119 duration_s: 2.0,
5120 tracks: vec![Track {
5121 bone: 0,
5122 property: Property::Translation,
5123 interpolation: Interpolation::Linear,
5124 times: vec![0.0, 2.0],
5125 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X]),
5126 }],
5127 };
5128 let skeleton = Skeleton {
5129 bones: vec![
5130 Bone {
5131 name: "hips".into(),
5132 parent: None,
5133 rest: Transform::IDENTITY,
5134 inverse_bind: None,
5135 },
5136 Bone {
5137 name: "left_foot".into(),
5138 parent: Some(0),
5139 rest: Transform::IDENTITY,
5140 inverse_bind: None,
5141 },
5142 Bone {
5143 name: "right_foot".into(),
5144 parent: Some(0),
5145 rest: Transform::IDENTITY,
5146 inverse_bind: None,
5147 },
5148 ],
5149 };
5150 let roles = ResolvedRoles::from_names(
5151 &skeleton,
5152 [
5153 (Role::Hips, "hips".into()),
5154 (Role::LeftFoot, "left_foot".into()),
5155 (Role::RightFoot, "right_foot".into()),
5156 ],
5157 );
5158 let earlier_doc = Document {
5159 skeleton: skeleton.clone(),
5160 clips: vec![earlier.clone()],
5161 ..Document::default()
5162 };
5163 let earlier_grids = MetricGrids::new(&earlier_doc);
5164 let earlier_measurement =
5165 &measure_document(&earlier_grids, &roles, &Config::default())["duplicate"];
5166 assert!(earlier_measurement.loop_seam_ratio.is_some());
5167 assert!(earlier_measurement.gait.is_some());
5168 assert!(earlier_measurement.speed_mps.is_some());
5169
5170 let doc = Document {
5171 skeleton,
5172 clips: vec![earlier, later],
5173 ..Document::default()
5174 };
5175 let grids = MetricGrids::new(&doc);
5176 let indexed = measure_document_indexed(&grids, &roles, &Config::default());
5177 assert_eq!(indexed.len(), 2);
5178 assert_eq!(indexed[0].duration_s, 1.0);
5179 assert_eq!(indexed[1].duration_s, 2.0);
5180 assert!(indexed[0].gait.is_some());
5181 assert!(indexed[1].gait.is_none());
5182
5183 let measurements = measure_document(&grids, &roles, &Config::default());
5184
5185 assert_eq!(
5186 serde_json::to_value(measurements).expect("duplicate measurements serialize"),
5187 serde_json::json!({
5188 "duplicate": {
5189 "duration_s": 2.0,
5190 "frame_count": 2,
5191 "animated_bones": ["hips"],
5192 "bone_channels": [{
5193 "bone_index": 0,
5194 "bone_name": "hips",
5195 "properties": ["translation"]
5196 }],
5197 "bone_rotation_range_deg": {},
5198 "loop_continuity_availability": "unavailable",
5199 "loop_endpoint_mode_availability": "not_applicable",
5200 "frame_grid_availability": "not_applicable",
5201 "loop_seam_ratio_availability": "unavailable",
5202 "gait_availability": "unavailable",
5203 "root_trajectory": {
5204 "bone_index": 0,
5205 "bone_name": "hips",
5206 "source_role": "hips_fallback",
5207 "translation_availability": "unavailable",
5208 "yaw_availability": "unavailable"
5209 },
5210 "root_trajectory_availability": "measured",
5211 "speed_mps_availability": "unavailable",
5212 }
5213 })
5214 );
5215 }
5216
5217 #[test]
5224 fn resolved_gait_roles_with_no_real_stride_report_loop_seam_ratio_not_applicable() {
5225 let clip = Clip {
5226 name: "planted".into(),
5227 duration_s: 1.0,
5228 tracks: vec![Track {
5229 bone: 0,
5230 property: Property::Translation,
5231 interpolation: Interpolation::Linear,
5232 times: vec![0.0, 0.5, 1.0],
5233 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO, Vec3::ZERO]),
5234 }],
5235 };
5236 let skeleton = Skeleton {
5237 bones: vec![
5238 Bone {
5239 name: "hips".into(),
5240 parent: None,
5241 rest: Transform::IDENTITY,
5242 inverse_bind: None,
5243 },
5244 Bone {
5245 name: "left_foot".into(),
5246 parent: Some(0),
5247 rest: Transform::IDENTITY,
5248 inverse_bind: None,
5249 },
5250 Bone {
5251 name: "right_foot".into(),
5252 parent: Some(0),
5253 rest: Transform::IDENTITY,
5254 inverse_bind: None,
5255 },
5256 ],
5257 };
5258 let roles = ResolvedRoles::from_names(
5259 &skeleton,
5260 [
5261 (Role::Hips, "hips".into()),
5262 (Role::LeftFoot, "left_foot".into()),
5263 (Role::RightFoot, "right_foot".into()),
5264 ],
5265 );
5266 let doc = Document {
5267 skeleton,
5268 clips: vec![clip],
5269 ..Document::default()
5270 };
5271 let grids = MetricGrids::new(&doc);
5272 let measurements = measure_document(&grids, &roles, &Config::default());
5273 let measured = &measurements["planted"];
5274
5275 assert_eq!(measured.loop_seam_ratio, None);
5276 assert_eq!(
5277 measured.loop_seam_ratio_availability,
5278 MeasurementAvailability::NotApplicable,
5279 "the Hips + foot role domain resolved, but the clip has no real \
5280 stride to normalize the seam against, so the ratio is a \
5281 legitimately missing subject, not a derivation failure"
5282 );
5283 }
5284
5285 fn gait_skeleton_and_roles() -> (Skeleton, ResolvedRoles) {
5288 let skeleton = Skeleton {
5289 bones: vec![
5290 Bone {
5291 name: "hips".into(),
5292 parent: None,
5293 rest: Transform::IDENTITY,
5294 inverse_bind: None,
5295 },
5296 Bone {
5297 name: "left_foot".into(),
5298 parent: Some(0),
5299 rest: Transform::IDENTITY,
5300 inverse_bind: None,
5301 },
5302 Bone {
5303 name: "right_foot".into(),
5304 parent: Some(0),
5305 rest: Transform::IDENTITY,
5306 inverse_bind: None,
5307 },
5308 ],
5309 };
5310 let roles = ResolvedRoles::from_names(
5311 &skeleton,
5312 [
5313 (Role::Hips, "hips".into()),
5314 (Role::LeftFoot, "left_foot".into()),
5315 (Role::RightFoot, "right_foot".into()),
5316 ],
5317 );
5318 (skeleton, roles)
5319 }
5320
5321 fn foot_translation_clip(name: &str, values: [Vec3; 3]) -> Clip {
5322 Clip {
5323 name: name.into(),
5324 duration_s: 1.0,
5325 tracks: vec![Track {
5326 bone: 1,
5327 property: Property::Translation,
5328 interpolation: Interpolation::Linear,
5329 times: vec![0.0, 0.5, 1.0],
5330 values: TrackValues::Vec3s(values.to_vec()),
5331 }],
5332 }
5333 }
5334
5335 #[test]
5342 fn loop_seam_ratio_floor_boundary_partitions_not_applicable_from_measured() {
5343 let (skeleton, roles) = gait_skeleton_and_roles();
5344 let floor = 0.05;
5345 let below_floor = foot_translation_clip(
5346 "below_floor",
5347 [
5348 Vec3::ZERO,
5349 Vec3::new(floor as f32 - 0.01, 0.0, 0.0),
5350 Vec3::ZERO,
5351 ],
5352 );
5353 let at_floor = foot_translation_clip(
5354 "at_floor",
5355 [
5356 Vec3::ZERO,
5357 Vec3::new(floor as f32, 0.0, 0.0),
5358 Vec3::new(0.01, 0.0, 0.0),
5359 ],
5360 );
5361 let seam_pop = foot_translation_clip(
5362 "seam_pop",
5363 [
5364 Vec3::ZERO,
5365 Vec3::new(floor as f32, 0.0, 0.0),
5366 Vec3::new(2.0 * floor as f32, 0.0, 0.0),
5367 ],
5368 );
5369 let doc = Document {
5370 skeleton,
5371 clips: vec![below_floor, at_floor, seam_pop],
5372 ..Document::default()
5373 };
5374 let grids = MetricGrids::new(&doc);
5375 let mut config = Config::default();
5376 config.checks.insert(
5377 "loop-seam".into(),
5378 CheckSettings {
5379 min_stride_step_m: Some(floor),
5380 ..CheckSettings::default()
5381 },
5382 );
5383 let measurements = measure_document(&grids, &roles, &config);
5384
5385 let below = &measurements["below_floor"];
5386 assert_eq!(below.loop_seam_ratio, None);
5387 assert_eq!(
5388 below.loop_seam_ratio_availability,
5389 MeasurementAvailability::NotApplicable,
5390 "a neighbour step strictly under the configured floor is not a \
5391 real stride"
5392 );
5393
5394 let at = &measurements["at_floor"];
5395 assert_eq!(
5396 at.loop_seam_ratio_availability,
5397 MeasurementAvailability::Measured,
5398 "a neighbour step meeting the floor exactly (>=) is a real \
5399 stride with a derivable ratio"
5400 );
5401 let ratio = at.loop_seam_ratio.expect("real stride derives a ratio");
5402 assert!(
5403 (ratio - 0.01 / floor).abs() < 1e-6,
5404 "seam / neighbour_step for the constructed positions, got {ratio}"
5405 );
5406
5407 let pop = &measurements["seam_pop"];
5412 assert_eq!(
5413 pop.loop_seam_ratio_availability,
5414 MeasurementAvailability::Measured,
5415 "a real stride with a seam pop still derives a finite ratio"
5416 );
5417 let pop_ratio = pop.loop_seam_ratio.expect("real stride derives a ratio");
5418 assert!(
5419 (pop_ratio - 2.0).abs() < 1e-6,
5420 "seam / neighbour_step for the constructed positions, got {pop_ratio}"
5421 );
5422 }
5423
5424 #[test]
5432 fn real_stride_beyond_f32_squaring_range_reports_loop_seam_ratio_unavailable() {
5433 let (mut skeleton, _) = gait_skeleton_and_roles();
5434 skeleton.bones.truncate(2); let clip = Clip {
5436 name: "extreme".into(),
5437 duration_s: 1.0,
5438 tracks: vec![Track {
5439 bone: 1,
5440 property: Property::Translation,
5441 interpolation: Interpolation::Linear,
5442 times: vec![0.0, 0.25, 0.5, 1.0],
5443 values: TrackValues::Vec3s(vec![
5444 Vec3::ZERO,
5445 Vec3::new(f32::MIN_POSITIVE, 0.0, 0.0),
5446 Vec3::new(f32::MAX - f32::MIN_POSITIVE, 0.0, 0.0),
5447 Vec3::new(f32::MAX, 0.0, 0.0),
5448 ]),
5449 }],
5450 };
5451 let roles = ResolvedRoles::from_names(
5452 &skeleton,
5453 [
5454 (Role::Hips, "hips".to_string()),
5455 (Role::LeftFoot, "left_foot".to_string()),
5456 ],
5457 );
5458 let doc = Document {
5459 skeleton,
5460 clips: vec![clip],
5461 ..Document::default()
5462 };
5463 let grids = MetricGrids::new(&doc);
5464 let mut config = Config::default();
5465 config.checks.insert(
5466 "loop-seam".into(),
5467 CheckSettings {
5468 min_stride_step_m: Some(f64::from(f32::MIN_POSITIVE)),
5469 ..CheckSettings::default()
5470 },
5471 );
5472 let measurements = measure_document(&grids, &roles, &config);
5473 let measured = &measurements["extreme"];
5474
5475 assert_eq!(measured.loop_seam_ratio, None);
5476 assert_eq!(
5477 measured.loop_seam_ratio_availability,
5478 MeasurementAvailability::Unavailable,
5479 "the role domain resolved and the neighbour step met the (tiny) \
5480 configured floor, so this is a derivation failure, not a \
5481 missing subject"
5482 );
5483 }
5484
5485 #[test]
5486 fn inverse_bind_conditioning_is_scale_free_and_tracks_anisotropy() {
5487 for (scales, expected) in [
5488 (Vec3::splat(1.0), 1.0),
5489 (Vec3::new(1.0, 0.1, 0.1), 0.1),
5490 (Vec3::new(1.0, 0.01, 0.01), 0.01),
5491 (Vec3::splat(1.0e-20), 1.0),
5492 ] {
5493 let assessment = assess_inverse_bind(Mat4::from_scale(scales));
5494 assert!(assessment.inverse.is_ok(), "scales {scales:?}");
5495 let actual = assessment
5496 .quality
5497 .expect("affine linear transform has quality")
5498 .reciprocal_condition_number_inf;
5499 assert!(
5500 (actual - expected).abs() <= 1.0e-6,
5501 "{actual} != {expected}"
5502 );
5503 }
5504
5505 let shear = Mat4::from_cols_array(&[
5506 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
5510 ]);
5511 let quality = assess_inverse_bind(shear)
5512 .quality
5513 .expect("finite affine shear has quality");
5514 assert_eq!(
5515 quality.reciprocal_condition_number_inf, 0.25,
5516 "infinity-norm conditioning includes off-diagonal row sums"
5517 );
5518 }
5519
5520 #[test]
5521 fn inverse_bind_assessment_distinguishes_non_affine_singular_and_ill_conditioned() {
5522 let inside_zero = INVERSE_BIND_AFFINE_TOLERANCE as f32;
5523 let outside_zero = f32::from_bits(inside_zero.to_bits() + 1);
5524 assert!(f64::from(inside_zero) <= INVERSE_BIND_AFFINE_TOLERANCE);
5525 assert!(f64::from(outside_zero) > INVERSE_BIND_AFFINE_TOLERANCE);
5526 for slot in [3, 7, 11] {
5527 for value in [inside_zero, -inside_zero] {
5528 let mut affine = Mat4::IDENTITY.to_cols_array();
5529 affine[slot] = value;
5530 assert!(
5531 assess_inverse_bind(Mat4::from_cols_array(&affine))
5532 .inverse
5533 .is_ok(),
5534 "bottom-row slot {slot} accepts signed values inside the tolerance"
5535 );
5536 }
5537 for value in [outside_zero, -outside_zero] {
5538 let mut non_affine = Mat4::IDENTITY.to_cols_array();
5539 non_affine[slot] = value;
5540 let assessment = assess_inverse_bind(Mat4::from_cols_array(&non_affine));
5541 assert_eq!(
5542 assessment.inverse,
5543 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine),
5544 "bottom-row slot {slot} rejects signed values outside the tolerance"
5545 );
5546 assert_eq!(assessment.quality, None);
5547 }
5548 }
5549 let inside_one = 1.0 + INVERSE_BIND_AFFINE_TOLERANCE as f32;
5550 let outside_one = f32::from_bits(inside_one.to_bits() + 1);
5551 assert!((f64::from(inside_one) - 1.0).abs() <= INVERSE_BIND_AFFINE_TOLERANCE);
5552 assert!((f64::from(outside_one) - 1.0).abs() > INVERSE_BIND_AFFINE_TOLERANCE);
5553 for value in [inside_one, 2.0 - inside_one] {
5554 let mut affine = Mat4::IDENTITY.to_cols_array();
5555 affine[15] = value;
5556 assert!(
5557 assess_inverse_bind(Mat4::from_cols_array(&affine))
5558 .inverse
5559 .is_ok()
5560 );
5561 }
5562 for value in [outside_one, 2.0 - outside_one] {
5563 let mut non_affine = Mat4::IDENTITY.to_cols_array();
5564 non_affine[15] = value;
5565 assert_eq!(
5566 assess_inverse_bind(Mat4::from_cols_array(&non_affine)).inverse,
5567 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine)
5568 );
5569 }
5570
5571 let singular = assess_inverse_bind(Mat4::from_scale(Vec3::new(1.0, 1.0, 0.0)));
5572 assert_eq!(
5573 singular.inverse,
5574 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible)
5575 );
5576 assert_eq!(
5577 singular
5578 .quality
5579 .expect("singular affine matrix has quality")
5580 .reciprocal_condition_number_inf,
5581 0.0
5582 );
5583
5584 let ill_conditioned = assess_inverse_bind(Mat4::from_scale(Vec3::new(1.0, 1.0, 1.0e-7)));
5585 assert_eq!(
5586 ill_conditioned.inverse,
5587 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned)
5588 );
5589 assert_eq!(
5590 ill_conditioned
5591 .quality
5592 .expect("ill-conditioned affine matrix has quality")
5593 .reciprocal_condition_number_inf,
5594 1.0e-7_f32 as f64
5595 );
5596
5597 for (shear, expected_reason) in [
5598 (
5599 999.0,
5600 Some(SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned),
5601 ),
5602 (998.0, None),
5603 ] {
5604 let matrix = Mat4::from_cols_array(&[
5605 1.0, 0.0, 0.0, 0.0, shear, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
5606 ]);
5607 let assessment = assess_inverse_bind(matrix);
5608 let expected_quality = 1.0 / (1.0 + f64::from(shear)).powi(2);
5609 assert_eq!(
5610 assessment
5611 .quality
5612 .expect("affine shear has quality")
5613 .reciprocal_condition_number_inf,
5614 expected_quality
5615 );
5616 match expected_reason {
5617 Some(reason) => assert_eq!(assessment.inverse, Err(reason)),
5618 None => assert!(assessment.inverse.is_ok()),
5619 }
5620 }
5621 }
5622}