Skip to main content

animsmith_core/
measure.rs

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