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 MetricGrids, foot_cycle_metrics, loop_continuity_metrics, root_motion_speed_mps,
11 rotation_range_deg,
12};
13use crate::model::{
14 AffineGeometryFacts, DecodedImageColorType, Document, ImageContainerFormat, ImageSourceKind,
15 ImageUnavailableReason, MaterialResourceCoverage, MaterialTextureSlot, MeshAsset,
16 SourceImageInspection, SourceInverseBindAccessorStatus, SourceNodeLocalRest,
17 SourceSkeletonCoverage, tolerant_world_rest_matrices, values_equal_to_mean,
18};
19use crate::profile::ResolvedRoles;
20use crate::sample::PoseGrid;
21use crate::transform::analyze_duplicate_loop_endpoint;
22use glam::{Mat3, Mat4, Vec3};
23use serde::{Deserialize, Serialize};
24use std::collections::{BTreeMap, BTreeSet};
25
26pub const MIN_RECORDED_ROTATION_DEG: f64 = 0.1;
29
30pub const LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE: f64 = 1.0e-5;
32pub const LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE: f64 = 1.0e-6;
34pub const INVERSE_BIND_AFFINE_TOLERANCE: f64 = 1.0e-6;
38pub const INVERSE_BIND_MIN_RECIPROCAL_CONDITION_INF: f64 = 1.0e-6;
43
44#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
47#[non_exhaustive]
48pub struct Aabb {
49 pub min: [f32; 3],
51 pub max: [f32; 3],
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
63#[non_exhaustive]
64pub struct MeshDefinitionMeasurements {
65 pub mesh_index: usize,
67 pub name: String,
69 pub vertex_count: u32,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub geometry_aabb: Option<Aabb>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub geometry_centroid: Option<[f32; 3]>,
80 pub max_joints_per_vertex: u32,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub weight_sum_min: Option<f64>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub weight_sum_max: Option<f64>,
91 pub additional_influence_sets: Vec<AdditionalInfluenceSetMeasurements>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[non_exhaustive]
104pub struct AdditionalInfluenceSetMeasurements {
105 pub set_index: u32,
107 pub joints_present: bool,
109 pub weights_present: bool,
111 pub joints_without_weights_present: bool,
114 pub weights_without_joints_present: bool,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122#[non_exhaustive]
123pub enum StaticNodeAabbUnavailableReason {
124 NoFinitePositions,
126 SkinnedDeformationExcluded,
129 NonFiniteTransform,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135#[non_exhaustive]
136pub struct NodeInstanceMeasurements {
137 pub node_index: usize,
139 pub node_name: String,
141 pub mesh_index: usize,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub static_node_world_aabb: Option<Aabb>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub static_node_world_aabb_unavailable_reason: Option<StaticNodeAabbUnavailableReason>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155#[non_exhaustive]
156pub struct SceneMeasurements {
157 pub scene_index: usize,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub name: Option<String>,
162 pub instance_count: usize,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub static_scene_world_aabb: Option<Aabb>,
167 pub excluded_instance_count: usize,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[non_exhaustive]
175pub struct MaterialTextureBindingMeasurements {
176 pub slot: MaterialTextureSlot,
178 pub texture_index: usize,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184#[non_exhaustive]
185pub struct MaterialDefinitionMeasurements {
186 pub material_index: usize,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub name: Option<String>,
191 pub texture_bindings: Vec<MaterialTextureBindingMeasurements>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197#[non_exhaustive]
198pub struct TextureMeasurements {
199 pub texture_index: usize,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub name: Option<String>,
204 pub image_index: usize,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
211#[non_exhaustive]
212pub struct ImageMeasurements {
213 pub image_index: usize,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub name: Option<String>,
218 pub source_kind: ImageSourceKind,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub declared_mime_type: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub detected_container: Option<ImageContainerFormat>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub width: Option<u32>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub height: Option<u32>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub channel_count: Option<u8>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub decoded_color_type: Option<DecodedImageColorType>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub unavailable_reason: Option<ImageUnavailableReason>,
241}
242
243pub type SkeletonSourceCoverage = SourceSkeletonCoverage;
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(tag = "kind", rename_all = "snake_case")]
256#[non_exhaustive]
257pub enum SkeletonNodeLocalRestMeasurements {
258 Trs {
260 translation_parent_space_m: [f32; 3],
264 rotation_xyzw: [f32; 4],
266 scale: [f32; 3],
268 },
269 Matrix {
271 matrix: [f32; 16],
273 },
274 Unavailable {
276 reason: SkeletonNodeLocalRestUnavailableReason,
278 },
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287#[non_exhaustive]
288pub enum LinearTransformClassification {
289 UnitOrthonormal,
291 UniformScaled,
293 NonUniform,
295 Sheared,
297 Reflected,
300 Singular,
303 NonFinite,
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311#[non_exhaustive]
312pub enum LinearTransformOrientation {
313 Positive,
315 Negative,
317 Zero,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
329#[non_exhaustive]
330pub struct LinearTransformMeasurements {
331 pub classification: LinearTransformClassification,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub axis_lengths: Option<[f64; 3]>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub determinant: Option<f64>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub orientation: Option<LinearTransformOrientation>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub uniform_scale: Option<f64>,
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351#[non_exhaustive]
352pub enum SkeletonNodeLocalRestUnavailableReason {
353 NonFiniteTransform,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360#[non_exhaustive]
361pub enum SkeletonRestWorldMatrixUnavailableReason {
362 NonFiniteLocalRest,
364 ParentRestWorldUnavailable,
366 NonFiniteWorldMatrix,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372#[non_exhaustive]
373pub struct SkeletonNodeMeasurements {
374 pub node_index: usize,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub name: Option<String>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub parent_node_index: Option<usize>,
382 pub scene_root_indices: Vec<usize>,
386 pub local_rest: SkeletonNodeLocalRestMeasurements,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub rest_world_matrix: Option<[f32; 16]>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub rest_world_translation_m: Option<[f32; 3]>,
396 pub rest_world_linear: LinearTransformMeasurements,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub rest_world_matrix_unavailable_reason: Option<SkeletonRestWorldMatrixUnavailableReason>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
406#[non_exhaustive]
407pub struct SkinInverseBindAccessorMeasurements {
408 pub status: SourceInverseBindAccessorStatus,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub declared_count: Option<usize>,
413 pub matrices: Vec<[f32; 16]>,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
421#[non_exhaustive]
422pub struct SkinJointMeasurements {
423 pub joint_index: usize,
425 pub node_index: usize,
427 pub joint_bind_to_mesh: SkinDerivedMatrixMeasurements,
429 pub mesh_bind_world: SkinDerivedMatrixMeasurements,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
437#[non_exhaustive]
438pub struct SkinAttachmentMeasurements {
439 pub node_index: usize,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub mesh_index: Option<usize>,
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "snake_case")]
449#[non_exhaustive]
450pub enum SkinDerivedMatrixUnavailableReason {
451 InverseBindAccessorAbsent,
453 InverseBindAccessorEmpty,
455 InverseBindAccessorCountMismatch,
457 InverseBindAccessorUnreadable,
460 JointRestWorldUnavailable,
462 InverseBindMatrixNonInvertible,
464 InverseBindMatrixNonAffine,
467 InverseBindMatrixIllConditioned,
470 NonFiniteDerivedMatrix,
472}
473
474#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
481#[non_exhaustive]
482pub struct SkinMatrixInversionQuality {
483 pub reciprocal_condition_number_inf: f64,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
491#[non_exhaustive]
492pub struct SkinDerivedMatrixMeasurements {
493 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub source_inverse_bind_matrix: Option<[f32; 16]>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub inversion_quality: Option<SkinMatrixInversionQuality>,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub matrix: Option<[f32; 16]>,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub linear: Option<LinearTransformMeasurements>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub unavailable_reason: Option<SkinDerivedMatrixUnavailableReason>,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "snake_case")]
521#[non_exhaustive]
522pub enum SkinBindLinearSummaryClassification {
523 NoJoints,
525 Unavailable,
527 PartiallyUnavailable,
529 ConsistentUniform,
532 MixedUniform,
534 NonUniformOrSheared,
536 ReflectedOrSingular,
538 Mixed,
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
544#[non_exhaustive]
545pub struct SkinBindLinearSummaryMeasurements {
546 pub classification: SkinBindLinearSummaryClassification,
548 pub joint_count: usize,
550 pub available_joint_count: usize,
552 pub unavailable_joint_count: usize,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub consistent_uniform_scale: Option<f64>,
558}
559
560fn unavailable_linear_transform() -> LinearTransformMeasurements {
561 LinearTransformMeasurements {
562 classification: LinearTransformClassification::NonFinite,
563 axis_lengths: None,
564 determinant: None,
565 orientation: None,
566 uniform_scale: None,
567 }
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
572#[non_exhaustive]
573pub struct SkinMeasurements {
574 pub skin_index: usize,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub name: Option<String>,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub skeleton_root_node_index: Option<usize>,
582 pub joints: Vec<SkinJointMeasurements>,
584 pub joint_bind_linear_summary: SkinBindLinearSummaryMeasurements,
586 pub inverse_bind_accessor: SkinInverseBindAccessorMeasurements,
588 pub attachments: Vec<SkinAttachmentMeasurements>,
590}
591
592#[derive(Debug, Clone, Default, Serialize, Deserialize)]
594#[non_exhaustive]
595pub struct AssetMeasurements {
596 pub material_resource_coverage: MaterialResourceCoverage,
598 pub material_definitions: Vec<MaterialDefinitionMeasurements>,
600 pub textures: Vec<TextureMeasurements>,
602 pub images: Vec<ImageMeasurements>,
604 #[serde(default)]
606 pub skeleton_source_coverage: SkeletonSourceCoverage,
607 #[serde(default)]
609 pub skeleton_nodes: Vec<SkeletonNodeMeasurements>,
610 #[serde(default)]
612 pub skins: Vec<SkinMeasurements>,
613 pub mesh_definitions: Vec<MeshDefinitionMeasurements>,
615 pub node_instances: Vec<NodeInstanceMeasurements>,
617 pub scenes: Vec<SceneMeasurements>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub default_scene_index: Option<usize>,
622}
623
624#[derive(Debug, Clone, Copy)]
625struct Bounds {
626 min: [f32; 3],
627 max: [f32; 3],
628 any: bool,
629}
630
631impl Default for Bounds {
632 fn default() -> Self {
633 Self {
634 min: [f32::INFINITY; 3],
635 max: [f32::NEG_INFINITY; 3],
636 any: false,
637 }
638 }
639}
640
641impl Bounds {
642 fn include(&mut self, point: Vec3) -> bool {
643 let point = point.to_array();
644 if !point.iter().all(|value| value.is_finite()) {
645 return false;
646 }
647 self.any = true;
648 for ((min, max), value) in self.min.iter_mut().zip(&mut self.max).zip(point) {
649 *min = min.min(value);
650 *max = max.max(value);
651 }
652 true
653 }
654
655 fn include_aabb(&mut self, aabb: Aabb) {
656 self.any = true;
657 for ((min, max), (aabb_min, aabb_max)) in self
658 .min
659 .iter_mut()
660 .zip(&mut self.max)
661 .zip(aabb.min.into_iter().zip(aabb.max))
662 {
663 *min = min.min(aabb_min);
664 *max = max.max(aabb_max);
665 }
666 }
667
668 fn finish(self) -> Option<Aabb> {
669 self.any.then_some(Aabb {
670 min: self.min,
671 max: self.max,
672 })
673 }
674}
675
676#[derive(Default)]
681struct Centroid {
682 sum: [f64; 3],
683 count: u64,
684}
685
686impl Centroid {
687 fn include(&mut self, point: Vec3) {
688 let point = point.to_array();
689 for (sum, value) in self.sum.iter_mut().zip(point) {
690 *sum += f64::from(value);
691 }
692 self.count += 1;
693 }
694
695 fn finish(self) -> Option<[f32; 3]> {
696 (self.count != 0).then(|| {
697 let count = self.count as f64;
698 self.sum.map(|sum| (sum / count) as f32)
699 })
700 }
701}
702
703fn measure_mesh_definition(mesh: &MeshAsset) -> MeshDefinitionMeasurements {
704 let mut vertex_count = 0u32;
705 let mut bounds = Bounds::default();
706 let mut centroid = Centroid::default();
707 let mut max_joints_per_vertex = 0u32;
708 let mut weight_sum_min = f64::INFINITY;
709 let mut weight_sum_max = f64::NEG_INFINITY;
710 let mut any_finite_weight = false;
711 let mut additional_influence_sets: BTreeMap<u32, AdditionalInfluenceSetMeasurements> =
712 BTreeMap::new();
713
714 for primitive in &mesh.primitives {
715 vertex_count = vertex_count.saturating_add(primitive.positions.len() as u32);
716 for &position in &primitive.positions {
717 if bounds.include(position) {
720 centroid.include(position);
721 }
722 }
723 for weights in &primitive.weights {
724 let influences = weights.iter().filter(|&&weight| weight > 0.0).count() as u32;
725 max_joints_per_vertex = max_joints_per_vertex.max(influences);
726 let sum: f64 = weights.iter().map(|&weight| f64::from(weight)).sum();
727 if sum.is_finite() {
728 any_finite_weight = true;
729 weight_sum_min = weight_sum_min.min(sum);
730 weight_sum_max = weight_sum_max.max(sum);
731 }
732 }
733 for set in &primitive.additional_influence_sets {
734 additional_influence_sets
735 .entry(set.set_index)
736 .and_modify(|entry| {
737 entry.joints_present |= set.joints_present;
738 entry.weights_present |= set.weights_present;
739 entry.joints_without_weights_present |=
740 set.joints_present && !set.weights_present;
741 entry.weights_without_joints_present |=
742 set.weights_present && !set.joints_present;
743 })
744 .or_insert(AdditionalInfluenceSetMeasurements {
745 set_index: set.set_index,
746 joints_present: set.joints_present,
747 weights_present: set.weights_present,
748 joints_without_weights_present: set.joints_present && !set.weights_present,
749 weights_without_joints_present: set.weights_present && !set.joints_present,
750 });
751 }
752 }
753
754 MeshDefinitionMeasurements {
755 mesh_index: mesh.source_mesh_index,
756 name: mesh.name.clone(),
757 vertex_count,
758 geometry_aabb: bounds.finish(),
759 geometry_centroid: centroid.finish(),
760 max_joints_per_vertex,
761 weight_sum_min: any_finite_weight.then_some(weight_sum_min),
762 weight_sum_max: any_finite_weight.then_some(weight_sum_max),
763 additional_influence_sets: additional_influence_sets.into_values().collect(),
764 }
765}
766
767fn matrix_is_finite(matrix: Mat4) -> bool {
768 matrix
769 .to_cols_array()
770 .into_iter()
771 .all(|component| component.is_finite())
772}
773
774fn matrix_to_columns(matrix: Mat4) -> [f32; 16] {
775 matrix.to_cols_array()
776}
777
778fn vec3_is_finite(value: Vec3) -> bool {
779 value.to_array().into_iter().all(f32::is_finite)
780}
781
782fn quat_is_finite(value: glam::Quat) -> bool {
783 value.to_array().into_iter().all(f32::is_finite)
784}
785
786fn source_local_rest_measurement(
787 local_rest: &SourceNodeLocalRest,
788) -> (SkeletonNodeLocalRestMeasurements, Option<Mat4>) {
789 match local_rest {
790 SourceNodeLocalRest::Trs {
791 translation,
792 rotation,
793 scale,
794 } if vec3_is_finite(*translation)
795 && quat_is_finite(*rotation)
796 && vec3_is_finite(*scale) =>
797 {
798 let matrix = Mat4::from_scale_rotation_translation(*scale, *rotation, *translation);
799 if matrix_is_finite(matrix) {
800 (
801 SkeletonNodeLocalRestMeasurements::Trs {
802 translation_parent_space_m: translation.to_array(),
803 rotation_xyzw: rotation.to_array(),
804 scale: scale.to_array(),
805 },
806 Some(matrix),
807 )
808 } else {
809 (
810 SkeletonNodeLocalRestMeasurements::Unavailable {
811 reason: SkeletonNodeLocalRestUnavailableReason::NonFiniteTransform,
812 },
813 None,
814 )
815 }
816 }
817 SourceNodeLocalRest::Matrix(matrix) if matrix_is_finite(*matrix) => (
818 SkeletonNodeLocalRestMeasurements::Matrix {
819 matrix: matrix_to_columns(*matrix),
820 },
821 Some(*matrix),
822 ),
823 _ => (
824 SkeletonNodeLocalRestMeasurements::Unavailable {
825 reason: SkeletonNodeLocalRestUnavailableReason::NonFiniteTransform,
826 },
827 None,
828 ),
829 }
830}
831
832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833enum RestWorldVisit {
834 Visiting,
835 Done,
836}
837
838#[derive(Debug, Clone, Copy, PartialEq, Eq)]
839enum SourceRestWorldError {
840 NonFiniteLocalRest,
841 MissingParentNode,
842 ParentRestWorldUnavailable,
843 ParentCycle,
844 NonFiniteWorldMatrix,
845}
846
847fn source_rest_world(
848 node_index: usize,
849 source_nodes: &BTreeMap<usize, (&crate::model::SourceNodeAsset, Option<Mat4>)>,
850 visits: &mut BTreeMap<usize, RestWorldVisit>,
851 worlds: &mut BTreeMap<usize, Result<Mat4, SourceRestWorldError>>,
852) -> Result<Mat4, SourceRestWorldError> {
853 if let Some(result) = worlds.get(&node_index) {
854 return *result;
855 }
856 let mut path = Vec::new();
857 let mut current = node_index;
858 let mut parent_result = loop {
859 if let Some(result) = worlds.get(¤t) {
860 break *result;
861 }
862 if visits.get(¤t) == Some(&RestWorldVisit::Visiting) {
863 break Err(SourceRestWorldError::ParentCycle);
864 }
865 let Some((node, local)) = source_nodes.get(¤t) else {
866 if path.is_empty() {
867 return Err(SourceRestWorldError::MissingParentNode);
868 }
869 break Err(SourceRestWorldError::MissingParentNode);
870 };
871 let Some(local) = *local else {
872 let result = Err(SourceRestWorldError::NonFiniteLocalRest);
873 worlds.insert(current, result);
874 visits.insert(current, RestWorldVisit::Done);
875 break result;
876 };
877 visits.insert(current, RestWorldVisit::Visiting);
878 path.push((current, local));
879 match node.parent_source_node_index {
880 Some(parent) => current = parent,
881 None => {
882 let result = Ok(local);
883 worlds.insert(current, result);
884 visits.insert(current, RestWorldVisit::Done);
885 path.pop();
886 break result;
887 }
888 }
889 };
890
891 for (current, local) in path.into_iter().rev() {
892 parent_result = match parent_result {
893 Err(
894 error @ (SourceRestWorldError::MissingParentNode
895 | SourceRestWorldError::ParentCycle),
896 ) => Err(error),
897 Err(_) => Err(SourceRestWorldError::ParentRestWorldUnavailable),
898 Ok(parent_world) => {
899 let world = parent_world * local;
900 matrix_is_finite(world)
901 .then_some(world)
902 .ok_or(SourceRestWorldError::NonFiniteWorldMatrix)
903 }
904 };
905 visits.insert(current, RestWorldVisit::Done);
906 worlds.insert(current, parent_result);
907 }
908 parent_result
909}
910
911fn derived_accessor_global_unavailable_reason(
912 status: SourceInverseBindAccessorStatus,
913) -> Option<SkinDerivedMatrixUnavailableReason> {
914 match status {
915 SourceInverseBindAccessorStatus::Absent => {
916 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
917 }
918 SourceInverseBindAccessorStatus::EmptyAccessor => {
919 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
920 }
921 SourceInverseBindAccessorStatus::Unreadable => {
922 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
923 }
924 SourceInverseBindAccessorStatus::Available
925 | SourceInverseBindAccessorStatus::CountMismatch => None,
926 }
927}
928
929pub(crate) struct InverseBindAssessment {
930 pub(crate) inverse: Result<Mat4, SkinDerivedMatrixUnavailableReason>,
931 pub(crate) quality: Option<SkinMatrixInversionQuality>,
932}
933
934pub(crate) fn assess_inverse_bind(matrix: Mat4) -> InverseBindAssessment {
935 let values = matrix.to_cols_array();
936 let affine = [values[3], values[7], values[11]]
937 .into_iter()
938 .all(|value| f64::from(value).abs() <= INVERSE_BIND_AFFINE_TOLERANCE)
939 && (f64::from(values[15]) - 1.0).abs() <= INVERSE_BIND_AFFINE_TOLERANCE;
940 if !affine {
941 return InverseBindAssessment {
942 inverse: Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine),
943 quality: None,
944 };
945 }
946
947 let linear = [
948 [
949 f64::from(values[0]),
950 f64::from(values[4]),
951 f64::from(values[8]),
952 ],
953 [
954 f64::from(values[1]),
955 f64::from(values[5]),
956 f64::from(values[9]),
957 ],
958 [
959 f64::from(values[2]),
960 f64::from(values[6]),
961 f64::from(values[10]),
962 ],
963 ];
964 let determinant = linear[0][0] * (linear[1][1] * linear[2][2] - linear[1][2] * linear[2][1])
965 - linear[0][1] * (linear[1][0] * linear[2][2] - linear[1][2] * linear[2][0])
966 + linear[0][2] * (linear[1][0] * linear[2][1] - linear[1][1] * linear[2][0]);
967 let norm = linear
968 .iter()
969 .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
970 .fold(0.0_f64, f64::max);
971 if determinant == 0.0 || norm == 0.0 || !determinant.is_finite() || !norm.is_finite() {
972 return InverseBindAssessment {
973 inverse: Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible),
974 quality: Some(SkinMatrixInversionQuality {
975 reciprocal_condition_number_inf: 0.0,
976 }),
977 };
978 }
979 let inverse_linear = [
980 [
981 (linear[1][1] * linear[2][2] - linear[1][2] * linear[2][1]) / determinant,
982 (linear[0][2] * linear[2][1] - linear[0][1] * linear[2][2]) / determinant,
983 (linear[0][1] * linear[1][2] - linear[0][2] * linear[1][1]) / determinant,
984 ],
985 [
986 (linear[1][2] * linear[2][0] - linear[1][0] * linear[2][2]) / determinant,
987 (linear[0][0] * linear[2][2] - linear[0][2] * linear[2][0]) / determinant,
988 (linear[0][2] * linear[1][0] - linear[0][0] * linear[1][2]) / determinant,
989 ],
990 [
991 (linear[1][0] * linear[2][1] - linear[1][1] * linear[2][0]) / determinant,
992 (linear[0][1] * linear[2][0] - linear[0][0] * linear[2][1]) / determinant,
993 (linear[0][0] * linear[1][1] - linear[0][1] * linear[1][0]) / determinant,
994 ],
995 ];
996 let inverse_norm = inverse_linear
997 .iter()
998 .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
999 .fold(0.0_f64, f64::max);
1000 let reciprocal_condition_number_inf = (1.0 / (norm * inverse_norm)).clamp(0.0, 1.0);
1001 let quality = Some(SkinMatrixInversionQuality {
1002 reciprocal_condition_number_inf,
1003 });
1004 if !reciprocal_condition_number_inf.is_finite()
1005 || reciprocal_condition_number_inf <= INVERSE_BIND_MIN_RECIPROCAL_CONDITION_INF
1006 {
1007 return InverseBindAssessment {
1008 inverse: Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned),
1009 quality,
1010 };
1011 }
1012 let mut inverse = matrix.inverse();
1013 if !matrix_is_finite(inverse) {
1014 let translation = [
1015 f64::from(values[12]),
1016 f64::from(values[13]),
1017 f64::from(values[14]),
1018 ];
1019 let inverse_translation = [
1020 -inverse_linear[0]
1021 .iter()
1022 .zip(translation)
1023 .map(|(coefficient, value)| coefficient * value)
1024 .sum::<f64>(),
1025 -inverse_linear[1]
1026 .iter()
1027 .zip(translation)
1028 .map(|(coefficient, value)| coefficient * value)
1029 .sum::<f64>(),
1030 -inverse_linear[2]
1031 .iter()
1032 .zip(translation)
1033 .map(|(coefficient, value)| coefficient * value)
1034 .sum::<f64>(),
1035 ];
1036 let widened = [
1037 inverse_linear[0][0],
1038 inverse_linear[1][0],
1039 inverse_linear[2][0],
1040 0.0,
1041 inverse_linear[0][1],
1042 inverse_linear[1][1],
1043 inverse_linear[2][1],
1044 0.0,
1045 inverse_linear[0][2],
1046 inverse_linear[1][2],
1047 inverse_linear[2][2],
1048 0.0,
1049 inverse_translation[0],
1050 inverse_translation[1],
1051 inverse_translation[2],
1052 1.0,
1053 ];
1054 let narrowed = widened.map(|value| value as f32);
1055 inverse = Mat4::from_cols_array(&narrowed);
1056 }
1057 InverseBindAssessment {
1058 inverse: matrix_is_finite(inverse)
1059 .then_some(inverse)
1060 .ok_or(SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix),
1061 quality,
1062 }
1063}
1064
1065pub fn measure_linear_transform(matrix: Mat4) -> LinearTransformMeasurements {
1076 if !matrix_is_finite(matrix) {
1077 return LinearTransformMeasurements {
1078 classification: LinearTransformClassification::NonFinite,
1079 axis_lengths: None,
1080 determinant: None,
1081 orientation: None,
1082 uniform_scale: None,
1083 };
1084 }
1085
1086 let facts = match AffineGeometryFacts::from_linear(Mat3::from_mat4(matrix)) {
1091 Ok(facts) => facts,
1092 Err(_) => return unavailable_linear_transform(),
1093 };
1094
1095 let singular = facts.axis_length_product == 0.0
1096 || facts.determinant.abs()
1097 <= LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE * facts.axis_length_product;
1098 let orientation = if singular {
1099 LinearTransformOrientation::Zero
1100 } else if facts.determinant < 0.0 {
1101 LinearTransformOrientation::Negative
1102 } else {
1103 LinearTransformOrientation::Positive
1104 };
1105 let orthogonal = [(0usize, 1usize), (0, 2), (1, 2)]
1106 .into_iter()
1107 .zip(facts.cross_axis_dots)
1108 .all(|((left, right), dot)| {
1109 let length_product = facts.axis_lengths[left] * facts.axis_lengths[right];
1110 length_product == 0.0
1111 || dot.abs() <= LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE * length_product
1112 });
1113 let uniform = facts.has_equal_axis_lengths(LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE);
1114 let uniform_scale = (orthogonal && uniform).then_some(facts.mean_axis_length);
1115 let unit = uniform_scale
1116 .is_some_and(|scale| (scale - 1.0).abs() <= LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE);
1117 let classification = if singular {
1118 LinearTransformClassification::Singular
1119 } else if orientation == LinearTransformOrientation::Negative {
1120 LinearTransformClassification::Reflected
1121 } else if !orthogonal {
1122 LinearTransformClassification::Sheared
1123 } else if unit {
1124 LinearTransformClassification::UnitOrthonormal
1125 } else if uniform {
1126 LinearTransformClassification::UniformScaled
1127 } else {
1128 LinearTransformClassification::NonUniform
1129 };
1130
1131 LinearTransformMeasurements {
1132 classification,
1133 axis_lengths: Some(facts.axis_lengths),
1134 determinant: Some(facts.determinant),
1135 orientation: Some(orientation),
1136 uniform_scale,
1137 }
1138}
1139
1140pub(crate) fn summarize_skin_bind_linear(
1141 joints: &[SkinJointMeasurements],
1142) -> SkinBindLinearSummaryMeasurements {
1143 let joint_count = joints.len();
1144 let available: Vec<_> = joints
1145 .iter()
1146 .filter_map(|joint| joint.joint_bind_to_mesh.linear)
1147 .collect();
1148 let available_joint_count = available.len();
1149 let unavailable_joint_count = joint_count.saturating_sub(available_joint_count);
1150 let (classification, consistent_uniform_scale) = if joint_count == 0 {
1151 (SkinBindLinearSummaryClassification::NoJoints, None)
1152 } else if available_joint_count == 0 {
1153 (SkinBindLinearSummaryClassification::Unavailable, None)
1154 } else if unavailable_joint_count > 0 {
1155 (
1156 SkinBindLinearSummaryClassification::PartiallyUnavailable,
1157 None,
1158 )
1159 } else if available.iter().all(|linear| {
1160 matches!(
1161 linear.classification,
1162 LinearTransformClassification::UnitOrthonormal
1163 | LinearTransformClassification::UniformScaled
1164 )
1165 }) {
1166 let mut factors = available
1167 .iter()
1168 .map(|linear| {
1169 linear
1170 .uniform_scale
1171 .expect("uniform classifications carry a scale")
1172 })
1173 .collect::<Vec<_>>();
1174 factors.sort_by(f64::total_cmp);
1178 let mean = factors.iter().sum::<f64>() / factors.len() as f64;
1179 if values_equal_to_mean(&factors, mean, LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE) {
1180 (
1181 SkinBindLinearSummaryClassification::ConsistentUniform,
1182 Some(mean),
1183 )
1184 } else {
1185 (SkinBindLinearSummaryClassification::MixedUniform, None)
1186 }
1187 } else if available.iter().all(|linear| {
1188 matches!(
1189 linear.classification,
1190 LinearTransformClassification::NonUniform | LinearTransformClassification::Sheared
1191 )
1192 }) {
1193 (
1194 SkinBindLinearSummaryClassification::NonUniformOrSheared,
1195 None,
1196 )
1197 } else if available.iter().all(|linear| {
1198 matches!(
1199 linear.classification,
1200 LinearTransformClassification::Reflected | LinearTransformClassification::Singular
1201 )
1202 }) {
1203 (
1204 SkinBindLinearSummaryClassification::ReflectedOrSingular,
1205 None,
1206 )
1207 } else {
1208 (SkinBindLinearSummaryClassification::Mixed, None)
1209 };
1210 SkinBindLinearSummaryMeasurements {
1211 classification,
1212 joint_count,
1213 available_joint_count,
1214 unavailable_joint_count,
1215 consistent_uniform_scale,
1216 }
1217}
1218
1219fn unavailable_derived_matrix(
1220 reason: SkinDerivedMatrixUnavailableReason,
1221) -> SkinDerivedMatrixMeasurements {
1222 SkinDerivedMatrixMeasurements {
1223 source_inverse_bind_matrix: None,
1224 inversion_quality: None,
1225 matrix: None,
1226 linear: None,
1227 unavailable_reason: Some(reason),
1228 }
1229}
1230
1231fn available_derived_matrix(matrix: Mat4) -> SkinDerivedMatrixMeasurements {
1232 SkinDerivedMatrixMeasurements {
1233 source_inverse_bind_matrix: None,
1234 inversion_quality: None,
1235 matrix: Some(matrix_to_columns(matrix)),
1236 linear: Some(measure_linear_transform(matrix)),
1237 unavailable_reason: None,
1238 }
1239}
1240
1241fn with_inverse_bind_source(
1242 mut measurements: SkinDerivedMatrixMeasurements,
1243 raw: Mat4,
1244 quality: Option<SkinMatrixInversionQuality>,
1245) -> SkinDerivedMatrixMeasurements {
1246 measurements.source_inverse_bind_matrix = Some(matrix_to_columns(raw));
1247 measurements.inversion_quality = quality;
1248 measurements
1249}
1250
1251pub(crate) fn measure_source_skeleton(
1252 doc: &Document,
1253) -> (
1254 SkeletonSourceCoverage,
1255 Vec<SkeletonNodeMeasurements>,
1256 Vec<SkinMeasurements>,
1257) {
1258 let source = &doc.assets.source_skeleton;
1259 if source.coverage == SourceSkeletonCoverage::Unavailable {
1260 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1261 }
1262
1263 let mut source_nodes = BTreeMap::new();
1264 for node in &source.nodes {
1265 let (_, local) = source_local_rest_measurement(&node.local_rest);
1266 if source_nodes
1267 .insert(node.source_node_index, (node, local))
1268 .is_some()
1269 {
1270 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1271 }
1272 }
1273 for skin in &source.skins {
1274 if skin
1275 .joint_source_node_indices
1276 .iter()
1277 .any(|joint| !source_nodes.contains_key(joint))
1278 || skin
1279 .skeleton_root_source_node_index
1280 .is_some_and(|root| !source_nodes.contains_key(&root))
1281 || skin
1282 .attachments
1283 .iter()
1284 .any(|attachment| !source_nodes.contains_key(&attachment.source_node_index))
1285 {
1286 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1287 }
1288 }
1289
1290 let mut visits = BTreeMap::new();
1291 let mut worlds = BTreeMap::new();
1292 for node in &source.nodes {
1293 let _ = source_rest_world(
1294 node.source_node_index,
1295 &source_nodes,
1296 &mut visits,
1297 &mut worlds,
1298 );
1299 }
1300 let mut skeleton_nodes = Vec::with_capacity(source.nodes.len());
1301 for node in &source.nodes {
1302 let (local_rest, _) = source_local_rest_measurement(&node.local_rest);
1303 let Some(world) = worlds.get(&node.source_node_index).copied() else {
1304 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1305 };
1306 let (
1307 rest_world_matrix,
1308 rest_world_translation_m,
1309 rest_world_linear,
1310 rest_world_matrix_unavailable_reason,
1311 ) = match world {
1312 Ok(matrix) => (
1313 Some(matrix_to_columns(matrix)),
1314 Some(matrix.w_axis.truncate().to_array()),
1315 measure_linear_transform(matrix),
1316 None,
1317 ),
1318 Err(SourceRestWorldError::NonFiniteLocalRest) => (
1319 None,
1320 None,
1321 unavailable_linear_transform(),
1322 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest),
1323 ),
1324 Err(SourceRestWorldError::ParentRestWorldUnavailable) => (
1325 None,
1326 None,
1327 unavailable_linear_transform(),
1328 Some(SkeletonRestWorldMatrixUnavailableReason::ParentRestWorldUnavailable),
1329 ),
1330 Err(SourceRestWorldError::NonFiniteWorldMatrix) => (
1331 None,
1332 None,
1333 unavailable_linear_transform(),
1334 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteWorldMatrix),
1335 ),
1336 Err(SourceRestWorldError::MissingParentNode | SourceRestWorldError::ParentCycle) => {
1337 return (SourceSkeletonCoverage::Unavailable, Vec::new(), Vec::new());
1338 }
1339 };
1340 skeleton_nodes.push(SkeletonNodeMeasurements {
1341 node_index: node.source_node_index,
1342 name: node.name.clone(),
1343 parent_node_index: node.parent_source_node_index,
1344 scene_root_indices: node.scene_root_indices.clone(),
1345 local_rest,
1346 rest_world_matrix,
1347 rest_world_translation_m,
1348 rest_world_linear,
1349 rest_world_matrix_unavailable_reason,
1350 });
1351 }
1352
1353 let skins = source
1354 .skins
1355 .iter()
1356 .map(|skin| {
1357 let all_raw_finite = skin
1358 .inverse_bind_accessor
1359 .matrices
1360 .iter()
1361 .all(|matrix| matrix_is_finite(*matrix));
1362 let status = if all_raw_finite {
1363 skin.inverse_bind_accessor.status
1364 } else {
1365 SourceInverseBindAccessorStatus::Unreadable
1366 };
1367 let raw_matrices = if all_raw_finite {
1368 skin.inverse_bind_accessor
1369 .matrices
1370 .iter()
1371 .copied()
1372 .map(matrix_to_columns)
1373 .collect()
1374 } else {
1375 Vec::new()
1376 };
1377 let inverse_bind_accessor = SkinInverseBindAccessorMeasurements {
1378 status,
1379 declared_count: skin.inverse_bind_accessor.declared_count,
1380 matrices: raw_matrices,
1381 };
1382 let joints: Vec<_> = skin
1383 .joint_source_node_indices
1384 .iter()
1385 .enumerate()
1386 .map(|(joint_index, &node_index)| {
1387 let unavailable_joint = |reason| SkinJointMeasurements {
1388 joint_index,
1389 node_index,
1390 joint_bind_to_mesh: unavailable_derived_matrix(reason),
1391 mesh_bind_world: unavailable_derived_matrix(reason),
1392 };
1393 let raw = match derived_accessor_global_unavailable_reason(status) {
1394 Some(reason) => return unavailable_joint(reason),
1395 None => match skin.inverse_bind_accessor.matrices.get(joint_index).copied() {
1396 Some(raw) => raw,
1397 None => {
1398 let reason =
1399 SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch;
1400 return unavailable_joint(reason);
1401 }
1402 },
1403 };
1404 let assessment = assess_inverse_bind(raw);
1405 let joint_bind_to_mesh = with_inverse_bind_source(
1406 match assessment.inverse {
1407 Ok(inverse) => available_derived_matrix(inverse),
1408 Err(reason) => unavailable_derived_matrix(reason),
1409 },
1410 raw,
1411 assessment.quality,
1412 );
1413 let world = worlds
1414 .get(&node_index)
1415 .copied()
1416 .unwrap_or(Err(SourceRestWorldError::ParentRestWorldUnavailable));
1417 let mesh_bind_world = match world {
1418 Ok(world) => {
1419 let matrix = world * raw;
1420 with_inverse_bind_source(matrix_is_finite(matrix).then_some(()).map_or_else(
1421 || {
1422 unavailable_derived_matrix(
1423 SkinDerivedMatrixUnavailableReason::NonFiniteDerivedMatrix,
1424 )
1425 },
1426 |_| available_derived_matrix(matrix),
1427 ), raw, None)
1428 }
1429 Err(_) => with_inverse_bind_source(
1430 unavailable_derived_matrix(
1431 SkinDerivedMatrixUnavailableReason::JointRestWorldUnavailable,
1432 ),
1433 raw,
1434 None,
1435 ),
1436 };
1437 SkinJointMeasurements {
1438 joint_index,
1439 node_index,
1440 joint_bind_to_mesh,
1441 mesh_bind_world,
1442 }
1443 })
1444 .collect();
1445 let joint_bind_linear_summary = summarize_skin_bind_linear(&joints);
1446 SkinMeasurements {
1447 skin_index: skin.source_skin_index,
1448 name: skin.name.clone(),
1449 skeleton_root_node_index: skin.skeleton_root_source_node_index,
1450 joints,
1451 joint_bind_linear_summary,
1452 inverse_bind_accessor,
1453 attachments: skin
1454 .attachments
1455 .iter()
1456 .map(|attachment| SkinAttachmentMeasurements {
1457 node_index: attachment.source_node_index,
1458 mesh_index: attachment.source_mesh_index,
1459 })
1460 .collect(),
1461 }
1462 })
1463 .collect();
1464 (SourceSkeletonCoverage::Complete, skeleton_nodes, skins)
1465}
1466
1467fn transformed_definition_aabb(
1468 mesh: &MeshAsset,
1469 world: Mat4,
1470) -> Result<Aabb, StaticNodeAabbUnavailableReason> {
1471 let mut bounds = Bounds::default();
1472 let mut any_finite_source = false;
1473 for primitive in &mesh.primitives {
1474 for &position in &primitive.positions {
1475 if !position.is_finite() {
1476 continue;
1477 }
1478 any_finite_source = true;
1479 if !bounds.include(world.transform_point3(position)) {
1480 return Err(StaticNodeAabbUnavailableReason::NonFiniteTransform);
1481 }
1482 }
1483 }
1484 if !any_finite_source {
1485 return Err(StaticNodeAabbUnavailableReason::NoFinitePositions);
1486 }
1487 bounds
1488 .finish()
1489 .ok_or(StaticNodeAabbUnavailableReason::NonFiniteTransform)
1490}
1491
1492#[derive(Debug, Clone, Copy, Default)]
1493struct NodeAggregate {
1494 bounds: Bounds,
1495 instance_count: usize,
1496 excluded_instance_count: usize,
1497}
1498
1499impl NodeAggregate {
1500 fn include(&mut self, other: Self) {
1501 if let Some(aabb) = other.bounds.finish() {
1502 self.bounds.include_aabb(aabb);
1503 }
1504 self.instance_count = self.instance_count.saturating_add(other.instance_count);
1505 self.excluded_instance_count = self
1506 .excluded_instance_count
1507 .saturating_add(other.excluded_instance_count);
1508 }
1509}
1510
1511pub fn measure_assets(doc: &Document) -> AssetMeasurements {
1520 let (skeleton_source_coverage, skeleton_nodes, skins) = measure_source_skeleton(doc);
1521 let material_resource_coverage = doc.assets.material_resources.coverage;
1522 let material_definitions = doc
1523 .assets
1524 .material_resources
1525 .materials
1526 .iter()
1527 .map(|material| MaterialDefinitionMeasurements {
1528 material_index: material.material_index,
1529 name: material.name.clone(),
1530 texture_bindings: material
1531 .texture_bindings
1532 .iter()
1533 .map(|binding| MaterialTextureBindingMeasurements {
1534 slot: binding.slot,
1535 texture_index: binding.texture_index,
1536 })
1537 .collect(),
1538 })
1539 .collect();
1540 let textures = doc
1541 .assets
1542 .material_resources
1543 .textures
1544 .iter()
1545 .map(|texture| TextureMeasurements {
1546 texture_index: texture.texture_index,
1547 name: texture.name.clone(),
1548 image_index: texture.image_index,
1549 })
1550 .collect();
1551 let images = doc
1552 .assets
1553 .material_resources
1554 .images
1555 .iter()
1556 .map(|image| {
1557 let (width, height, channel_count, decoded_color_type, unavailable_reason) =
1558 match image.inspection {
1559 SourceImageInspection::Available {
1560 width,
1561 height,
1562 channel_count,
1563 color_type,
1564 } => (
1565 Some(width),
1566 Some(height),
1567 Some(channel_count),
1568 Some(color_type),
1569 None,
1570 ),
1571 SourceImageInspection::Unavailable { reason } => {
1572 (None, None, None, None, Some(reason))
1573 }
1574 };
1575 ImageMeasurements {
1576 image_index: image.image_index,
1577 name: image.name.clone(),
1578 source_kind: image.source_kind,
1579 declared_mime_type: image.declared_mime_type.clone(),
1580 detected_container: image.detected_container,
1581 width,
1582 height,
1583 channel_count,
1584 decoded_color_type,
1585 unavailable_reason,
1586 }
1587 })
1588 .collect();
1589 let mesh_definitions = doc
1590 .assets
1591 .meshes
1592 .iter()
1593 .map(measure_mesh_definition)
1594 .collect::<Vec<_>>();
1595 let worlds = tolerant_world_rest_matrices(&doc.skeleton);
1596 let mut node_aggregates = vec![NodeAggregate::default(); doc.skeleton.bones.len()];
1597 let mut node_instances = Vec::with_capacity(doc.assets.instances.len());
1598
1599 for instance in &doc.assets.instances {
1600 let Some(mesh) = doc.assets.meshes.get(instance.mesh) else {
1601 continue;
1602 };
1603 let bounds = if !instance.skin_joints.is_empty() {
1604 Err(StaticNodeAabbUnavailableReason::SkinnedDeformationExcluded)
1605 } else {
1606 match worlds.get(instance.node).copied().flatten() {
1607 Some(world) => transformed_definition_aabb(mesh, world),
1608 None => Err(StaticNodeAabbUnavailableReason::NonFiniteTransform),
1609 }
1610 };
1611 let (static_node_world_aabb, unavailable) = match bounds {
1612 Ok(aabb) => (Some(aabb), None),
1613 Err(reason) => (None, Some(reason)),
1614 };
1615 let node_name = doc
1616 .skeleton
1617 .bones
1618 .get(instance.node)
1619 .map(|bone| bone.name.clone())
1620 .unwrap_or_else(|| format!("node-{}", instance.source_node_index));
1621 let measurement = NodeInstanceMeasurements {
1622 node_index: instance.source_node_index,
1623 node_name,
1624 mesh_index: mesh.source_mesh_index,
1625 static_node_world_aabb,
1626 static_node_world_aabb_unavailable_reason: unavailable,
1627 };
1628 if let Some(aggregate) = node_aggregates.get_mut(instance.node) {
1629 aggregate.instance_count = aggregate.instance_count.saturating_add(1);
1630 match measurement.static_node_world_aabb {
1631 Some(aabb) => aggregate.bounds.include_aabb(aabb),
1632 None => {
1633 aggregate.excluded_instance_count =
1634 aggregate.excluded_instance_count.saturating_add(1);
1635 }
1636 }
1637 }
1638 node_instances.push(measurement);
1639 }
1640
1641 for node in (0..doc.skeleton.bones.len()).rev() {
1645 let Some(parent) = doc.skeleton.bones[node].parent else {
1646 continue;
1647 };
1648 let child = node_aggregates[node];
1649 if let Some(parent_aggregate) = node_aggregates.get_mut(parent) {
1650 parent_aggregate.include(child);
1651 }
1652 }
1653
1654 let scenes = doc
1655 .assets
1656 .scenes
1657 .iter()
1658 .map(|scene| {
1659 let mut aggregate = NodeAggregate::default();
1660 for &root in &scene.roots {
1661 if let Some(root_aggregate) = node_aggregates.get(root).copied() {
1662 aggregate.include(root_aggregate);
1663 }
1664 }
1665 SceneMeasurements {
1666 scene_index: scene.source_scene_index,
1667 name: scene.name.clone(),
1668 instance_count: aggregate.instance_count,
1669 static_scene_world_aabb: aggregate.bounds.finish(),
1670 excluded_instance_count: aggregate.excluded_instance_count,
1671 }
1672 })
1673 .collect();
1674
1675 AssetMeasurements {
1676 material_resource_coverage,
1677 material_definitions,
1678 textures,
1679 images,
1680 skeleton_source_coverage,
1681 skeleton_nodes,
1682 skins,
1683 mesh_definitions,
1684 node_instances,
1685 scenes,
1686 default_scene_index: doc.assets.default_scene,
1687 }
1688}
1689
1690#[derive(Debug, Clone, Serialize, Deserialize)]
1692#[non_exhaustive]
1693pub struct GaitMeasurement {
1694 #[serde(default, skip_serializing_if = "Option::is_none")]
1697 pub phase: Option<f64>,
1698 pub lr_amplitude_m: f64,
1700}
1701
1702#[derive(Debug, Clone, Serialize, Deserialize)]
1704#[non_exhaustive]
1705pub struct BoneLoopContinuityMeasurement {
1706 pub bone_index: u32,
1708 pub bone_name: String,
1711 pub position_delta_m: f64,
1713 pub rotation_delta_deg: f64,
1715 pub seam_velocity_delta_mps: f64,
1718 pub seam_angular_velocity_delta_degps: f64,
1721}
1722
1723#[derive(Debug, Clone, Serialize, Deserialize)]
1726#[non_exhaustive]
1727pub struct LoopContinuityMeasurement {
1728 pub bones: Vec<BoneLoopContinuityMeasurement>,
1730}
1731
1732#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1739#[serde(rename_all = "snake_case")]
1740#[non_exhaustive]
1741pub enum LoopEndpointMode {
1742 UniqueCycle,
1745 DuplicateEndpoint,
1748 NonClosing,
1751}
1752
1753impl LoopEndpointMode {
1754 pub const fn as_str(self) -> &'static str {
1756 match self {
1757 Self::UniqueCycle => "unique_cycle",
1758 Self::DuplicateEndpoint => "duplicate_endpoint",
1759 Self::NonClosing => "non_closing",
1760 }
1761 }
1762}
1763
1764#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1766#[non_exhaustive]
1767pub struct FrameGridMeasurement {
1768 pub fps: f64,
1770 pub frame_intervals: u32,
1772}
1773
1774#[derive(Debug, Clone, Serialize, Deserialize)]
1776#[non_exhaustive]
1777pub struct ClipMeasurements {
1778 pub duration_s: f64,
1780 pub frame_count: u32,
1783 pub animated_bones: Vec<String>,
1785 pub bone_rotation_range_deg: BTreeMap<String, f64>,
1789 #[serde(default, skip_serializing_if = "Option::is_none")]
1793 pub loop_continuity: Option<LoopContinuityMeasurement>,
1794 #[serde(default, skip_serializing_if = "Option::is_none")]
1797 pub loop_endpoint_mode: Option<LoopEndpointMode>,
1798 #[serde(default, skip_serializing_if = "Option::is_none")]
1801 pub frame_grid: Option<FrameGridMeasurement>,
1802 #[serde(default, skip_serializing_if = "Option::is_none")]
1805 pub loop_seam_ratio: Option<f64>,
1806 #[serde(default, skip_serializing_if = "Option::is_none")]
1808 pub gait: Option<GaitMeasurement>,
1809 #[serde(default, skip_serializing_if = "Option::is_none")]
1812 pub speed_mps: Option<f64>,
1813}
1814
1815pub fn measure_document(
1824 grids: &MetricGrids<'_>,
1825 roles: &ResolvedRoles,
1826 config: &Config,
1827) -> BTreeMap<String, ClipMeasurements> {
1828 let doc = grids.document();
1829 let min_stride_step_m = config.loop_seam_min_stride_step_m();
1830 doc.clips
1831 .iter()
1832 .enumerate()
1833 .map(|(clip_index, clip)| {
1834 let mut animated: BTreeSet<String> = BTreeSet::new();
1835 let mut rotation_range: BTreeMap<String, f64> = BTreeMap::new();
1836 let mut frame_count = 0usize;
1837
1838 for track in &clip.tracks {
1839 let Some(bone) = doc.skeleton.bones.get(track.bone) else {
1840 continue;
1841 };
1842 if track.key_count() == 0 {
1843 continue;
1844 }
1845 animated.insert(bone.name.clone());
1846 frame_count = frame_count.max(track.key_count());
1847
1848 if let Some(max_deg) = rotation_range_deg(track)
1849 && max_deg >= MIN_RECORDED_ROTATION_DEG
1850 {
1851 let entry = rotation_range.entry(bone.name.clone()).or_insert(0.0);
1852 *entry = entry.max(max_deg);
1853 }
1854 }
1855
1856 let grid = grids.grid(clip_index);
1857 let cycle = grid
1858 .as_ref()
1859 .and_then(|g| foot_cycle_metrics(g, roles, min_stride_step_m));
1860 let loop_continuity = grid.as_ref().and_then(|grid| {
1861 let metrics = loop_continuity_metrics(grid)?;
1862 Some(LoopContinuityMeasurement {
1863 bones: metrics
1864 .into_iter()
1865 .enumerate()
1866 .map(|(bone_index, metrics)| BoneLoopContinuityMeasurement {
1867 bone_index: bone_index as u32,
1868 bone_name: doc.skeleton.bones[bone_index].name.clone(),
1869 position_delta_m: metrics.position_delta_m,
1870 rotation_delta_deg: metrics.rotation_delta_deg,
1871 seam_velocity_delta_mps: metrics.seam_velocity_delta_mps,
1872 seam_angular_velocity_delta_degps: metrics
1873 .seam_angular_velocity_delta_degps,
1874 })
1875 .collect(),
1876 })
1877 });
1878 let expectations = config.expectations_for(&clip.name);
1879 let (position_cap, rotation_cap) = effective_caps(config, &expectations);
1880 let loop_endpoint_mode = (expectations.looping == Some(true))
1881 .then(|| {
1882 measure_loop_endpoint_mode(clip, grid.as_deref(), position_cap, rotation_cap)
1883 })
1884 .flatten();
1885 let frame_grid = measure_frame_grid(clip, expectations.fps);
1886 let speed_mps = grid.as_ref().and_then(|g| root_motion_speed_mps(g, roles));
1887 let duration_s = if clip.duration_s.is_finite() {
1888 clip.duration_s
1889 } else {
1890 clip.tracks
1891 .iter()
1892 .flat_map(|track| track.times.iter().copied())
1893 .filter(|time| time.is_finite())
1894 .map(f64::from)
1895 .fold(0.0, f64::max)
1896 };
1897
1898 (
1899 clip.name.clone(),
1900 ClipMeasurements {
1901 duration_s,
1902 frame_count: frame_count as u32,
1903 animated_bones: animated.into_iter().collect(),
1904 bone_rotation_range_deg: rotation_range,
1905 loop_continuity,
1906 loop_endpoint_mode,
1907 frame_grid,
1908 loop_seam_ratio: cycle.as_ref().and_then(|c| c.loop_seam_ratio),
1909 gait: cycle.map(|c| GaitMeasurement {
1910 phase: c.gait_phase,
1911 lr_amplitude_m: c.lr_amplitude_m,
1912 }),
1913 speed_mps,
1914 },
1915 )
1916 })
1917 .collect()
1918}
1919
1920pub(crate) fn measure_loop_endpoint_mode(
1923 clip: &crate::model::Clip,
1924 grid: Option<&PoseGrid>,
1925 max_position_delta_m: f64,
1926 max_rotation_delta_deg: f64,
1927) -> Option<LoopEndpointMode> {
1928 match analyze_duplicate_loop_endpoint(clip) {
1929 Ok(Some(_)) => return Some(LoopEndpointMode::DuplicateEndpoint),
1930 Ok(None) => {}
1931 Err(_) => return None,
1932 }
1933 let continuity = loop_continuity_metrics(grid?)?;
1934 let closes = continuity.iter().all(|bone| {
1935 !exceeds_f32_cap(bone.position_delta_m, max_position_delta_m)
1936 && !exceeds_f32_cap(bone.rotation_delta_deg, max_rotation_delta_deg)
1937 });
1938 Some(if closes {
1939 LoopEndpointMode::UniqueCycle
1940 } else {
1941 LoopEndpointMode::NonClosing
1942 })
1943}
1944
1945pub(crate) fn measure_frame_grid(
1947 clip: &crate::model::Clip,
1948 declared_fps: Option<f64>,
1949) -> Option<FrameGridMeasurement> {
1950 let fps = declared_fps?;
1951 if !fps.is_finite() || fps <= 0.0 || !clip.duration_s.is_finite() || clip.duration_s <= 0.0 {
1952 return None;
1953 }
1954 let intervals = clip.duration_s * fps;
1955 if !intervals.is_finite() || (intervals - intervals.round()).abs() > GRID_TOLERANCE_FRAMES {
1956 return None;
1957 }
1958 let rounded = intervals.round();
1959 if !(0.0..=f64::from(u32::MAX)).contains(&rounded) {
1960 return None;
1961 }
1962 if clip
1963 .tracks
1964 .iter()
1965 .flat_map(|track| &track.times)
1966 .any(|&time| {
1967 let frames = f64::from(time) * fps;
1968 !frames.is_finite() || (frames - frames.round()).abs() > GRID_TOLERANCE_FRAMES
1969 })
1970 {
1971 return None;
1972 }
1973 Some(FrameGridMeasurement {
1974 fps,
1975 frame_intervals: rounded as u32,
1976 })
1977}
1978
1979#[cfg(test)]
1980mod tests {
1981 use super::*;
1982 use crate::model::{
1983 AdditionalInfluenceSet, AffineDomainViolation, Bone, Clip, Document, Interpolation,
1984 MeshAsset, PositiveUniformAffineTolerance, Primitive, Property, SceneAsset, SceneAssets,
1985 Skeleton, SourceInverseBindAccessor, SourceInverseBindAccessorStatus, SourceNodeAsset,
1986 SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage, SourceSkinAsset,
1987 SourceSkinAttachment, Track, TrackValues, Transform, classify_positive_uniform_affine,
1988 };
1989 use crate::profile::Role;
1990 use glam::{Mat4, Quat, Vec3};
1991
1992 fn mesh(name: &str, primitives: Vec<Primitive>) -> MeshDefinitionMeasurements {
1993 let doc = Document {
1994 assets: SceneAssets {
1995 meshes: vec![MeshAsset {
1996 name: name.into(),
1997 source_mesh_index: 0,
1998 primitives,
1999 }],
2000 ..SceneAssets::default()
2001 },
2002 ..Document::default()
2003 };
2004 measure_assets(&doc).mesh_definitions.remove(0)
2005 }
2006
2007 #[test]
2008 fn only_globally_unavailable_inverse_bind_accessors_have_a_derived_reason() {
2009 assert_eq!(
2010 derived_accessor_global_unavailable_reason(SourceInverseBindAccessorStatus::Absent),
2011 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent)
2012 );
2013 assert_eq!(
2014 derived_accessor_global_unavailable_reason(
2015 SourceInverseBindAccessorStatus::EmptyAccessor
2016 ),
2017 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorEmpty)
2018 );
2019 assert_eq!(
2020 derived_accessor_global_unavailable_reason(SourceInverseBindAccessorStatus::Unreadable),
2021 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorUnreadable)
2022 );
2023 assert_eq!(
2024 derived_accessor_global_unavailable_reason(SourceInverseBindAccessorStatus::Available),
2025 None
2026 );
2027 assert_eq!(
2028 derived_accessor_global_unavailable_reason(
2029 SourceInverseBindAccessorStatus::CountMismatch
2030 ),
2031 None,
2032 "a readable count-mismatched accessor can still supply earlier slots"
2033 );
2034 }
2035
2036 #[test]
2037 fn linear_transform_measurements_classify_affine_shape_and_orientation() {
2038 let cases = [
2039 (
2040 Mat4::IDENTITY,
2041 LinearTransformClassification::UnitOrthonormal,
2042 Some(LinearTransformOrientation::Positive),
2043 Some(1.0),
2044 ),
2045 (
2046 Mat4::from_scale(Vec3::splat(0.01)),
2047 LinearTransformClassification::UniformScaled,
2048 Some(LinearTransformOrientation::Positive),
2049 Some(f64::from(0.01f32)),
2050 ),
2051 (
2052 Mat4::from_scale(Vec3::new(2.0, 3.0, 4.0)),
2053 LinearTransformClassification::NonUniform,
2054 Some(LinearTransformOrientation::Positive),
2055 None,
2056 ),
2057 (
2058 Mat4::from_cols_array(&[
2059 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,
2060 ]),
2061 LinearTransformClassification::Sheared,
2062 Some(LinearTransformOrientation::Positive),
2063 None,
2064 ),
2065 (
2066 Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)),
2067 LinearTransformClassification::Reflected,
2068 Some(LinearTransformOrientation::Negative),
2069 Some(1.0),
2070 ),
2071 (
2072 Mat4::from_scale(Vec3::new(1.0, 0.0, 1.0)),
2073 LinearTransformClassification::Singular,
2074 Some(LinearTransformOrientation::Zero),
2075 None,
2076 ),
2077 ];
2078 for (matrix, classification, orientation, uniform_scale) in cases {
2079 let measured = measure_linear_transform(matrix);
2080 assert_eq!(measured.classification, classification);
2081 assert_eq!(measured.orientation, orientation);
2082 assert_eq!(measured.uniform_scale, uniform_scale);
2083 assert!(measured.axis_lengths.is_some());
2084 assert!(measured.determinant.is_some());
2085 }
2086
2087 let non_finite = measure_linear_transform(Mat4::from_cols_array(&[f32::NAN; 16]));
2088 assert_eq!(
2089 non_finite,
2090 LinearTransformMeasurements {
2091 classification: LinearTransformClassification::NonFinite,
2092 axis_lengths: None,
2093 determinant: None,
2094 orientation: None,
2095 uniform_scale: None,
2096 }
2097 );
2098
2099 for scale in [1.0e-30f32, 1.0e-16, 1.0e13, 1.0e30] {
2100 let measured = measure_linear_transform(Mat4::from_scale(Vec3::splat(scale)));
2101 assert_eq!(
2102 measured.classification,
2103 LinearTransformClassification::UniformScaled,
2104 "finite uniform scale {scale:e}"
2105 );
2106 assert_eq!(measured.uniform_scale, Some(f64::from(scale)));
2107 assert!(measured.determinant.is_some_and(f64::is_finite));
2108 assert_ne!(measured.determinant, Some(0.0));
2109 }
2110 }
2111
2112 #[test]
2113 fn linear_measurement_reconciles_equal_axis_fixtures_in_every_axis_order() {
2114 let permutations = |[x, y, z]: [f32; 3]| {
2115 [
2116 Vec3::new(x, y, z),
2117 Vec3::new(x, z, y),
2118 Vec3::new(y, x, z),
2119 Vec3::new(y, z, x),
2120 Vec3::new(z, x, y),
2121 Vec3::new(z, y, x),
2122 ]
2123 };
2124 let policy = PositiveUniformAffineTolerance {
2125 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2126 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2127 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
2128 };
2129
2130 for diagonal in permutations([1.0, 1.0, 1.000_012]) {
2131 let measured = measure_linear_transform(Mat4::from_scale(diagonal));
2132 assert_eq!(
2133 measured.classification,
2134 LinearTransformClassification::UnitOrthonormal,
2135 "issue fixture {diagonal:?}"
2136 );
2137 assert_eq!(
2138 classify_positive_uniform_affine(Mat3::from_diagonal(diagonal), policy),
2139 measured
2140 .uniform_scale
2141 .ok_or(AffineDomainViolation::NonFinite),
2142 "measurement and Appendix D share the equal-axis decision"
2143 );
2144 }
2145
2146 let high = f32::from_bits(0x3f80_004b);
2150 let low = f32::from_bits(0x3f7f_ff69);
2151 for diagonal in permutations([1.0, high, low]) {
2152 assert_eq!(
2153 measure_linear_transform(Mat4::from_scale(diagonal)).classification,
2154 LinearTransformClassification::UnitOrthonormal,
2155 "axis-order counterexample {diagonal:?}"
2156 );
2157 }
2158 }
2159
2160 #[test]
2161 fn linear_measurement_uses_the_shared_canonical_mean_in_every_axis_order() {
2162 let columns = [
2168 Vec3::new(
2169 f32::from_bits(0x3f7f_fd59),
2170 f32::from_bits(0x3bd8_d637),
2171 0.0,
2172 ),
2173 Vec3::new(
2174 -f32::from_bits(0x3bd8_d69d),
2175 f32::from_bits(0x3f7f_fdd1),
2176 0.0,
2177 ),
2178 Vec3::Z,
2179 ];
2180 let permutations = [
2181 Mat3::from_cols(columns[0], columns[1], columns[2]),
2182 Mat3::from_cols(-columns[0], columns[2], columns[1]),
2183 Mat3::from_cols(-columns[1], columns[0], columns[2]),
2184 Mat3::from_cols(columns[1], columns[2], columns[0]),
2185 Mat3::from_cols(columns[2], columns[0], columns[1]),
2186 Mat3::from_cols(-columns[2], columns[1], columns[0]),
2187 ];
2188 let policy = PositiveUniformAffineTolerance {
2189 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2190 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2191 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
2192 };
2193 let expected_mean = f64::from_bits(0x3fef_ffeb_074a_771d);
2194
2195 for (index, linear) in permutations.into_iter().enumerate() {
2196 let measured = measure_linear_transform(Mat4::from_mat3(linear));
2197 assert_eq!(
2198 measured.classification,
2199 LinearTransformClassification::UnitOrthonormal,
2200 "canonical mean must give proper permutation {index} one stable class"
2201 );
2202 assert_eq!(
2203 measured.uniform_scale,
2204 Some(expected_mean),
2205 "measurement must publish the canonical mean for permutation {index}"
2206 );
2207 assert_eq!(
2208 classify_positive_uniform_affine(linear, policy),
2209 Ok(expected_mean),
2210 "the shared classifier must consume the same mean for permutation {index}"
2211 );
2212 }
2213 }
2214
2215 #[test]
2216 fn linear_measurement_reports_axis_lengths_in_xyz_column_order() {
2217 let measured = measure_linear_transform(Mat4::from_scale(Vec3::new(2.0, 3.0, 5.0)));
2218
2219 assert_eq!(measured.axis_lengths, Some([2.0, 3.0, 5.0]));
2220 }
2221
2222 #[test]
2223 fn affine_consumers_widen_each_pair_dot_before_comparison() {
2224 let x = Vec3::new(
2229 f32::from_bits(0x3fd8_2778),
2230 f32::from_bits(0x3fd9_ea4a),
2231 0.0,
2232 );
2233 let y = Vec3::new(
2234 f32::from_bits(0xbfd9_e92c),
2235 f32::from_bits(0x3fd8_2778),
2236 0.0,
2237 );
2238 let z = Vec3::new(0.0, 0.0, f32::from_bits(0x4019_77cc));
2239 let widened_dot = x.as_dvec3().dot(y.as_dvec3()).abs();
2240 let f32_first_dot = f64::from(x.dot(y).abs());
2241 let x_length = x.as_dvec3().length();
2242 let y_length = y.as_dvec3().length();
2243 let z_length = f64::from(z.z);
2244 let pair_tolerance = LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE * x_length * y_length;
2245 let mean = (x_length + y_length + z_length) / 3.0;
2246 let common_tolerance = LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE * mean * mean;
2247 let policy = PositiveUniformAffineTolerance {
2248 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2249 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2250 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
2251 };
2252
2253 assert!(f32_first_dot <= pair_tolerance && widened_dot > pair_tolerance);
2254 assert!(f32_first_dot <= common_tolerance && widened_dot > common_tolerance);
2255
2256 for (pair, linear) in [
2257 ("positive XY", Mat3::from_cols(x, y, z)),
2258 ("negative XY", Mat3::from_cols(x, -y, -z)),
2259 ("positive XZ", Mat3::from_cols(x, -z, y)),
2260 ("negative XZ", Mat3::from_cols(x, z, -y)),
2261 ("positive YZ", Mat3::from_cols(z, x, y)),
2262 ("negative YZ", Mat3::from_cols(-z, x, -y)),
2263 ] {
2264 let measured = measure_linear_transform(Mat4::from_mat3(linear));
2265 assert_eq!(
2266 measured.classification,
2267 LinearTransformClassification::Sheared,
2268 "measurement must compare the widened {pair} dot"
2269 );
2270 assert_eq!(
2271 classify_positive_uniform_affine(linear, policy),
2272 Err(AffineDomainViolation::Sheared),
2273 "the positive-uniform classifier must compare the same widened {pair} dot"
2274 );
2275 }
2276 }
2277
2278 #[test]
2279 fn linear_measurement_pins_equal_axis_boundaries_and_extreme_finite_scales() {
2280 let on_long_edge = Vec3::new(99_998.5, 99_998.5, 100_000.0);
2281 let measured = measure_linear_transform(Mat4::from_scale(on_long_edge));
2282 assert_eq!(
2283 measured.classification,
2284 LinearTransformClassification::UniformScaled
2285 );
2286 assert_eq!(measured.uniform_scale, Some(99_999.0));
2287
2288 let short = 99_998.5;
2289 let outside = 100_000.0 + 0.007_812_5;
2290 for diagonal in [
2291 Vec3::new(outside, short, short),
2292 Vec3::new(short, outside, short),
2293 Vec3::new(short, short, outside),
2294 ] {
2295 assert_eq!(
2296 measure_linear_transform(Mat4::from_scale(diagonal)).classification,
2297 LinearTransformClassification::NonUniform
2298 );
2299 }
2300
2301 for scale in [f32::from_bits(1), f32::MIN_POSITIVE, f32::MAX] {
2302 let measured = measure_linear_transform(Mat4::from_scale(Vec3::splat(scale)));
2303 assert_eq!(
2304 measured.classification,
2305 LinearTransformClassification::UniformScaled,
2306 "complete finite f32 scale range at {scale:e}"
2307 );
2308 assert_eq!(measured.uniform_scale, Some(f64::from(scale)));
2309 assert!(measured.determinant.is_some_and(f64::is_finite));
2310 }
2311 }
2312
2313 #[test]
2314 fn linear_measurement_pins_pair_normalization_and_public_precedence() {
2315 let pair_normalized_shear = Mat3::from_cols(
2316 Vec3::X,
2317 Vec3::new(3.0e-5, 2.0, 0.0),
2318 Vec3::new(0.0, 0.0, 3.0),
2319 );
2320 let facts = AffineGeometryFacts::from_linear(pair_normalized_shear).unwrap();
2321 assert!(
2322 facts.cross_axis_dots[0].abs()
2323 > LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE
2324 * facts.axis_lengths[0]
2325 * facts.axis_lengths[1]
2326 );
2327 assert!(
2328 facts.cross_axis_dots[0].abs()
2329 <= LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE
2330 * facts.mean_axis_length
2331 * facts.mean_axis_length,
2332 "measurement intentionally does not use the operation classifier's common-factor band"
2333 );
2334 let measured = measure_linear_transform(Mat4::from_mat3(pair_normalized_shear));
2335 assert_eq!(
2336 measured.classification,
2337 LinearTransformClassification::Sheared,
2338 "public measurement must use the XY pair product, not mean squared"
2339 );
2340 for shear in [3.0e-5, -3.0e-5] {
2341 let signed_shear = Mat3::from_cols(Vec3::X, Vec3::new(shear, 2.0, 0.0), Vec3::Z);
2342 assert_eq!(
2343 measure_linear_transform(Mat4::from_mat3(signed_shear)).classification,
2344 LinearTransformClassification::Sheared,
2345 "orthogonality is independent of the dot-product sign"
2346 );
2347 }
2348 for (pair, linear) in [
2349 (
2350 "XZ",
2351 Mat3::from_cols(
2352 Vec3::X,
2353 Vec3::new(0.0, 100.0, 0.0),
2354 Vec3::new(1.5e-5, 0.0, 1.0),
2355 ),
2356 ),
2357 (
2358 "negative XZ",
2359 Mat3::from_cols(
2360 Vec3::X,
2361 Vec3::new(0.0, 100.0, 0.0),
2362 Vec3::new(-1.5e-5, 0.0, 1.0),
2363 ),
2364 ),
2365 (
2366 "YZ",
2367 Mat3::from_cols(
2368 Vec3::new(100.0, 0.0, 0.0),
2369 Vec3::Y,
2370 Vec3::new(0.0, 1.5e-5, 1.0),
2371 ),
2372 ),
2373 (
2374 "negative YZ",
2375 Mat3::from_cols(
2376 Vec3::new(100.0, 0.0, 0.0),
2377 Vec3::Y,
2378 Vec3::new(0.0, -1.5e-5, 1.0),
2379 ),
2380 ),
2381 ] {
2382 assert_eq!(
2383 measure_linear_transform(Mat4::from_mat3(linear)).classification,
2384 LinearTransformClassification::Sheared,
2385 "{pair} dot must use that pair's own length product"
2386 );
2387 }
2388 assert_eq!(
2389 classify_positive_uniform_affine(
2390 pair_normalized_shear,
2391 PositiveUniformAffineTolerance {
2392 equal_axis: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2393 relative_orthogonality: LINEAR_CLASSIFICATION_RELATIVE_TOLERANCE,
2394 singular_determinant_relative: LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE,
2395 },
2396 ),
2397 Err(AffineDomainViolation::NonUniformScale),
2398 "the positive-uniform operation classifier intentionally rejects shape before shear"
2399 );
2400
2401 let singular_reflected_shear = Mat4::from_cols(
2402 (-Vec3::X).extend(0.0),
2403 Vec3::new(0.5, 1.0e-8, 0.0).extend(0.0),
2404 Vec3::Z.extend(0.0),
2405 glam::Vec4::W,
2406 );
2407 let singular = measure_linear_transform(singular_reflected_shear);
2408 assert_eq!(
2409 singular.classification,
2410 LinearTransformClassification::Singular
2411 );
2412 assert_eq!(
2413 singular.orientation,
2414 Some(LinearTransformOrientation::Zero),
2415 "singularity owns the public orientation before determinant sign"
2416 );
2417 assert!(singular.determinant.is_some_and(|value| value < 0.0));
2418
2419 let reflected_shear = Mat4::from_cols(
2420 (-Vec3::X).extend(0.0),
2421 Vec3::new(0.5, 1.0, 0.0).extend(0.0),
2422 Vec3::Z.extend(0.0),
2423 glam::Vec4::W,
2424 );
2425 assert_eq!(
2426 measure_linear_transform(reflected_shear).classification,
2427 LinearTransformClassification::Reflected
2428 );
2429 }
2430
2431 #[test]
2432 fn linear_measurement_uses_axis_length_product_for_singularity() {
2433 let linear = Mat3::from_cols(
2434 Vec3::new(1.0, 0.0, 0.0),
2435 Vec3::new(0.0, 100.0, 0.0),
2436 Vec3::new(100.0, 0.0, 0.001),
2437 );
2438 let facts = AffineGeometryFacts::from_linear(linear).unwrap();
2439 let determinant = facts.determinant.abs();
2440 let product_threshold =
2441 LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE * facts.axis_length_product;
2442 let mean_cubed_threshold =
2443 LINEAR_CLASSIFICATION_SINGULAR_TOLERANCE * facts.mean_axis_length.powi(3);
2444
2445 assert!(
2446 determinant > product_threshold,
2447 "the true axis-length-product threshold must not classify this matrix as singular"
2448 );
2449 assert!(
2450 determinant <= mean_cubed_threshold,
2451 "a mean-cubed threshold must disagree on this singularity boundary fixture"
2452 );
2453
2454 let measured = measure_linear_transform(Mat4::from_mat3(linear));
2455 assert_eq!(
2456 measured.classification,
2457 LinearTransformClassification::Sheared
2458 );
2459 assert_eq!(
2460 measured.orientation,
2461 Some(LinearTransformOrientation::Positive)
2462 );
2463 }
2464
2465 #[test]
2466 fn linear_measurement_is_atomic_for_non_finite_mat4_components() {
2467 for index in 0..16 {
2468 let mut columns = Mat4::IDENTITY.to_cols_array();
2469 columns[index] = f32::NAN;
2470 assert_eq!(
2471 measure_linear_transform(Mat4::from_cols_array(&columns)),
2472 unavailable_linear_transform(),
2473 "component {index} must make every numeric fact unavailable"
2474 );
2475 }
2476 }
2477
2478 #[test]
2479 fn linear_measurement_reports_the_canonical_widened_determinant() {
2480 let linear = Mat3::from_cols(
2481 Vec3::new(
2482 f32::from_bits(0x3ff3_5574),
2483 f32::from_bits(0x3f0e_fa3c),
2484 0.0,
2485 ),
2486 Vec3::new(
2487 f32::from_bits(0x3ff5_5e17),
2488 f32::from_bits(0x3f10_2c31),
2489 0.0,
2490 ),
2491 Vec3::Z,
2492 );
2493 let measured = measure_linear_transform(Mat4::from_mat3(linear));
2494 assert_eq!(
2495 measured.determinant.map(f64::to_bits),
2496 Some(0x3eb4_b98f_a000_0000)
2497 );
2498 assert_ne!(measured.determinant, Some(f64::from(linear.determinant())));
2499 }
2500
2501 #[test]
2502 fn skin_bind_summary_covers_every_stable_aggregate_class() {
2503 let available_joint = |joint_index, matrix| SkinJointMeasurements {
2504 joint_index,
2505 node_index: joint_index,
2506 joint_bind_to_mesh: available_derived_matrix(matrix),
2507 mesh_bind_world: available_derived_matrix(Mat4::IDENTITY),
2508 };
2509 let unavailable_joint = |joint_index| SkinJointMeasurements {
2510 joint_index,
2511 node_index: joint_index,
2512 joint_bind_to_mesh: unavailable_derived_matrix(
2513 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent,
2514 ),
2515 mesh_bind_world: unavailable_derived_matrix(
2516 SkinDerivedMatrixUnavailableReason::InverseBindAccessorAbsent,
2517 ),
2518 };
2519 let assert_summary = |joints: &[SkinJointMeasurements],
2520 classification,
2521 available_joint_count,
2522 unavailable_joint_count,
2523 consistent_uniform_scale| {
2524 assert_eq!(
2525 summarize_skin_bind_linear(joints),
2526 SkinBindLinearSummaryMeasurements {
2527 classification,
2528 joint_count: joints.len(),
2529 available_joint_count,
2530 unavailable_joint_count,
2531 consistent_uniform_scale,
2532 }
2533 );
2534 };
2535
2536 assert_summary(
2537 &[],
2538 SkinBindLinearSummaryClassification::NoJoints,
2539 0,
2540 0,
2541 None,
2542 );
2543 assert_summary(
2544 &[unavailable_joint(0)],
2545 SkinBindLinearSummaryClassification::Unavailable,
2546 0,
2547 1,
2548 None,
2549 );
2550 assert_summary(
2551 &[available_joint(0, Mat4::IDENTITY), unavailable_joint(1)],
2552 SkinBindLinearSummaryClassification::PartiallyUnavailable,
2553 1,
2554 1,
2555 None,
2556 );
2557 assert_summary(
2558 &[
2559 available_joint(0, Mat4::IDENTITY),
2560 available_joint(1, Mat4::IDENTITY),
2561 ],
2562 SkinBindLinearSummaryClassification::ConsistentUniform,
2563 2,
2564 0,
2565 Some(1.0),
2566 );
2567 assert_summary(
2568 &[
2569 available_joint(0, Mat4::IDENTITY),
2570 available_joint(1, Mat4::from_scale(Vec3::splat(2.0))),
2571 ],
2572 SkinBindLinearSummaryClassification::MixedUniform,
2573 2,
2574 0,
2575 None,
2576 );
2577 assert_summary(
2578 &[
2579 available_joint(0, Mat4::from_scale(Vec3::new(1.0, 2.0, 3.0))),
2580 available_joint(
2581 1,
2582 Mat4::from_cols_array(&[
2583 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,
2584 1.0,
2585 ]),
2586 ),
2587 ],
2588 SkinBindLinearSummaryClassification::NonUniformOrSheared,
2589 2,
2590 0,
2591 None,
2592 );
2593 assert_summary(
2594 &[
2595 available_joint(0, Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0))),
2596 available_joint(
2597 1,
2598 Mat4::from_cols_array(&[
2599 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,
2600 0.0, 1.0,
2601 ]),
2602 ),
2603 ],
2604 SkinBindLinearSummaryClassification::ReflectedOrSingular,
2605 2,
2606 0,
2607 None,
2608 );
2609 assert_summary(
2610 &[
2611 available_joint(0, Mat4::IDENTITY),
2612 available_joint(1, Mat4::from_scale(Vec3::new(1.0, 2.0, 3.0))),
2613 ],
2614 SkinBindLinearSummaryClassification::Mixed,
2615 2,
2616 0,
2617 None,
2618 );
2619 }
2620
2621 #[test]
2622 fn skin_bind_summary_is_joint_order_invariant_and_reports_the_mean() {
2623 let matrix_from_bits = |columns: [[u32; 4]; 4]| {
2624 Mat4::from_cols(
2625 glam::Vec4::from_array(columns[0].map(f32::from_bits)),
2626 glam::Vec4::from_array(columns[1].map(f32::from_bits)),
2627 glam::Vec4::from_array(columns[2].map(f32::from_bits)),
2628 glam::Vec4::from_array(columns[3].map(f32::from_bits)),
2629 )
2630 };
2631 let raw_inverse_binds = [
2632 matrix_from_bits([
2633 [0xbcde_4500, 0xbd7b_2918, 0x3f7f_6c80, 0],
2634 [0x3f40_907c, 0xbf28_9ba8, 0xbca4_0480, 0],
2635 [0x3f28_8afa, 0x3f3f_fdef, 0x3d83_0f78, 0],
2636 [0, 0, 0, 0x3f80_0000],
2637 ]),
2638 matrix_from_bits([
2639 [0x3da5_7c20, 0xbf7e_c9a2, 0xbd5d_55e0, 0],
2640 [0x3e48_71f6, 0xbd18_d560, 0x3f7a_dda0, 0],
2641 [0xbf7a_31a0, 0xbdb7_d42c, 0x3e44_6898, 0],
2642 [0, 0, 0, 0x3f80_0000],
2643 ]),
2644 matrix_from_bits([
2645 [0xbee1_b0e8, 0xbd50_c238, 0xbf65_6a79, 0],
2646 [0xbf62_2552, 0xbe1c_0be8, 0x3ee2_e94f, 0],
2647 [0xbe22_f8bc, 0x3f7c_ac66, 0x3cb5_7540, 0],
2648 [0, 0, 0, 0x3f80_0000],
2649 ]),
2650 ];
2651 let expected_factor_bits = [
2652 0x3ff0_0000_110e_4203,
2653 0x3ff0_0000_2d55_0083,
2654 0x3fef_ffff_b3bb_b2b8,
2655 ];
2656 let expected_mean = f64::from_bits(0x3ff0_0000_0815_b3f6);
2657 let permutations = [
2658 [0usize, 1usize, 2usize],
2659 [0, 2, 1],
2660 [1, 0, 2],
2661 [1, 2, 0],
2662 [2, 0, 1],
2663 [2, 1, 0],
2664 ];
2665
2666 for order in permutations {
2667 let doc = Document {
2668 assets: SceneAssets {
2669 source_skeleton: SourceSkeletonAssets {
2670 coverage: SourceSkeletonCoverage::Complete,
2671 nodes: (0..3)
2672 .map(|source_node_index| SourceNodeAsset {
2673 source_node_index,
2674 name: Some(format!("joint_{source_node_index}")),
2675 parent_source_node_index: None,
2676 scene_root_indices: vec![0],
2677 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
2678 bone: None,
2679 })
2680 .collect(),
2681 skins: vec![SourceSkinAsset {
2682 source_skin_index: 0,
2683 name: Some("order_invariant_uniform_bind_scale".into()),
2684 skeleton_root_source_node_index: Some(0),
2685 joint_source_node_indices: order.to_vec(),
2686 inverse_bind_accessor: SourceInverseBindAccessor {
2687 status: SourceInverseBindAccessorStatus::Available,
2688 declared_count: Some(3),
2689 matrices: order.map(|index| raw_inverse_binds[index]).to_vec(),
2690 },
2691 attachments: Vec::new(),
2692 }],
2693 },
2694 ..SceneAssets::default()
2695 },
2696 ..Document::default()
2697 };
2698
2699 let measured = measure_assets(&doc);
2700 let skin = &measured.skins[0];
2701 assert_eq!(
2702 skin.joints
2703 .iter()
2704 .map(|joint| {
2705 let linear = joint
2706 .joint_bind_to_mesh
2707 .linear
2708 .expect("finite invertible raw inverse binds are measurable");
2709 assert_eq!(
2710 linear.classification,
2711 LinearTransformClassification::UnitOrthonormal
2712 );
2713 linear
2714 .uniform_scale
2715 .expect("uniform joint binds carry their factor")
2716 .to_bits()
2717 })
2718 .collect::<Vec<_>>(),
2719 order.map(|index| expected_factor_bits[index]).to_vec(),
2720 "source joint order {order:?}"
2721 );
2722 assert_eq!(
2723 skin.joint_bind_linear_summary,
2724 SkinBindLinearSummaryMeasurements {
2725 classification: SkinBindLinearSummaryClassification::ConsistentUniform,
2726 joint_count: 3,
2727 available_joint_count: 3,
2728 unavailable_joint_count: 0,
2729 consistent_uniform_scale: Some(expected_mean),
2730 },
2731 "source joint order {order:?}"
2732 );
2733 }
2734 assert_ne!(
2735 expected_mean, 1.0,
2736 "the summary reports its mean, not joint 0"
2737 );
2738 }
2739
2740 #[test]
2741 fn skin_bind_summary_classification_is_mean_relative_in_every_joint_order() {
2742 let factors = [
2743 1.0_f32,
2744 f32::from_bits(0x3f80_004b),
2745 f32::from_bits(0x3f7f_ff69),
2746 ];
2747 let mut sorted_factors = factors.map(f64::from);
2748 sorted_factors.sort_by(f64::total_cmp);
2749 let expected_mean = sorted_factors.into_iter().sum::<f64>() / factors.len() as f64;
2750 let permutations = [
2751 [0usize, 1usize, 2usize],
2752 [0, 2, 1],
2753 [1, 0, 2],
2754 [1, 2, 0],
2755 [2, 0, 1],
2756 [2, 1, 0],
2757 ];
2758
2759 for order in permutations {
2760 let joints = order.map(|index| SkinJointMeasurements {
2761 joint_index: index,
2762 node_index: index,
2763 joint_bind_to_mesh: available_derived_matrix(Mat4::from_scale(Vec3::splat(
2764 factors[index],
2765 ))),
2766 mesh_bind_world: available_derived_matrix(Mat4::IDENTITY),
2767 });
2768 assert_eq!(
2769 summarize_skin_bind_linear(&joints),
2770 SkinBindLinearSummaryMeasurements {
2771 classification: SkinBindLinearSummaryClassification::ConsistentUniform,
2772 joint_count: 3,
2773 available_joint_count: 3,
2774 unavailable_joint_count: 0,
2775 consistent_uniform_scale: Some(expected_mean),
2776 },
2777 "high/low factors straddle the first-joint band in order {order:?}"
2778 );
2779 }
2780 }
2781
2782 #[test]
2783 fn source_measurement_reports_disagreeing_uniform_joint_bind_scales() {
2784 let doc = Document {
2785 assets: SceneAssets {
2786 source_skeleton: SourceSkeletonAssets {
2787 coverage: SourceSkeletonCoverage::Complete,
2788 nodes: (0..2)
2789 .map(|source_node_index| SourceNodeAsset {
2790 source_node_index,
2791 name: Some(format!("joint_{source_node_index}")),
2792 parent_source_node_index: None,
2793 scene_root_indices: vec![0],
2794 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
2795 bone: None,
2796 })
2797 .collect(),
2798 skins: vec![SourceSkinAsset {
2799 source_skin_index: 0,
2800 name: Some("mixed_uniform_bind_scale".into()),
2801 skeleton_root_source_node_index: Some(0),
2802 joint_source_node_indices: vec![0, 1],
2803 inverse_bind_accessor: SourceInverseBindAccessor {
2804 status: SourceInverseBindAccessorStatus::Available,
2805 declared_count: Some(2),
2806 matrices: vec![Mat4::IDENTITY, Mat4::from_scale(Vec3::splat(0.5))],
2807 },
2808 attachments: Vec::new(),
2809 }],
2810 },
2811 ..SceneAssets::default()
2812 },
2813 ..Document::default()
2814 };
2815
2816 let measured = measure_assets(&doc);
2817 let skin = &measured.skins[0];
2818 assert_eq!(
2819 skin.joints
2820 .iter()
2821 .map(|joint| {
2822 let linear = joint
2823 .joint_bind_to_mesh
2824 .linear
2825 .expect("finite invertible raw inverse binds are measurable");
2826 (linear.classification, linear.uniform_scale)
2827 })
2828 .collect::<Vec<_>>(),
2829 vec![
2830 (LinearTransformClassification::UnitOrthonormal, Some(1.0)),
2831 (LinearTransformClassification::UniformScaled, Some(2.0)),
2832 ]
2833 );
2834 assert_eq!(
2835 skin.joint_bind_linear_summary,
2836 SkinBindLinearSummaryMeasurements {
2837 classification: SkinBindLinearSummaryClassification::MixedUniform,
2838 joint_count: 2,
2839 available_joint_count: 2,
2840 unavailable_joint_count: 0,
2841 consistent_uniform_scale: None,
2842 }
2843 );
2844 }
2845
2846 #[test]
2847 fn non_finite_source_rest_is_explicit_in_matrix_and_linear_domains() {
2848 let doc = Document {
2849 assets: SceneAssets {
2850 source_skeleton: SourceSkeletonAssets {
2851 coverage: SourceSkeletonCoverage::Complete,
2852 nodes: vec![SourceNodeAsset {
2853 source_node_index: 0,
2854 name: None,
2855 parent_source_node_index: None,
2856 scene_root_indices: Vec::new(),
2857 local_rest: SourceNodeLocalRest::Matrix(Mat4::from_cols_array(
2858 &[f32::NAN; 16],
2859 )),
2860 bone: None,
2861 }],
2862 skins: Vec::new(),
2863 },
2864 ..SceneAssets::default()
2865 },
2866 ..Document::default()
2867 };
2868 let node = &measure_assets(&doc).skeleton_nodes[0];
2869 assert!(node.rest_world_matrix.is_none());
2870 assert!(node.rest_world_translation_m.is_none());
2871 assert_eq!(
2872 node.rest_world_matrix_unavailable_reason,
2873 Some(SkeletonRestWorldMatrixUnavailableReason::NonFiniteLocalRest)
2874 );
2875 assert_eq!(
2876 node.rest_world_linear.classification,
2877 LinearTransformClassification::NonFinite
2878 );
2879 assert!(node.rest_world_linear.axis_lengths.is_none());
2880 }
2881
2882 #[test]
2883 fn source_skeleton_measurement_preserves_source_order_and_bind_domains() {
2884 let skeleton = Skeleton {
2888 bones: vec![
2889 Bone {
2890 name: "root".into(),
2891 parent: None,
2892 rest: Transform {
2893 translation: Vec3::new(10.0, 0.0, 0.0),
2894 ..Transform::IDENTITY
2895 },
2896 inverse_bind: None,
2897 },
2898 Bone {
2899 name: "joint".into(),
2900 parent: Some(0),
2901 rest: Transform {
2902 translation: Vec3::new(2.0, 0.0, 0.0),
2903 ..Transform::IDENTITY
2904 },
2905 inverse_bind: None,
2906 },
2907 Bone {
2908 name: "mesh".into(),
2909 parent: Some(0),
2910 rest: Transform::IDENTITY,
2911 inverse_bind: None,
2912 },
2913 ],
2914 };
2915 let doc = Document {
2916 skeleton,
2917 assets: SceneAssets {
2918 scenes: vec![SceneAsset {
2919 source_scene_index: 4,
2920 name: None,
2921 roots: vec![0],
2922 }],
2923 source_skeleton: SourceSkeletonAssets {
2924 coverage: SourceSkeletonCoverage::Complete,
2925 nodes: vec![
2926 SourceNodeAsset {
2927 source_node_index: 0,
2928 name: Some("joint".into()),
2929 parent_source_node_index: Some(1),
2930 scene_root_indices: vec![],
2931 local_rest: SourceNodeLocalRest::Trs {
2932 translation: Vec3::new(2.0, 0.0, 0.0),
2933 rotation: Quat::IDENTITY,
2934 scale: Vec3::ONE,
2935 },
2936 bone: None,
2937 },
2938 SourceNodeAsset {
2939 source_node_index: 1,
2940 name: Some("root".into()),
2941 parent_source_node_index: None,
2942 scene_root_indices: vec![4],
2943 local_rest: SourceNodeLocalRest::Trs {
2944 translation: Vec3::new(10.0, 0.0, 0.0),
2945 rotation: Quat::IDENTITY,
2946 scale: Vec3::ONE,
2947 },
2948 bone: None,
2949 },
2950 SourceNodeAsset {
2951 source_node_index: 2,
2952 name: Some("mesh".into()),
2953 parent_source_node_index: Some(1),
2954 scene_root_indices: vec![],
2955 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
2956 bone: None,
2957 },
2958 ],
2959 skins: vec![SourceSkinAsset {
2960 source_skin_index: 0,
2961 name: Some("skin".into()),
2962 skeleton_root_source_node_index: Some(1),
2963 joint_source_node_indices: vec![0],
2964 inverse_bind_accessor: SourceInverseBindAccessor {
2965 status: SourceInverseBindAccessorStatus::Available,
2966 declared_count: Some(2),
2967 matrices: vec![
2968 Mat4::from_translation(Vec3::new(-12.0, 0.0, 0.0)),
2969 Mat4::IDENTITY,
2970 ],
2971 },
2972 attachments: vec![SourceSkinAttachment {
2973 source_node_index: 2,
2974 source_mesh_index: Some(7),
2975 }],
2976 }],
2977 },
2978 ..SceneAssets::default()
2979 },
2980 ..Document::default()
2981 };
2982
2983 let measured = measure_assets(&doc);
2984 assert_eq!(
2985 measured.skeleton_source_coverage,
2986 SourceSkeletonCoverage::Complete
2987 );
2988 assert_eq!(
2989 measured
2990 .skeleton_nodes
2991 .iter()
2992 .map(|node| node.node_index)
2993 .collect::<Vec<_>>(),
2994 vec![0, 1, 2]
2995 );
2996 assert_eq!(measured.skeleton_nodes[0].parent_node_index, Some(1));
2997 assert_eq!(measured.skeleton_nodes[1].scene_root_indices, vec![4]);
2998 assert_eq!(
2999 measured.skeleton_nodes[0]
3000 .rest_world_matrix
3001 .expect("finite child rest world")[12],
3002 12.0
3003 );
3004 let skin = &measured.skins[0];
3005 assert_eq!(skin.skeleton_root_node_index, Some(1));
3006 assert_eq!(
3007 skin.inverse_bind_accessor.matrices.len(),
3008 2,
3009 "extra raw IBM survives"
3010 );
3011 assert_eq!(skin.attachments[0].node_index, 2);
3012 assert_eq!(skin.attachments[0].mesh_index, Some(7));
3013 assert_eq!(skin.joints[0].joint_bind_to_mesh.matrix.unwrap()[12], 12.0);
3014 assert_eq!(
3015 skin.joints[0].mesh_bind_world.matrix.unwrap(),
3016 Mat4::IDENTITY.to_cols_array()
3017 );
3018 }
3019
3020 #[test]
3021 fn count_mismatched_inverse_bind_accessor_keeps_present_slots_and_marks_missing_ones() {
3022 let doc = Document {
3023 assets: SceneAssets {
3024 source_skeleton: SourceSkeletonAssets {
3025 coverage: SourceSkeletonCoverage::Complete,
3026 nodes: vec![SourceNodeAsset {
3027 source_node_index: 0,
3028 name: None,
3029 parent_source_node_index: None,
3030 scene_root_indices: vec![],
3031 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
3032 bone: None,
3033 }],
3034 skins: vec![SourceSkinAsset {
3035 source_skin_index: 0,
3036 name: None,
3037 skeleton_root_source_node_index: None,
3038 joint_source_node_indices: vec![0, 0],
3039 inverse_bind_accessor: SourceInverseBindAccessor {
3040 status: SourceInverseBindAccessorStatus::CountMismatch,
3041 declared_count: Some(1),
3042 matrices: vec![Mat4::IDENTITY],
3043 },
3044 attachments: vec![],
3045 }],
3046 },
3047 ..SceneAssets::default()
3048 },
3049 ..Document::default()
3050 };
3051
3052 let skin = &measure_assets(&doc).skins[0];
3053 assert_eq!(
3054 skin.joints[0].joint_bind_to_mesh.matrix,
3055 Some(Mat4::IDENTITY.to_cols_array())
3056 );
3057 assert_eq!(
3058 skin.joints[1].joint_bind_to_mesh.unavailable_reason,
3059 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
3060 );
3061 assert_eq!(
3062 skin.joints[1].mesh_bind_world.unavailable_reason,
3063 Some(SkinDerivedMatrixUnavailableReason::InverseBindAccessorCountMismatch)
3064 );
3065 }
3066
3067 #[test]
3068 fn source_skeleton_measurement_preserves_full_matrix_domains() {
3069 let doc = Document {
3072 assets: SceneAssets {
3073 source_skeleton: SourceSkeletonAssets {
3074 coverage: SourceSkeletonCoverage::Complete,
3075 nodes: vec![SourceNodeAsset {
3076 source_node_index: 0,
3077 name: None,
3078 parent_source_node_index: None,
3079 scene_root_indices: vec![],
3080 local_rest: SourceNodeLocalRest::Matrix(Mat4::from_cols_array(&[
3081 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,
3082 30.0, 1.0,
3083 ])),
3084 bone: None,
3085 }],
3086 skins: vec![SourceSkinAsset {
3087 source_skin_index: 0,
3088 name: None,
3089 skeleton_root_source_node_index: Some(0),
3090 joint_source_node_indices: vec![0],
3091 inverse_bind_accessor: SourceInverseBindAccessor {
3092 status: SourceInverseBindAccessorStatus::Available,
3093 declared_count: Some(1),
3094 matrices: vec![Mat4::from_cols_array(&[
3095 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,
3096 2.0, 3.0, 1.0,
3097 ])],
3098 },
3099 attachments: vec![],
3100 }],
3101 },
3102 ..SceneAssets::default()
3103 },
3104 ..Document::default()
3105 };
3106
3107 let joint = &measure_assets(&doc).skins[0].joints[0];
3108 assert_eq!(
3109 joint.joint_bind_to_mesh.matrix,
3110 Some([
3111 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,
3112 ])
3113 );
3114 assert_eq!(
3115 joint.mesh_bind_world.matrix,
3116 Some([
3117 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,
3118 ])
3119 );
3120 }
3121
3122 #[test]
3123 fn source_skeleton_measurement_handles_a_deep_leaf_first_hierarchy() {
3124 const NODE_COUNT: usize = 16_384;
3125 let nodes = (0..NODE_COUNT)
3126 .map(|node_index| SourceNodeAsset {
3127 source_node_index: node_index,
3128 name: None,
3129 parent_source_node_index: (node_index + 1 < NODE_COUNT).then_some(node_index + 1),
3130 scene_root_indices: Vec::new(),
3131 local_rest: SourceNodeLocalRest::Matrix(if node_index + 1 == NODE_COUNT {
3132 Mat4::from_translation(Vec3::X)
3133 } else {
3134 Mat4::IDENTITY
3135 }),
3136 bone: None,
3137 })
3138 .collect();
3139 let doc = Document {
3140 assets: SceneAssets {
3141 source_skeleton: SourceSkeletonAssets {
3142 coverage: SourceSkeletonCoverage::Complete,
3143 nodes,
3144 skins: Vec::new(),
3145 },
3146 ..SceneAssets::default()
3147 },
3148 ..Document::default()
3149 };
3150
3151 let measured = measure_assets(&doc);
3152 assert_eq!(measured.skeleton_nodes.len(), NODE_COUNT);
3153 assert_eq!(
3154 measured.skeleton_nodes[0]
3155 .rest_world_matrix
3156 .expect("deep leaf rest world")[12],
3157 1.0
3158 );
3159 }
3160
3161 #[test]
3162 fn malformed_source_parent_graph_downgrades_source_coverage() {
3163 for parent_source_node_index in [Some(7), Some(0)] {
3164 let doc = Document {
3165 assets: SceneAssets {
3166 source_skeleton: SourceSkeletonAssets {
3167 coverage: SourceSkeletonCoverage::Complete,
3168 nodes: vec![SourceNodeAsset {
3169 source_node_index: 0,
3170 name: None,
3171 parent_source_node_index,
3172 scene_root_indices: Vec::new(),
3173 local_rest: SourceNodeLocalRest::Matrix(Mat4::IDENTITY),
3174 bone: None,
3175 }],
3176 skins: Vec::new(),
3177 },
3178 ..SceneAssets::default()
3179 },
3180 ..Document::default()
3181 };
3182
3183 let measured = measure_assets(&doc);
3184 assert_eq!(
3185 measured.skeleton_source_coverage,
3186 SourceSkeletonCoverage::Unavailable
3187 );
3188 assert!(measured.skeleton_nodes.is_empty());
3189 assert!(measured.skins.is_empty());
3190 }
3191 }
3192
3193 #[test]
3194 fn skinned_mesh_measures_bbox_joints_and_weight_sums() {
3195 let prim = Primitive {
3197 positions: vec![
3198 Vec3::new(0.0, 0.0, 0.0),
3199 Vec3::new(2.0, 0.0, 0.0),
3200 Vec3::new(0.0, 3.0, 0.0),
3201 Vec3::new(0.0, 0.0, 4.0),
3202 ],
3203 weights: vec![
3206 [1.0, 0.0, 0.0, 0.0],
3207 [0.5, 0.5, 0.0, 0.0],
3208 [0.4, 0.3, 0.3, 0.0],
3209 [0.3, 0.3, 0.3, 0.0],
3210 ],
3211 joints: vec![[0, 0, 0, 0]; 4],
3212 ..Primitive::default()
3213 };
3214 let m = mesh("body", vec![prim]);
3215
3216 assert_eq!(m.name, "body");
3217 assert_eq!(m.vertex_count, 4);
3218 let aabb = m.geometry_aabb.as_ref().expect("positions present");
3219 assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
3220 assert_eq!(aabb.max, [2.0, 3.0, 4.0]);
3221 assert_eq!(m.geometry_centroid, Some([0.5, 0.75, 1.0]));
3222 assert_eq!(m.max_joints_per_vertex, 3);
3223 assert!((m.weight_sum_min.unwrap() - 0.9).abs() < 1e-6);
3225 assert!((m.weight_sum_max.unwrap() - 1.0).abs() < 1e-6);
3226 }
3227
3228 #[test]
3229 fn mesh_measurements_preserve_secondary_influence_set_mismatches_without_affecting_primary_stats()
3230 {
3231 let primary = Primitive {
3232 positions: vec![Vec3::ZERO],
3233 joints: vec![[0, 1, 0, 0]],
3234 weights: vec![[0.75, 0.25, 0.0, 0.0]],
3235 additional_influence_sets: vec![AdditionalInfluenceSet {
3236 set_index: 2,
3237 joints_present: true,
3238 weights_present: false,
3239 }],
3240 ..Primitive::default()
3241 };
3242 let secondary = Primitive {
3243 positions: vec![Vec3::ONE],
3244 additional_influence_sets: vec![
3245 AdditionalInfluenceSet {
3246 set_index: 1,
3247 joints_present: false,
3248 weights_present: true,
3249 },
3250 AdditionalInfluenceSet {
3251 set_index: 2,
3252 joints_present: false,
3253 weights_present: true,
3254 },
3255 ],
3256 ..Primitive::default()
3257 };
3258
3259 let measured = mesh("body", vec![primary, secondary]);
3260
3261 assert_eq!(measured.max_joints_per_vertex, 2);
3262 assert_eq!(measured.weight_sum_min, Some(1.0));
3263 assert_eq!(measured.weight_sum_max, Some(1.0));
3264 assert_eq!(
3265 measured.additional_influence_sets,
3266 vec![
3267 AdditionalInfluenceSetMeasurements {
3268 set_index: 1,
3269 joints_present: false,
3270 weights_present: true,
3271 joints_without_weights_present: false,
3272 weights_without_joints_present: true,
3273 },
3274 AdditionalInfluenceSetMeasurements {
3275 set_index: 2,
3276 joints_present: true,
3277 weights_present: true,
3278 joints_without_weights_present: true,
3279 weights_without_joints_present: true,
3280 },
3281 ]
3282 );
3283 }
3284
3285 #[test]
3286 fn unskinned_mesh_has_bbox_but_no_weight_stats() {
3287 let prim = Primitive {
3288 positions: vec![Vec3::new(-1.0, -2.0, -3.0), Vec3::new(1.0, 2.0, 3.0)],
3289 ..Primitive::default()
3290 };
3291 let m = mesh("prop", vec![prim]);
3292
3293 assert_eq!(m.vertex_count, 2);
3294 assert_eq!(m.geometry_aabb.as_ref().unwrap().min, [-1.0, -2.0, -3.0]);
3295 assert_eq!(m.geometry_centroid, Some([0.0, 0.0, 0.0]));
3296 assert_eq!(m.max_joints_per_vertex, 0);
3297 assert_eq!(m.weight_sum_min, None, "no skin ⇒ no weight-sum");
3298 assert_eq!(m.weight_sum_max, None);
3299 }
3300
3301 #[test]
3302 fn empty_mesh_reports_no_bbox() {
3303 let m = mesh("hollow", vec![Primitive::default()]);
3304 assert_eq!(m.vertex_count, 0);
3305 assert!(m.geometry_aabb.is_none(), "no positions ⇒ no bounding box");
3306 assert!(m.geometry_centroid.is_none(), "no positions ⇒ no centroid");
3307 }
3308
3309 #[test]
3310 fn non_finite_position_is_dropped_from_the_bbox() {
3311 let prim = Primitive {
3315 positions: vec![
3316 Vec3::new(0.0, 0.0, 0.0),
3317 Vec3::new(f32::NAN, 5.0, 0.0),
3318 Vec3::new(f32::INFINITY, 9.0, 0.0),
3319 Vec3::new(2.0, 3.0, 0.0),
3320 ],
3321 ..Primitive::default()
3322 };
3323 let m = mesh("nan", vec![prim]);
3324 let aabb = m.geometry_aabb.as_ref().unwrap();
3325 assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
3328 assert_eq!(aabb.max, [2.0, 3.0, 0.0]);
3329 assert_eq!(m.geometry_centroid, Some([1.0, 1.5, 0.0]));
3330 assert!(
3331 aabb.min.iter().chain(&aabb.max).all(|c| c.is_finite()),
3332 "no non-finite bound is ever emitted"
3333 );
3334 }
3335
3336 #[test]
3337 fn all_non_finite_positions_yield_no_bbox() {
3338 let prim = Primitive {
3341 positions: vec![Vec3::splat(f32::NAN), Vec3::splat(f32::INFINITY)],
3342 ..Primitive::default()
3343 };
3344 let m = mesh("allnan", vec![prim]);
3345 assert_eq!(m.vertex_count, 2, "count still reflects the vertices");
3346 assert!(
3347 m.geometry_aabb.is_none(),
3348 "no finite vertex ⇒ no box (never null bounds)"
3349 );
3350 assert!(
3351 m.geometry_centroid.is_none(),
3352 "no finite vertex ⇒ no centroid"
3353 );
3354 }
3355
3356 #[test]
3357 fn non_finite_weight_sum_is_omitted() {
3358 let prim = Primitive {
3361 positions: vec![Vec3::ZERO, Vec3::ONE],
3362 weights: vec![[0.5, 0.5, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 0.0]],
3363 ..Primitive::default()
3364 };
3365 let m = mesh("nanw", vec![prim]);
3366 assert_eq!(m.weight_sum_min, Some(1.0));
3368 assert_eq!(m.weight_sum_max, Some(1.0));
3369 }
3370
3371 #[test]
3372 fn all_non_finite_weight_sums_yield_no_weight_stats() {
3373 let prim = Primitive {
3376 positions: vec![Vec3::ZERO, Vec3::ONE],
3377 weights: vec![[f32::NAN, 0.0, 0.0, 0.0], [f32::INFINITY, 0.0, 0.0, 0.0]],
3378 ..Primitive::default()
3379 };
3380 let m = mesh("allnanw", vec![prim]);
3381 assert_eq!(m.weight_sum_min, None, "no finite weight sum ⇒ omitted");
3382 assert_eq!(m.weight_sum_max, None);
3383 assert_eq!(m.max_joints_per_vertex, 1);
3385 }
3386
3387 #[test]
3388 fn vertex_count_sums_across_primitives() {
3389 let a = Primitive {
3390 positions: vec![Vec3::ZERO; 3],
3391 ..Primitive::default()
3392 };
3393 let b = Primitive {
3394 positions: vec![Vec3::ONE; 5],
3395 ..Primitive::default()
3396 };
3397 let m = mesh("multi", vec![a, b]);
3398 assert_eq!(m.vertex_count, 8, "3 + 5 corners across two primitives");
3399 }
3400
3401 #[test]
3402 fn geometry_centroid_is_the_finite_position_mean_across_primitives() {
3403 let indexed = Primitive {
3407 positions: vec![
3408 Vec3::new(0.0, 0.0, 0.0),
3409 Vec3::new(6.0, 0.0, 0.0),
3410 Vec3::new(0.0, 3.0, 0.0),
3411 ],
3412 indices: vec![0, 1, 2, 0, 1, 2],
3413 ..Primitive::default()
3414 };
3415 let unindexed = Primitive {
3416 positions: vec![Vec3::new(0.0, 3.0, 0.0), Vec3::splat(f32::NAN)],
3417 ..Primitive::default()
3418 };
3419 let m = mesh("asymmetric", vec![indexed, unindexed]);
3420
3421 assert_eq!(m.vertex_count, 5, "all authored position rows count");
3422 assert_eq!(m.geometry_aabb.unwrap().max, [6.0, 3.0, 0.0]);
3423 assert_eq!(
3424 m.geometry_centroid,
3425 Some([1.5, 1.5, 0.0]),
3426 "four finite position rows, independent of six index references"
3427 );
3428 }
3429
3430 #[test]
3431 fn non_finite_instance_transform_makes_scene_coverage_partial() {
3432 let doc = Document {
3433 skeleton: Skeleton {
3434 bones: vec![
3435 Bone {
3436 name: "finite".into(),
3437 parent: None,
3438 rest: Transform::IDENTITY,
3439 inverse_bind: None,
3440 },
3441 Bone {
3442 name: "overflow".into(),
3443 parent: Some(0),
3444 rest: Transform {
3445 scale: Vec3::splat(f32::MAX),
3446 ..Transform::IDENTITY
3447 },
3448 inverse_bind: None,
3449 },
3450 ],
3451 },
3452 assets: SceneAssets {
3453 meshes: vec![MeshAsset {
3454 name: "point".into(),
3455 source_mesh_index: 4,
3456 primitives: vec![Primitive {
3457 positions: vec![Vec3::new(2.0, 0.0, 0.0)],
3458 ..Primitive::default()
3459 }],
3460 }],
3461 instances: vec![
3462 crate::model::MeshInstance {
3463 source_node_index: 10,
3464 node: 0,
3465 mesh: 0,
3466 ..crate::model::MeshInstance::default()
3467 },
3468 crate::model::MeshInstance {
3469 source_node_index: 11,
3470 node: 1,
3471 mesh: 0,
3472 ..crate::model::MeshInstance::default()
3473 },
3474 ],
3475 scenes: vec![crate::model::SceneAsset {
3476 source_scene_index: 3,
3477 name: Some("partial".into()),
3478 roots: vec![0],
3479 }],
3480 default_scene: None,
3481 ..SceneAssets::default()
3482 },
3483 ..Document::default()
3484 };
3485
3486 let measured = measure_assets(&doc);
3487 assert_eq!(measured.default_scene_index, None, "no implicit scene zero");
3488 assert_eq!(measured.node_instances.len(), 2);
3489 assert_eq!(
3490 measured.node_instances[0].static_node_world_aabb,
3491 Some(Aabb {
3492 min: [2.0, 0.0, 0.0],
3493 max: [2.0, 0.0, 0.0],
3494 })
3495 );
3496 assert_eq!(
3497 measured.node_instances[1].static_node_world_aabb_unavailable_reason,
3498 Some(StaticNodeAabbUnavailableReason::NonFiniteTransform)
3499 );
3500 assert_eq!(measured.scenes[0].instance_count, 2);
3501 assert_eq!(measured.scenes[0].excluded_instance_count, 1);
3502 assert_eq!(
3503 measured.scenes[0].static_scene_world_aabb,
3504 measured.node_instances[0].static_node_world_aabb,
3505 "partial aggregate retains the finite instance"
3506 );
3507 }
3508
3509 #[test]
3510 fn malformed_skeleton_chain_does_not_hide_an_unrelated_instance() {
3511 let doc = Document {
3512 skeleton: Skeleton {
3513 bones: vec![
3514 Bone {
3515 name: "malformed".into(),
3516 parent: Some(1),
3517 rest: Transform::IDENTITY,
3518 inverse_bind: None,
3519 },
3520 Bone {
3521 name: "malformed_child".into(),
3522 parent: Some(0),
3523 rest: Transform::IDENTITY,
3524 inverse_bind: None,
3525 },
3526 Bone {
3527 name: "valid_root".into(),
3528 parent: None,
3529 rest: Transform {
3530 translation: Vec3::X,
3531 ..Transform::IDENTITY
3532 },
3533 inverse_bind: None,
3534 },
3535 Bone {
3536 name: "valid_instance".into(),
3537 parent: Some(2),
3538 rest: Transform {
3539 translation: Vec3::Y,
3540 ..Transform::IDENTITY
3541 },
3542 inverse_bind: None,
3543 },
3544 ],
3545 },
3546 assets: SceneAssets {
3547 meshes: vec![MeshAsset {
3548 name: "point".into(),
3549 source_mesh_index: 0,
3550 primitives: vec![Primitive {
3551 positions: vec![Vec3::X],
3552 ..Primitive::default()
3553 }],
3554 }],
3555 instances: vec![
3556 crate::model::MeshInstance {
3557 source_node_index: 10,
3558 node: 0,
3559 mesh: 0,
3560 ..crate::model::MeshInstance::default()
3561 },
3562 crate::model::MeshInstance {
3563 source_node_index: 11,
3564 node: 3,
3565 mesh: 0,
3566 ..crate::model::MeshInstance::default()
3567 },
3568 ],
3569 scenes: vec![SceneAsset {
3570 source_scene_index: 0,
3571 name: None,
3572 roots: vec![0, 2],
3573 }],
3574 ..SceneAssets::default()
3575 },
3576 ..Document::default()
3577 };
3578
3579 let measured = measure_assets(&doc);
3580 assert_eq!(
3581 measured.node_instances[0].static_node_world_aabb_unavailable_reason,
3582 Some(StaticNodeAabbUnavailableReason::NonFiniteTransform)
3583 );
3584 assert_eq!(
3585 measured.node_instances[1].static_node_world_aabb,
3586 Some(Aabb {
3587 min: [2.0, 1.0, 0.0],
3588 max: [2.0, 1.0, 0.0],
3589 })
3590 );
3591 assert_eq!(measured.scenes[0].excluded_instance_count, 1);
3592 assert_eq!(
3593 measured.scenes[0].static_scene_world_aabb,
3594 measured.node_instances[1].static_node_world_aabb
3595 );
3596 }
3597
3598 #[test]
3599 fn later_duplicate_clip_name_replaces_earlier_measurement() {
3600 let earlier = Clip {
3601 name: "duplicate".into(),
3602 duration_s: 1.0,
3603 tracks: vec![
3604 Track {
3605 bone: 0,
3606 property: Property::Rotation,
3607 interpolation: Interpolation::Linear,
3608 times: vec![0.0, 0.5, 1.0],
3609 values: TrackValues::Quats(vec![
3610 Quat::IDENTITY,
3611 Quat::from_rotation_x(0.25),
3612 Quat::from_rotation_x(0.5),
3613 ]),
3614 },
3615 Track {
3616 bone: 0,
3617 property: Property::Translation,
3618 interpolation: Interpolation::Linear,
3619 times: vec![0.0, 0.5, 1.0],
3620 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::Z * 0.5, Vec3::Z]),
3621 },
3622 Track {
3623 bone: 1,
3624 property: Property::Translation,
3625 interpolation: Interpolation::Linear,
3626 times: vec![0.0, 0.5, 1.0],
3627 values: TrackValues::Vec3s(vec![
3628 Vec3::new(-0.1, -1.0, 0.0),
3629 Vec3::new(-0.1, -0.9, 0.15),
3630 Vec3::new(-0.1, -1.0, 0.0),
3631 ]),
3632 },
3633 Track {
3634 bone: 2,
3635 property: Property::Translation,
3636 interpolation: Interpolation::Linear,
3637 times: vec![0.0, 0.5, 1.0],
3638 values: TrackValues::Vec3s(vec![
3639 Vec3::new(0.1, -1.0, 0.0),
3640 Vec3::new(0.1, -1.1, -0.15),
3641 Vec3::new(0.1, -1.0, 0.0),
3642 ]),
3643 },
3644 ],
3645 };
3646 let later = Clip {
3647 name: "duplicate".into(),
3648 duration_s: 2.0,
3649 tracks: vec![Track {
3650 bone: 0,
3651 property: Property::Translation,
3652 interpolation: Interpolation::Linear,
3653 times: vec![0.0, 2.0],
3654 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X]),
3655 }],
3656 };
3657 let skeleton = Skeleton {
3658 bones: vec![
3659 Bone {
3660 name: "hips".into(),
3661 parent: None,
3662 rest: Transform::IDENTITY,
3663 inverse_bind: None,
3664 },
3665 Bone {
3666 name: "left_foot".into(),
3667 parent: Some(0),
3668 rest: Transform::IDENTITY,
3669 inverse_bind: None,
3670 },
3671 Bone {
3672 name: "right_foot".into(),
3673 parent: Some(0),
3674 rest: Transform::IDENTITY,
3675 inverse_bind: None,
3676 },
3677 ],
3678 };
3679 let roles = ResolvedRoles::from_names(
3680 &skeleton,
3681 [
3682 (Role::Hips, "hips".into()),
3683 (Role::LeftFoot, "left_foot".into()),
3684 (Role::RightFoot, "right_foot".into()),
3685 ],
3686 );
3687 let earlier_doc = Document {
3688 skeleton: skeleton.clone(),
3689 clips: vec![earlier.clone()],
3690 ..Document::default()
3691 };
3692 let earlier_grids = MetricGrids::new(&earlier_doc);
3693 let earlier_measurement =
3694 &measure_document(&earlier_grids, &roles, &Config::default())["duplicate"];
3695 assert!(earlier_measurement.loop_seam_ratio.is_some());
3696 assert!(earlier_measurement.gait.is_some());
3697 assert!(earlier_measurement.speed_mps.is_some());
3698
3699 let doc = Document {
3700 skeleton,
3701 clips: vec![earlier, later],
3702 ..Document::default()
3703 };
3704 let grids = MetricGrids::new(&doc);
3705 let measurements = measure_document(&grids, &roles, &Config::default());
3706
3707 assert_eq!(
3708 serde_json::to_value(measurements).expect("duplicate measurements serialize"),
3709 serde_json::json!({
3710 "duplicate": {
3711 "duration_s": 2.0,
3712 "frame_count": 2,
3713 "animated_bones": ["hips"],
3714 "bone_rotation_range_deg": {},
3715 }
3716 })
3717 );
3718 }
3719
3720 #[test]
3721 fn inverse_bind_conditioning_is_scale_free_and_tracks_anisotropy() {
3722 for (scales, expected) in [
3723 (Vec3::splat(1.0), 1.0),
3724 (Vec3::new(1.0, 0.1, 0.1), 0.1),
3725 (Vec3::new(1.0, 0.01, 0.01), 0.01),
3726 (Vec3::splat(1.0e-20), 1.0),
3727 ] {
3728 let assessment = assess_inverse_bind(Mat4::from_scale(scales));
3729 assert!(assessment.inverse.is_ok(), "scales {scales:?}");
3730 let actual = assessment
3731 .quality
3732 .expect("affine linear transform has quality")
3733 .reciprocal_condition_number_inf;
3734 assert!(
3735 (actual - expected).abs() <= 1.0e-6,
3736 "{actual} != {expected}"
3737 );
3738 }
3739
3740 let shear = Mat4::from_cols_array(&[
3741 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,
3745 ]);
3746 let quality = assess_inverse_bind(shear)
3747 .quality
3748 .expect("finite affine shear has quality");
3749 assert_eq!(
3750 quality.reciprocal_condition_number_inf, 0.25,
3751 "infinity-norm conditioning includes off-diagonal row sums"
3752 );
3753 }
3754
3755 #[test]
3756 fn inverse_bind_assessment_distinguishes_non_affine_singular_and_ill_conditioned() {
3757 let inside_zero = INVERSE_BIND_AFFINE_TOLERANCE as f32;
3758 let outside_zero = f32::from_bits(inside_zero.to_bits() + 1);
3759 assert!(f64::from(inside_zero) <= INVERSE_BIND_AFFINE_TOLERANCE);
3760 assert!(f64::from(outside_zero) > INVERSE_BIND_AFFINE_TOLERANCE);
3761 for slot in [3, 7, 11] {
3762 for value in [inside_zero, -inside_zero] {
3763 let mut affine = Mat4::IDENTITY.to_cols_array();
3764 affine[slot] = value;
3765 assert!(
3766 assess_inverse_bind(Mat4::from_cols_array(&affine))
3767 .inverse
3768 .is_ok(),
3769 "bottom-row slot {slot} accepts signed values inside the tolerance"
3770 );
3771 }
3772 for value in [outside_zero, -outside_zero] {
3773 let mut non_affine = Mat4::IDENTITY.to_cols_array();
3774 non_affine[slot] = value;
3775 let assessment = assess_inverse_bind(Mat4::from_cols_array(&non_affine));
3776 assert_eq!(
3777 assessment.inverse,
3778 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine),
3779 "bottom-row slot {slot} rejects signed values outside the tolerance"
3780 );
3781 assert_eq!(assessment.quality, None);
3782 }
3783 }
3784 let inside_one = 1.0 + INVERSE_BIND_AFFINE_TOLERANCE as f32;
3785 let outside_one = f32::from_bits(inside_one.to_bits() + 1);
3786 assert!((f64::from(inside_one) - 1.0).abs() <= INVERSE_BIND_AFFINE_TOLERANCE);
3787 assert!((f64::from(outside_one) - 1.0).abs() > INVERSE_BIND_AFFINE_TOLERANCE);
3788 for value in [inside_one, 2.0 - inside_one] {
3789 let mut affine = Mat4::IDENTITY.to_cols_array();
3790 affine[15] = value;
3791 assert!(
3792 assess_inverse_bind(Mat4::from_cols_array(&affine))
3793 .inverse
3794 .is_ok()
3795 );
3796 }
3797 for value in [outside_one, 2.0 - outside_one] {
3798 let mut non_affine = Mat4::IDENTITY.to_cols_array();
3799 non_affine[15] = value;
3800 assert_eq!(
3801 assess_inverse_bind(Mat4::from_cols_array(&non_affine)).inverse,
3802 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonAffine)
3803 );
3804 }
3805
3806 let singular = assess_inverse_bind(Mat4::from_scale(Vec3::new(1.0, 1.0, 0.0)));
3807 assert_eq!(
3808 singular.inverse,
3809 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixNonInvertible)
3810 );
3811 assert_eq!(
3812 singular
3813 .quality
3814 .expect("singular affine matrix has quality")
3815 .reciprocal_condition_number_inf,
3816 0.0
3817 );
3818
3819 let ill_conditioned = assess_inverse_bind(Mat4::from_scale(Vec3::new(1.0, 1.0, 1.0e-7)));
3820 assert_eq!(
3821 ill_conditioned.inverse,
3822 Err(SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned)
3823 );
3824 assert_eq!(
3825 ill_conditioned
3826 .quality
3827 .expect("ill-conditioned affine matrix has quality")
3828 .reciprocal_condition_number_inf,
3829 1.0e-7_f32 as f64
3830 );
3831
3832 for (shear, expected_reason) in [
3833 (
3834 999.0,
3835 Some(SkinDerivedMatrixUnavailableReason::InverseBindMatrixIllConditioned),
3836 ),
3837 (998.0, None),
3838 ] {
3839 let matrix = Mat4::from_cols_array(&[
3840 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,
3841 ]);
3842 let assessment = assess_inverse_bind(matrix);
3843 let expected_quality = 1.0 / (1.0 + f64::from(shear)).powi(2);
3844 assert_eq!(
3845 assessment
3846 .quality
3847 .expect("affine shear has quality")
3848 .reciprocal_condition_number_inf,
3849 expected_quality
3850 );
3851 match expected_reason {
3852 Some(reason) => assert_eq!(assessment.inverse, Err(reason)),
3853 None => assert!(assessment.inverse.is_ok()),
3854 }
3855 }
3856 }
3857}