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