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