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