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