Skip to main content

animsmith_core/
measure.rs

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