Skip to main content

animsmith_core/
measure.rs

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