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