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