Skip to main content

animsmith_core/
measure.rs

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