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 grids
1992 .document()
1993 .clips
1994 .iter()
1995 .map(|clip| clip.name.clone())
1996 .zip(measure_document_indexed(grids, roles, config))
1997 .collect()
1998}
1999
2000pub fn measure_document_indexed(
2010 grids: &MetricGrids<'_>,
2011 roles: &ResolvedRoles,
2012 config: &Config,
2013) -> Vec<ClipMeasurements> {
2014 let doc = grids.document();
2015 let min_stride_step_m = config.loop_seam_min_stride_step_m();
2016 doc.clips
2017 .iter()
2018 .enumerate()
2019 .map(|(clip_index, clip)| {
2020 let mut animated: BTreeSet<String> = BTreeSet::new();
2021 let mut bone_channels: BTreeMap<usize, BTreeSet<Property>> = BTreeMap::new();
2022 let mut rotation_range: BTreeMap<String, f64> = BTreeMap::new();
2023 let mut frame_count = 0usize;
2024
2025 for track in &clip.tracks {
2026 let Some(bone) = doc.skeleton.bones.get(track.bone) else {
2027 continue;
2028 };
2029 if track.key_count() == 0 {
2030 continue;
2031 }
2032 let track_is_structurally_valid = validate_track_shape(clip_index, track).is_ok();
2033 if track_is_structurally_valid {
2034 animated.insert(bone.name.clone());
2035 bone_channels
2036 .entry(track.bone)
2037 .or_default()
2038 .insert(track.property);
2039
2040 if let Some(max_deg) = rotation_range_deg(track)
2041 && max_deg >= MIN_RECORDED_ROTATION_DEG
2042 {
2043 let entry = rotation_range.entry(bone.name.clone()).or_insert(0.0);
2044 *entry = entry.max(max_deg);
2045 }
2046 }
2047 frame_count = frame_count.max(track.key_count());
2048 }
2049
2050 let grid = grids.grid(clip_index);
2051 let cycle = grid
2052 .as_ref()
2053 .and_then(|g| foot_cycle_metrics(g, roles, min_stride_step_m));
2054 let gait_roles_applicable = roles.get(Role::Hips).is_some()
2055 && [
2056 Role::LeftFoot,
2057 Role::LeftToe,
2058 Role::RightFoot,
2059 Role::RightToe,
2060 ]
2061 .iter()
2062 .any(|&role| roles.get(role).is_some());
2063 let (loop_continuity, loop_continuity_availability) = if doc.skeleton.bones.is_empty() {
2064 (None, MeasurementAvailability::NotApplicable)
2065 } else {
2066 match grid.as_ref().and_then(|grid| loop_continuity_metrics(grid)) {
2067 Some(metrics) => (
2068 Some(LoopContinuityMeasurement {
2069 bones: metrics
2070 .into_iter()
2071 .enumerate()
2072 .map(|(bone_index, metrics)| BoneLoopContinuityMeasurement {
2073 bone_index: bone_index as u32,
2074 bone_name: doc.skeleton.bones[bone_index].name.clone(),
2075 position_delta_m: metrics.position_delta_m,
2076 rotation_delta_deg: metrics.rotation_delta_deg,
2077 seam_velocity_delta_mps: metrics.seam_velocity_delta_mps,
2078 seam_angular_velocity_delta_degps: metrics
2079 .seam_angular_velocity_delta_degps,
2080 })
2081 .collect(),
2082 }),
2083 MeasurementAvailability::Measured,
2084 ),
2085 None => (None, MeasurementAvailability::Unavailable),
2086 }
2087 };
2088 let expectations = config.expectations_for(&clip.name);
2089 let (position_cap, rotation_cap) = effective_caps(config, &expectations);
2090 let (loop_endpoint_mode, loop_endpoint_mode_availability) = if expectations.looping
2091 == Some(true)
2092 {
2093 match measure_loop_endpoint_mode(clip, grid.as_deref(), position_cap, rotation_cap)
2094 {
2095 Some(mode) => (Some(mode), MeasurementAvailability::Measured),
2096 None => (None, MeasurementAvailability::Unavailable),
2097 }
2098 } else {
2099 (None, MeasurementAvailability::NotApplicable)
2100 };
2101 let (frame_grid, frame_grid_availability) = match expectations.fps {
2102 None => (None, MeasurementAvailability::NotApplicable),
2103 Some(_) => match measure_frame_grid(clip, expectations.fps) {
2104 Some(measurement) => (Some(measurement), MeasurementAvailability::Measured),
2105 None => (None, MeasurementAvailability::Unavailable),
2106 },
2107 };
2108 let (loop_seam_ratio, loop_seam_ratio_availability) = match &cycle {
2109 Some(metrics) => match metrics.loop_seam_ratio {
2121 Some(ratio) => (Some(ratio), MeasurementAvailability::Measured),
2122 None if !metrics.has_real_stride => {
2123 (None, MeasurementAvailability::NotApplicable)
2124 }
2125 None => (None, MeasurementAvailability::Unavailable),
2126 },
2127 None if !gait_roles_applicable => (None, MeasurementAvailability::NotApplicable),
2128 None => (None, MeasurementAvailability::Unavailable),
2129 };
2130 let (gait, gait_availability) = match &cycle {
2131 Some(metrics) => {
2132 let (phase, phase_availability) = match metrics.gait_phase_outcome(roles) {
2133 GaitPhaseOutcome::MissingBilateralFootRoles
2134 | GaitPhaseOutcome::NoFootHeightSwing => {
2135 (None, MeasurementAvailability::NotApplicable)
2136 }
2137 GaitPhaseOutcome::Measured(phase) => {
2138 (Some(phase), MeasurementAvailability::Measured)
2139 }
2140 GaitPhaseOutcome::Unavailable => {
2141 (None, MeasurementAvailability::Unavailable)
2142 }
2143 };
2144 (
2145 Some(GaitMeasurement {
2146 phase,
2147 phase_availability,
2148 lr_amplitude_m: metrics.lr_amplitude_m,
2149 }),
2150 MeasurementAvailability::Measured,
2151 )
2152 }
2153 None if !gait_roles_applicable => (None, MeasurementAvailability::NotApplicable),
2154 None => (None, MeasurementAvailability::Unavailable),
2155 };
2156 let root_selection = roles
2157 .get_with_name(Role::Root)
2158 .map(|(bone, name)| (bone, name, RootTrajectorySourceRole::Root))
2159 .or_else(|| {
2160 roles
2161 .get_with_name(Role::Hips)
2162 .map(|(bone, name)| (bone, name, RootTrajectorySourceRole::HipsFallback))
2163 });
2164 let root_roles_applicable = root_selection.is_some();
2165 let (root_trajectory, root_trajectory_availability) = match root_selection {
2166 None => (None, MeasurementAvailability::NotApplicable),
2167 Some((bone, resolved_name, source_role)) => match doc.skeleton.bones.get(bone) {
2168 Some(selected_bone) if selected_bone.name == resolved_name => {
2169 let trajectory = grid
2170 .as_ref()
2171 .and_then(|grid| root_trajectory_metrics(grid, bone));
2172 let (translation, translation_availability) = match trajectory
2173 .as_ref()
2174 .and_then(|trajectory| trajectory.translation)
2175 {
2176 Some(translation) => (
2177 Some(RootTranslationMeasurement {
2178 horizontal_displacement_x_m: translation
2179 .horizontal_displacement_x_m,
2180 horizontal_displacement_z_m: translation
2181 .horizontal_displacement_z_m,
2182 horizontal_travel_m: translation.horizontal_travel_m,
2183 vertical_displacement_m: translation.vertical_displacement_m,
2184 vertical_min_displacement_m: translation
2185 .vertical_min_displacement_m,
2186 vertical_max_displacement_m: translation
2187 .vertical_max_displacement_m,
2188 }),
2189 MeasurementAvailability::Measured,
2190 ),
2191 None => (None, MeasurementAvailability::Unavailable),
2192 };
2193 let (yaw, yaw_availability) =
2194 match trajectory.and_then(|trajectory| trajectory.yaw) {
2195 Some(yaw) => (
2196 Some(RootYawMeasurement {
2197 heading_axis: yaw.heading_axis,
2198 net_yaw_deg: yaw.net_yaw_deg,
2199 unwrapped_yaw_deg: yaw.unwrapped_yaw_deg,
2200 yaw_travel_deg: yaw.yaw_travel_deg,
2201 }),
2202 MeasurementAvailability::Measured,
2203 ),
2204 None => (None, MeasurementAvailability::Unavailable),
2205 };
2206 (
2207 Some(RootTrajectoryMeasurement {
2208 bone_index: bone as u32,
2209 bone_name: selected_bone.name.clone(),
2210 source_role,
2211 translation,
2212 translation_availability,
2213 yaw,
2214 yaw_availability,
2215 }),
2216 MeasurementAvailability::Measured,
2217 )
2218 }
2219 _ => (None, MeasurementAvailability::Unavailable),
2220 },
2221 };
2222 let (speed_mps, speed_mps_availability) = if !root_roles_applicable {
2223 (None, MeasurementAvailability::NotApplicable)
2224 } else if root_trajectory.is_none() {
2225 (None, MeasurementAvailability::Unavailable)
2226 } else {
2227 match grid.as_ref().and_then(|g| root_motion_speed_mps(g, roles)) {
2228 Some(speed) => (Some(speed), MeasurementAvailability::Measured),
2229 None => (None, MeasurementAvailability::Unavailable),
2230 }
2231 };
2232 let duration_s = if clip.duration_s.is_finite() {
2233 clip.duration_s
2234 } else {
2235 clip.tracks
2236 .iter()
2237 .flat_map(|track| track.times.iter().copied())
2238 .filter(|time| time.is_finite())
2239 .map(f64::from)
2240 .fold(0.0, f64::max)
2241 };
2242 let bone_channels = bone_channels
2243 .into_iter()
2244 .map(|(bone_index, properties)| BoneChannelCoverage {
2245 bone_index: bone_index as u32,
2246 bone_name: doc.skeleton.bones[bone_index].name.clone(),
2247 properties: properties.into_iter().collect(),
2248 })
2249 .collect();
2250
2251 ClipMeasurements {
2252 duration_s,
2253 frame_count: frame_count as u32,
2254 animated_bones: animated.into_iter().collect(),
2255 bone_channels,
2256 bone_rotation_range_deg: rotation_range,
2257 loop_continuity,
2258 loop_continuity_availability,
2259 loop_endpoint_mode,
2260 loop_endpoint_mode_availability,
2261 frame_grid,
2262 frame_grid_availability,
2263 loop_seam_ratio,
2264 loop_seam_ratio_availability,
2265 gait,
2266 gait_availability,
2267 root_trajectory,
2268 root_trajectory_availability,
2269 speed_mps,
2270 speed_mps_availability,
2271 }
2272 })
2273 .collect()
2274}
2275
2276pub(crate) fn measure_loop_endpoint_mode(
2279 clip: &crate::model::Clip,
2280 grid: Option<&PoseGrid>,
2281 max_position_delta_m: f64,
2282 max_rotation_delta_deg: f64,
2283) -> Option<LoopEndpointMode> {
2284 match analyze_duplicate_loop_endpoint(clip) {
2285 Ok(Some(_)) => return Some(LoopEndpointMode::DuplicateEndpoint),
2286 Ok(None) => {}
2287 Err(_) => return None,
2288 }
2289 let continuity = loop_continuity_metrics(grid?)?;
2290 let closes = continuity.iter().all(|bone| {
2291 !exceeds_f32_cap(bone.position_delta_m, max_position_delta_m)
2292 && !exceeds_f32_cap(bone.rotation_delta_deg, max_rotation_delta_deg)
2293 });
2294 Some(if closes {
2295 LoopEndpointMode::UniqueCycle
2296 } else {
2297 LoopEndpointMode::NonClosing
2298 })
2299}
2300
2301pub(crate) fn measure_frame_grid(
2303 clip: &crate::model::Clip,
2304 declared_fps: Option<f64>,
2305) -> Option<FrameGridMeasurement> {
2306 let fps = declared_fps?;
2307 if !fps.is_finite() || fps <= 0.0 || !clip.duration_s.is_finite() || clip.duration_s <= 0.0 {
2308 return None;
2309 }
2310 let intervals = clip.duration_s * fps;
2311 if !intervals.is_finite() || (intervals - intervals.round()).abs() > GRID_TOLERANCE_FRAMES {
2312 return None;
2313 }
2314 let rounded = intervals.round();
2315 if !(0.0..=f64::from(u32::MAX)).contains(&rounded) {
2316 return None;
2317 }
2318 if clip
2319 .tracks
2320 .iter()
2321 .flat_map(|track| &track.times)
2322 .any(|&time| {
2323 let frames = f64::from(time) * fps;
2324 !frames.is_finite() || (frames - frames.round()).abs() > GRID_TOLERANCE_FRAMES
2325 })
2326 {
2327 return None;
2328 }
2329 Some(FrameGridMeasurement {
2330 fps,
2331 frame_intervals: rounded as u32,
2332 })
2333}
2334
2335#[cfg(test)]
2336mod tests {
2337 use super::*;
2338 use crate::config::CheckSettings;
2339 use crate::model::{
2340 AdditionalInfluenceSet, AffineDomainViolation, Bone, Clip, Document, Interpolation,
2341 MeshAsset, PositiveUniformAffineTolerance, Primitive, Property, SceneAsset, SceneAssets,
2342 Skeleton, SourceInverseBindAccessor, SourceInverseBindAccessorStatus, SourceNodeAsset,
2343 SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage, SourceSkinAsset,
2344 SourceSkinAttachment, Track, TrackValues, Transform, classify_positive_uniform_affine,
2345 };
2346 use crate::profile::Role;
2347 use glam::{Mat4, Quat, Vec3};
2348
2349 fn mesh(name: &str, primitives: Vec<Primitive>) -> MeshDefinitionMeasurements {
2350 let doc = Document {
2351 assets: SceneAssets {
2352 meshes: vec![MeshAsset {
2353 name: name.into(),
2354 source_mesh_index: 0,
2355 primitives,
2356 }],
2357 ..SceneAssets::default()
2358 },
2359 ..Document::default()
2360 };
2361 measure_assets(&doc).mesh_definitions.remove(0)
2362 }
2363
2364 fn channel_track(bone: usize, property: Property) -> Track {
2365 let values = match property {
2366 Property::Rotation => TrackValues::Quats(vec![Quat::IDENTITY]),
2367 Property::Translation | Property::Scale => TrackValues::Vec3s(vec![Vec3::ZERO]),
2368 };
2369 Track {
2370 bone,
2371 property,
2372 interpolation: Interpolation::Linear,
2373 times: vec![0.0],
2374 values,
2375 }
2376 }
2377
2378 #[test]
2379 fn bone_channel_coverage_is_a_canonical_artifact_set() {
2380 let document = Document {
2381 skeleton: Skeleton {
2382 bones: vec![
2383 Bone {
2384 name: "duplicate".into(),
2385 parent: None,
2386 rest: Transform::IDENTITY,
2387 inverse_bind: None,
2388 },
2389 Bone {
2390 name: "duplicate".into(),
2391 parent: Some(0),
2392 rest: Transform::IDENTITY,
2393 inverse_bind: None,
2394 },
2395 Bone {
2396 name: "empty".into(),
2397 parent: Some(1),
2398 rest: Transform::IDENTITY,
2399 inverse_bind: None,
2400 },
2401 ],
2402 },
2403 clips: vec![Clip {
2404 name: "coverage".into(),
2405 duration_s: 0.0,
2406 tracks: vec![
2407 channel_track(1, Property::Scale),
2408 channel_track(0, Property::Rotation),
2409 channel_track(99, Property::Translation),
2410 channel_track(0, Property::Translation),
2411 channel_track(0, Property::Translation),
2412 channel_track(1, Property::Rotation),
2413 Track {
2414 bone: 2,
2415 property: Property::Translation,
2416 interpolation: Interpolation::Linear,
2417 times: Vec::new(),
2418 values: TrackValues::Vec3s(Vec::new()),
2419 },
2420 Track {
2421 bone: 2,
2422 property: Property::Translation,
2423 interpolation: Interpolation::Linear,
2424 times: vec![0.0, 1.0],
2425 values: TrackValues::Vec3s(vec![Vec3::ZERO]),
2426 },
2427 Track {
2428 bone: 2,
2429 property: Property::Rotation,
2430 interpolation: Interpolation::Linear,
2431 times: vec![0.0],
2432 values: TrackValues::Vec3s(vec![Vec3::ZERO]),
2433 },
2434 Track {
2435 bone: 2,
2436 property: Property::Rotation,
2437 interpolation: Interpolation::Linear,
2438 times: vec![0.0, 0.0],
2439 values: TrackValues::Quats(vec![
2440 Quat::IDENTITY,
2441 Quat::from_rotation_y(std::f32::consts::FRAC_PI_2),
2442 ]),
2443 },
2444 Track {
2445 bone: 2,
2446 property: Property::Scale,
2447 interpolation: Interpolation::Linear,
2448 times: vec![f32::NAN],
2449 values: TrackValues::Vec3s(vec![Vec3::ONE]),
2450 },
2451 Track {
2452 bone: 2,
2453 property: Property::Scale,
2454 interpolation: Interpolation::Linear,
2455 times: vec![0.0],
2456 values: TrackValues::Vec3s(vec![Vec3::splat(f32::INFINITY)]),
2457 },
2458 ],
2459 }],
2460 ..Document::default()
2461 };
2462 let grids = MetricGrids::new(&document);
2463
2464 let measured =
2465 &measure_document(&grids, &ResolvedRoles::default(), &Config::default())["coverage"];
2466
2467 assert_eq!(measured.animated_bones, ["duplicate"]);
2468 assert_eq!(
2469 measured.bone_channels,
2470 [
2471 BoneChannelCoverage {
2472 bone_index: 0,
2473 bone_name: "duplicate".into(),
2474 properties: vec![Property::Translation, Property::Rotation],
2475 },
2476 BoneChannelCoverage {
2477 bone_index: 1,
2478 bone_name: "duplicate".into(),
2479 properties: vec![Property::Rotation, Property::Scale],
2480 },
2481 ]
2482 );
2483 assert!(
2484 measured.bone_rotation_range_deg.is_empty(),
2485 "a malformed rotation track cannot contribute a range fact"
2486 );
2487 }
2488
2489 #[test]
2490 fn root_trajectory_selection_is_root_first_with_typed_hips_fallback() {
2491 let skeleton = Skeleton {
2492 bones: vec![
2493 Bone {
2494 name: "root".into(),
2495 parent: None,
2496 rest: Transform::IDENTITY,
2497 inverse_bind: None,
2498 },
2499 Bone {
2500 name: "hips".into(),
2501 parent: Some(0),
2502 rest: Transform::IDENTITY,
2503 inverse_bind: None,
2504 },
2505 ],
2506 };
2507 let document = Document {
2508 skeleton: skeleton.clone(),
2509 clips: vec![Clip {
2510 name: "travel".into(),
2511 duration_s: 1.0,
2512 tracks: vec![
2513 Track {
2514 bone: 0,
2515 property: Property::Translation,
2516 interpolation: Interpolation::Linear,
2517 times: vec![0.0, 0.5, 1.0],
2518 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X * 0.5, Vec3::X]),
2519 },
2520 Track {
2521 bone: 1,
2522 property: Property::Translation,
2523 interpolation: Interpolation::Linear,
2524 times: vec![0.0, 0.5, 1.0],
2525 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::Z * 0.5, Vec3::Z]),
2526 },
2527 ],
2528 }],
2529 ..Document::default()
2530 };
2531 let both_roles = ResolvedRoles::from_names(
2532 &skeleton,
2533 [(Role::Root, "root".into()), (Role::Hips, "hips".into())],
2534 );
2535 let grids = MetricGrids::new(&document);
2536 let measured = &measure_document(&grids, &both_roles, &Config::default())["travel"];
2537 let trajectory = measured.root_trajectory.as_ref().expect("selected Root");
2538 assert_eq!(trajectory.bone_index, 0);
2539 assert_eq!(trajectory.source_role, RootTrajectorySourceRole::Root);
2540 assert_eq!(
2541 trajectory
2542 .translation
2543 .as_ref()
2544 .unwrap()
2545 .horizontal_displacement_x_m,
2546 1.0
2547 );
2548
2549 let hips_only = ResolvedRoles::from_names(&skeleton, [(Role::Hips, "hips".into())]);
2550 let measured = &measure_document(&grids, &hips_only, &Config::default())["travel"];
2551 let trajectory = measured.root_trajectory.as_ref().expect("Hips fallback");
2552 assert_eq!(trajectory.bone_index, 1);
2553 assert_eq!(
2554 trajectory.source_role,
2555 RootTrajectorySourceRole::HipsFallback
2556 );
2557 let translation = trajectory.translation.as_ref().unwrap();
2558 assert_eq!(translation.horizontal_displacement_x_m, 1.0);
2559 assert_eq!(translation.horizontal_displacement_z_m, 1.0);
2560
2561 let measured =
2562 &measure_document(&grids, &ResolvedRoles::default(), &Config::default())["travel"];
2563 assert!(measured.root_trajectory.is_none());
2564 assert_eq!(
2565 measured.root_trajectory_availability,
2566 MeasurementAvailability::NotApplicable
2567 );
2568
2569 let mut too_short = document.clone();
2570 for track in &mut too_short.clips[0].tracks {
2571 track.times.truncate(2);
2572 match &mut track.values {
2573 TrackValues::Vec3s(values) => values.truncate(2),
2574 TrackValues::Quats(values) => values.truncate(2),
2575 }
2576 }
2577 let too_short_grids = MetricGrids::new(&too_short);
2578 let measured =
2579 &measure_document(&too_short_grids, &both_roles, &Config::default())["travel"];
2580 let trajectory = measured
2581 .root_trajectory
2582 .as_ref()
2583 .expect("selection remains observable without a metric grid");
2584 assert_eq!(
2585 trajectory.translation_availability,
2586 MeasurementAvailability::Unavailable
2587 );
2588 assert_eq!(
2589 trajectory.yaw_availability,
2590 MeasurementAvailability::Unavailable
2591 );
2592
2593 let roles_from_larger_skeleton = ResolvedRoles::from_names(
2594 &Skeleton {
2595 bones: vec![
2596 Bone {
2597 name: "hips".into(),
2598 parent: None,
2599 rest: Transform::IDENTITY,
2600 inverse_bind: None,
2601 },
2602 Bone {
2603 name: "root".into(),
2604 parent: None,
2605 rest: Transform::IDENTITY,
2606 inverse_bind: None,
2607 },
2608 ],
2609 },
2610 [(Role::Root, "root".into()), (Role::Hips, "hips".into())],
2611 );
2612 let stale_role_document = Document {
2613 skeleton: Skeleton {
2614 bones: vec![Bone {
2615 name: "hips".into(),
2616 parent: None,
2617 rest: Transform::IDENTITY,
2618 inverse_bind: None,
2619 }],
2620 },
2621 clips: document.clips.clone(),
2622 ..Document::default()
2623 };
2624 let stale_role_grids = MetricGrids::new(&stale_role_document);
2625 let measured = &measure_document(
2626 &stale_role_grids,
2627 &roles_from_larger_skeleton,
2628 &Config::default(),
2629 )["travel"];
2630 assert!(
2631 measured.root_trajectory.is_none(),
2632 "invalid Root must not fall back to the valid Hips index"
2633 );
2634 assert_eq!(
2635 measured.root_trajectory_availability,
2636 MeasurementAvailability::Unavailable
2637 );
2638 assert!(measured.speed_mps.is_none());
2639 assert_eq!(
2640 measured.speed_mps_availability,
2641 MeasurementAvailability::Unavailable
2642 );
2643
2644 let mismatched_name_document = Document {
2645 skeleton: Skeleton {
2646 bones: vec![
2647 Bone {
2648 name: "hips".into(),
2649 parent: None,
2650 rest: Transform::IDENTITY,
2651 inverse_bind: None,
2652 },
2653 Bone {
2654 name: "other".into(),
2655 parent: None,
2656 rest: Transform::IDENTITY,
2657 inverse_bind: None,
2658 },
2659 ],
2660 },
2661 clips: document.clips.clone(),
2662 ..Document::default()
2663 };
2664 let mismatched_name_grids = MetricGrids::new(&mismatched_name_document);
2665 let measured = &measure_document(
2666 &mismatched_name_grids,
2667 &roles_from_larger_skeleton,
2668 &Config::default(),
2669 )["travel"];
2670 assert!(
2671 measured.root_trajectory.is_none(),
2672 "a stale Root name must not bind a different in-range bone or fall back"
2673 );
2674 assert_eq!(
2675 measured.root_trajectory_availability,
2676 MeasurementAvailability::Unavailable
2677 );
2678 assert!(measured.speed_mps.is_none());
2679 assert_eq!(
2680 measured.speed_mps_availability,
2681 MeasurementAvailability::Unavailable
2682 );
2683 }
2684
2685 #[test]
2686 fn resolved_root_derivation_failure_does_not_fall_back_to_measurable_hips() {
2687 let skeleton = Skeleton {
2688 bones: vec![
2689 Bone {
2690 name: "root".into(),
2691 parent: None,
2692 rest: Transform::IDENTITY,
2693 inverse_bind: None,
2694 },
2695 Bone {
2696 name: "hips".into(),
2697 parent: None,
2698 rest: Transform::IDENTITY,
2699 inverse_bind: None,
2700 },
2701 ],
2702 };
2703 let document = Document {
2704 skeleton: skeleton.clone(),
2705 clips: vec![Clip {
2706 name: "root_failure".into(),
2707 duration_s: 1.0,
2708 tracks: vec![
2709 Track {
2710 bone: 0,
2711 property: Property::Translation,
2712 interpolation: Interpolation::Linear,
2713 times: vec![0.0, 0.5, 1.0],
2714 values: TrackValues::Vec3s(vec![
2715 Vec3::ZERO,
2716 Vec3::new(f32::NAN, 0.0, 0.0),
2717 Vec3::ZERO,
2718 ]),
2719 },
2720 Track {
2721 bone: 0,
2722 property: Property::Rotation,
2723 interpolation: Interpolation::Linear,
2724 times: vec![0.0, 0.5, 1.0],
2725 values: TrackValues::Quats(vec![Quat::from_xyzw(0.0, 0.0, 0.0, 0.0); 3]),
2726 },
2727 Track {
2728 bone: 1,
2729 property: Property::Translation,
2730 interpolation: Interpolation::Linear,
2731 times: vec![0.0, 0.5, 1.0],
2732 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::Z, Vec3::Z * 2.0]),
2733 },
2734 Track {
2735 bone: 1,
2736 property: Property::Rotation,
2737 interpolation: Interpolation::Linear,
2738 times: vec![0.0, 0.5, 1.0],
2739 values: TrackValues::Quats(vec![Quat::IDENTITY; 3]),
2740 },
2741 ],
2742 }],
2743 ..Document::default()
2744 };
2745 let roles = ResolvedRoles::from_names(
2746 &skeleton,
2747 [(Role::Root, "root".into()), (Role::Hips, "hips".into())],
2748 );
2749 let grids = MetricGrids::new(&document);
2750 let grid = grids.grid(0).expect("shared metric grid");
2751 let hips = root_trajectory_metrics(&grid, 1).expect("separate Hips evidence");
2752 let hips_translation = hips.translation.expect("Hips translation is measurable");
2753 assert_eq!(hips_translation.horizontal_displacement_x_m, 0.0);
2754 assert_eq!(hips_translation.horizontal_displacement_z_m, 2.0);
2755 assert_eq!(hips_translation.horizontal_travel_m, 2.0);
2756 assert!(hips.yaw.is_some(), "Hips yaw is independently measurable");
2757
2758 let measured = &measure_document(&grids, &roles, &Config::default())["root_failure"];
2759 let trajectory = measured
2760 .root_trajectory
2761 .as_ref()
2762 .expect("valid resolved Root identity remains observable");
2763 assert_eq!(trajectory.bone_index, 0);
2764 assert_eq!(trajectory.bone_name, "root");
2765 assert_eq!(trajectory.source_role, RootTrajectorySourceRole::Root);
2766 assert!(trajectory.translation.is_none());
2767 assert_eq!(
2768 trajectory.translation_availability,
2769 MeasurementAvailability::Unavailable
2770 );
2771 assert!(trajectory.yaw.is_none());
2772 assert_eq!(
2773 trajectory.yaw_availability,
2774 MeasurementAvailability::Unavailable
2775 );
2776 assert_eq!(
2777 measured.root_trajectory_availability,
2778 MeasurementAvailability::Measured
2779 );
2780 }
2781
2782 #[test]
2783 fn only_globally_unavailable_inverse_bind_accessors_have_a_derived_reason() {
2784 assert_eq!(
2785 derived_accessor_global_unavailable_reason(SourceInverseBindAccessorStatus::Absent),
2786 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
2787 );
2788 assert_eq!(
2789 derived_accessor_global_unavailable_reason(
2790 SourceInverseBindAccessorStatus::EmptyAccessor
2791 ),
2792 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
2793 );
2794 assert_eq!(
2795 derived_accessor_global_unavailable_reason(SourceInverseBindAccessorStatus::Unreadable),
2796 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
2797 );
2798 assert_eq!(
2799 derived_accessor_global_unavailable_reason(SourceInverseBindAccessorStatus::Available),
2800 None
2801 );
2802 assert_eq!(
2803 derived_accessor_global_unavailable_reason(
2804 SourceInverseBindAccessorStatus::CountMismatch
2805 ),
2806 None,
2807 "a readable count-mismatched accessor can still supply earlier slots"
2808 );
2809 }
2810
2811 #[test]
2812 fn linear_transform_measurements_classify_affine_shape_and_orientation() {
2813 let cases = [
2814 (
2815 Mat4::IDENTITY,
2816 LinearTransformClassification::UnitOrthonormal,
2817 Some(LinearTransformOrientation::Positive),
2818 Some(1.0),
2819 ),
2820 (
2821 Mat4::from_scale(Vec3::splat(0.01)),
2822 LinearTransformClassification::UniformScaled,
2823 Some(LinearTransformOrientation::Positive),
2824 Some(f64::from(0.01f32)),
2825 ),
2826 (
2827 Mat4::from_scale(Vec3::new(2.0, 3.0, 4.0)),
2828 LinearTransformClassification::NonUniform,
2829 Some(LinearTransformOrientation::Positive),
2830 None,
2831 ),
2832 (
2833 Mat4::from_cols_array(&[
2834 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,
2835 ]),
2836 LinearTransformClassification::Sheared,
2837 Some(LinearTransformOrientation::Positive),
2838 None,
2839 ),
2840 (
2841 Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)),
2842 LinearTransformClassification::Reflected,
2843 Some(LinearTransformOrientation::Negative),
2844 Some(1.0),
2845 ),
2846 (
2847 Mat4::from_scale(Vec3::new(1.0, 0.0, 1.0)),
2848 LinearTransformClassification::Singular,
2849 Some(LinearTransformOrientation::Zero),
2850 None,
2851 ),
2852 ];
2853 for (matrix, classification, orientation, uniform_scale) in cases {
2854 let measured = measure_linear_transform(matrix);
2855 assert_eq!(measured.classification, classification);
2856 assert_eq!(measured.orientation, orientation);
2857 assert_eq!(measured.uniform_scale, uniform_scale);
2858 assert!(measured.axis_lengths.is_some());
2859 assert!(measured.determinant.is_some());
2860 }
2861
2862 let non_finite = measure_linear_transform(Mat4::from_cols_array(&[f32::NAN; 16]));
2863 assert_eq!(
2864 non_finite,
2865 LinearTransformMeasurements {
2866 classification: LinearTransformClassification::NonFinite,
2867 axis_lengths: None,
2868 determinant: None,
2869 orientation: None,
2870 uniform_scale: None,
2871 }
2872 );
2873
2874 for scale in [1.0e-30f32, 1.0e-16, 1.0e13, 1.0e30] {
2875 let measured = measure_linear_transform(Mat4::from_scale(Vec3::splat(scale)));
2876 assert_eq!(
2877 measured.classification,
2878 LinearTransformClassification::UniformScaled,
2879 "finite uniform scale {scale:e}"
2880 );
2881 assert_eq!(measured.uniform_scale, Some(f64::from(scale)));
2882 assert!(measured.determinant.is_some_and(f64::is_finite));
2883 assert_ne!(measured.determinant, Some(0.0));
2884 }
2885 }
2886
2887 #[test]
2888 fn linear_measurement_reconciles_equal_axis_fixtures_in_every_axis_order() {
2889 let permutations = |[x, y, z]: [f32; 3]| {
2890 [
2891 Vec3::new(x, y, z),
2892 Vec3::new(x, z, y),
2893 Vec3::new(y, x, z),
2894 Vec3::new(y, z, x),
2895 Vec3::new(z, x, y),
2896 Vec3::new(z, y, x),
2897 ]
2898 };
2899 let policy = PositiveUniformAffineTolerance {
2900 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2901 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2902 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
2903 };
2904
2905 for diagonal in permutations([1.0, 1.0, 1.000_012]) {
2906 let measured = measure_linear_transform(Mat4::from_scale(diagonal));
2907 assert_eq!(
2908 measured.classification,
2909 LinearTransformClassification::UnitOrthonormal,
2910 "issue fixture {diagonal:?}"
2911 );
2912 assert_eq!(
2913 classify_positive_uniform_affine(Mat3::from_diagonal(diagonal), policy),
2914 measured
2915 .uniform_scale
2916 .ok_or(AffineDomainViolation::NonFinite),
2917 "measurement and Appendix D share the equal-axis decision"
2918 );
2919 }
2920
2921 let high = f32::from_bits(0x3f80_004b);
2925 let low = f32::from_bits(0x3f7f_ff69);
2926 for diagonal in permutations([1.0, high, low]) {
2927 assert_eq!(
2928 measure_linear_transform(Mat4::from_scale(diagonal)).classification,
2929 LinearTransformClassification::UnitOrthonormal,
2930 "axis-order counterexample {diagonal:?}"
2931 );
2932 }
2933 }
2934
2935 #[test]
2936 fn linear_measurement_uses_the_shared_canonical_mean_in_every_axis_order() {
2937 let columns = [
2943 Vec3::new(
2944 f32::from_bits(0x3f7f_fd59),
2945 f32::from_bits(0x3bd8_d637),
2946 0.0,
2947 ),
2948 Vec3::new(
2949 -f32::from_bits(0x3bd8_d69d),
2950 f32::from_bits(0x3f7f_fdd1),
2951 0.0,
2952 ),
2953 Vec3::Z,
2954 ];
2955 let permutations = [
2956 Mat3::from_cols(columns[0], columns[1], columns[2]),
2957 Mat3::from_cols(-columns[0], columns[2], columns[1]),
2958 Mat3::from_cols(-columns[1], columns[0], columns[2]),
2959 Mat3::from_cols(columns[1], columns[2], columns[0]),
2960 Mat3::from_cols(columns[2], columns[0], columns[1]),
2961 Mat3::from_cols(-columns[2], columns[1], columns[0]),
2962 ];
2963 let policy = PositiveUniformAffineTolerance {
2964 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2965 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2966 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
2967 };
2968 let expected_mean = f64::from_bits(0x3fef_ffeb_074a_771d);
2969
2970 for (index, linear) in permutations.into_iter().enumerate() {
2971 let measured = measure_linear_transform(Mat4::from_mat3(linear));
2972 assert_eq!(
2973 measured.classification,
2974 LinearTransformClassification::UnitOrthonormal,
2975 "canonical mean must give proper permutation {index} one stable class"
2976 );
2977 assert_eq!(
2978 measured.uniform_scale,
2979 Some(expected_mean),
2980 "measurement must publish the canonical mean for permutation {index}"
2981 );
2982 assert_eq!(
2983 classify_positive_uniform_affine(linear, policy),
2984 Ok(expected_mean),
2985 "the shared classifier must consume the same mean for permutation {index}"
2986 );
2987 }
2988 }
2989
2990 #[test]
2991 fn linear_measurement_reports_axis_lengths_in_xyz_column_order() {
2992 let measured = measure_linear_transform(Mat4::from_scale(Vec3::new(2.0, 3.0, 5.0)));
2993
2994 assert_eq!(measured.axis_lengths, Some([2.0, 3.0, 5.0]));
2995 }
2996
2997 #[test]
2998 fn affine_consumers_widen_each_pair_dot_before_comparison() {
2999 let x = Vec3::new(
3004 f32::from_bits(0x3fd8_2778),
3005 f32::from_bits(0x3fd9_ea4a),
3006 0.0,
3007 );
3008 let y = Vec3::new(
3009 f32::from_bits(0xbfd9_e92c),
3010 f32::from_bits(0x3fd8_2778),
3011 0.0,
3012 );
3013 let z = Vec3::new(0.0, 0.0, f32::from_bits(0x4019_77cc));
3014 let widened_dot = x.as_dvec3().dot(y.as_dvec3()).abs();
3015 let f32_first_dot = f64::from(x.dot(y).abs());
3016 let x_length = x.as_dvec3().length();
3017 let y_length = y.as_dvec3().length();
3018 let z_length = f64::from(z.z);
3019 let pair_tolerance = LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE * x_length * y_length;
3020 let mean = (x_length + y_length + z_length) / 3.0;
3021 let common_tolerance = LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE * mean * mean;
3022 let policy = PositiveUniformAffineTolerance {
3023 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3024 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3025 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
3026 };
3027
3028 assert!(f32_first_dot <= pair_tolerance && widened_dot > pair_tolerance);
3029 assert!(f32_first_dot <= common_tolerance && widened_dot > common_tolerance);
3030
3031 for (pair, linear) in [
3032 ("positive XY", Mat3::from_cols(x, y, z)),
3033 ("negative XY", Mat3::from_cols(x, -y, -z)),
3034 ("positive XZ", Mat3::from_cols(x, -z, y)),
3035 ("negative XZ", Mat3::from_cols(x, z, -y)),
3036 ("positive YZ", Mat3::from_cols(z, x, y)),
3037 ("negative YZ", Mat3::from_cols(-z, x, -y)),
3038 ] {
3039 let measured = measure_linear_transform(Mat4::from_mat3(linear));
3040 assert_eq!(
3041 measured.classification,
3042 LinearTransformClassification::Sheared,
3043 "measurement must compare the widened {pair} dot"
3044 );
3045 assert_eq!(
3046 classify_positive_uniform_affine(linear, policy),
3047 Err(AffineDomainViolation::Sheared),
3048 "the positive-uniform classifier must compare the same widened {pair} dot"
3049 );
3050 }
3051 }
3052
3053 #[test]
3054 fn linear_measurement_pins_equal_axis_boundaries_and_extreme_finite_scales() {
3055 let on_long_edge = Vec3::new(99_998.5, 99_998.5, 100_000.0);
3056 let measured = measure_linear_transform(Mat4::from_scale(on_long_edge));
3057 assert_eq!(
3058 measured.classification,
3059 LinearTransformClassification::UniformScaled
3060 );
3061 assert_eq!(measured.uniform_scale, Some(99_999.0));
3062
3063 let short = 99_998.5;
3064 let outside = 100_000.0 + 0.007_812_5;
3065 for diagonal in [
3066 Vec3::new(outside, short, short),
3067 Vec3::new(short, outside, short),
3068 Vec3::new(short, short, outside),
3069 ] {
3070 assert_eq!(
3071 measure_linear_transform(Mat4::from_scale(diagonal)).classification,
3072 LinearTransformClassification::NonUniform
3073 );
3074 }
3075
3076 for scale in [f32::from_bits(1), f32::MIN_POSITIVE, f32::MAX] {
3077 let measured = measure_linear_transform(Mat4::from_scale(Vec3::splat(scale)));
3078 assert_eq!(
3079 measured.classification,
3080 LinearTransformClassification::UniformScaled,
3081 "complete finite f32 scale range at {scale:e}"
3082 );
3083 assert_eq!(measured.uniform_scale, Some(f64::from(scale)));
3084 assert!(measured.determinant.is_some_and(f64::is_finite));
3085 }
3086 }
3087
3088 #[test]
3089 fn linear_measurement_pins_pair_normalization_and_public_precedence() {
3090 let pair_normalized_shear = Mat3::from_cols(
3091 Vec3::X,
3092 Vec3::new(3.0e-5, 2.0, 0.0),
3093 Vec3::new(0.0, 0.0, 3.0),
3094 );
3095 let facts = AffineGeometryFacts::from_linear(pair_normalized_shear).unwrap();
3096 assert!(
3097 facts.cross_axis_dots[0].abs()
3098 > LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE
3099 * facts.axis_lengths[0]
3100 * facts.axis_lengths[1]
3101 );
3102 assert!(
3103 facts.cross_axis_dots[0].abs()
3104 <= LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE
3105 * facts.mean_axis_length
3106 * facts.mean_axis_length,
3107 "measurement intentionally does not use the operation classifier's common-factor band"
3108 );
3109 let measured = measure_linear_transform(Mat4::from_mat3(pair_normalized_shear));
3110 assert_eq!(
3111 measured.classification,
3112 LinearTransformClassification::Sheared,
3113 "public measurement must use the XY pair product, not mean squared"
3114 );
3115 for shear in [3.0e-5, -3.0e-5] {
3116 let signed_shear = Mat3::from_cols(Vec3::X, Vec3::new(shear, 2.0, 0.0), Vec3::Z);
3117 assert_eq!(
3118 measure_linear_transform(Mat4::from_mat3(signed_shear)).classification,
3119 LinearTransformClassification::Sheared,
3120 "orthogonality is independent of the dot-product sign"
3121 );
3122 }
3123 for (pair, linear) in [
3124 (
3125 "XZ",
3126 Mat3::from_cols(
3127 Vec3::X,
3128 Vec3::new(0.0, 100.0, 0.0),
3129 Vec3::new(1.5e-5, 0.0, 1.0),
3130 ),
3131 ),
3132 (
3133 "negative XZ",
3134 Mat3::from_cols(
3135 Vec3::X,
3136 Vec3::new(0.0, 100.0, 0.0),
3137 Vec3::new(-1.5e-5, 0.0, 1.0),
3138 ),
3139 ),
3140 (
3141 "YZ",
3142 Mat3::from_cols(
3143 Vec3::new(100.0, 0.0, 0.0),
3144 Vec3::Y,
3145 Vec3::new(0.0, 1.5e-5, 1.0),
3146 ),
3147 ),
3148 (
3149 "negative YZ",
3150 Mat3::from_cols(
3151 Vec3::new(100.0, 0.0, 0.0),
3152 Vec3::Y,
3153 Vec3::new(0.0, -1.5e-5, 1.0),
3154 ),
3155 ),
3156 ] {
3157 assert_eq!(
3158 measure_linear_transform(Mat4::from_mat3(linear)).classification,
3159 LinearTransformClassification::Sheared,
3160 "{pair} dot must use that pair's own length product"
3161 );
3162 }
3163 assert_eq!(
3164 classify_positive_uniform_affine(
3165 pair_normalized_shear,
3166 PositiveUniformAffineTolerance {
3167 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3168 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
3169 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
3170 },
3171 ),
3172 Err(AffineDomainViolation::NonUniformScale),
3173 "the positive-uniform operation classifier intentionally rejects shape before shear"
3174 );
3175
3176 let singular_reflected_shear = Mat4::from_cols(
3177 (-Vec3::X).extend(0.0),
3178 Vec3::new(0.5, 1.0e-8, 0.0).extend(0.0),
3179 Vec3::Z.extend(0.0),
3180 glam::Vec4::W,
3181 );
3182 let singular = measure_linear_transform(singular_reflected_shear);
3183 assert_eq!(
3184 singular.classification,
3185 LinearTransformClassification::Singular
3186 );
3187 assert_eq!(
3188 singular.orientation,
3189 Some(LinearTransformOrientation::Zero),
3190 "singularity owns the public orientation before determinant sign"
3191 );
3192 assert!(singular.determinant.is_some_and(|value| value < 0.0));
3193
3194 let reflected_shear = Mat4::from_cols(
3195 (-Vec3::X).extend(0.0),
3196 Vec3::new(0.5, 1.0, 0.0).extend(0.0),
3197 Vec3::Z.extend(0.0),
3198 glam::Vec4::W,
3199 );
3200 assert_eq!(
3201 measure_linear_transform(reflected_shear).classification,
3202 LinearTransformClassification::Reflected
3203 );
3204 }
3205
3206 #[test]
3207 fn linear_measurement_uses_axis_length_product_for_singularity() {
3208 let linear = Mat3::from_cols(
3209 Vec3::new(1.0, 0.0, 0.0),
3210 Vec3::new(0.0, 100.0, 0.0),
3211 Vec3::new(100.0, 0.0, 0.001),
3212 );
3213 let facts = AffineGeometryFacts::from_linear(linear).unwrap();
3214 let determinant = facts.determinant.abs();
3215 let product_threshold =
3216 LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE * facts.axis_length_product;
3217 let mean_cubed_threshold =
3218 LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE * facts.mean_axis_length.powi(3);
3219
3220 assert!(
3221 determinant > product_threshold,
3222 "the true axis-length-product threshold must not classify this matrix as singular"
3223 );
3224 assert!(
3225 determinant <= mean_cubed_threshold,
3226 "a mean-cubed threshold must disagree on this singularity boundary fixture"
3227 );
3228
3229 let measured = measure_linear_transform(Mat4::from_mat3(linear));
3230 assert_eq!(
3231 measured.classification,
3232 LinearTransformClassification::Sheared
3233 );
3234 assert_eq!(
3235 measured.orientation,
3236 Some(LinearTransformOrientation::Positive)
3237 );
3238 }
3239
3240 #[test]
3241 fn linear_measurement_is_atomic_for_non_finite_mat4_components() {
3242 for index in 0..16 {
3243 let mut columns = Mat4::IDENTITY.to_cols_array();
3244 columns[index] = f32::NAN;
3245 assert_eq!(
3246 measure_linear_transform(Mat4::from_cols_array(&columns)),
3247 unavailable_linear_transform(),
3248 "component {index} must make every numeric fact unavailable"
3249 );
3250 }
3251 }
3252
3253 #[test]
3254 fn linear_measurement_reports_the_canonical_widened_determinant() {
3255 let linear = Mat3::from_cols(
3256 Vec3::new(
3257 f32::from_bits(0x3ff3_5574),
3258 f32::from_bits(0x3f0e_fa3c),
3259 0.0,
3260 ),
3261 Vec3::new(
3262 f32::from_bits(0x3ff5_5e17),
3263 f32::from_bits(0x3f10_2c31),
3264 0.0,
3265 ),
3266 Vec3::Z,
3267 );
3268 let measured = measure_linear_transform(Mat4::from_mat3(linear));
3269 assert_eq!(
3270 measured.determinant.map(f64::to_bits),
3271 Some(0x3eb4_b98f_a000_0000)
3272 );
3273 assert_ne!(measured.determinant, Some(f64::from(linear.determinant())));
3274 }
3275
3276 #[test]
3277 fn skin_bind_summary_covers_every_stable_aggregate_class() {
3278 let available_joint = |joint_index, matrix| SkinJointMeasurements {
3279 joint_index,
3280 node_index: joint_index,
3281 joint_bind_to_mesh: available_derived_matrix(matrix),
3282 mesh_bind_world: available_derived_matrix(Mat4::IDENTITY),
3283 };
3284 let unavailable_joint = |joint_index| SkinJointMeasurements {
3285 joint_index,
3286 node_index: joint_index,
3287 joint_bind_to_mesh: unavailable_derived_matrix(
3288 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent,
3289 ),
3290 mesh_bind_world: unavailable_derived_matrix(
3291 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent,
3292 ),
3293 };
3294 let assert_summary = |joints: &[SkinJointMeasurements],
3295 classification,
3296 available_joint_count,
3297 unavailable_joint_count,
3298 consistent_uniform_scale| {
3299 assert_eq!(
3300 summarize_skin_bind_linear(joints),
3301 SkinBindLinearSummaryMeasurements {
3302 classification,
3303 joint_count: joints.len(),
3304 available_joint_count,
3305 unavailable_joint_count,
3306 consistent_uniform_scale,
3307 }
3308 );
3309 };
3310
3311 assert_summary(
3312 &[],
3313 SkinBindLinearSummaryClassification::NoJoints,
3314 0,
3315 0,
3316 None,
3317 );
3318 assert_summary(
3319 &[unavailable_joint(0)],
3320 SkinBindLinearSummaryClassification::Unavailable,
3321 0,
3322 1,
3323 None,
3324 );
3325 assert_summary(
3326 &[available_joint(0, Mat4::IDENTITY), unavailable_joint(1)],
3327 SkinBindLinearSummaryClassification::PartiallyUnavailable,
3328 1,
3329 1,
3330 None,
3331 );
3332 assert_summary(
3333 &[
3334 available_joint(0, Mat4::IDENTITY),
3335 available_joint(1, Mat4::IDENTITY),
3336 ],
3337 SkinBindLinearSummaryClassification::ConsistentUniform,
3338 2,
3339 0,
3340 Some(1.0),
3341 );
3342 assert_summary(
3343 &[
3344 available_joint(0, Mat4::IDENTITY),
3345 available_joint(1, Mat4::from_scale(Vec3::splat(2.0))),
3346 ],
3347 SkinBindLinearSummaryClassification::MixedUniform,
3348 2,
3349 0,
3350 None,
3351 );
3352 assert_summary(
3353 &[
3354 available_joint(0, Mat4::from_scale(Vec3::new(1.0, 2.0, 3.0))),
3355 available_joint(
3356 1,
3357 Mat4::from_cols_array(&[
3358 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,
3359 1.0,
3360 ]),
3361 ),
3362 ],
3363 SkinBindLinearSummaryClassification::NonUniformOrSheared,
3364 2,
3365 0,
3366 None,
3367 );
3368 assert_summary(
3369 &[
3370 available_joint(0, Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0))),
3371 available_joint(
3372 1,
3373 Mat4::from_cols_array(&[
3374 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,
3375 0.0, 1.0,
3376 ]),
3377 ),
3378 ],
3379 SkinBindLinearSummaryClassification::ReflectedOrSingular,
3380 2,
3381 0,
3382 None,
3383 );
3384 assert_summary(
3385 &[
3386 available_joint(0, Mat4::IDENTITY),
3387 available_joint(1, Mat4::from_scale(Vec3::new(1.0, 2.0, 3.0))),
3388 ],
3389 SkinBindLinearSummaryClassification::Mixed,
3390 2,
3391 0,
3392 None,
3393 );
3394 }
3395
3396 #[test]
3397 fn skin_bind_summary_is_joint_order_invariant_and_reports_the_mean() {
3398 let matrix_from_bits = |columns: [[u32; 4]; 4]| {
3399 Mat4::from_cols(
3400 glam::Vec4::from_array(columns[0].map(f32::from_bits)),
3401 glam::Vec4::from_array(columns[1].map(f32::from_bits)),
3402 glam::Vec4::from_array(columns[2].map(f32::from_bits)),
3403 glam::Vec4::from_array(columns[3].map(f32::from_bits)),
3404 )
3405 };
3406 let raw_inverse_binds = [
3407 matrix_from_bits([
3408 [0xbcde_4500, 0xbd7b_2918, 0x3f7f_6c80, 0],
3409 [0x3f40_907c, 0xbf28_9ba8, 0xbca4_0480, 0],
3410 [0x3f28_8afa, 0x3f3f_fdef, 0x3d83_0f78, 0],
3411 [0, 0, 0, 0x3f80_0000],
3412 ]),
3413 matrix_from_bits([
3414 [0x3da5_7c20, 0xbf7e_c9a2, 0xbd5d_55e0, 0],
3415 [0x3e48_71f6, 0xbd18_d560, 0x3f7a_dda0, 0],
3416 [0xbf7a_31a0, 0xbdb7_d42c, 0x3e44_6898, 0],
3417 [0, 0, 0, 0x3f80_0000],
3418 ]),
3419 matrix_from_bits([
3420 [0xbee1_b0e8, 0xbd50_c238, 0xbf65_6a79, 0],
3421 [0xbf62_2552, 0xbe1c_0be8, 0x3ee2_e94f, 0],
3422 [0xbe22_f8bc, 0x3f7c_ac66, 0x3cb5_7540, 0],
3423 [0, 0, 0, 0x3f80_0000],
3424 ]),
3425 ];
3426 let expected_factor_bits = [
3427 0x3ff0_0000_110e_4203,
3428 0x3ff0_0000_2d55_0083,
3429 0x3fef_ffff_b3bb_b2b8,
3430 ];
3431 let expected_mean = f64::from_bits(0x3ff0_0000_0815_b3f6);
3432 let permutations = [
3433 [0usize, 1usize, 2usize],
3434 [0, 2, 1],
3435 [1, 0, 2],
3436 [1, 2, 0],
3437 [2, 0, 1],
3438 [2, 1, 0],
3439 ];
3440
3441 for order in permutations {
3442 let doc = Document {
3443 assets: SceneAssets {
3444 source_skeleton: SourceSkeletonAssets {
3445 coverage: SourceSkeletonCoverage::Complete,
3446 nodes: (0..3)
3447 .map(|source_node_index| SourceNodeAsset {
3448 source_node_index,
3449 name: Some(format!("joint_{source_node_index}")),
3450 parent_source_node_index: None,
3451 scene_root_indices: vec![0],
3452 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
3453 bone: None,
3454 })
3455 .collect(),
3456 skins: vec![SourceSkinAsset {
3457 source_skin_index: 0,
3458 name: Some("order_invariant_uniform_bind_scale".into()),
3459 skeleton_root_source_node_index: Some(0),
3460 joint_source_node_indices: order.to_vec(),
3461 inverse_bind_accessor: SourceInverseBindAccessor {
3462 status: SourceInverseBindAccessorStatus::Available,
3463 declared_count: Some(3),
3464 matrices: order.map(|index| raw_inverse_binds[index]).to_vec(),
3465 },
3466 attachments: Vec::new(),
3467 }],
3468 },
3469 ..SceneAssets::default()
3470 },
3471 ..Document::default()
3472 };
3473
3474 let measured = measure_assets(&doc);
3475 let skin = &measured.skins[0];
3476 assert_eq!(
3477 skin.joints
3478 .iter()
3479 .map(|joint| {
3480 let linear = joint
3481 .joint_bind_to_mesh
3482 .linear
3483 .expect("finite invertible raw inverse binds are measurable");
3484 assert_eq!(
3485 linear.classification,
3486 LinearTransformClassification::UnitOrthonormal
3487 );
3488 linear
3489 .uniform_scale
3490 .expect("uniform joint binds carry their factor")
3491 .to_bits()
3492 })
3493 .collect::<Vec<_>>(),
3494 order.map(|index| expected_factor_bits[index]).to_vec(),
3495 "source joint order {order:?}"
3496 );
3497 assert_eq!(
3498 skin.joint_bind_linear_summary,
3499 SkinBindLinearSummaryMeasurements {
3500 classification: SkinBindLinearSummaryClassification::ConsistentUniform,
3501 joint_count: 3,
3502 available_joint_count: 3,
3503 unavailable_joint_count: 0,
3504 consistent_uniform_scale: Some(expected_mean),
3505 },
3506 "source joint order {order:?}"
3507 );
3508 }
3509 assert_ne!(
3510 expected_mean, 1.0,
3511 "the summary reports its mean, not joint 0"
3512 );
3513 }
3514
3515 #[test]
3516 fn skin_bind_summary_classification_is_mean_relative_in_every_joint_order() {
3517 let factors = [
3518 1.0_f32,
3519 f32::from_bits(0x3f80_004b),
3520 f32::from_bits(0x3f7f_ff69),
3521 ];
3522 let mut sorted_factors = factors.map(f64::from);
3523 sorted_factors.sort_by(f64::total_cmp);
3524 let expected_mean = sorted_factors.into_iter().sum::<f64>() / factors.len() as f64;
3525 let permutations = [
3526 [0usize, 1usize, 2usize],
3527 [0, 2, 1],
3528 [1, 0, 2],
3529 [1, 2, 0],
3530 [2, 0, 1],
3531 [2, 1, 0],
3532 ];
3533
3534 for order in permutations {
3535 let joints = order.map(|index| SkinJointMeasurements {
3536 joint_index: index,
3537 node_index: index,
3538 joint_bind_to_mesh: available_derived_matrix(Mat4::from_scale(Vec3::splat(
3539 factors[index],
3540 ))),
3541 mesh_bind_world: available_derived_matrix(Mat4::IDENTITY),
3542 });
3543 assert_eq!(
3544 summarize_skin_bind_linear(&joints),
3545 SkinBindLinearSummaryMeasurements {
3546 classification: SkinBindLinearSummaryClassification::ConsistentUniform,
3547 joint_count: 3,
3548 available_joint_count: 3,
3549 unavailable_joint_count: 0,
3550 consistent_uniform_scale: Some(expected_mean),
3551 },
3552 "high/low factors straddle the first-joint band in order {order:?}"
3553 );
3554 }
3555 }
3556
3557 #[test]
3558 fn source_measurement_reports_disagreeing_uniform_joint_bind_scales() {
3559 let doc = Document {
3560 assets: SceneAssets {
3561 source_skeleton: SourceSkeletonAssets {
3562 coverage: SourceSkeletonCoverage::Complete,
3563 nodes: (0..2)
3564 .map(|source_node_index| SourceNodeAsset {
3565 source_node_index,
3566 name: Some(format!("joint_{source_node_index}")),
3567 parent_source_node_index: None,
3568 scene_root_indices: vec![0],
3569 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
3570 bone: None,
3571 })
3572 .collect(),
3573 skins: vec![SourceSkinAsset {
3574 source_skin_index: 0,
3575 name: Some("mixed_uniform_bind_scale".into()),
3576 skeleton_root_source_node_index: Some(0),
3577 joint_source_node_indices: vec![0, 1],
3578 inverse_bind_accessor: SourceInverseBindAccessor {
3579 status: SourceInverseBindAccessorStatus::Available,
3580 declared_count: Some(2),
3581 matrices: vec![Mat4::IDENTITY, Mat4::from_scale(Vec3::splat(0.5))],
3582 },
3583 attachments: Vec::new(),
3584 }],
3585 },
3586 ..SceneAssets::default()
3587 },
3588 ..Document::default()
3589 };
3590
3591 let measured = measure_assets(&doc);
3592 let skin = &measured.skins[0];
3593 assert_eq!(
3594 skin.joints
3595 .iter()
3596 .map(|joint| {
3597 let linear = joint
3598 .joint_bind_to_mesh
3599 .linear
3600 .expect("finite invertible raw inverse binds are measurable");
3601 (linear.classification, linear.uniform_scale)
3602 })
3603 .collect::<Vec<_>>(),
3604 vec![
3605 (LinearTransformClassification::UnitOrthonormal, Some(1.0)),
3606 (LinearTransformClassification::UniformScaled, Some(2.0)),
3607 ]
3608 );
3609 assert_eq!(
3610 skin.joint_bind_linear_summary,
3611 SkinBindLinearSummaryMeasurements {
3612 classification: SkinBindLinearSummaryClassification::MixedUniform,
3613 joint_count: 2,
3614 available_joint_count: 2,
3615 unavailable_joint_count: 0,
3616 consistent_uniform_scale: None,
3617 }
3618 );
3619 }
3620
3621 #[test]
3622 fn non_finite_source_rest_is_explicit_in_matrix_and_linear_domains() {
3623 let doc = Document {
3624 assets: SceneAssets {
3625 source_skeleton: SourceSkeletonAssets {
3626 coverage: SourceSkeletonCoverage::Complete,
3627 nodes: vec![SourceNodeAsset {
3628 source_node_index: 0,
3629 name: None,
3630 parent_source_node_index: None,
3631 scene_root_indices: Vec::new(),
3632 local_rest: SourceNodeLocalRest::Matrix(Mat4::from_cols_array(
3633 &[f32::NAN; 16],
3634 )),
3635 bone: None,
3636 }],
3637 skins: Vec::new(),
3638 },
3639 ..SceneAssets::default()
3640 },
3641 ..Document::default()
3642 };
3643 let node = &measure_assets(&doc).skeleton_nodes[0];
3644 assert!(node.rest_world_matrix.is_none());
3645 assert!(node.rest_world_translation_m.is_none());
3646 assert_eq!(
3647 node.rest_world_matrix_unavailable_reason,
3648 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
3649 );
3650 assert_eq!(
3651 node.rest_world_linear.classification,
3652 LinearTransformClassification::NonFinite
3653 );
3654 assert!(node.rest_world_linear.axis_lengths.is_none());
3655 }
3656
3657 #[test]
3658 fn source_skeleton_measurement_preserves_source_order_and_bind_domains() {
3659 let skeleton = Skeleton {
3663 bones: vec![
3664 Bone {
3665 name: "root".into(),
3666 parent: None,
3667 rest: Transform {
3668 translation: Vec3::new(10.0, 0.0, 0.0),
3669 ..Transform::IDENTITY
3670 },
3671 inverse_bind: None,
3672 },
3673 Bone {
3674 name: "joint".into(),
3675 parent: Some(0),
3676 rest: Transform {
3677 translation: Vec3::new(2.0, 0.0, 0.0),
3678 ..Transform::IDENTITY
3679 },
3680 inverse_bind: None,
3681 },
3682 Bone {
3683 name: "mesh".into(),
3684 parent: Some(0),
3685 rest: Transform::IDENTITY,
3686 inverse_bind: None,
3687 },
3688 ],
3689 };
3690 let doc = Document {
3691 skeleton,
3692 assets: SceneAssets {
3693 scenes: vec![SceneAsset {
3694 source_scene_index: 4,
3695 name: None,
3696 roots: vec![0],
3697 }],
3698 source_skeleton: SourceSkeletonAssets {
3699 coverage: SourceSkeletonCoverage::Complete,
3700 nodes: vec![
3701 SourceNodeAsset {
3702 source_node_index: 0,
3703 name: Some("joint".into()),
3704 parent_source_node_index: Some(1),
3705 scene_root_indices: vec![],
3706 local_rest: SourceNodeLocalRest::Trs {
3707 translation: Vec3::new(2.0, 0.0, 0.0),
3708 rotation: Quat::IDENTITY,
3709 scale: Vec3::ONE,
3710 },
3711 bone: None,
3712 },
3713 SourceNodeAsset {
3714 source_node_index: 1,
3715 name: Some("root".into()),
3716 parent_source_node_index: None,
3717 scene_root_indices: vec![4],
3718 local_rest: SourceNodeLocalRest::Trs {
3719 translation: Vec3::new(10.0, 0.0, 0.0),
3720 rotation: Quat::IDENTITY,
3721 scale: Vec3::ONE,
3722 },
3723 bone: None,
3724 },
3725 SourceNodeAsset {
3726 source_node_index: 2,
3727 name: Some("mesh".into()),
3728 parent_source_node_index: Some(1),
3729 scene_root_indices: vec![],
3730 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
3731 bone: None,
3732 },
3733 ],
3734 skins: vec![SourceSkinAsset {
3735 source_skin_index: 0,
3736 name: Some("skin".into()),
3737 skeleton_root_source_node_index: Some(1),
3738 joint_source_node_indices: vec![0],
3739 inverse_bind_accessor: SourceInverseBindAccessor {
3740 status: SourceInverseBindAccessorStatus::Available,
3741 declared_count: Some(2),
3742 matrices: vec![
3743 Mat4::from_translation(Vec3::new(-12.0, 0.0, 0.0)),
3744 Mat4::IDENTITY,
3745 ],
3746 },
3747 attachments: vec![SourceSkinAttachment {
3748 source_node_index: 2,
3749 source_mesh_index: Some(7),
3750 }],
3751 }],
3752 },
3753 ..SceneAssets::default()
3754 },
3755 ..Document::default()
3756 };
3757
3758 let measured = measure_assets(&doc);
3759 assert_eq!(
3760 measured.skeleton_source_coverage,
3761 SourceSkeletonCoverage::Complete
3762 );
3763 assert_eq!(
3764 measured
3765 .skeleton_nodes
3766 .iter()
3767 .map(|node| node.node_index)
3768 .collect::<Vec<_>>(),
3769 vec![0, 1, 2]
3770 );
3771 assert_eq!(measured.skeleton_nodes[0].parent_node_index, Some(1));
3772 assert_eq!(measured.skeleton_nodes[1].scene_root_indices, vec![4]);
3773 assert_eq!(
3774 measured.skeleton_nodes[0]
3775 .rest_world_matrix
3776 .expect("finite child rest world")[12],
3777 12.0
3778 );
3779 let skin = &measured.skins[0];
3780 assert_eq!(skin.skeleton_root_node_index, Some(1));
3781 assert_eq!(
3782 skin.inverse_bind_accessor.matrices.len(),
3783 2,
3784 "extra raw IBM survives"
3785 );
3786 assert_eq!(skin.attachments[0].node_index, 2);
3787 assert_eq!(skin.attachments[0].mesh_index, Some(7));
3788 assert_eq!(skin.joints[0].joint_bind_to_mesh.matrix.unwrap()[12], 12.0);
3789 assert_eq!(
3790 skin.joints[0].mesh_bind_world.matrix.unwrap(),
3791 Mat4::IDENTITY.to_cols_array()
3792 );
3793 }
3794
3795 #[test]
3796 fn count_mismatched_inverse_bind_accessor_keeps_present_slots_and_marks_missing_ones() {
3797 let doc = Document {
3798 assets: SceneAssets {
3799 source_skeleton: SourceSkeletonAssets {
3800 coverage: SourceSkeletonCoverage::Complete,
3801 nodes: vec![SourceNodeAsset {
3802 source_node_index: 0,
3803 name: None,
3804 parent_source_node_index: None,
3805 scene_root_indices: vec![],
3806 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
3807 bone: None,
3808 }],
3809 skins: vec![SourceSkinAsset {
3810 source_skin_index: 0,
3811 name: None,
3812 skeleton_root_source_node_index: None,
3813 joint_source_node_indices: vec![0, 0],
3814 inverse_bind_accessor: SourceInverseBindAccessor {
3815 status: SourceInverseBindAccessorStatus::CountMismatch,
3816 declared_count: Some(1),
3817 matrices: vec![Mat4::IDENTITY],
3818 },
3819 attachments: vec![],
3820 }],
3821 },
3822 ..SceneAssets::default()
3823 },
3824 ..Document::default()
3825 };
3826
3827 let skin = &measure_assets(&doc).skins[0];
3828 assert_eq!(
3829 skin.joints[0].joint_bind_to_mesh.matrix,
3830 Some(Mat4::IDENTITY.to_cols_array())
3831 );
3832 assert_eq!(
3833 skin.joints[1].joint_bind_to_mesh.unavailable_reason,
3834 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
3835 );
3836 assert_eq!(
3837 skin.joints[1].mesh_bind_world.unavailable_reason,
3838 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
3839 );
3840 }
3841
3842 #[test]
3843 fn source_skeleton_measurement_preserves_full_matrix_domains() {
3844 let doc = Document {
3847 assets: SceneAssets {
3848 source_skeleton: SourceSkeletonAssets {
3849 coverage: SourceSkeletonCoverage::Complete,
3850 nodes: vec![SourceNodeAsset {
3851 source_node_index: 0,
3852 name: None,
3853 parent_source_node_index: None,
3854 scene_root_indices: vec![],
3855 local_rest: SourceNodeLocalRest::Matrix(Mat4::from_cols_array(&[
3856 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,
3857 30.0, 1.0,
3858 ])),
3859 bone: None,
3860 }],
3861 skins: vec![SourceSkinAsset {
3862 source_skin_index: 0,
3863 name: None,
3864 skeleton_root_source_node_index: Some(0),
3865 joint_source_node_indices: vec![0],
3866 inverse_bind_accessor: SourceInverseBindAccessor {
3867 status: SourceInverseBindAccessorStatus::Available,
3868 declared_count: Some(1),
3869 matrices: vec![Mat4::from_cols_array(&[
3870 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,
3871 2.0, 3.0, 1.0,
3872 ])],
3873 },
3874 attachments: vec![],
3875 }],
3876 },
3877 ..SceneAssets::default()
3878 },
3879 ..Document::default()
3880 };
3881
3882 let joint = &measure_assets(&doc).skins[0].joints[0];
3883 assert_eq!(
3884 joint.joint_bind_to_mesh.matrix,
3885 Some([
3886 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,
3887 ])
3888 );
3889 assert_eq!(
3890 joint.mesh_bind_world.matrix,
3891 Some([
3892 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,
3893 ])
3894 );
3895 }
3896
3897 #[test]
3898 fn source_skeleton_measurement_handles_a_deep_leaf_first_hierarchy() {
3899 const NODE_COUNT: usize = 16_384;
3900 let nodes = (0..NODE_COUNT)
3901 .map(|node_index| SourceNodeAsset {
3902 source_node_index: node_index,
3903 name: None,
3904 parent_source_node_index: (node_index + 1 < NODE_COUNT).then_some(node_index + 1),
3905 scene_root_indices: Vec::new(),
3906 local_rest: SourceNodeLocalRest::Matrix(if node_index + 1 == NODE_COUNT {
3907 Mat4::from_translation(Vec3::X)
3908 } else {
3909 Mat4::IDENTITY
3910 }),
3911 bone: None,
3912 })
3913 .collect();
3914 let doc = Document {
3915 assets: SceneAssets {
3916 source_skeleton: SourceSkeletonAssets {
3917 coverage: SourceSkeletonCoverage::Complete,
3918 nodes,
3919 skins: Vec::new(),
3920 },
3921 ..SceneAssets::default()
3922 },
3923 ..Document::default()
3924 };
3925
3926 let measured = measure_assets(&doc);
3927 assert_eq!(measured.skeleton_nodes.len(), NODE_COUNT);
3928 assert_eq!(
3929 measured.skeleton_nodes[0]
3930 .rest_world_matrix
3931 .expect("deep leaf rest world")[12],
3932 1.0
3933 );
3934 }
3935
3936 #[test]
3937 fn malformed_source_parent_graph_downgrades_source_coverage() {
3938 for parent_source_node_index in [Some(7), Some(0)] {
3939 let doc = Document {
3940 assets: SceneAssets {
3941 source_skeleton: SourceSkeletonAssets {
3942 coverage: SourceSkeletonCoverage::Complete,
3943 nodes: vec![SourceNodeAsset {
3944 source_node_index: 0,
3945 name: None,
3946 parent_source_node_index,
3947 scene_root_indices: Vec::new(),
3948 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
3949 bone: None,
3950 }],
3951 skins: Vec::new(),
3952 },
3953 ..SceneAssets::default()
3954 },
3955 ..Document::default()
3956 };
3957
3958 let measured = measure_assets(&doc);
3959 assert_eq!(
3960 measured.skeleton_source_coverage,
3961 SourceSkeletonCoverage::Unavailable
3962 );
3963 assert!(measured.skeleton_nodes.is_empty());
3964 assert!(measured.skins.is_empty());
3965 }
3966 }
3967
3968 #[test]
3969 fn skinned_mesh_measures_bbox_joints_and_weight_sums() {
3970 let prim = Primitive {
3972 positions: vec![
3973 Vec3::new(0.0, 0.0, 0.0),
3974 Vec3::new(2.0, 0.0, 0.0),
3975 Vec3::new(0.0, 3.0, 0.0),
3976 Vec3::new(0.0, 0.0, 4.0),
3977 ],
3978 weights: vec![
3981 [1.0, 0.0, 0.0, 0.0],
3982 [0.5, 0.5, 0.0, 0.0],
3983 [0.4, 0.3, 0.3, 0.0],
3984 [0.3, 0.3, 0.3, 0.0],
3985 ],
3986 joints: vec![[0, 0, 0, 0]; 4],
3987 ..Primitive::default()
3988 };
3989 let m = mesh("body", vec![prim]);
3990
3991 assert_eq!(m.name, "body");
3992 assert_eq!(m.vertex_count, 4);
3993 let aabb = m.geometry_aabb.as_ref().expect("positions present");
3994 assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
3995 assert_eq!(aabb.max, [2.0, 3.0, 4.0]);
3996 assert_eq!(m.geometry_centroid, Some([0.5, 0.75, 1.0]));
3997 assert_eq!(m.max_joints_per_vertex, 3);
3998 assert!((m.weight_sum_min.unwrap() - 0.9).abs() < 1e-6);
4000 assert!((m.weight_sum_max.unwrap() - 1.0).abs() < 1e-6);
4001 }
4002
4003 #[test]
4004 fn mesh_measurements_preserve_secondary_influence_set_mismatches_without_affecting_primary_stats()
4005 {
4006 let primary = Primitive {
4007 positions: vec![Vec3::ZERO],
4008 joints: vec![[0, 1, 0, 0]],
4009 weights: vec![[0.75, 0.25, 0.0, 0.0]],
4010 additional_influence_sets: vec![AdditionalInfluenceSet {
4011 set_index: 2,
4012 joints_present: true,
4013 weights_present: false,
4014 }],
4015 ..Primitive::default()
4016 };
4017 let secondary = Primitive {
4018 positions: vec![Vec3::ONE],
4019 additional_influence_sets: vec![
4020 AdditionalInfluenceSet {
4021 set_index: 1,
4022 joints_present: false,
4023 weights_present: true,
4024 },
4025 AdditionalInfluenceSet {
4026 set_index: 2,
4027 joints_present: false,
4028 weights_present: true,
4029 },
4030 ],
4031 ..Primitive::default()
4032 };
4033
4034 let measured = mesh("body", vec![primary, secondary]);
4035
4036 assert_eq!(measured.max_joints_per_vertex, 2);
4037 assert_eq!(measured.weight_sum_min, Some(1.0));
4038 assert_eq!(measured.weight_sum_max, Some(1.0));
4039 assert_eq!(
4040 measured.additional_influence_sets,
4041 vec![
4042 AdditionalInfluenceSetMeasurements {
4043 set_index: 1,
4044 joints_present: false,
4045 weights_present: true,
4046 joints_without_weights_present: false,
4047 weights_without_joints_present: true,
4048 },
4049 AdditionalInfluenceSetMeasurements {
4050 set_index: 2,
4051 joints_present: true,
4052 weights_present: true,
4053 joints_without_weights_present: true,
4054 weights_without_joints_present: true,
4055 },
4056 ]
4057 );
4058 }
4059
4060 #[test]
4061 fn unskinned_mesh_has_bbox_but_no_weight_stats() {
4062 let prim = Primitive {
4063 positions: vec![Vec3::new(-1.0, -2.0, -3.0), Vec3::new(1.0, 2.0, 3.0)],
4064 ..Primitive::default()
4065 };
4066 let m = mesh("prop", vec![prim]);
4067
4068 assert_eq!(m.vertex_count, 2);
4069 assert_eq!(m.geometry_aabb.as_ref().unwrap().min, [-1.0, -2.0, -3.0]);
4070 assert_eq!(m.geometry_centroid, Some([0.0, 0.0, 0.0]));
4071 assert_eq!(m.max_joints_per_vertex, 0);
4072 assert_eq!(m.weight_sum_min, None, "no skin ⇒ no weight-sum");
4073 assert_eq!(m.weight_sum_max, None);
4074 }
4075
4076 #[test]
4077 fn empty_mesh_reports_no_bbox() {
4078 let m = mesh("hollow", vec![Primitive::default()]);
4079 assert_eq!(m.vertex_count, 0);
4080 assert!(m.geometry_aabb.is_none(), "no positions ⇒ no bounding box");
4081 assert!(m.geometry_centroid.is_none(), "no positions ⇒ no centroid");
4082 }
4083
4084 #[test]
4085 fn non_finite_position_is_dropped_from_the_bbox() {
4086 let prim = Primitive {
4090 positions: vec![
4091 Vec3::new(0.0, 0.0, 0.0),
4092 Vec3::new(f32::NAN, 5.0, 0.0),
4093 Vec3::new(f32::INFINITY, 9.0, 0.0),
4094 Vec3::new(2.0, 3.0, 0.0),
4095 ],
4096 ..Primitive::default()
4097 };
4098 let m = mesh("nan", vec![prim]);
4099 let aabb = m.geometry_aabb.as_ref().unwrap();
4100 assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
4103 assert_eq!(aabb.max, [2.0, 3.0, 0.0]);
4104 assert_eq!(m.geometry_centroid, Some([1.0, 1.5, 0.0]));
4105 assert!(
4106 aabb.min.iter().chain(&aabb.max).all(|c| c.is_finite()),
4107 "no non-finite bound is ever emitted"
4108 );
4109 }
4110
4111 #[test]
4112 fn all_non_finite_positions_yield_no_bbox() {
4113 let prim = Primitive {
4116 positions: vec![Vec3::splat(f32::NAN), Vec3::splat(f32::INFINITY)],
4117 ..Primitive::default()
4118 };
4119 let m = mesh("allnan", vec![prim]);
4120 assert_eq!(m.vertex_count, 2, "count still reflects the vertices");
4121 assert!(
4122 m.geometry_aabb.is_none(),
4123 "no finite vertex ⇒ no box (never null bounds)"
4124 );
4125 assert!(
4126 m.geometry_centroid.is_none(),
4127 "no finite vertex ⇒ no centroid"
4128 );
4129 }
4130
4131 #[test]
4132 fn non_finite_weight_sum_is_omitted() {
4133 let prim = Primitive {
4136 positions: vec![Vec3::ZERO, Vec3::ONE],
4137 weights: vec![[0.5, 0.5, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 0.0]],
4138 ..Primitive::default()
4139 };
4140 let m = mesh("nanw", vec![prim]);
4141 assert_eq!(m.weight_sum_min, Some(1.0));
4143 assert_eq!(m.weight_sum_max, Some(1.0));
4144 }
4145
4146 #[test]
4147 fn all_non_finite_weight_sums_yield_no_weight_stats() {
4148 let prim = Primitive {
4151 positions: vec![Vec3::ZERO, Vec3::ONE],
4152 weights: vec![[f32::NAN, 0.0, 0.0, 0.0], [f32::INFINITY, 0.0, 0.0, 0.0]],
4153 ..Primitive::default()
4154 };
4155 let m = mesh("allnanw", vec![prim]);
4156 assert_eq!(m.weight_sum_min, None, "no finite weight sum ⇒ omitted");
4157 assert_eq!(m.weight_sum_max, None);
4158 assert_eq!(m.max_joints_per_vertex, 1);
4160 }
4161
4162 #[test]
4163 fn vertex_count_sums_across_primitives() {
4164 let a = Primitive {
4165 positions: vec![Vec3::ZERO; 3],
4166 ..Primitive::default()
4167 };
4168 let b = Primitive {
4169 positions: vec![Vec3::ONE; 5],
4170 ..Primitive::default()
4171 };
4172 let m = mesh("multi", vec![a, b]);
4173 assert_eq!(m.vertex_count, 8, "3 + 5 corners across two primitives");
4174 }
4175
4176 #[test]
4177 fn geometry_centroid_is_the_finite_position_mean_across_primitives() {
4178 let indexed = Primitive {
4182 positions: vec![
4183 Vec3::new(0.0, 0.0, 0.0),
4184 Vec3::new(6.0, 0.0, 0.0),
4185 Vec3::new(0.0, 3.0, 0.0),
4186 ],
4187 indices: vec![0, 1, 2, 0, 1, 2],
4188 ..Primitive::default()
4189 };
4190 let unindexed = Primitive {
4191 positions: vec![Vec3::new(0.0, 3.0, 0.0), Vec3::splat(f32::NAN)],
4192 ..Primitive::default()
4193 };
4194 let m = mesh("asymmetric", vec![indexed, unindexed]);
4195
4196 assert_eq!(m.vertex_count, 5, "all authored position rows count");
4197 assert_eq!(m.geometry_aabb.unwrap().max, [6.0, 3.0, 0.0]);
4198 assert_eq!(
4199 m.geometry_centroid,
4200 Some([1.5, 1.5, 0.0]),
4201 "four finite position rows, independent of six index references"
4202 );
4203 }
4204
4205 #[test]
4206 fn non_finite_instance_transform_makes_scene_coverage_partial() {
4207 let doc = Document {
4208 skeleton: Skeleton {
4209 bones: vec![
4210 Bone {
4211 name: "finite".into(),
4212 parent: None,
4213 rest: Transform::IDENTITY,
4214 inverse_bind: None,
4215 },
4216 Bone {
4217 name: "overflow".into(),
4218 parent: Some(0),
4219 rest: Transform {
4220 scale: Vec3::splat(f32::MAX),
4221 ..Transform::IDENTITY
4222 },
4223 inverse_bind: None,
4224 },
4225 ],
4226 },
4227 assets: SceneAssets {
4228 meshes: vec![MeshAsset {
4229 name: "point".into(),
4230 source_mesh_index: 4,
4231 primitives: vec![Primitive {
4232 positions: vec![Vec3::new(2.0, 0.0, 0.0)],
4233 ..Primitive::default()
4234 }],
4235 }],
4236 instances: vec![
4237 crate::model::MeshInstance {
4238 source_node_index: 10,
4239 node: 0,
4240 mesh: 0,
4241 ..crate::model::MeshInstance::default()
4242 },
4243 crate::model::MeshInstance {
4244 source_node_index: 11,
4245 node: 1,
4246 mesh: 0,
4247 ..crate::model::MeshInstance::default()
4248 },
4249 ],
4250 scenes: vec![crate::model::SceneAsset {
4251 source_scene_index: 3,
4252 name: Some("partial".into()),
4253 roots: vec![0],
4254 }],
4255 default_scene: None,
4256 ..SceneAssets::default()
4257 },
4258 ..Document::default()
4259 };
4260
4261 let measured = measure_assets(&doc);
4262 assert_eq!(measured.default_scene_index, None, "no implicit scene zero");
4263 assert_eq!(measured.node_instances.len(), 2);
4264 assert_eq!(
4265 measured.node_instances[0].static_node_world_aabb,
4266 Some(Aabb {
4267 min: [2.0, 0.0, 0.0],
4268 max: [2.0, 0.0, 0.0],
4269 })
4270 );
4271 assert_eq!(
4272 measured.node_instances[1].static_node_world_aabb_unavailable_reason,
4273 Some(StaticNodeAabbUnavailableReason::NonFiniteTransform)
4274 );
4275 assert_eq!(measured.scenes[0].instance_count, 2);
4276 assert_eq!(measured.scenes[0].excluded_instance_count, 1);
4277 assert_eq!(
4278 measured.scenes[0].static_scene_world_aabb,
4279 measured.node_instances[0].static_node_world_aabb,
4280 "partial aggregate retains the finite instance"
4281 );
4282 }
4283
4284 #[test]
4285 fn malformed_skeleton_chain_does_not_hide_an_unrelated_instance() {
4286 let doc = Document {
4287 skeleton: Skeleton {
4288 bones: vec![
4289 Bone {
4290 name: "malformed".into(),
4291 parent: Some(1),
4292 rest: Transform::IDENTITY,
4293 inverse_bind: None,
4294 },
4295 Bone {
4296 name: "malformed_child".into(),
4297 parent: Some(0),
4298 rest: Transform::IDENTITY,
4299 inverse_bind: None,
4300 },
4301 Bone {
4302 name: "valid_root".into(),
4303 parent: None,
4304 rest: Transform {
4305 translation: Vec3::X,
4306 ..Transform::IDENTITY
4307 },
4308 inverse_bind: None,
4309 },
4310 Bone {
4311 name: "valid_instance".into(),
4312 parent: Some(2),
4313 rest: Transform {
4314 translation: Vec3::Y,
4315 ..Transform::IDENTITY
4316 },
4317 inverse_bind: None,
4318 },
4319 ],
4320 },
4321 assets: SceneAssets {
4322 meshes: vec![MeshAsset {
4323 name: "point".into(),
4324 source_mesh_index: 0,
4325 primitives: vec![Primitive {
4326 positions: vec![Vec3::X],
4327 ..Primitive::default()
4328 }],
4329 }],
4330 instances: vec![
4331 crate::model::MeshInstance {
4332 source_node_index: 10,
4333 node: 0,
4334 mesh: 0,
4335 ..crate::model::MeshInstance::default()
4336 },
4337 crate::model::MeshInstance {
4338 source_node_index: 11,
4339 node: 3,
4340 mesh: 0,
4341 ..crate::model::MeshInstance::default()
4342 },
4343 ],
4344 scenes: vec![SceneAsset {
4345 source_scene_index: 0,
4346 name: None,
4347 roots: vec![0, 2],
4348 }],
4349 ..SceneAssets::default()
4350 },
4351 ..Document::default()
4352 };
4353
4354 let measured = measure_assets(&doc);
4355 assert_eq!(
4356 measured.node_instances[0].static_node_world_aabb_unavailable_reason,
4357 Some(StaticNodeAabbUnavailableReason::NonFiniteTransform)
4358 );
4359 assert_eq!(
4360 measured.node_instances[1].static_node_world_aabb,
4361 Some(Aabb {
4362 min: [2.0, 1.0, 0.0],
4363 max: [2.0, 1.0, 0.0],
4364 })
4365 );
4366 assert_eq!(measured.scenes[0].excluded_instance_count, 1);
4367 assert_eq!(
4368 measured.scenes[0].static_scene_world_aabb,
4369 measured.node_instances[1].static_node_world_aabb
4370 );
4371 }
4372
4373 #[test]
4374 fn later_duplicate_clip_name_replaces_earlier_measurement() {
4375 let earlier = Clip {
4376 name: "duplicate".into(),
4377 duration_s: 1.0,
4378 tracks: vec![
4379 Track {
4380 bone: 0,
4381 property: Property::Rotation,
4382 interpolation: Interpolation::Linear,
4383 times: vec![0.0, 0.5, 1.0],
4384 values: TrackValues::Quats(vec![
4385 Quat::IDENTITY,
4386 Quat::from_rotation_x(0.25),
4387 Quat::from_rotation_x(0.5),
4388 ]),
4389 },
4390 Track {
4391 bone: 0,
4392 property: Property::Translation,
4393 interpolation: Interpolation::Linear,
4394 times: vec![0.0, 0.5, 1.0],
4395 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::Z * 0.5, Vec3::Z]),
4396 },
4397 Track {
4398 bone: 1,
4399 property: Property::Translation,
4400 interpolation: Interpolation::Linear,
4401 times: vec![0.0, 0.5, 1.0],
4402 values: TrackValues::Vec3s(vec![
4403 Vec3::new(-0.1, -1.0, 0.0),
4404 Vec3::new(-0.1, -0.9, 0.15),
4405 Vec3::new(-0.1, -1.0, 0.0),
4406 ]),
4407 },
4408 Track {
4409 bone: 2,
4410 property: Property::Translation,
4411 interpolation: Interpolation::Linear,
4412 times: vec![0.0, 0.5, 1.0],
4413 values: TrackValues::Vec3s(vec![
4414 Vec3::new(0.1, -1.0, 0.0),
4415 Vec3::new(0.1, -1.1, -0.15),
4416 Vec3::new(0.1, -1.0, 0.0),
4417 ]),
4418 },
4419 ],
4420 };
4421 let later = Clip {
4422 name: "duplicate".into(),
4423 duration_s: 2.0,
4424 tracks: vec![Track {
4425 bone: 0,
4426 property: Property::Translation,
4427 interpolation: Interpolation::Linear,
4428 times: vec![0.0, 2.0],
4429 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X]),
4430 }],
4431 };
4432 let skeleton = Skeleton {
4433 bones: vec![
4434 Bone {
4435 name: "hips".into(),
4436 parent: None,
4437 rest: Transform::IDENTITY,
4438 inverse_bind: None,
4439 },
4440 Bone {
4441 name: "left_foot".into(),
4442 parent: Some(0),
4443 rest: Transform::IDENTITY,
4444 inverse_bind: None,
4445 },
4446 Bone {
4447 name: "right_foot".into(),
4448 parent: Some(0),
4449 rest: Transform::IDENTITY,
4450 inverse_bind: None,
4451 },
4452 ],
4453 };
4454 let roles = ResolvedRoles::from_names(
4455 &skeleton,
4456 [
4457 (Role::Hips, "hips".into()),
4458 (Role::LeftFoot, "left_foot".into()),
4459 (Role::RightFoot, "right_foot".into()),
4460 ],
4461 );
4462 let earlier_doc = Document {
4463 skeleton: skeleton.clone(),
4464 clips: vec![earlier.clone()],
4465 ..Document::default()
4466 };
4467 let earlier_grids = MetricGrids::new(&earlier_doc);
4468 let earlier_measurement =
4469 &measure_document(&earlier_grids, &roles, &Config::default())["duplicate"];
4470 assert!(earlier_measurement.loop_seam_ratio.is_some());
4471 assert!(earlier_measurement.gait.is_some());
4472 assert!(earlier_measurement.speed_mps.is_some());
4473
4474 let doc = Document {
4475 skeleton,
4476 clips: vec![earlier, later],
4477 ..Document::default()
4478 };
4479 let grids = MetricGrids::new(&doc);
4480 let indexed = measure_document_indexed(&grids, &roles, &Config::default());
4481 assert_eq!(indexed.len(), 2);
4482 assert_eq!(indexed[0].duration_s, 1.0);
4483 assert_eq!(indexed[1].duration_s, 2.0);
4484 assert!(indexed[0].gait.is_some());
4485 assert!(indexed[1].gait.is_none());
4486
4487 let measurements = measure_document(&grids, &roles, &Config::default());
4488
4489 assert_eq!(
4490 serde_json::to_value(measurements).expect("duplicate measurements serialize"),
4491 serde_json::json!({
4492 "duplicate": {
4493 "duration_s": 2.0,
4494 "frame_count": 2,
4495 "animated_bones": ["hips"],
4496 "bone_channels": [{
4497 "bone_index": 0,
4498 "bone_name": "hips",
4499 "properties": ["translation"]
4500 }],
4501 "bone_rotation_range_deg": {},
4502 "loop_continuity_availability": "unavailable",
4503 "loop_endpoint_mode_availability": "not_applicable",
4504 "frame_grid_availability": "not_applicable",
4505 "loop_seam_ratio_availability": "unavailable",
4506 "gait_availability": "unavailable",
4507 "root_trajectory": {
4508 "bone_index": 0,
4509 "bone_name": "hips",
4510 "source_role": "hips_fallback",
4511 "translation_availability": "unavailable",
4512 "yaw_availability": "unavailable"
4513 },
4514 "root_trajectory_availability": "measured",
4515 "speed_mps_availability": "unavailable",
4516 }
4517 })
4518 );
4519 }
4520
4521 #[test]
4528 fn resolved_gait_roles_with_no_real_stride_report_loop_seam_ratio_not_applicable() {
4529 let clip = Clip {
4530 name: "planted".into(),
4531 duration_s: 1.0,
4532 tracks: vec![Track {
4533 bone: 0,
4534 property: Property::Translation,
4535 interpolation: Interpolation::Linear,
4536 times: vec![0.0, 0.5, 1.0],
4537 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO, Vec3::ZERO]),
4538 }],
4539 };
4540 let skeleton = Skeleton {
4541 bones: vec![
4542 Bone {
4543 name: "hips".into(),
4544 parent: None,
4545 rest: Transform::IDENTITY,
4546 inverse_bind: None,
4547 },
4548 Bone {
4549 name: "left_foot".into(),
4550 parent: Some(0),
4551 rest: Transform::IDENTITY,
4552 inverse_bind: None,
4553 },
4554 Bone {
4555 name: "right_foot".into(),
4556 parent: Some(0),
4557 rest: Transform::IDENTITY,
4558 inverse_bind: None,
4559 },
4560 ],
4561 };
4562 let roles = ResolvedRoles::from_names(
4563 &skeleton,
4564 [
4565 (Role::Hips, "hips".into()),
4566 (Role::LeftFoot, "left_foot".into()),
4567 (Role::RightFoot, "right_foot".into()),
4568 ],
4569 );
4570 let doc = Document {
4571 skeleton,
4572 clips: vec![clip],
4573 ..Document::default()
4574 };
4575 let grids = MetricGrids::new(&doc);
4576 let measurements = measure_document(&grids, &roles, &Config::default());
4577 let measured = &measurements["planted"];
4578
4579 assert_eq!(measured.loop_seam_ratio, None);
4580 assert_eq!(
4581 measured.loop_seam_ratio_availability,
4582 MeasurementAvailability::NotApplicable,
4583 "the Hips + foot role domain resolved, but the clip has no real \
4584 stride to normalize the seam against, so the ratio is a \
4585 legitimately missing subject, not a derivation failure"
4586 );
4587 }
4588
4589 fn gait_skeleton_and_roles() -> (Skeleton, ResolvedRoles) {
4592 let skeleton = Skeleton {
4593 bones: vec![
4594 Bone {
4595 name: "hips".into(),
4596 parent: None,
4597 rest: Transform::IDENTITY,
4598 inverse_bind: None,
4599 },
4600 Bone {
4601 name: "left_foot".into(),
4602 parent: Some(0),
4603 rest: Transform::IDENTITY,
4604 inverse_bind: None,
4605 },
4606 Bone {
4607 name: "right_foot".into(),
4608 parent: Some(0),
4609 rest: Transform::IDENTITY,
4610 inverse_bind: None,
4611 },
4612 ],
4613 };
4614 let roles = ResolvedRoles::from_names(
4615 &skeleton,
4616 [
4617 (Role::Hips, "hips".into()),
4618 (Role::LeftFoot, "left_foot".into()),
4619 (Role::RightFoot, "right_foot".into()),
4620 ],
4621 );
4622 (skeleton, roles)
4623 }
4624
4625 fn foot_translation_clip(name: &str, values: [Vec3; 3]) -> Clip {
4626 Clip {
4627 name: name.into(),
4628 duration_s: 1.0,
4629 tracks: vec![Track {
4630 bone: 1,
4631 property: Property::Translation,
4632 interpolation: Interpolation::Linear,
4633 times: vec![0.0, 0.5, 1.0],
4634 values: TrackValues::Vec3s(values.to_vec()),
4635 }],
4636 }
4637 }
4638
4639 #[test]
4646 fn loop_seam_ratio_floor_boundary_partitions_not_applicable_from_measured() {
4647 let (skeleton, roles) = gait_skeleton_and_roles();
4648 let floor = 0.05;
4649 let below_floor = foot_translation_clip(
4650 "below_floor",
4651 [
4652 Vec3::ZERO,
4653 Vec3::new(floor as f32 - 0.01, 0.0, 0.0),
4654 Vec3::ZERO,
4655 ],
4656 );
4657 let at_floor = foot_translation_clip(
4658 "at_floor",
4659 [
4660 Vec3::ZERO,
4661 Vec3::new(floor as f32, 0.0, 0.0),
4662 Vec3::new(0.01, 0.0, 0.0),
4663 ],
4664 );
4665 let seam_pop = foot_translation_clip(
4666 "seam_pop",
4667 [
4668 Vec3::ZERO,
4669 Vec3::new(floor as f32, 0.0, 0.0),
4670 Vec3::new(2.0 * floor as f32, 0.0, 0.0),
4671 ],
4672 );
4673 let doc = Document {
4674 skeleton,
4675 clips: vec![below_floor, at_floor, seam_pop],
4676 ..Document::default()
4677 };
4678 let grids = MetricGrids::new(&doc);
4679 let mut config = Config::default();
4680 config.checks.insert(
4681 "loop-seam".into(),
4682 CheckSettings {
4683 min_stride_step_m: Some(floor),
4684 ..CheckSettings::default()
4685 },
4686 );
4687 let measurements = measure_document(&grids, &roles, &config);
4688
4689 let below = &measurements["below_floor"];
4690 assert_eq!(below.loop_seam_ratio, None);
4691 assert_eq!(
4692 below.loop_seam_ratio_availability,
4693 MeasurementAvailability::NotApplicable,
4694 "a neighbour step strictly under the configured floor is not a \
4695 real stride"
4696 );
4697
4698 let at = &measurements["at_floor"];
4699 assert_eq!(
4700 at.loop_seam_ratio_availability,
4701 MeasurementAvailability::Measured,
4702 "a neighbour step meeting the floor exactly (>=) is a real \
4703 stride with a derivable ratio"
4704 );
4705 let ratio = at.loop_seam_ratio.expect("real stride derives a ratio");
4706 assert!(
4707 (ratio - 0.01 / floor).abs() < 1e-6,
4708 "seam / neighbour_step for the constructed positions, got {ratio}"
4709 );
4710
4711 let pop = &measurements["seam_pop"];
4716 assert_eq!(
4717 pop.loop_seam_ratio_availability,
4718 MeasurementAvailability::Measured,
4719 "a real stride with a seam pop still derives a finite ratio"
4720 );
4721 let pop_ratio = pop.loop_seam_ratio.expect("real stride derives a ratio");
4722 assert!(
4723 (pop_ratio - 2.0).abs() < 1e-6,
4724 "seam / neighbour_step for the constructed positions, got {pop_ratio}"
4725 );
4726 }
4727
4728 #[test]
4736 fn real_stride_beyond_f32_squaring_range_reports_loop_seam_ratio_unavailable() {
4737 let (mut skeleton, _) = gait_skeleton_and_roles();
4738 skeleton.bones.truncate(2); let clip = Clip {
4740 name: "extreme".into(),
4741 duration_s: 1.0,
4742 tracks: vec![Track {
4743 bone: 1,
4744 property: Property::Translation,
4745 interpolation: Interpolation::Linear,
4746 times: vec![0.0, 0.25, 0.5, 1.0],
4747 values: TrackValues::Vec3s(vec![
4748 Vec3::ZERO,
4749 Vec3::new(f32::MIN_POSITIVE, 0.0, 0.0),
4750 Vec3::new(f32::MAX - f32::MIN_POSITIVE, 0.0, 0.0),
4751 Vec3::new(f32::MAX, 0.0, 0.0),
4752 ]),
4753 }],
4754 };
4755 let roles = ResolvedRoles::from_names(
4756 &skeleton,
4757 [
4758 (Role::Hips, "hips".to_string()),
4759 (Role::LeftFoot, "left_foot".to_string()),
4760 ],
4761 );
4762 let doc = Document {
4763 skeleton,
4764 clips: vec![clip],
4765 ..Document::default()
4766 };
4767 let grids = MetricGrids::new(&doc);
4768 let mut config = Config::default();
4769 config.checks.insert(
4770 "loop-seam".into(),
4771 CheckSettings {
4772 min_stride_step_m: Some(f64::from(f32::MIN_POSITIVE)),
4773 ..CheckSettings::default()
4774 },
4775 );
4776 let measurements = measure_document(&grids, &roles, &config);
4777 let measured = &measurements["extreme"];
4778
4779 assert_eq!(measured.loop_seam_ratio, None);
4780 assert_eq!(
4781 measured.loop_seam_ratio_availability,
4782 MeasurementAvailability::Unavailable,
4783 "the role domain resolved and the neighbour step met the (tiny) \
4784 configured floor, so this is a derivation failure, not a \
4785 missing subject"
4786 );
4787 }
4788
4789 #[test]
4790 fn inverse_bind_conditioning_is_scale_free_and_tracks_anisotropy() {
4791 for (scales, expected) in [
4792 (Vec3::splat(1.0), 1.0),
4793 (Vec3::new(1.0, 0.1, 0.1), 0.1),
4794 (Vec3::new(1.0, 0.01, 0.01), 0.01),
4795 (Vec3::splat(1.0e-20), 1.0),
4796 ] {
4797 let assessment = assess_inverse_bind(Mat4::from_scale(scales));
4798 assert!(assessment.inverse.is_ok(), "scales {scales:?}");
4799 let actual = assessment
4800 .quality
4801 .expect("affine linear transform has quality")
4802 .reciprocal_condition_number_inf;
4803 assert!(
4804 (actual - expected).abs() <= 1.0e-6,
4805 "{actual} != {expected}"
4806 );
4807 }
4808
4809 let shear = Mat4::from_cols_array(&[
4810 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,
4814 ]);
4815 let quality = assess_inverse_bind(shear)
4816 .quality
4817 .expect("finite affine shear has quality");
4818 assert_eq!(
4819 quality.reciprocal_condition_number_inf, 0.25,
4820 "infinity-norm conditioning includes off-diagonal row sums"
4821 );
4822 }
4823
4824 #[test]
4825 fn inverse_bind_assessment_distinguishes_non_affine_singular_and_ill_conditioned() {
4826 let inside_zero = INVERSE_BIND_AFFINE_TOLERANCE as f32;
4827 let outside_zero = f32::from_bits(inside_zero.to_bits() + 1);
4828 assert!(f64::from(inside_zero) <= INVERSE_BIND_AFFINE_TOLERANCE);
4829 assert!(f64::from(outside_zero) > INVERSE_BIND_AFFINE_TOLERANCE);
4830 for slot in [3, 7, 11] {
4831 for value in [inside_zero, -inside_zero] {
4832 let mut affine = Mat4::IDENTITY.to_cols_array();
4833 affine[slot] = value;
4834 assert!(
4835 assess_inverse_bind(Mat4::from_cols_array(&affine))
4836 .inverse
4837 .is_ok(),
4838 "bottom-row slot {slot} accepts signed values inside the tolerance"
4839 );
4840 }
4841 for value in [outside_zero, -outside_zero] {
4842 let mut non_affine = Mat4::IDENTITY.to_cols_array();
4843 non_affine[slot] = value;
4844 let assessment = assess_inverse_bind(Mat4::from_cols_array(&non_affine));
4845 assert_eq!(
4846 assessment.inverse,
4847 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine),
4848 "bottom-row slot {slot} rejects signed values outside the tolerance"
4849 );
4850 assert_eq!(assessment.quality, None);
4851 }
4852 }
4853 let inside_one = 1.0 + INVERSE_BIND_AFFINE_TOLERANCE as f32;
4854 let outside_one = f32::from_bits(inside_one.to_bits() + 1);
4855 assert!((f64::from(inside_one) - 1.0).abs() <= INVERSE_BIND_AFFINE_TOLERANCE);
4856 assert!((f64::from(outside_one) - 1.0).abs() > INVERSE_BIND_AFFINE_TOLERANCE);
4857 for value in [inside_one, 2.0 - inside_one] {
4858 let mut affine = Mat4::IDENTITY.to_cols_array();
4859 affine[15] = value;
4860 assert!(
4861 assess_inverse_bind(Mat4::from_cols_array(&affine))
4862 .inverse
4863 .is_ok()
4864 );
4865 }
4866 for value in [outside_one, 2.0 - outside_one] {
4867 let mut non_affine = Mat4::IDENTITY.to_cols_array();
4868 non_affine[15] = value;
4869 assert_eq!(
4870 assess_inverse_bind(Mat4::from_cols_array(&non_affine)).inverse,
4871 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine)
4872 );
4873 }
4874
4875 let singular = assess_inverse_bind(Mat4::from_scale(Vec3::new(1.0, 1.0, 0.0)));
4876 assert_eq!(
4877 singular.inverse,
4878 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible)
4879 );
4880 assert_eq!(
4881 singular
4882 .quality
4883 .expect("singular affine matrix has quality")
4884 .reciprocal_condition_number_inf,
4885 0.0
4886 );
4887
4888 let ill_conditioned = assess_inverse_bind(Mat4::from_scale(Vec3::new(1.0, 1.0, 1.0e-7)));
4889 assert_eq!(
4890 ill_conditioned.inverse,
4891 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned)
4892 );
4893 assert_eq!(
4894 ill_conditioned
4895 .quality
4896 .expect("ill-conditioned affine matrix has quality")
4897 .reciprocal_condition_number_inf,
4898 1.0e-7_f32 as f64
4899 );
4900
4901 for (shear, expected_reason) in [
4902 (
4903 999.0,
4904 Some(SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned),
4905 ),
4906 (998.0, None),
4907 ] {
4908 let matrix = Mat4::from_cols_array(&[
4909 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,
4910 ]);
4911 let assessment = assess_inverse_bind(matrix);
4912 let expected_quality = 1.0 / (1.0 + f64::from(shear)).powi(2);
4913 assert_eq!(
4914 assessment
4915 .quality
4916 .expect("affine shear has quality")
4917 .reciprocal_condition_number_inf,
4918 expected_quality
4919 );
4920 match expected_reason {
4921 Some(reason) => assert_eq!(assessment.inverse, Err(reason)),
4922 None => assert!(assessment.inverse.is_ok()),
4923 }
4924 }
4925 }
4926}