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