Skip to main content

animsmith_core/
model.rs

1//! The loader-facing layer: clips, tracks, and the skeleton before metric
2//! resampling or repair. The glTF loader preserves authored animation
3//! values; the FBX loader normalizes scene coordinates and bakes takes to
4//! linear TRS tracks. Mechanical checks (NaN, quaternion flips, key
5//! density, …) read this layer; semantic checks read the sampled layer
6//! built from it (see [`crate::sample`]).
7
8use glam::{Mat3, Mat4, Quat, Vec3};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12/// Stable machine-readable reason a positive-uniform affine linear part failed
13/// classification.
14///
15/// Core consumers map these typed facts into their own domain-specific
16/// diagnostics rather than sharing a broad operation error enum.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum AffineDomainViolation {
20    /// The linear part's axis lengths are not equal (non-uniform scale).
21    NonUniformScale,
22    /// The linear part's axes are not mutually orthogonal (shear).
23    Sheared,
24    /// The linear part has a negative determinant (reflection).
25    Reflected,
26    /// The linear part is singular or near-singular.
27    Singular,
28    /// The linear part contains a non-finite component.
29    NonFinite,
30}
31
32/// Tolerances supplied by one caller of
33/// [`classify_positive_uniform_affine`].
34///
35/// The classifier deliberately owns no global policy: Appendix D scale
36/// planning uses its versioned `f64` policy, while skinned bind-pose
37/// canonicalization retains its established `1e-4` acceptance band.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub(crate) struct PositiveUniformAffineTolerance {
40    pub(crate) equal_axis: f64,
41    pub(crate) relative_orthogonality: f64,
42    pub(crate) singular_determinant_relative: f64,
43}
44
45/// Policy-neutral geometric facts for one affine linear part.
46///
47/// Every derived operation widens the source `f32` columns to `f64` first.
48/// Callers deliberately apply their own tolerance and precedence policies to
49/// this one fact record: strict positive-uniform operations reject a domain
50/// violation, while measurement retains finite descriptive evidence.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub(crate) struct AffineGeometryFacts {
53    pub(crate) axis_lengths: [f64; 3],
54    pub(crate) mean_axis_length: f64,
55    pub(crate) determinant: f64,
56    pub(crate) axis_length_product: f64,
57    /// Widened dot products in XY, XZ, YZ order.
58    pub(crate) cross_axis_dots: [f64; 3],
59}
60
61impl AffineGeometryFacts {
62    /// Derive the complete finite fact record, or reject it atomically.
63    ///
64    /// The scalar triple product's operation order is part of the established
65    /// Appendix D classifier behavior and must not be replaced with an f32
66    /// determinant or a differently associated f64 expansion.
67    pub(crate) fn from_linear(linear: Mat3) -> Result<Self, AffineDomainViolation> {
68        if !linear.is_finite() {
69            return Err(AffineDomainViolation::NonFinite);
70        }
71        let columns = [
72            linear.x_axis.as_dvec3(),
73            linear.y_axis.as_dvec3(),
74            linear.z_axis.as_dvec3(),
75        ];
76        let axis_lengths = affine_axis_lengths(linear);
77        let mean_axis_length = average_affine_axis_length(axis_lengths);
78        let determinant = columns[2].dot(columns[0].cross(columns[1]));
79        let axis_length_product = axis_lengths[0] * axis_lengths[1] * axis_lengths[2];
80        let cross_axis_dots = [
81            columns[0].dot(columns[1]),
82            columns[0].dot(columns[2]),
83            columns[1].dot(columns[2]),
84        ];
85        if axis_lengths.iter().any(|value| !value.is_finite())
86            || !mean_axis_length.is_finite()
87            || !determinant.is_finite()
88            || !axis_length_product.is_finite()
89            || cross_axis_dots.iter().any(|value| !value.is_finite())
90        {
91            return Err(AffineDomainViolation::NonFinite);
92        }
93        Ok(Self {
94            axis_lengths,
95            mean_axis_length,
96            determinant,
97            axis_length_product,
98            cross_axis_dots,
99        })
100    }
101
102    /// Whether every axis lies within a symmetric mean-relative band.
103    ///
104    /// Comparing every value to the mean removes the privileged-X behavior
105    /// of pairwise-from-X tests. The longer-operand base keeps the predicate
106    /// symmetric around the mean, and `<=` makes the boundary inclusive.
107    pub(crate) fn has_equal_axis_lengths(self, relative_tolerance: f64) -> bool {
108        values_equal_to_mean(
109            &self.axis_lengths,
110            self.mean_axis_length,
111            relative_tolerance,
112        )
113    }
114}
115
116/// Whether every finite value lies within a symmetric relative band around
117/// `mean`, using the longer operand as the relative base.
118pub(crate) fn values_equal_to_mean(values: &[f64], mean: f64, relative_tolerance: f64) -> bool {
119    values
120        .iter()
121        .all(|&value| (value - mean).abs() <= relative_tolerance * mean.abs().max(value.abs()))
122}
123
124/// Classify an affine linear part as an orientation-preserving positive
125/// uniform scale and return its common factor.
126///
127/// Inputs widen to `f64` before every derived calculation. This preserves the
128/// Appendix D scale classifier's boundary behaviour; callers choose the
129/// tolerance policy appropriate to their separate contract.
130pub(crate) fn classify_positive_uniform_affine(
131    linear: Mat3,
132    tolerance: PositiveUniformAffineTolerance,
133) -> Result<f64, AffineDomainViolation> {
134    let facts = AffineGeometryFacts::from_linear(linear)?;
135    if facts.mean_axis_length <= 0.0 {
136        return Err(AffineDomainViolation::Singular);
137    }
138
139    // Check singularity before the shape facts. A degenerate basis that is
140    // also non-uniform or sheared is still singular, which keeps the
141    // independently named rejection classes deterministic.
142    // Expand the scalar triple product from the widened columns. Calling
143    // `Mat3::determinant` here would perform the derived arithmetic in f32
144    // before widening and moves the singular boundary.
145    if facts.determinant.abs()
146        <= tolerance.singular_determinant_relative * facts.axis_length_product
147    {
148        return Err(AffineDomainViolation::Singular);
149    }
150    // This is a relative band with no unit floor: a floor would become an
151    // absolute tolerance for sub-unit transforms. The longer-operand base
152    // keeps the comparison symmetric, and `>` makes the boundary inclusive.
153    if !facts.has_equal_axis_lengths(tolerance.equal_axis) {
154        return Err(AffineDomainViolation::NonUniformScale);
155    }
156
157    // Scale the dot-product band by the square of the common factor. As with
158    // the axis band, equality is accepted and only a value beyond it rejects.
159    let orthogonality_tolerance =
160        tolerance.relative_orthogonality * facts.mean_axis_length * facts.mean_axis_length;
161    if facts
162        .cross_axis_dots
163        .iter()
164        .any(|dot| dot.abs() > orthogonality_tolerance)
165    {
166        return Err(AffineDomainViolation::Sheared);
167    }
168    if facts.determinant < 0.0 {
169        return Err(AffineDomainViolation::Reflected);
170    }
171    Ok(facts.mean_axis_length)
172}
173
174/// The three column lengths of a linear part, widened to `f64` first.
175///
176/// The positive-uniform classifier and the scale proof's observed-factor
177/// witness both use this helper so that their factor is one shared quantity.
178pub(crate) fn affine_axis_lengths(linear: Mat3) -> [f64; 3] {
179    [
180        linear.x_axis.as_dvec3().length(),
181        linear.y_axis.as_dvec3().length(),
182        linear.z_axis.as_dvec3().length(),
183    ]
184}
185
186/// The arithmetic-mean common factor represented by three affine axis
187/// lengths.
188///
189/// The finite widened inputs are summed in ascending order so this shared
190/// factor does not depend on an affine matrix's authored column order.
191pub(crate) fn average_affine_axis_length(lengths: [f64; 3]) -> f64 {
192    let mut ascending = lengths;
193    ascending.sort_by(f64::total_cmp);
194    (ascending[0] + ascending[1] + ascending[2]) / 3.0
195}
196
197#[cfg(test)]
198pub(crate) mod affine_test_fixtures {
199    use super::{Mat3, Vec3};
200
201    /// A finite diagonal basis intentionally between the two callers' equal
202    /// axis bands: Appendix D rejects it, while skinned canonicalization's
203    /// established `1e-4` policy accepts it.
204    pub(crate) fn tolerance_divergence_basis() -> Mat3 {
205        Mat3::from_diagonal(Vec3::new(1.0, 1.000_05, 1.0))
206    }
207
208    /// A nearly orthogonal basis whose only non-zero cross-axis dot product
209    /// lies between the two callers' orthogonality bands.
210    pub(crate) fn orthogonality_tolerance_divergence_basis() -> Mat3 {
211        Mat3::from_cols(Vec3::X, Vec3::new(5.0e-5, 1.0, 0.0), Vec3::Z)
212    }
213
214    /// All signed column orders of the exact Appendix D v6 mean fixture.
215    ///
216    /// Odd permutations negate their first column, preserving orientation
217    /// without changing any axis length.
218    pub(crate) fn appendix_d_v6_mean_permutations() -> [Mat3; 6] {
219        let columns = [
220            Vec3::new(
221                f32::from_bits(0x3f0e_8cbb),
222                f32::from_bits(0x3f26_fbbe),
223                f32::from_bits(0x3f21_9bc7),
224            ),
225            Vec3::new(
226                f32::from_bits(0x3d9c_b415),
227                f32::from_bits(0x3e92_d82b),
228                f32::from_bits(0x3f82_e85d),
229            ),
230            Vec3::new(
231                f32::from_bits(0x3f14_5226),
232                f32::from_bits(0x3e9e_e50d),
233                f32::from_bits(0x3f56_817c),
234            ),
235        ];
236        [
237            Mat3::from_cols(columns[0], columns[1], columns[2]),
238            Mat3::from_cols(-columns[0], columns[2], columns[1]),
239            Mat3::from_cols(-columns[1], columns[0], columns[2]),
240            Mat3::from_cols(columns[1], columns[2], columns[0]),
241            Mat3::from_cols(columns[2], columns[0], columns[1]),
242            Mat3::from_cols(-columns[2], columns[1], columns[0]),
243        ]
244    }
245}
246
247/// Index into [`Skeleton::bones`].
248pub type BoneId = usize;
249
250/// Node-local TRS transform.
251#[derive(Debug, Clone, Copy, PartialEq)]
252pub struct Transform {
253    /// Translation in scene units.
254    pub translation: Vec3,
255    /// Orientation relative to the parent node.
256    pub rotation: Quat,
257    /// Non-uniform local scale.
258    pub scale: Vec3,
259}
260
261impl Transform {
262    /// The identity transform: zero translation, identity rotation, and
263    /// unit scale.
264    pub const IDENTITY: Self = Self {
265        translation: Vec3::ZERO,
266        rotation: Quat::IDENTITY,
267        scale: Vec3::ONE,
268    };
269
270    /// Convert this TRS transform to a matrix using glam's
271    /// scale-rotation-translation order.
272    pub fn to_mat4(&self) -> Mat4 {
273        Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
274    }
275}
276
277impl Default for Transform {
278    fn default() -> Self {
279        Self::IDENTITY
280    }
281}
282
283/// One skeleton node/bone in parent-before-child order.
284#[derive(Debug, Clone)]
285pub struct Bone {
286    /// Bone/node name as authored or normalized by the loader.
287    pub name: String,
288    /// Parent bone index; `None` means this is a root bone.
289    pub parent: Option<BoneId>,
290    /// Rest pose, node-local. Whether this or the inverse-bind-derived
291    /// rest is authoritative is a `bind-pose` check concern.
292    pub rest: Transform,
293    /// Inverse bind matrix from a skin, when one references this bone.
294    pub inverse_bind: Option<Mat4>,
295}
296
297/// Bones in topological order: a bone's parent always precedes it.
298/// Loaders are responsible for establishing this invariant.
299#[derive(Debug, Clone, Default)]
300pub struct Skeleton {
301    /// Bones in topological order.
302    pub bones: Vec<Bone>,
303}
304
305impl Skeleton {
306    /// Name of the bone at `id`.
307    ///
308    /// # Panics
309    ///
310    /// Panics if `id` is not a valid index into [`Skeleton::bones`].
311    pub fn bone_name(&self, id: BoneId) -> &str {
312        &self.bones[id].name
313    }
314}
315
316/// Structural failure composing [`Skeleton`] rest-local transforms into
317/// world matrices, returned by [`world_rest_matrices`].
318///
319/// This is deliberately generic over the eventual caller-facing error: every
320/// caller of [`world_rest_matrices`] maps one of these two structural facts
321/// into its own typed error variant rather than sharing an error enum across
322/// module boundaries.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub(crate) enum WorldMatrixError {
325    /// The node's local or accumulated world transform has a non-finite
326    /// component.
327    NonFiniteTransform {
328        /// The node with the non-finite transform.
329        node: BoneId,
330    },
331    /// The node's parent is not earlier in [`Skeleton::bones`], so the
332    /// skeleton is not in the required parent-before-child order.
333    InvalidParent {
334        /// The node with an invalid parent.
335        node: BoneId,
336        /// The invalid parent index.
337        parent: BoneId,
338    },
339}
340
341/// Compose every [`Bone::rest`] local transform in `skeleton` into a
342/// parent-before-child world matrix, shared by every module that needs plain
343/// rest-world FK (skin bind-pose canonicalization, static mesh baking, and
344/// scale planning/proof).
345///
346/// `skeleton.bones` order is trusted as parent-before-child, matching
347/// [`Skeleton`]'s documented invariant; a parent index that is not strictly
348/// less than its child's is reported as [`WorldMatrixError::InvalidParent`]
349/// rather than assumed.
350pub(crate) fn world_rest_matrices(skeleton: &Skeleton) -> Result<Vec<Mat4>, WorldMatrixError> {
351    let mut worlds = Vec::with_capacity(skeleton.bones.len());
352    for (node, bone) in skeleton.bones.iter().enumerate() {
353        let local = bone.rest.to_mat4();
354        if !mat4_is_finite(local) {
355            return Err(WorldMatrixError::NonFiniteTransform { node });
356        }
357        let world = match bone.parent {
358            Some(parent) if parent < node => worlds[parent] * local,
359            Some(parent) => return Err(WorldMatrixError::InvalidParent { node, parent }),
360            None => local,
361        };
362        if !mat4_is_finite(world) {
363            return Err(WorldMatrixError::NonFiniteTransform { node });
364        }
365        worlds.push(world);
366    }
367    Ok(worlds)
368}
369
370/// Compose rest-world matrices for a partial-evidence consumer.
371///
372/// Unlike [`world_rest_matrices`], this deliberately preserves a slot for
373/// every bone and makes only the malformed chain unavailable. Measurement
374/// uses that behaviour to retain finite evidence from unrelated roots.
375pub(crate) fn tolerant_world_rest_matrices(skeleton: &Skeleton) -> Vec<Option<Mat4>> {
376    let mut worlds = Vec::with_capacity(skeleton.bones.len());
377    for bone in &skeleton.bones {
378        let local = bone.rest.to_mat4();
379        let world = match bone.parent {
380            Some(parent) => worlds
381                .get(parent)
382                .copied()
383                .flatten()
384                .map(|parent_world| parent_world * local),
385            None => Some(local),
386        }
387        .filter(|matrix| mat4_is_finite(*matrix));
388        worlds.push(world);
389    }
390    worlds
391}
392
393pub(crate) fn mat4_is_finite(matrix: Mat4) -> bool {
394    matrix.to_cols_array().into_iter().all(f32::is_finite)
395}
396
397/// Animated property targeted by a [`Track`].
398#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
399#[serde(rename_all = "snake_case")]
400pub enum Property {
401    /// Local translation channel.
402    Translation,
403    /// Local rotation channel.
404    Rotation,
405    /// Local scale channel.
406    Scale,
407}
408
409impl Property {
410    /// Stable snake-case name used in diagnostics and serialized
411    /// metadata.
412    pub fn as_str(self) -> &'static str {
413        match self {
414            Property::Translation => "translation",
415            Property::Rotation => "rotation",
416            Property::Scale => "scale",
417        }
418    }
419}
420
421/// Interpolation mode for a [`Track`].
422#[derive(Debug, Clone, Copy, PartialEq, Eq)]
423pub enum Interpolation {
424    /// Linear interpolation between key values.
425    Linear,
426    /// Hold the previous key until the next key.
427    Step,
428    /// glTF cubic spline: `values` holds `[in-tangent, value, out-tangent]`
429    /// triplets per keyframe. Use [`Track::value_index`] to address the
430    /// value elements.
431    CubicSpline,
432}
433
434/// Storage for a track's key values.
435#[derive(Debug, Clone)]
436pub enum TrackValues {
437    /// Translation or scale values.
438    Vec3s(Vec<Vec3>),
439    /// Rotation values.
440    Quats(Vec<Quat>),
441}
442
443impl TrackValues {
444    /// Number of stored values, including tangents for cubic-spline
445    /// tracks.
446    pub fn len(&self) -> usize {
447        match self {
448            TrackValues::Vec3s(v) => v.len(),
449            TrackValues::Quats(v) => v.len(),
450        }
451    }
452
453    /// Whether there are no stored values.
454    pub fn is_empty(&self) -> bool {
455        self.len() == 0
456    }
457}
458
459/// One animated property of one bone.
460#[derive(Debug, Clone)]
461pub struct Track {
462    /// Bone index targeted by this track.
463    pub bone: BoneId,
464    /// Property animated on the target bone.
465    pub property: Property,
466    /// Interpolation mode used between keys.
467    pub interpolation: Interpolation,
468    /// Keyframe times in seconds. Same length as the keyframe count
469    /// (tangent elements in cubic tracks do not add times).
470    pub times: Vec<f32>,
471    /// Key values, with cubic-spline tracks storing tangent triplets.
472    pub values: TrackValues,
473}
474
475impl Track {
476    /// Number of keyframes.
477    pub fn key_count(&self) -> usize {
478        self.times.len()
479    }
480
481    /// Index into `values` of keyframe `k`'s value element (skips
482    /// tangents for cubic tracks).
483    pub fn value_index(&self, k: usize) -> usize {
484        match self.interpolation {
485            Interpolation::CubicSpline => 3 * k + 1,
486            _ => k,
487        }
488    }
489
490    /// Keyframe `k`'s value, for Vec3 tracks.
491    pub fn key_vec3(&self, k: usize) -> Option<Vec3> {
492        match &self.values {
493            TrackValues::Vec3s(v) => v.get(self.value_index(k)).copied(),
494            TrackValues::Quats(_) => None,
495        }
496    }
497
498    /// Keyframe `k`'s value, for rotation tracks.
499    pub fn key_quat(&self, k: usize) -> Option<Quat> {
500        match &self.values {
501            TrackValues::Quats(v) => v.get(self.value_index(k)).copied(),
502            TrackValues::Vec3s(_) => None,
503        }
504    }
505
506    /// First key time, or `0.0` for an empty track.
507    pub fn start_time(&self) -> f32 {
508        self.times.first().copied().unwrap_or(0.0)
509    }
510
511    /// Last key time, or `0.0` for an empty track.
512    pub fn end_time(&self) -> f32 {
513        self.times.last().copied().unwrap_or(0.0)
514    }
515}
516
517/// One animation clip targeting the document skeleton.
518#[derive(Debug, Clone)]
519pub struct Clip {
520    /// Clip name, used as the key in measurement maps and config
521    /// expectations.
522    pub name: String,
523    /// Clip length in seconds (max sampler end time across tracks).
524    pub duration_s: f64,
525    /// Animated tracks belonging to this clip.
526    pub tracks: Vec<Track>,
527}
528
529/// Loader-provided provenance for a [`Document`].
530#[derive(Debug, Clone, Default)]
531pub struct SourceInfo {
532    /// Source path, when the loader was given one.
533    pub path: Option<String>,
534    /// Source format label such as `"glb"` or `"fbx"`.
535    pub format: Option<String>,
536}
537
538/// A loaded file: one skeleton, any number of clips targeting it, and
539/// the scene assets (meshes, materials, and textures) that rode in alongside
540/// them.
541/// `assets` is default-empty: the check catalog judges animation and
542/// ignores it, but the load/write round-trip carries it so `transform`
543/// and `convert` preserve geometry instead of silently dropping it.
544#[derive(Debug, Clone, Default)]
545pub struct Document {
546    /// Skeleton shared by every clip.
547    pub skeleton: Skeleton,
548    /// Animation clips targeting [`Document::skeleton`].
549    pub clips: Vec<Clip>,
550    /// Meshes, materials, and textures carried by the loaded scene.
551    pub assets: SceneAssets,
552    /// Optional source provenance.
553    pub source: SourceInfo,
554}
555
556/// A structural invariant violated by [`validate_document_shape`].
557///
558/// Validation is a snapshot, not a durable guarantee: [`Document`] and its
559/// nested fields are publicly mutable. Strict operations that rely on this
560/// full shape must validate again at each public boundary.
561#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
562#[non_exhaustive]
563pub enum DocumentShapeError {
564    /// A bone's local rest transform or its composed rest-world transform is
565    /// non-finite.
566    #[error("node {node} has a non-finite rest transform")]
567    NonFiniteSkeletonRest {
568        /// The affected skeleton node.
569        node: BoneId,
570    },
571    /// A bone's parent does not precede it in parent-before-child order.
572    #[error("node {node} has invalid parent {parent}")]
573    InvalidSkeletonParent {
574        /// The affected skeleton node.
575        node: BoneId,
576        /// The invalid parent index.
577        parent: BoneId,
578    },
579    /// A declared scene root is outside the normalized skeleton.
580    #[error("scene {scene_index} root {root_index} references missing node {bone}")]
581    SceneRootOutOfBounds {
582        /// Index into [`SceneAssets::scenes`].
583        scene_index: usize,
584        /// Root slot within the scene.
585        root_index: usize,
586        /// Missing normalized skeleton node index.
587        bone: BoneId,
588    },
589    /// The source-node projection declares one source node identity twice.
590    #[error("source skeleton declares duplicate source node index {source_node_index}")]
591    DuplicateSourceNodeIndex {
592        /// The duplicated source-node index.
593        source_node_index: usize,
594    },
595    /// The source-skin projection declares one source skin identity twice.
596    #[error("source skeleton declares duplicate source skin index {source_skin_index}")]
597    DuplicateSourceSkinIndex {
598        /// The duplicated source-skin index.
599        source_skin_index: usize,
600    },
601    /// A complete source-node projection contradicts the normalized skeleton.
602    #[error(
603        "source node {source_node_index} contradicts the document skeleton's parent chain ({violation})"
604    )]
605    SourceProjection {
606        /// The source node whose projection failed.
607        source_node_index: usize,
608        /// The typed projection failure.
609        violation: SourceProjectionViolation,
610    },
611    /// A clip declares the same target `(node, property)` more than once.
612    #[error("clip {clip_index} declares duplicate {property:?} tracks for node {node}")]
613    DuplicateClipTrack {
614        /// Index into [`Document::clips`].
615        clip_index: usize,
616        /// The duplicated target node.
617        node: BoneId,
618        /// The duplicated animated property.
619        property: Property,
620    },
621    /// A track is malformed for its target, interpolation, or value storage.
622    #[error("clip {clip_index} track for node {node} has an invalid shape ({violation})")]
623    TrackShape {
624        /// Index into [`Document::clips`].
625        clip_index: usize,
626        /// The track's target node.
627        node: BoneId,
628        /// The typed track-shape failure.
629        violation: TrackShapeViolation,
630    },
631    /// A mesh instance has an invalid reference or inverse-bind payload.
632    #[error("mesh instance {instance_index} is invalid ({violation})")]
633    MeshInstanceShape {
634        /// Index into [`SceneAssets::instances`].
635        instance_index: usize,
636        /// The typed mesh-instance failure.
637        violation: MeshInstanceShapeViolation,
638    },
639    /// A bone-level inverse-bind matrix is non-finite.
640    #[error("node {node} has a non-finite inverse-bind matrix")]
641    NonFiniteBoneInverseBind {
642        /// The affected skeleton node.
643        node: BoneId,
644    },
645}
646
647/// The way a complete source-node projection contradicts the skeleton.
648#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
649#[non_exhaustive]
650pub enum SourceProjectionViolation {
651    /// A projected bone index is outside [`Skeleton::bones`].
652    #[error("projected_bone_out_of_range")]
653    ProjectedBoneOutOfRange,
654    /// Two source-node rows project to the same normalized bone.
655    #[error("two_source_nodes_project_to_one_bone")]
656    TwoSourceNodesProjectToOneBone,
657    /// An ancestor walk names a source node absent from the projection table.
658    #[error("parent_source_node_is_missing")]
659    ParentSourceNodeMissing,
660    /// An ancestor walk through unprojected rows does not terminate.
661    #[error("cyclic_unprojected_source_parent_chain")]
662    CyclicUnprojectedSourceParentChain,
663    /// The nearest projected source ancestor differs from the bone parent.
664    #[error("projection_and_skeleton_parents_differ")]
665    NearestProjectedParentMismatch,
666    /// A projected bone has an unprojected direct skeleton child.
667    #[error("projected_bone_has_an_unprojected_skeleton_child")]
668    ProjectedBoneHasUnprojectedSkeletonChild,
669}
670
671/// The way a clip track is malformed.
672#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
673#[non_exhaustive]
674pub enum TrackShapeViolation {
675    /// The target bone index is outside [`Skeleton::bones`].
676    #[error("bone_index_out_of_range")]
677    BoneIndexOutOfRange,
678    /// The track has no keyframe times.
679    #[error("empty_times")]
680    EmptyTimes,
681    /// At least one keyframe time is not finite.
682    #[error("non_finite_time")]
683    NonFiniteTime,
684    /// Keyframe times are not strictly increasing.
685    #[error("times_not_strictly_increasing")]
686    TimesNotStrictlyIncreasing,
687    /// Stored value count disagrees with the interpolation's key count.
688    #[error("value_count_mismatch")]
689    ValueCountMismatch,
690    /// The value storage does not match the targeted property.
691    #[error("value_type_mismatches_property")]
692    ValueTypeMismatchesProperty,
693    /// At least one stored value is not finite.
694    #[error("non_finite_value")]
695    NonFiniteValue,
696}
697
698/// The way a mesh instance is malformed.
699#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
700#[non_exhaustive]
701pub enum MeshInstanceShapeViolation {
702    /// The instance node index is outside [`Skeleton::bones`].
703    #[error("node_index_out_of_range")]
704    NodeIndexOutOfRange,
705    /// The mesh index is outside [`SceneAssets::meshes`].
706    #[error("mesh_index_out_of_range")]
707    MeshIndexOutOfRange,
708    /// A skin joint index is outside [`Skeleton::bones`].
709    #[error("skin_joint_out_of_range")]
710    SkinJointOutOfRange,
711    /// A non-empty inverse-bind array has a different length from skin joints.
712    #[error("skin_ibm_count_mismatch")]
713    SkinInverseBindCountMismatch,
714    /// An instance inverse-bind matrix is not finite.
715    #[error("non_finite_inverse_bind")]
716    NonFiniteSkinInverseBind,
717}
718
719// --- Scene assets (meshes/materials) -----------------------------------
720//
721// The geometry half of a [`Document`]. Populated by both format loaders and
722// emitted by the writer, so a full conversion preserves geometry. Primitives
723// may be indexed already; [`Primitive::weld`] can index unindexed exact
724// duplicates without collapsing authored seams.
725
726/// One triangle-list primitive sharing a material. Attribute arrays may be
727/// indexed already; [`Primitive::weld`] dedupes an unindexed primitive into
728/// indexed form.
729///
730/// Additional glTF skin-influence attribute sets are intentionally metadata
731/// only. The primary four influences remain in [`Self::joints`] and
732/// [`Self::weights`]; consumers can use this metadata to apply their own
733/// policy without the core assuming how additional influences are evaluated.
734#[derive(Debug, Clone, Default)]
735pub struct Primitive {
736    /// Stable index in the source mesh's primitive array, when the loader can
737    /// preserve it. Consumers fall back to retained primitive order when this
738    /// source identity is unavailable on a normalized or manually built
739    /// document.
740    pub source_primitive_index: Option<usize>,
741    /// Index into [`SceneAssets::materials`].
742    pub material: Option<usize>,
743    /// Triangle indices into the attribute arrays; empty = unindexed.
744    pub indices: Vec<u32>,
745    /// Vertex positions in scene units.
746    pub positions: Vec<Vec3>,
747    /// Same length as `positions`, or empty.
748    pub normals: Vec<Vec3>,
749    /// Same length as `positions`, or empty.
750    pub uvs: Vec<[f32; 2]>,
751    /// Indices into an owning instance's skin-joint list; empty if unskinned.
752    pub joints: Vec<[u16; 4]>,
753    /// Skinning weights parallel to [`Primitive::joints`].
754    pub weights: Vec<[f32; 4]>,
755    /// Declared non-primary skin-influence attribute sets.
756    ///
757    /// Each entry records whether the glTF primitive had `JOINTS_n` and/or
758    /// `WEIGHTS_n` for `n >= 1`. Entries are sorted by
759    /// [`AdditionalInfluenceSet::set_index`].
760    pub additional_influence_sets: Vec<AdditionalInfluenceSet>,
761}
762
763/// Presence metadata for one non-primary glTF skin-influence attribute set.
764///
765/// A set may contain only one side because source assets can declare
766/// `JOINTS_n` and `WEIGHTS_n` independently. This type deliberately does not
767/// retain the corresponding per-vertex values: the core model's skinning
768/// semantics remain the primary `JOINTS_0` / `WEIGHTS_0` set.
769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
770pub struct AdditionalInfluenceSet {
771    /// glTF attribute-set number (`n >= 1`).
772    pub set_index: u32,
773    /// Whether `JOINTS_n` was declared.
774    pub joints_present: bool,
775    /// Whether `WEIGHTS_n` was declared.
776    pub weights_present: bool,
777}
778
779/// One source mesh definition, independent of any node that instances it.
780#[derive(Debug, Clone, Default)]
781pub struct MeshAsset {
782    /// Mesh name.
783    pub name: String,
784    /// Stable index of this definition in the source format.
785    ///
786    /// glTF permits several nodes to instance one mesh definition. Loaders
787    /// preserve that distinction through [`SceneAssets::instances`].
788    pub source_mesh_index: usize,
789    /// Triangle-list primitives belonging to this mesh.
790    pub primitives: Vec<Primitive>,
791}
792
793/// One node instance of a source [`MeshAsset`] definition.
794#[derive(Debug, Clone, Default)]
795pub struct MeshInstance {
796    /// Index of the source-format node that owns this mesh instance.
797    pub source_node_index: usize,
798    /// The node this mesh hangs off in the core skeleton.
799    pub node: BoneId,
800    /// Index into [`SceneAssets::meshes`] of the instanced definition.
801    pub mesh: usize,
802    /// Skin joints in cluster order. Empty = unskinned.
803    pub skin_joints: Vec<BoneId>,
804    /// Per-joint inverse bind matrices, parallel to `skin_joints`
805    /// (glTF convention: joint-bind-world⁻¹ × geometry-to-world, all
806    /// in the converted scene space). Falls back to the bones'
807    /// `inverse_bind` when empty.
808    pub skin_ibms: Vec<Mat4>,
809}
810
811/// One declared source scene and its root nodes.
812#[derive(Debug, Clone, Default)]
813pub struct SceneAsset {
814    /// Index of this scene in the source-format scene array.
815    pub source_scene_index: usize,
816    /// Authored scene name, when the source format provides one.
817    pub name: Option<String>,
818    /// Root nodes belonging to this scene, represented as core bone ids.
819    pub roots: Vec<BoneId>,
820}
821
822/// Whether a loader supplied source-node and source-skin identity evidence.
823///
824/// The skeleton used by sampling is deliberately format-neutral and is ordered
825/// for parent-before-child FK. Source formats can use a different stable node
826/// order, so this coverage flag keeps an empty source table from being
827/// mistaken for a source file with no nodes or skins.
828#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
829#[serde(rename_all = "snake_case")]
830pub enum SourceSkeletonCoverage {
831    /// The loader cannot provide source-node and source-skin identity facts.
832    #[default]
833    Unavailable,
834    /// The source-node and source-skin tables describe the loaded input.
835    /// Source nodes with [`SourceNodeAsset::bone`] form the downward-closed
836    /// projection consumed by strict operations.
837    Complete,
838}
839
840/// The source-projected local-rest representation of one source node.
841///
842/// glTF permits either decomposed TRS properties or a matrix. Keeping this
843/// representation separate from [`Bone::rest`] avoids presenting a lossy
844/// matrix decomposition as though it were authored TRS evidence. For glTF the
845/// value is the authored node member. A loader whose format semantics require
846/// coordinate, helper-node, or inheritance normalization may instead project
847/// that documented source-side result; its format-specific capability
848/// inventory must make the distinction explicit. In particular, the FBX
849/// loader records ufbx's adjusted/compensated TRS here and never claims it is
850/// the raw FBX transform stack.
851#[derive(Debug, Clone)]
852pub enum SourceNodeLocalRest {
853    /// Source-declared or format-normalized translation, rotation, and scale.
854    Trs {
855        /// Local translation in scene units.
856        translation: Vec3,
857        /// Local orientation relative to the parent node.
858        rotation: Quat,
859        /// Local non-uniform scale.
860        scale: Vec3,
861    },
862    /// Source-declared or format-normalized column-major 4×4 local transform.
863    Matrix(Mat4),
864}
865
866/// One source-side node with stable loader identity facts.
867///
868/// For a direct format projection this is an authored node. A loader that
869/// normalizes helper or inheritance semantics may also include generated
870/// source-side nodes, provided its capability inventory records that boundary.
871///
872/// Marked `#[non_exhaustive]` because this projection grows as loaders learn
873/// to carry more source-native identity (`bone` was the most recent
874/// addition): out-of-crate embedders construct it through
875/// [`SourceNodeAsset::new`] and assign the optional facts they have, so a
876/// later field cannot break their build. The sibling source-asset structs in
877/// this module are not yet marked; they are stable in a way this one has
878/// already demonstrated it is not.
879#[derive(Debug, Clone)]
880#[non_exhaustive]
881pub struct SourceNodeAsset {
882    /// Stable index in the loader's complete source-side node table.
883    pub source_node_index: usize,
884    /// Source-projected node name, when present.
885    pub name: Option<String>,
886    /// Source-side node-table index of the projected parent, when any.
887    pub parent_source_node_index: Option<usize>,
888    /// Source scenes that name this projected node as a root, in source-scene order.
889    pub scene_root_indices: Vec<usize>,
890    /// Source-projected local-rest representation.
891    pub local_rest: SourceNodeLocalRest,
892    /// The core [`BoneId`] this source node normalized to, when the loader
893    /// retained it as an independent normalized node.
894    ///
895    /// `None` means this source row has no independent [`Skeleton`] bone. A
896    /// loader may have dropped an unreachable node, or it may have folded a
897    /// static connector's authored local rest into the next projected node.
898    /// The row remains authoritative source identity and local-rest evidence
899    /// under [`SourceSkeletonCoverage::Complete`]. Format-neutral consumers
900    /// that need to resolve a raw source-node selector (for example
901    /// [`crate::scale::ScaleOperation::RestBindUniformScale`]'s
902    /// `source_root_node_index`/skin joints) into the normalized
903    /// [`Skeleton`] must use this field rather than assuming source-node
904    /// order equals bone order.
905    ///
906    /// With [`SourceSkeletonCoverage::Complete`] coverage, the `Some` rows
907    /// must form a downward-closed, nearest-projected-parent-preserving
908    /// projection into the normalized skeleton. Unprojected source rows may
909    /// occur between projected ancestors; [`validate_document_shape`]
910    /// verifies that relation.
911    pub bone: Option<BoneId>,
912}
913
914impl SourceNodeAsset {
915    /// One source node identified by its stable source-array index and its
916    /// source-projected local rest — the two facts every loader necessarily has.
917    ///
918    /// Every remaining fact ([`Self::name`], [`Self::parent_source_node_index`],
919    /// [`Self::scene_root_indices`], [`Self::bone`]) starts absent and is
920    /// assigned through the public fields. This is the only way to build the
921    /// value outside `animsmith-core`, since the type is `#[non_exhaustive]`.
922    pub fn new(source_node_index: usize, local_rest: SourceNodeLocalRest) -> Self {
923        Self {
924            source_node_index,
925            name: None,
926            parent_source_node_index: None,
927            scene_root_indices: Vec::new(),
928            local_rest,
929            bone: None,
930        }
931    }
932}
933
934/// Read status for a source skin's inverse-bind declaration.
935///
936/// glTF supplies this through an accessor. Other formats may supply an
937/// equivalent ordered declaration (for example, FBX cluster bind matrices)
938/// that the loader projects into target coordinates. Format-specific
939/// capability evidence must distinguish projected values from exact source
940/// payload preservation.
941#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
942#[serde(rename_all = "snake_case")]
943pub enum SourceInverseBindAccessorStatus {
944    /// The skin did not declare inverse-bind matrices.
945    #[default]
946    Absent,
947    /// The declaration was readable and had at least one matrix per declared joint.
948    Available,
949    /// The source declared a count-zero inverse-bind payload.
950    EmptyAccessor,
951    /// The declaration was readable but has fewer matrices than declared joints.
952    CountMismatch,
953    /// The source declared bind matrices that the loader could not read.
954    Unreadable,
955}
956
957/// Read-side evidence for one source skin inverse-bind declaration.
958#[derive(Debug, Clone, Default)]
959pub struct SourceInverseBindAccessor {
960    /// Whether the source bind declaration was absent, complete, or malformed.
961    pub status: SourceInverseBindAccessorStatus,
962    /// Declared source matrix count, or `None` when no matrices were declared.
963    pub declared_count: Option<usize>,
964    /// Matrices in declared joint order when they were readable.
965    ///
966    /// glTF retains raw accessor values. A format loader may instead retain a
967    /// documented coordinate-normalized projection of the source bind
968    /// declaration. This may contain non-finite values from a parseable binary
969    /// accessor or equivalent source structure.
970    /// Measurement serialization must classify those values rather than emit
971    /// non-finite JSON numbers.
972    pub matrices: Vec<Mat4>,
973}
974
975/// One source node that declares use of a source skin.
976#[derive(Debug, Clone)]
977pub struct SourceSkinAttachment {
978    /// Stable node-array index of the attachment node.
979    pub source_node_index: usize,
980    /// Stable source mesh-definition index, when the node declares a mesh.
981    ///
982    /// This remains present even when the current core mesh importer skips the
983    /// definition (for example, because it has no triangle-list primitive).
984    pub source_mesh_index: Option<usize>,
985}
986
987/// One source skin definition, kept separate from bone-level convenience data.
988#[derive(Debug, Clone, Default)]
989pub struct SourceSkinAsset {
990    /// Stable skin-array index in the source format.
991    pub source_skin_index: usize,
992    /// Authored skin name, when present.
993    pub name: Option<String>,
994    /// Explicitly declared skeleton root, when present; never inferred.
995    pub skeleton_root_source_node_index: Option<usize>,
996    /// Source joints in declared skin-slot order.
997    pub joint_source_node_indices: Vec<usize>,
998    /// Source inverse-bind declaration evidence for this skin.
999    ///
1000    /// glTF retains exact accessor values. Other loaders may retain a
1001    /// documented coordinate-normalized projection, as described by
1002    /// [`SourceInverseBindAccessor`].
1003    pub inverse_bind_accessor: SourceInverseBindAccessor,
1004    /// Source nodes that reference this skin, in source-node order.
1005    pub attachments: Vec<SourceSkinAttachment>,
1006}
1007
1008/// Source-node and source-skin evidence carried beside normalized scene assets.
1009#[derive(Debug, Clone, Default)]
1010pub struct SourceSkeletonAssets {
1011    /// Whether these source tables are complete for the loaded input.
1012    pub coverage: SourceSkeletonCoverage,
1013    /// Source nodes in stable source-node order.
1014    pub nodes: Vec<SourceNodeAsset>,
1015    /// Source skins in stable source-skin order.
1016    pub skins: Vec<SourceSkinAsset>,
1017}
1018
1019/// An embedded texture: raw encoded image bytes (glTF embeds the file
1020/// as-is, no decoding).
1021#[derive(Debug, Clone)]
1022pub struct TextureAsset {
1023    /// Encoded image bytes.
1024    pub bytes: Vec<u8>,
1025    /// "image/png" or "image/jpeg".
1026    pub mime: String,
1027}
1028
1029/// A normal-map texture and the scalar applied to its X/Y components.
1030///
1031/// Keeping the scale beside the texture makes the glTF normal-texture state
1032/// atomic: a scale cannot accidentally survive after its texture is removed.
1033#[derive(Debug, Clone)]
1034pub struct NormalTextureAsset {
1035    /// Embedded encoded normal-map image.
1036    pub texture: TextureAsset,
1037    /// Scalar multiplier for the decoded tangent-space X/Y components.
1038    pub scale: f32,
1039}
1040
1041/// An occlusion texture and the scalar applied to its sampled value.
1042///
1043/// Keeping the strength beside the texture makes the glTF occlusion-texture
1044/// state atomic: a strength cannot accidentally survive after its texture is
1045/// removed.
1046#[derive(Debug, Clone)]
1047pub struct OcclusionTextureAsset {
1048    /// Embedded encoded occlusion texture.
1049    pub texture: TextureAsset,
1050    /// Scalar multiplier for the sampled occlusion value.
1051    pub strength: f32,
1052}
1053
1054/// PBR material factors plus optional embedded glTF texture slots.
1055#[derive(Debug, Clone)]
1056pub struct MaterialAsset {
1057    /// Material name.
1058    pub name: String,
1059    /// Multiplied with the texture when one is present (set to white
1060    /// by the FBX loader in that case, matching exporter convention).
1061    pub base_color: [f32; 4],
1062    /// Metallic factor.
1063    pub metallic: f32,
1064    /// Roughness factor.
1065    pub roughness: f32,
1066    /// Embedded base-color texture, if one was loaded.
1067    pub base_color_texture: Option<TextureAsset>,
1068    /// Embedded tangent-space normal texture, if one was loaded.
1069    pub normal_texture: Option<NormalTextureAsset>,
1070    /// Embedded metallic-roughness texture, if one was loaded.
1071    ///
1072    /// glTF stores roughness in green and metallic in blue.
1073    pub metallic_roughness_texture: Option<TextureAsset>,
1074    /// Embedded occlusion texture, if one was loaded.
1075    pub occlusion_texture: Option<OcclusionTextureAsset>,
1076}
1077
1078/// Whether source material-resource inspection covers the whole input.
1079///
1080/// This sidecar is deliberately separate from writer-facing [`MaterialAsset`]
1081/// values. A loader may preserve materials for writing while declining to
1082/// inspect resource provenance or decode image metadata.
1083#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1084#[serde(rename_all = "snake_case")]
1085pub enum MaterialResourceCoverage {
1086    /// The loader inspected its complete documented source material-resource
1087    /// domain. Format-specific documentation defines which binding slots that
1088    /// domain includes.
1089    Complete,
1090    /// The loader cannot provide source resource evidence.
1091    #[default]
1092    Unavailable,
1093}
1094
1095/// A material texture slot with stable source-format meaning.
1096///
1097/// Declaration order is the stable wire order used by material-resource
1098/// measurements.
1099#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1100#[serde(rename_all = "snake_case")]
1101pub enum MaterialTextureSlot {
1102    /// Base-color texture.
1103    BaseColor,
1104    /// Tangent-space normal texture.
1105    Normal,
1106    /// Combined metallic-roughness texture.
1107    MetallicRoughness,
1108    /// Occlusion texture.
1109    Occlusion,
1110    /// Emissive texture.
1111    Emissive,
1112}
1113
1114/// One source material-to-texture binding.
1115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1116pub struct SourceMaterialTextureBinding {
1117    /// Material slot in stable semantic order.
1118    pub slot: MaterialTextureSlot,
1119    /// Stable source texture index.
1120    pub texture_index: usize,
1121}
1122
1123/// One source material definition, independent of writer-facing material data.
1124#[derive(Debug, Clone, Default)]
1125pub struct SourceMaterialAsset {
1126    /// Stable source material index.
1127    pub material_index: usize,
1128    /// Authored name, when present.
1129    pub name: Option<String>,
1130    /// Source texture bindings, sorted by [`SourceMaterialTextureBinding::slot`].
1131    pub texture_bindings: Vec<SourceMaterialTextureBinding>,
1132}
1133
1134/// One source texture definition.
1135#[derive(Debug, Clone, Default)]
1136pub struct SourceTextureAsset {
1137    /// Stable source texture index.
1138    pub texture_index: usize,
1139    /// Authored name, when present.
1140    pub name: Option<String>,
1141    /// Stable source image index referenced by this texture.
1142    pub image_index: usize,
1143}
1144
1145/// How an image payload was declared by its source format.
1146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1147#[serde(rename_all = "snake_case")]
1148pub enum ImageSourceKind {
1149    /// Bytes embedded directly in a container record.
1150    Embedded,
1151    /// Bytes encoded in a data URI.
1152    DataUri,
1153    /// A relative or otherwise external resource reference.
1154    External,
1155}
1156
1157/// Image container format recognized by inspection.
1158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1159#[serde(rename_all = "snake_case")]
1160pub enum ImageContainerFormat {
1161    /// PNG image data.
1162    Png,
1163    /// JPEG image data.
1164    Jpeg,
1165}
1166
1167/// Decoded image color representation reported by inspection.
1168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1169#[serde(rename_all = "snake_case")]
1170pub enum DecodedImageColorType {
1171    /// Single-channel, 8-bit luminance.
1172    L8,
1173    /// Luminance plus alpha, 8-bit channels.
1174    La8,
1175    /// RGB, 8-bit channels.
1176    Rgb8,
1177    /// RGBA, 8-bit channels.
1178    Rgba8,
1179    /// Single-channel, 16-bit luminance.
1180    L16,
1181    /// Luminance plus alpha, 16-bit channels.
1182    La16,
1183    /// RGB, 16-bit channels.
1184    Rgb16,
1185    /// RGBA, 16-bit channels.
1186    Rgba16,
1187}
1188
1189/// Why source-image inspection could not produce decoded metadata.
1190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1191#[serde(rename_all = "snake_case")]
1192pub enum ImageUnavailableReason {
1193    /// The source does not make the image payload available to the loader.
1194    SourceUnavailable,
1195    /// A data URI could not be parsed or decoded.
1196    InvalidDataUri,
1197    /// The image container is not supported for inspection.
1198    UnsupportedContainer,
1199    /// Supported image bytes could not be decoded.
1200    DecodeFailed,
1201    /// Inspection declined the resource because it exceeded a resource limit.
1202    ResourceLimit,
1203}
1204
1205/// Result of bounded source-image inspection.
1206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1207pub enum SourceImageInspection {
1208    /// Decoded metadata was available without retaining decoded pixels.
1209    Available {
1210        /// Pixel width.
1211        width: u32,
1212        /// Pixel height.
1213        height: u32,
1214        /// Number of decoded channels.
1215        channel_count: u8,
1216        /// Decoded color representation.
1217        color_type: DecodedImageColorType,
1218    },
1219    /// Inspection could not provide decoded metadata.
1220    Unavailable {
1221        /// Stable unavailability reason.
1222        reason: ImageUnavailableReason,
1223    },
1224}
1225
1226/// One source image definition and bounded inspection result.
1227#[derive(Debug, Clone)]
1228pub struct SourceImageAsset {
1229    /// Stable source image index.
1230    pub image_index: usize,
1231    /// Authored name, when present.
1232    pub name: Option<String>,
1233    /// Source declaration kind.
1234    pub source_kind: ImageSourceKind,
1235    /// MIME type declared by the source, when present.
1236    pub declared_mime_type: Option<String>,
1237    /// Detected container format, when recognisable.
1238    pub detected_container: Option<ImageContainerFormat>,
1239    /// Lowercase hex for the first at most 16 bytes of an unsupported,
1240    /// nonempty payload inspected without a supported container signature.
1241    pub leading_magic_hex: Option<String>,
1242    /// Bounded image-inspection result.
1243    pub inspection: SourceImageInspection,
1244}
1245
1246/// Read-only source material-resource evidence carried beside scene assets.
1247#[derive(Debug, Clone, Default)]
1248pub struct MaterialResourceAssets {
1249    /// Whether the source resource lists are complete.
1250    pub coverage: MaterialResourceCoverage,
1251    /// Source materials in source order.
1252    pub materials: Vec<SourceMaterialAsset>,
1253    /// Source textures in source order.
1254    pub textures: Vec<SourceTextureAsset>,
1255    /// Source images in source order.
1256    pub images: Vec<SourceImageAsset>,
1257}
1258
1259impl Primitive {
1260    /// Dedupe identical corners into indexed triangles. Exact
1261    /// bit-equality only — no tolerance welding, so seams authored via
1262    /// split normals/UVs are preserved.
1263    pub fn weld(&mut self) {
1264        if !self.indices.is_empty() || self.positions.is_empty() {
1265            return;
1266        }
1267        let corner_key = |i: usize| -> Vec<u8> {
1268            let mut key = Vec::with_capacity(64);
1269            let mut push_f32s = |vals: &[f32]| {
1270                for v in vals {
1271                    key.extend_from_slice(&v.to_le_bytes());
1272                }
1273            };
1274            push_f32s(&self.positions[i].to_array());
1275            if let Some(n) = self.normals.get(i) {
1276                push_f32s(&n.to_array());
1277            }
1278            if let Some(uv) = self.uvs.get(i) {
1279                push_f32s(uv);
1280            }
1281            if let Some(w) = self.weights.get(i) {
1282                push_f32s(w);
1283            }
1284            if let Some(j) = self.joints.get(i) {
1285                for v in j {
1286                    key.extend_from_slice(&v.to_le_bytes());
1287                }
1288            }
1289            key
1290        };
1291        let mut seen: std::collections::HashMap<Vec<u8>, u32> = std::collections::HashMap::new();
1292        let mut indices = Vec::with_capacity(self.positions.len());
1293        let mut positions = Vec::new();
1294        let mut normals = Vec::new();
1295        let mut uvs = Vec::new();
1296        let mut joints = Vec::new();
1297        let mut weights = Vec::new();
1298        for i in 0..self.positions.len() {
1299            let index = *seen.entry(corner_key(i)).or_insert_with(|| {
1300                positions.push(self.positions[i]);
1301                if let Some(n) = self.normals.get(i) {
1302                    normals.push(*n);
1303                }
1304                if let Some(uv) = self.uvs.get(i) {
1305                    uvs.push(*uv);
1306                }
1307                if let Some(j) = self.joints.get(i) {
1308                    joints.push(*j);
1309                }
1310                if let Some(w) = self.weights.get(i) {
1311                    weights.push(*w);
1312                }
1313                (positions.len() - 1) as u32
1314            });
1315            indices.push(index);
1316        }
1317        self.indices = indices;
1318        self.positions = positions;
1319        self.normals = normals;
1320        self.uvs = uvs;
1321        self.joints = joints;
1322        self.weights = weights;
1323    }
1324}
1325
1326/// Mesh definitions, their node instances, scenes, and materials carried
1327/// alongside animation data.
1328#[derive(Debug, Clone, Default)]
1329pub struct SceneAssets {
1330    /// Mesh definitions in source order, including definitions without a node
1331    /// instance.
1332    pub meshes: Vec<MeshAsset>,
1333    /// Node instances of the mesh definitions, in source node order.
1334    pub instances: Vec<MeshInstance>,
1335    /// Materials referenced by mesh primitives.
1336    pub materials: Vec<MaterialAsset>,
1337    /// Read-only source material, texture, and image evidence for measurement.
1338    /// Writer-facing material slots remain in [`Self::materials`].
1339    pub material_resources: MaterialResourceAssets,
1340    /// Declared source scenes in source order.
1341    pub scenes: Vec<SceneAsset>,
1342    /// Source scene index selected by default, when one was declared.
1343    pub default_scene: Option<usize>,
1344    /// Source-node and source-skin identity evidence for skeleton measurements.
1345    ///
1346    /// This is intentionally separate from the normalized [`Skeleton`] and
1347    /// from [`MeshInstance::skin_ibms`]: a source node order need not match
1348    /// FK order, and one joint can have different inverse binds in different
1349    /// source skins.
1350    pub source_skeleton: SourceSkeletonAssets,
1351}
1352
1353/// Validate the enumerated structural snapshot strict document operations
1354/// rely on.
1355///
1356/// This is a snapshot only: [`Document`] is publicly mutable, so a successful
1357/// call does not certify a document against later mutation. Any strict
1358/// operation that relies on this full shape must rerun validation at its own
1359/// public boundary.
1360/// Tolerant analysis APIs may intentionally accept documents this rejects and
1361/// preserve the valid evidence they can read. This function does not validate
1362/// operation-specific capability, affine, closure, proof, or payload
1363/// invariants such as primitive skinning shape and base positions.
1364///
1365/// # Errors
1366///
1367/// Returns a typed [`DocumentShapeError`] for the first violation in stable
1368/// validation order: skeleton rest/topology, scene roots, source
1369/// identity/projection, tracks, instances, then bone-level inverse binds.
1370pub fn validate_document_shape(document: &Document) -> Result<(), DocumentShapeError> {
1371    validate_document_structure_prefix(document)?;
1372    validate_clip_tracks(document)?;
1373    validate_mesh_instances(document)?;
1374    validate_bone_inverse_binds(&document.skeleton)
1375}
1376
1377fn validate_document_structure_prefix(document: &Document) -> Result<(), DocumentShapeError> {
1378    validate_skeleton_rest(&document.skeleton)?;
1379    validate_scene_roots(document)?;
1380    validate_source_skeleton_identity(&document.assets.source_skeleton)?;
1381    validate_source_projection(document)
1382}
1383
1384fn validate_scene_roots(document: &Document) -> Result<(), DocumentShapeError> {
1385    let bone_count = document.skeleton.bones.len();
1386    for (scene_index, scene) in document.assets.scenes.iter().enumerate() {
1387        for (root_index, &bone) in scene.roots.iter().enumerate() {
1388            if bone >= bone_count {
1389                return Err(DocumentShapeError::SceneRootOutOfBounds {
1390                    scene_index,
1391                    root_index,
1392                    bone,
1393                });
1394            }
1395        }
1396    }
1397    Ok(())
1398}
1399
1400fn validate_skeleton_rest(skeleton: &Skeleton) -> Result<(), DocumentShapeError> {
1401    world_rest_matrices(skeleton)
1402        .map(|_| ())
1403        .map_err(|error| match error {
1404            WorldMatrixError::NonFiniteTransform { node } => {
1405                DocumentShapeError::NonFiniteSkeletonRest { node }
1406            }
1407            WorldMatrixError::InvalidParent { node, parent } => {
1408                DocumentShapeError::InvalidSkeletonParent { node, parent }
1409            }
1410        })
1411}
1412
1413fn validate_source_skeleton_identity(
1414    source_skeleton: &SourceSkeletonAssets,
1415) -> Result<(), DocumentShapeError> {
1416    let mut seen_nodes = BTreeSet::new();
1417    for node in &source_skeleton.nodes {
1418        if !seen_nodes.insert(node.source_node_index) {
1419            return Err(DocumentShapeError::DuplicateSourceNodeIndex {
1420                source_node_index: node.source_node_index,
1421            });
1422        }
1423    }
1424    let mut seen_skins = BTreeSet::new();
1425    for skin in &source_skeleton.skins {
1426        if !seen_skins.insert(skin.source_skin_index) {
1427            return Err(DocumentShapeError::DuplicateSourceSkinIndex {
1428                source_skin_index: skin.source_skin_index,
1429            });
1430        }
1431    }
1432    Ok(())
1433}
1434
1435/// Validate the identity relation a `Complete` source projection claims.
1436///
1437/// Projected rows must be injective, preserve each bone's nearest projected
1438/// ancestor, and be downward-closed in the normalized skeleton. Unprojected
1439/// source rows may remain between projected ancestors, and unrelated
1440/// unprojected roots remain legal; totality is not required. Non-`Complete`
1441/// rows are not identity evidence and are deliberately ignored.
1442///
1443/// The rule is load-bearing for consumers such as scale that select a rewrite
1444/// domain through source-node ancestry but apply and prove it through
1445/// [`Skeleton::bones`]. Without agreement, a normalized child can sit outside
1446/// the selected source closure while its parent moves, leaving its displaced
1447/// world rest outside every declared proof walk.
1448fn validate_source_projection(document: &Document) -> Result<(), DocumentShapeError> {
1449    let source_skeleton = &document.assets.source_skeleton;
1450    if source_skeleton.coverage != SourceSkeletonCoverage::Complete {
1451        return Ok(());
1452    }
1453
1454    let bones = &document.skeleton.bones;
1455    let mut bone_of_source = BTreeMap::new();
1456    let mut source_of_bone = BTreeMap::new();
1457    let mut skeleton_parents = Vec::with_capacity(source_skeleton.nodes.len());
1458    for node in &source_skeleton.nodes {
1459        let Some(bone) = node.bone else {
1460            continue;
1461        };
1462        let skeleton_parent = bones
1463            .get(bone)
1464            .ok_or(DocumentShapeError::SourceProjection {
1465                source_node_index: node.source_node_index,
1466                violation: SourceProjectionViolation::ProjectedBoneOutOfRange,
1467            })?
1468            .parent;
1469        if source_of_bone
1470            .insert(bone, node.source_node_index)
1471            .is_some()
1472        {
1473            return Err(DocumentShapeError::SourceProjection {
1474                source_node_index: node.source_node_index,
1475                violation: SourceProjectionViolation::TwoSourceNodesProjectToOneBone,
1476            });
1477        }
1478        bone_of_source.insert(node.source_node_index, bone);
1479        skeleton_parents.push((node, skeleton_parent));
1480    }
1481
1482    let by_source_index: BTreeMap<_, _> = source_skeleton
1483        .nodes
1484        .iter()
1485        .map(|node| (node.source_node_index, node))
1486        .collect();
1487    let unprojected_rows = source_skeleton.nodes.len() - bone_of_source.len();
1488    // Cache only successful suffix resolutions. A malformed suffix still
1489    // fails on the first projected row that reaches it, preserving that row
1490    // as the error owner; a later row is never visited after the error.
1491    let mut resolved_unprojected = BTreeMap::<usize, Option<BoneId>>::new();
1492    for (node, skeleton_parent) in skeleton_parents {
1493        let mut cursor = node.parent_source_node_index;
1494        let mut unresolved_suffix = Vec::new();
1495        let projected_parent = loop {
1496            let Some(parent_source_node_index) = cursor else {
1497                break None;
1498            };
1499            if let Some(&bone) = bone_of_source.get(&parent_source_node_index) {
1500                break Some(bone);
1501            }
1502            if let Some(&projected_parent) = resolved_unprojected.get(&parent_source_node_index) {
1503                break projected_parent;
1504            }
1505            let parent = by_source_index.get(&parent_source_node_index).ok_or(
1506                DocumentShapeError::SourceProjection {
1507                    source_node_index: node.source_node_index,
1508                    violation: SourceProjectionViolation::ParentSourceNodeMissing,
1509                },
1510            )?;
1511            unresolved_suffix.push(parent_source_node_index);
1512            if unresolved_suffix.len() > unprojected_rows {
1513                return Err(DocumentShapeError::SourceProjection {
1514                    source_node_index: node.source_node_index,
1515                    violation: SourceProjectionViolation::CyclicUnprojectedSourceParentChain,
1516                });
1517            }
1518            cursor = parent.parent_source_node_index;
1519        };
1520        for source_node_index in unresolved_suffix {
1521            resolved_unprojected.insert(source_node_index, projected_parent);
1522        }
1523        if projected_parent != skeleton_parent {
1524            return Err(DocumentShapeError::SourceProjection {
1525                source_node_index: node.source_node_index,
1526                violation: SourceProjectionViolation::NearestProjectedParentMismatch,
1527            });
1528        }
1529    }
1530
1531    for (bone, child) in bones.iter().enumerate() {
1532        if source_of_bone.contains_key(&bone) {
1533            continue;
1534        }
1535        if let Some(parent) = child.parent
1536            && let Some(&source_node_index) = source_of_bone.get(&parent)
1537        {
1538            return Err(DocumentShapeError::SourceProjection {
1539                source_node_index,
1540                violation: SourceProjectionViolation::ProjectedBoneHasUnprojectedSkeletonChild,
1541            });
1542        }
1543    }
1544    Ok(())
1545}
1546
1547fn validate_clip_tracks(document: &Document) -> Result<(), DocumentShapeError> {
1548    let bone_count = document.skeleton.bones.len();
1549    for (clip_index, clip) in document.clips.iter().enumerate() {
1550        let mut seen = Vec::with_capacity(clip.tracks.len());
1551        for track in &clip.tracks {
1552            if track.bone >= bone_count {
1553                return Err(DocumentShapeError::TrackShape {
1554                    clip_index,
1555                    node: track.bone,
1556                    violation: TrackShapeViolation::BoneIndexOutOfRange,
1557                });
1558            }
1559            if seen.contains(&(track.bone, track.property)) {
1560                return Err(DocumentShapeError::DuplicateClipTrack {
1561                    clip_index,
1562                    node: track.bone,
1563                    property: track.property,
1564                });
1565            }
1566            seen.push((track.bone, track.property));
1567            validate_track_shape(clip_index, track)?;
1568        }
1569    }
1570    Ok(())
1571}
1572
1573pub(crate) fn validate_track_shape(
1574    clip_index: usize,
1575    track: &Track,
1576) -> Result<(), DocumentShapeError> {
1577    let violation = if track.times.is_empty() {
1578        Some(TrackShapeViolation::EmptyTimes)
1579    } else if track.times.iter().any(|time| !time.is_finite()) {
1580        Some(TrackShapeViolation::NonFiniteTime)
1581    } else if track.times.windows(2).any(|times| times[0] >= times[1]) {
1582        Some(TrackShapeViolation::TimesNotStrictlyIncreasing)
1583    } else {
1584        let expected_values = match track.interpolation {
1585            Interpolation::CubicSpline => track.times.len().checked_mul(3),
1586            Interpolation::Linear | Interpolation::Step => Some(track.times.len()),
1587        };
1588        if expected_values != Some(track.values.len()) {
1589            Some(TrackShapeViolation::ValueCountMismatch)
1590        } else if !matches!(
1591            (&track.values, track.property),
1592            (
1593                TrackValues::Vec3s(_),
1594                Property::Translation | Property::Scale
1595            ) | (TrackValues::Quats(_), Property::Rotation)
1596        ) {
1597            Some(TrackShapeViolation::ValueTypeMismatchesProperty)
1598        } else if match &track.values {
1599            TrackValues::Vec3s(values) => values.iter().any(|value| !value.is_finite()),
1600            TrackValues::Quats(values) => values.iter().any(|value| !value.is_finite()),
1601        } {
1602            Some(TrackShapeViolation::NonFiniteValue)
1603        } else {
1604            None
1605        }
1606    };
1607    violation.map_or(Ok(()), |violation| {
1608        Err(DocumentShapeError::TrackShape {
1609            clip_index,
1610            node: track.bone,
1611            violation,
1612        })
1613    })
1614}
1615
1616fn validate_mesh_instances(document: &Document) -> Result<(), DocumentShapeError> {
1617    let bone_count = document.skeleton.bones.len();
1618    let mesh_count = document.assets.meshes.len();
1619    for (instance_index, instance) in document.assets.instances.iter().enumerate() {
1620        let violation = if instance.node >= bone_count {
1621            Some(MeshInstanceShapeViolation::NodeIndexOutOfRange)
1622        } else if instance.mesh >= mesh_count {
1623            Some(MeshInstanceShapeViolation::MeshIndexOutOfRange)
1624        } else if instance
1625            .skin_joints
1626            .iter()
1627            .any(|&joint| joint >= bone_count)
1628        {
1629            Some(MeshInstanceShapeViolation::SkinJointOutOfRange)
1630        } else if !instance.skin_ibms.is_empty()
1631            && instance.skin_ibms.len() != instance.skin_joints.len()
1632        {
1633            Some(MeshInstanceShapeViolation::SkinInverseBindCountMismatch)
1634        } else if instance.skin_ibms.iter().any(|ibm| !mat4_is_finite(*ibm)) {
1635            Some(MeshInstanceShapeViolation::NonFiniteSkinInverseBind)
1636        } else {
1637            None
1638        };
1639        if let Some(violation) = violation {
1640            return Err(DocumentShapeError::MeshInstanceShape {
1641                instance_index,
1642                violation,
1643            });
1644        }
1645    }
1646    Ok(())
1647}
1648
1649fn validate_bone_inverse_binds(skeleton: &Skeleton) -> Result<(), DocumentShapeError> {
1650    for (node, bone) in skeleton.bones.iter().enumerate() {
1651        if let Some(inverse_bind) = bone.inverse_bind
1652            && !mat4_is_finite(inverse_bind)
1653        {
1654            return Err(DocumentShapeError::NonFiniteBoneInverseBind { node });
1655        }
1656    }
1657    Ok(())
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662    use super::*;
1663
1664    #[test]
1665    fn property_serde_uses_the_stable_trs_vocabulary() {
1666        assert_eq!(
1667            serde_json::to_value([Property::Translation, Property::Rotation, Property::Scale,])
1668                .expect("properties serialize"),
1669            serde_json::json!(["translation", "rotation", "scale"])
1670        );
1671        assert_eq!(
1672            serde_json::from_value::<Vec<Property>>(serde_json::json!([
1673                "translation",
1674                "rotation",
1675                "scale"
1676            ]))
1677            .expect("properties deserialize"),
1678            [Property::Translation, Property::Rotation, Property::Scale,]
1679        );
1680    }
1681
1682    fn bone(parent: Option<BoneId>) -> Bone {
1683        Bone {
1684            name: "bone".into(),
1685            parent,
1686            rest: Transform::IDENTITY,
1687            inverse_bind: None,
1688        }
1689    }
1690
1691    fn one_bone_document() -> Document {
1692        Document {
1693            skeleton: Skeleton {
1694                bones: vec![bone(None)],
1695            },
1696            ..Document::default()
1697        }
1698    }
1699
1700    #[test]
1701    fn document_shape_validation_checks_all_scene_roots_in_stable_order() {
1702        let mut document = one_bone_document();
1703        document.assets.scenes = vec![
1704            SceneAsset {
1705                roots: vec![0, 17],
1706                ..SceneAsset::default()
1707            },
1708            SceneAsset {
1709                roots: vec![23],
1710                ..SceneAsset::default()
1711            },
1712        ];
1713        assert_eq!(
1714            validate_document_shape(&document),
1715            Err(DocumentShapeError::SceneRootOutOfBounds {
1716                scene_index: 0,
1717                root_index: 1,
1718                bone: 17,
1719            })
1720        );
1721    }
1722
1723    #[test]
1724    fn document_shape_scene_roots_precede_source_identity_errors() {
1725        let mut document = one_bone_document();
1726        document.assets.scenes.push(SceneAsset {
1727            roots: vec![99],
1728            ..SceneAsset::default()
1729        });
1730        document.assets.source_skeleton.nodes =
1731            vec![source_node(0, None, None), source_node(0, None, None)];
1732        assert_eq!(
1733            validate_document_shape(&document),
1734            Err(DocumentShapeError::SceneRootOutOfBounds {
1735                scene_index: 0,
1736                root_index: 0,
1737                bone: 99,
1738            })
1739        );
1740    }
1741
1742    fn source_node(
1743        source_node_index: usize,
1744        parent_source_node_index: Option<usize>,
1745        bone: Option<BoneId>,
1746    ) -> SourceNodeAsset {
1747        SourceNodeAsset {
1748            source_node_index,
1749            name: None,
1750            parent_source_node_index,
1751            scene_root_indices: Vec::new(),
1752            local_rest: SourceNodeLocalRest::Trs {
1753                translation: Vec3::ZERO,
1754                rotation: Quat::IDENTITY,
1755                scale: Vec3::ONE,
1756            },
1757            bone,
1758        }
1759    }
1760
1761    fn valid_track() -> Track {
1762        Track {
1763            bone: 0,
1764            property: Property::Translation,
1765            interpolation: Interpolation::Linear,
1766            times: vec![0.0],
1767            values: TrackValues::Vec3s(vec![Vec3::ZERO]),
1768        }
1769    }
1770
1771    fn track_document(track: Track) -> Document {
1772        let mut document = one_bone_document();
1773        document.clips.push(Clip {
1774            name: "clip".into(),
1775            duration_s: 0.0,
1776            tracks: vec![track],
1777        });
1778        document
1779    }
1780
1781    fn instance_document() -> Document {
1782        let mut document = one_bone_document();
1783        document.assets.meshes.push(MeshAsset::default());
1784        document.assets.instances.push(MeshInstance {
1785            node: 0,
1786            mesh: 0,
1787            ..MeshInstance::default()
1788        });
1789        document
1790    }
1791
1792    #[test]
1793    fn document_shape_validation_accepts_a_complete_projection_with_an_unprojected_intermediate() {
1794        let mut document = Document {
1795            skeleton: Skeleton {
1796                bones: vec![bone(None), bone(Some(0))],
1797            },
1798            assets: SceneAssets {
1799                source_skeleton: SourceSkeletonAssets {
1800                    coverage: SourceSkeletonCoverage::Complete,
1801                    nodes: vec![
1802                        source_node(10, None, Some(0)),
1803                        source_node(11, Some(10), None),
1804                        source_node(12, Some(11), Some(1)),
1805                    ],
1806                    ..SourceSkeletonAssets::default()
1807                },
1808                meshes: vec![MeshAsset::default()],
1809                instances: vec![MeshInstance {
1810                    node: 1,
1811                    mesh: 0,
1812                    skin_joints: vec![0, 1],
1813                    skin_ibms: vec![Mat4::IDENTITY, Mat4::IDENTITY],
1814                    ..MeshInstance::default()
1815                }],
1816                ..SceneAssets::default()
1817            },
1818            ..Document::default()
1819        };
1820        document.clips.push(Clip {
1821            name: "clip".into(),
1822            duration_s: 0.0,
1823            tracks: vec![valid_track()],
1824        });
1825
1826        assert_eq!(validate_document_shape(&document), Ok(()));
1827    }
1828
1829    #[test]
1830    fn shared_unprojected_parent_suffix_preserves_each_projected_parent() {
1831        const CONNECTORS: usize = 64;
1832        const PROJECTED_CHILDREN: usize = 64;
1833
1834        let mut nodes = Vec::with_capacity(1 + CONNECTORS + PROJECTED_CHILDREN);
1835        nodes.push(source_node(0, None, Some(0)));
1836        for source_node_index in 1..=CONNECTORS {
1837            nodes.push(source_node(
1838                source_node_index,
1839                Some(source_node_index - 1),
1840                None,
1841            ));
1842        }
1843        for child in 0..PROJECTED_CHILDREN {
1844            nodes.push(source_node(
1845                1 + CONNECTORS + child,
1846                Some(CONNECTORS),
1847                Some(1 + child),
1848            ));
1849        }
1850        let document = Document {
1851            skeleton: Skeleton {
1852                bones: std::iter::once(bone(None))
1853                    .chain((0..PROJECTED_CHILDREN).map(|_| bone(Some(0))))
1854                    .collect(),
1855            },
1856            assets: SceneAssets {
1857                source_skeleton: SourceSkeletonAssets {
1858                    coverage: SourceSkeletonCoverage::Complete,
1859                    nodes,
1860                    ..SourceSkeletonAssets::default()
1861                },
1862                ..SceneAssets::default()
1863            },
1864            ..Document::default()
1865        };
1866
1867        assert_eq!(validate_document_shape(&document), Ok(()));
1868        let mut mismatched = document.clone();
1869        mismatched.skeleton.bones[PROJECTED_CHILDREN].parent = None;
1870        assert_eq!(
1871            validate_document_shape(&mismatched),
1872            Err(DocumentShapeError::SourceProjection {
1873                source_node_index: CONNECTORS + PROJECTED_CHILDREN,
1874                violation: SourceProjectionViolation::NearestProjectedParentMismatch,
1875            })
1876        );
1877    }
1878
1879    #[test]
1880    fn document_shape_validation_has_an_analytic_error_for_every_variant() {
1881        let projection_error =
1882            |source_node_index, violation| DocumentShapeError::SourceProjection {
1883                source_node_index,
1884                violation,
1885            };
1886        let track_error = |node, violation| DocumentShapeError::TrackShape {
1887            clip_index: 0,
1888            node,
1889            violation,
1890        };
1891        let instance_error = |violation| DocumentShapeError::MeshInstanceShape {
1892            instance_index: 0,
1893            violation,
1894        };
1895        let mut bad_scene_root = one_bone_document();
1896        bad_scene_root.assets.scenes = vec![
1897            SceneAsset {
1898                roots: vec![0],
1899                ..SceneAsset::default()
1900            },
1901            SceneAsset {
1902                roots: vec![99],
1903                ..SceneAsset::default()
1904            },
1905        ];
1906
1907        let mut non_finite_rest = one_bone_document();
1908        non_finite_rest.skeleton.bones[0].rest.translation.x = f32::NAN;
1909        let overflowed_rest_world = Document {
1910            skeleton: Skeleton {
1911                bones: vec![
1912                    Bone {
1913                        rest: Transform {
1914                            scale: Vec3::splat(f32::MAX),
1915                            ..Transform::IDENTITY
1916                        },
1917                        ..bone(None)
1918                    },
1919                    Bone {
1920                        rest: Transform {
1921                            translation: Vec3::splat(2.0),
1922                            ..Transform::IDENTITY
1923                        },
1924                        ..bone(Some(0))
1925                    },
1926                ],
1927            },
1928            ..Document::default()
1929        };
1930        let self_parent = Document {
1931            skeleton: Skeleton {
1932                bones: vec![bone(Some(0))],
1933            },
1934            ..Document::default()
1935        };
1936        let forward_parent = Document {
1937            skeleton: Skeleton {
1938                bones: vec![bone(Some(1)), bone(None)],
1939            },
1940            ..Document::default()
1941        };
1942        let far_parent = Document {
1943            skeleton: Skeleton {
1944                bones: vec![bone(Some(99))],
1945            },
1946            ..Document::default()
1947        };
1948        let duplicate_node = Document {
1949            assets: SceneAssets {
1950                source_skeleton: SourceSkeletonAssets {
1951                    nodes: vec![
1952                        source_node(9, None, None),
1953                        source_node(10, None, None),
1954                        source_node(9, None, None),
1955                    ],
1956                    ..SourceSkeletonAssets::default()
1957                },
1958                ..SceneAssets::default()
1959            },
1960            ..Document::default()
1961        };
1962        let duplicate_skin = Document {
1963            assets: SceneAssets {
1964                source_skeleton: SourceSkeletonAssets {
1965                    skins: vec![
1966                        SourceSkinAsset {
1967                            source_skin_index: 4,
1968                            ..SourceSkinAsset::default()
1969                        },
1970                        SourceSkinAsset {
1971                            source_skin_index: 5,
1972                            ..SourceSkinAsset::default()
1973                        },
1974                        SourceSkinAsset {
1975                            source_skin_index: 4,
1976                            ..SourceSkinAsset::default()
1977                        },
1978                    ],
1979                    ..SourceSkeletonAssets::default()
1980                },
1981                ..SceneAssets::default()
1982            },
1983            ..Document::default()
1984        };
1985        let complete_projection = |nodes| SceneAssets {
1986            source_skeleton: SourceSkeletonAssets {
1987                coverage: SourceSkeletonCoverage::Complete,
1988                nodes,
1989                ..SourceSkeletonAssets::default()
1990            },
1991            ..SceneAssets::default()
1992        };
1993        let out_of_range_projection = Document {
1994            skeleton: Skeleton {
1995                bones: vec![bone(None)],
1996            },
1997            assets: complete_projection(vec![source_node(10, None, Some(1))]),
1998            ..Document::default()
1999        };
2000        let non_injective_projection = Document {
2001            skeleton: Skeleton {
2002                bones: vec![bone(None)],
2003            },
2004            assets: complete_projection(vec![
2005                source_node(10, None, Some(0)),
2006                source_node(11, None, Some(0)),
2007            ]),
2008            ..Document::default()
2009        };
2010        let missing_projection_parent = Document {
2011            skeleton: Skeleton {
2012                bones: vec![bone(None), bone(Some(0))],
2013            },
2014            assets: complete_projection(vec![source_node(11, Some(99), Some(1))]),
2015            ..Document::default()
2016        };
2017        // Exactly one unprojected row lies between projected child 11 and the
2018        // genuinely missing parent 99. The strict `> unprojected_rows` guard
2019        // must preserve the missing-parent classification; `>=` reports a
2020        // cycle at this exact boundary instead.
2021        let missing_projection_parent_at_cycle_bound = Document {
2022            skeleton: Skeleton {
2023                bones: vec![bone(None), bone(Some(0))],
2024            },
2025            assets: complete_projection(vec![
2026                source_node(10, None, Some(0)),
2027                source_node(11, Some(12), Some(1)),
2028                source_node(12, Some(99), None),
2029            ]),
2030            ..Document::default()
2031        };
2032        let cyclic_unprojected_parent = Document {
2033            skeleton: Skeleton {
2034                bones: vec![bone(None), bone(Some(0))],
2035            },
2036            assets: complete_projection(vec![
2037                source_node(11, Some(12), Some(1)),
2038                source_node(12, Some(12), None),
2039            ]),
2040            ..Document::default()
2041        };
2042        let cyclic_unprojected_parent_pair = Document {
2043            skeleton: Skeleton {
2044                bones: vec![bone(None), bone(Some(0))],
2045            },
2046            assets: complete_projection(vec![
2047                source_node(11, Some(12), Some(1)),
2048                source_node(12, Some(13), None),
2049                source_node(13, Some(12), None),
2050            ]),
2051            ..Document::default()
2052        };
2053        let mismatched_nearest_parent = Document {
2054            skeleton: Skeleton {
2055                bones: vec![bone(None), bone(Some(0))],
2056            },
2057            assets: complete_projection(vec![
2058                source_node(10, None, Some(0)),
2059                source_node(11, None, Some(1)),
2060            ]),
2061            ..Document::default()
2062        };
2063        let unprojected_child = Document {
2064            skeleton: Skeleton {
2065                bones: vec![bone(None), bone(Some(0))],
2066            },
2067            assets: complete_projection(vec![source_node(10, None, Some(0))]),
2068            ..Document::default()
2069        };
2070
2071        let duplicate_track = {
2072            let track = valid_track();
2073            let mut document = track_document(track.clone());
2074            document.clips[0].tracks.push(Track {
2075                property: Property::Scale,
2076                ..valid_track()
2077            });
2078            document.clips[0].tracks.push(track);
2079            document
2080        };
2081        let mut boundary_out_of_range_track = valid_track();
2082        boundary_out_of_range_track.bone = 1;
2083        let mut far_out_of_range_track = valid_track();
2084        far_out_of_range_track.bone = 99;
2085        let empty_track = Track {
2086            times: Vec::new(),
2087            values: TrackValues::Vec3s(Vec::new()),
2088            ..valid_track()
2089        };
2090        let non_finite_later_time = Track {
2091            times: vec![0.0, f32::NAN],
2092            values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO]),
2093            ..valid_track()
2094        };
2095        let unordered_times = Track {
2096            times: vec![1.0, 0.0],
2097            values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO]),
2098            ..valid_track()
2099        };
2100        let equal_times = Track {
2101            times: vec![0.0, 0.0],
2102            values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO]),
2103            ..valid_track()
2104        };
2105        let wrong_linear_value_count = Track {
2106            values: TrackValues::Vec3s(Vec::new()),
2107            ..valid_track()
2108        };
2109        let excess_linear_value_count = Track {
2110            values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO]),
2111            ..valid_track()
2112        };
2113        let wrong_step_value_count = Track {
2114            interpolation: Interpolation::Step,
2115            times: vec![0.0, 1.0],
2116            values: TrackValues::Vec3s(vec![Vec3::ZERO]),
2117            ..valid_track()
2118        };
2119        let excess_step_value_count = Track {
2120            interpolation: Interpolation::Step,
2121            values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO]),
2122            ..valid_track()
2123        };
2124        let wrong_cubic_value_count = Track {
2125            interpolation: Interpolation::CubicSpline,
2126            times: vec![0.0, 1.0],
2127            values: TrackValues::Vec3s(vec![Vec3::ZERO; 4]),
2128            ..valid_track()
2129        };
2130        let excess_cubic_value_count = Track {
2131            interpolation: Interpolation::CubicSpline,
2132            values: TrackValues::Vec3s(vec![Vec3::ZERO; 4]),
2133            ..valid_track()
2134        };
2135        let wrong_translation_value_type = Track {
2136            values: TrackValues::Quats(vec![Quat::IDENTITY]),
2137            ..valid_track()
2138        };
2139        let wrong_scale_value_type = Track {
2140            property: Property::Scale,
2141            values: TrackValues::Quats(vec![Quat::IDENTITY]),
2142            ..valid_track()
2143        };
2144        let wrong_rotation_value_type = Track {
2145            property: Property::Rotation,
2146            values: TrackValues::Vec3s(vec![Vec3::ZERO]),
2147            ..valid_track()
2148        };
2149        let non_finite_value = Track {
2150            values: TrackValues::Vec3s(vec![Vec3::splat(f32::NAN)]),
2151            ..valid_track()
2152        };
2153
2154        let mut bad_instance_node = instance_document();
2155        bad_instance_node.assets.instances[0].node = 1;
2156        let mut far_instance_node = instance_document();
2157        far_instance_node.assets.instances[0].node = 99;
2158        let mut bad_instance_mesh = instance_document();
2159        bad_instance_mesh.assets.instances[0].mesh = 1;
2160        let mut far_instance_mesh = instance_document();
2161        far_instance_mesh.assets.instances[0].mesh = 99;
2162        let mut bad_instance_joint = instance_document();
2163        bad_instance_joint.assets.instances[0].skin_joints = vec![1];
2164        let mut far_instance_joint = instance_document();
2165        far_instance_joint.assets.instances[0].skin_joints = vec![99];
2166        let mut bad_instance_count = instance_document();
2167        bad_instance_count.assets.instances[0].skin_joints = vec![0];
2168        bad_instance_count.assets.instances[0].skin_ibms = vec![Mat4::IDENTITY, Mat4::IDENTITY];
2169        let mut short_instance_count = instance_document();
2170        short_instance_count.skeleton.bones.push(bone(Some(0)));
2171        short_instance_count.assets.instances[0].skin_joints = vec![0, 1];
2172        short_instance_count.assets.instances[0].skin_ibms = vec![Mat4::IDENTITY];
2173        let mut bad_instance_ibm = instance_document();
2174        bad_instance_ibm.assets.instances[0].skin_joints = vec![0];
2175        bad_instance_ibm.assets.instances[0].skin_ibms =
2176            vec![Mat4::from_cols_array(&[f32::NAN; 16])];
2177        let mut bad_bone_ibm = one_bone_document();
2178        bad_bone_ibm.skeleton.bones[0].inverse_bind = Some(Mat4::from_cols_array(&[f32::NAN; 16]));
2179
2180        let cases = vec![
2181            (
2182                "scene root boundary",
2183                bad_scene_root,
2184                DocumentShapeError::SceneRootOutOfBounds {
2185                    scene_index: 1,
2186                    root_index: 0,
2187                    bone: 99,
2188                },
2189            ),
2190            (
2191                "non-finite rest",
2192                non_finite_rest,
2193                DocumentShapeError::NonFiniteSkeletonRest { node: 0 },
2194            ),
2195            (
2196                "non-finite composed rest world",
2197                overflowed_rest_world,
2198                DocumentShapeError::NonFiniteSkeletonRest { node: 1 },
2199            ),
2200            (
2201                "self parent",
2202                self_parent,
2203                DocumentShapeError::InvalidSkeletonParent { node: 0, parent: 0 },
2204            ),
2205            (
2206                "forward parent",
2207                forward_parent,
2208                DocumentShapeError::InvalidSkeletonParent { node: 0, parent: 1 },
2209            ),
2210            (
2211                "far parent",
2212                far_parent,
2213                DocumentShapeError::InvalidSkeletonParent {
2214                    node: 0,
2215                    parent: 99,
2216                },
2217            ),
2218            (
2219                "duplicate source node",
2220                duplicate_node,
2221                DocumentShapeError::DuplicateSourceNodeIndex {
2222                    source_node_index: 9,
2223                },
2224            ),
2225            (
2226                "duplicate source skin",
2227                duplicate_skin,
2228                DocumentShapeError::DuplicateSourceSkinIndex {
2229                    source_skin_index: 4,
2230                },
2231            ),
2232            (
2233                "projected bone range",
2234                out_of_range_projection,
2235                projection_error(10, SourceProjectionViolation::ProjectedBoneOutOfRange),
2236            ),
2237            (
2238                "projection injectivity",
2239                non_injective_projection,
2240                projection_error(
2241                    11,
2242                    SourceProjectionViolation::TwoSourceNodesProjectToOneBone,
2243                ),
2244            ),
2245            (
2246                "missing projection parent",
2247                missing_projection_parent,
2248                projection_error(11, SourceProjectionViolation::ParentSourceNodeMissing),
2249            ),
2250            (
2251                "missing projection parent at cycle bound",
2252                missing_projection_parent_at_cycle_bound,
2253                projection_error(11, SourceProjectionViolation::ParentSourceNodeMissing),
2254            ),
2255            (
2256                "cyclic projection parent",
2257                cyclic_unprojected_parent,
2258                projection_error(
2259                    11,
2260                    SourceProjectionViolation::CyclicUnprojectedSourceParentChain,
2261                ),
2262            ),
2263            (
2264                "cyclic projection parent pair",
2265                cyclic_unprojected_parent_pair,
2266                projection_error(
2267                    11,
2268                    SourceProjectionViolation::CyclicUnprojectedSourceParentChain,
2269                ),
2270            ),
2271            (
2272                "nearest projection parent",
2273                mismatched_nearest_parent,
2274                projection_error(
2275                    11,
2276                    SourceProjectionViolation::NearestProjectedParentMismatch,
2277                ),
2278            ),
2279            (
2280                "projection downward closure",
2281                unprojected_child,
2282                projection_error(
2283                    10,
2284                    SourceProjectionViolation::ProjectedBoneHasUnprojectedSkeletonChild,
2285                ),
2286            ),
2287            (
2288                "duplicate track",
2289                duplicate_track,
2290                DocumentShapeError::DuplicateClipTrack {
2291                    clip_index: 0,
2292                    node: 0,
2293                    property: Property::Translation,
2294                },
2295            ),
2296            (
2297                "track bone range boundary",
2298                track_document(boundary_out_of_range_track),
2299                track_error(1, TrackShapeViolation::BoneIndexOutOfRange),
2300            ),
2301            (
2302                "track bone range far",
2303                track_document(far_out_of_range_track),
2304                track_error(99, TrackShapeViolation::BoneIndexOutOfRange),
2305            ),
2306            (
2307                "empty track",
2308                track_document(empty_track),
2309                track_error(0, TrackShapeViolation::EmptyTimes),
2310            ),
2311            (
2312                "non-finite time",
2313                track_document(non_finite_later_time),
2314                track_error(0, TrackShapeViolation::NonFiniteTime),
2315            ),
2316            (
2317                "unordered times",
2318                track_document(unordered_times),
2319                track_error(0, TrackShapeViolation::TimesNotStrictlyIncreasing),
2320            ),
2321            (
2322                "equal times",
2323                track_document(equal_times),
2324                track_error(0, TrackShapeViolation::TimesNotStrictlyIncreasing),
2325            ),
2326            (
2327                "linear value count",
2328                track_document(wrong_linear_value_count),
2329                track_error(0, TrackShapeViolation::ValueCountMismatch),
2330            ),
2331            (
2332                "linear excess value count",
2333                track_document(excess_linear_value_count),
2334                track_error(0, TrackShapeViolation::ValueCountMismatch),
2335            ),
2336            (
2337                "step value count",
2338                track_document(wrong_step_value_count),
2339                track_error(0, TrackShapeViolation::ValueCountMismatch),
2340            ),
2341            (
2342                "step excess value count",
2343                track_document(excess_step_value_count),
2344                track_error(0, TrackShapeViolation::ValueCountMismatch),
2345            ),
2346            (
2347                "cubic value count",
2348                track_document(wrong_cubic_value_count),
2349                track_error(0, TrackShapeViolation::ValueCountMismatch),
2350            ),
2351            (
2352                "cubic excess value count",
2353                track_document(excess_cubic_value_count),
2354                track_error(0, TrackShapeViolation::ValueCountMismatch),
2355            ),
2356            (
2357                "translation value type",
2358                track_document(wrong_translation_value_type),
2359                track_error(0, TrackShapeViolation::ValueTypeMismatchesProperty),
2360            ),
2361            (
2362                "scale value type",
2363                track_document(wrong_scale_value_type),
2364                track_error(0, TrackShapeViolation::ValueTypeMismatchesProperty),
2365            ),
2366            (
2367                "rotation value type",
2368                track_document(wrong_rotation_value_type),
2369                track_error(0, TrackShapeViolation::ValueTypeMismatchesProperty),
2370            ),
2371            (
2372                "non-finite value",
2373                track_document(non_finite_value),
2374                track_error(0, TrackShapeViolation::NonFiniteValue),
2375            ),
2376            (
2377                "instance node boundary",
2378                bad_instance_node,
2379                instance_error(MeshInstanceShapeViolation::NodeIndexOutOfRange),
2380            ),
2381            (
2382                "instance node far",
2383                far_instance_node,
2384                instance_error(MeshInstanceShapeViolation::NodeIndexOutOfRange),
2385            ),
2386            (
2387                "instance mesh boundary",
2388                bad_instance_mesh,
2389                instance_error(MeshInstanceShapeViolation::MeshIndexOutOfRange),
2390            ),
2391            (
2392                "instance mesh far",
2393                far_instance_mesh,
2394                instance_error(MeshInstanceShapeViolation::MeshIndexOutOfRange),
2395            ),
2396            (
2397                "instance joint boundary",
2398                bad_instance_joint,
2399                instance_error(MeshInstanceShapeViolation::SkinJointOutOfRange),
2400            ),
2401            (
2402                "instance joint far",
2403                far_instance_joint,
2404                instance_error(MeshInstanceShapeViolation::SkinJointOutOfRange),
2405            ),
2406            (
2407                "instance ibm count excess",
2408                bad_instance_count,
2409                instance_error(MeshInstanceShapeViolation::SkinInverseBindCountMismatch),
2410            ),
2411            (
2412                "instance ibm count short",
2413                short_instance_count,
2414                instance_error(MeshInstanceShapeViolation::SkinInverseBindCountMismatch),
2415            ),
2416            (
2417                "instance ibm finite",
2418                bad_instance_ibm,
2419                instance_error(MeshInstanceShapeViolation::NonFiniteSkinInverseBind),
2420            ),
2421            (
2422                "bone ibm finite",
2423                bad_bone_ibm,
2424                DocumentShapeError::NonFiniteBoneInverseBind { node: 0 },
2425            ),
2426        ];
2427        for (name, document, expected) in cases {
2428            assert_eq!(validate_document_shape(&document), Err(expected), "{name}");
2429        }
2430    }
2431
2432    #[test]
2433    fn document_shape_finiteness_checks_every_stored_component() {
2434        for component in 0..3 {
2435            let mut translation = Vec3::ZERO.to_array();
2436            translation[component] = f32::NAN;
2437            let mut document = one_bone_document();
2438            document.skeleton.bones[0].rest.translation = Vec3::from_array(translation);
2439            assert_eq!(
2440                validate_document_shape(&document),
2441                Err(DocumentShapeError::NonFiniteSkeletonRest { node: 0 }),
2442                "rest translation component {component}"
2443            );
2444
2445            let mut scale = Vec3::ONE.to_array();
2446            scale[component] = f32::NAN;
2447            let mut document = one_bone_document();
2448            document.skeleton.bones[0].rest.scale = Vec3::from_array(scale);
2449            assert_eq!(
2450                validate_document_shape(&document),
2451                Err(DocumentShapeError::NonFiniteSkeletonRest { node: 0 }),
2452                "rest scale component {component}"
2453            );
2454
2455            let mut value = Vec3::ZERO.to_array();
2456            value[component] = f32::NAN;
2457            let document = track_document(Track {
2458                values: TrackValues::Vec3s(vec![Vec3::from_array(value)]),
2459                ..valid_track()
2460            });
2461            assert_eq!(
2462                validate_document_shape(&document),
2463                Err(DocumentShapeError::TrackShape {
2464                    clip_index: 0,
2465                    node: 0,
2466                    violation: TrackShapeViolation::NonFiniteValue,
2467                }),
2468                "track Vec3 component {component}"
2469            );
2470        }
2471
2472        for component in 0..4 {
2473            let mut rotation = Quat::IDENTITY.to_array();
2474            rotation[component] = f32::NAN;
2475            let mut document = one_bone_document();
2476            document.skeleton.bones[0].rest.rotation = Quat::from_array(rotation);
2477            assert_eq!(
2478                validate_document_shape(&document),
2479                Err(DocumentShapeError::NonFiniteSkeletonRest { node: 0 }),
2480                "rest rotation component {component}"
2481            );
2482
2483            let document = track_document(Track {
2484                property: Property::Rotation,
2485                values: TrackValues::Quats(vec![Quat::from_array(rotation)]),
2486                ..valid_track()
2487            });
2488            assert_eq!(
2489                validate_document_shape(&document),
2490                Err(DocumentShapeError::TrackShape {
2491                    clip_index: 0,
2492                    node: 0,
2493                    violation: TrackShapeViolation::NonFiniteValue,
2494                }),
2495                "track quaternion component {component}"
2496            );
2497        }
2498
2499        for key in 0..3 {
2500            let mut times = vec![0.0, 1.0, 2.0];
2501            times[key] = f32::NAN;
2502            let document = track_document(Track {
2503                times,
2504                values: TrackValues::Vec3s(vec![Vec3::ZERO; 3]),
2505                ..valid_track()
2506            });
2507            assert_eq!(
2508                validate_document_shape(&document),
2509                Err(DocumentShapeError::TrackShape {
2510                    clip_index: 0,
2511                    node: 0,
2512                    violation: TrackShapeViolation::NonFiniteTime,
2513                }),
2514                "track time {key}"
2515            );
2516        }
2517
2518        for component in 0..16 {
2519            let mut columns = Mat4::IDENTITY.to_cols_array();
2520            columns[component] = f32::NAN;
2521            let inverse_bind = Mat4::from_cols_array(&columns);
2522
2523            let mut instance_document = instance_document();
2524            instance_document.assets.instances[0].skin_joints = vec![0];
2525            instance_document.assets.instances[0].skin_ibms = vec![inverse_bind];
2526            assert_eq!(
2527                validate_document_shape(&instance_document),
2528                Err(DocumentShapeError::MeshInstanceShape {
2529                    instance_index: 0,
2530                    violation: MeshInstanceShapeViolation::NonFiniteSkinInverseBind,
2531                }),
2532                "instance inverse-bind component {component}"
2533            );
2534
2535            let mut bone_document = one_bone_document();
2536            bone_document.skeleton.bones[0].inverse_bind = Some(inverse_bind);
2537            assert_eq!(
2538                validate_document_shape(&bone_document),
2539                Err(DocumentShapeError::NonFiniteBoneInverseBind { node: 0 }),
2540                "bone inverse-bind component {component}"
2541            );
2542        }
2543    }
2544
2545    #[test]
2546    fn document_shape_rejects_duplicate_tracks_for_every_property() {
2547        let tracks = [
2548            (Property::Translation, TrackValues::Vec3s(vec![Vec3::ZERO])),
2549            (Property::Scale, TrackValues::Vec3s(vec![Vec3::ONE])),
2550            (Property::Rotation, TrackValues::Quats(vec![Quat::IDENTITY])),
2551        ];
2552
2553        for (property, values) in tracks {
2554            let track = Track {
2555                property,
2556                values,
2557                ..valid_track()
2558            };
2559            let mut document = track_document(track.clone());
2560            document.clips[0].tracks.push(track);
2561
2562            assert_eq!(
2563                validate_document_shape(&document),
2564                Err(DocumentShapeError::DuplicateClipTrack {
2565                    clip_index: 0,
2566                    node: 0,
2567                    property,
2568                }),
2569                "duplicate {property:?} track"
2570            );
2571        }
2572    }
2573
2574    #[test]
2575    fn document_shape_rejects_infinite_times_quaternions_and_inverse_binds() {
2576        for non_finite in [f32::INFINITY, f32::NEG_INFINITY] {
2577            let document = track_document(Track {
2578                times: vec![non_finite],
2579                values: TrackValues::Vec3s(vec![Vec3::ZERO]),
2580                ..valid_track()
2581            });
2582            assert_eq!(
2583                validate_document_shape(&document),
2584                Err(DocumentShapeError::TrackShape {
2585                    clip_index: 0,
2586                    node: 0,
2587                    violation: TrackShapeViolation::NonFiniteTime,
2588                }),
2589                "track time {non_finite}"
2590            );
2591
2592            let document = track_document(Track {
2593                property: Property::Rotation,
2594                values: TrackValues::Quats(vec![Quat::from_xyzw(non_finite, 0.0, 0.0, 1.0)]),
2595                ..valid_track()
2596            });
2597            assert_eq!(
2598                validate_document_shape(&document),
2599                Err(DocumentShapeError::TrackShape {
2600                    clip_index: 0,
2601                    node: 0,
2602                    violation: TrackShapeViolation::NonFiniteValue,
2603                }),
2604                "track quaternion {non_finite}"
2605            );
2606
2607            let mut columns = Mat4::IDENTITY.to_cols_array();
2608            columns[0] = non_finite;
2609            let inverse_bind = Mat4::from_cols_array(&columns);
2610            let mut document = instance_document();
2611            document.assets.instances[0].skin_joints = vec![0];
2612            document.assets.instances[0].skin_ibms = vec![inverse_bind];
2613            assert_eq!(
2614                validate_document_shape(&document),
2615                Err(DocumentShapeError::MeshInstanceShape {
2616                    instance_index: 0,
2617                    violation: MeshInstanceShapeViolation::NonFiniteSkinInverseBind,
2618                }),
2619                "instance inverse bind {non_finite}"
2620            );
2621
2622            let mut document = one_bone_document();
2623            document.skeleton.bones[0].inverse_bind = Some(inverse_bind);
2624            assert_eq!(
2625                validate_document_shape(&document),
2626                Err(DocumentShapeError::NonFiniteBoneInverseBind { node: 0 }),
2627                "bone inverse bind {non_finite}"
2628            );
2629        }
2630    }
2631
2632    #[test]
2633    fn document_shape_checks_mesh_and_joint_references_on_later_instances() {
2634        let later_instance = MeshInstance {
2635            node: 0,
2636            mesh: 0,
2637            ..MeshInstance::default()
2638        };
2639
2640        let mut document = instance_document();
2641        document.assets.instances.push(later_instance.clone());
2642        document.assets.instances[1].mesh = 1;
2643        assert_eq!(
2644            validate_document_shape(&document),
2645            Err(DocumentShapeError::MeshInstanceShape {
2646                instance_index: 1,
2647                violation: MeshInstanceShapeViolation::MeshIndexOutOfRange,
2648            })
2649        );
2650
2651        let mut document = instance_document();
2652        document.assets.instances.push(later_instance);
2653        document.assets.instances[1].skin_joints = vec![1];
2654        assert_eq!(
2655            validate_document_shape(&document),
2656            Err(DocumentShapeError::MeshInstanceShape {
2657                instance_index: 1,
2658                violation: MeshInstanceShapeViolation::SkinJointOutOfRange,
2659            })
2660        );
2661    }
2662
2663    #[test]
2664    fn document_shape_finds_duplicates_that_do_not_involve_the_first_item() {
2665        let mut document = Document::default();
2666        document.assets.source_skeleton.skins = [4, 5, 5]
2667            .into_iter()
2668            .map(|source_skin_index| SourceSkinAsset {
2669                source_skin_index,
2670                ..SourceSkinAsset::default()
2671            })
2672            .collect();
2673        assert_eq!(
2674            validate_document_shape(&document),
2675            Err(DocumentShapeError::DuplicateSourceSkinIndex {
2676                source_skin_index: 5,
2677            })
2678        );
2679
2680        let scale_track = Track {
2681            property: Property::Scale,
2682            values: TrackValues::Vec3s(vec![Vec3::ONE]),
2683            ..valid_track()
2684        };
2685        let mut document = track_document(valid_track());
2686        document.clips[0].tracks.push(scale_track.clone());
2687        document.clips[0].tracks.push(scale_track);
2688        assert_eq!(
2689            validate_document_shape(&document),
2690            Err(DocumentShapeError::DuplicateClipTrack {
2691                clip_index: 0,
2692                node: 0,
2693                property: Property::Scale,
2694            })
2695        );
2696    }
2697
2698    #[test]
2699    fn document_shape_checks_later_tracks_and_inverse_binds() {
2700        let mut document = track_document(valid_track());
2701        document.clips[0].tracks.push(Track {
2702            property: Property::Scale,
2703            times: Vec::new(),
2704            values: TrackValues::Vec3s(Vec::new()),
2705            ..valid_track()
2706        });
2707        assert_eq!(
2708            validate_document_shape(&document),
2709            Err(DocumentShapeError::TrackShape {
2710                clip_index: 0,
2711                node: 0,
2712                violation: TrackShapeViolation::EmptyTimes,
2713            })
2714        );
2715
2716        let scale_track = Track {
2717            property: Property::Scale,
2718            values: TrackValues::Vec3s(vec![Vec3::ONE]),
2719            ..valid_track()
2720        };
2721        let mut document = track_document(valid_track());
2722        document.clips.push(Clip {
2723            name: "later".into(),
2724            duration_s: 0.0,
2725            tracks: vec![scale_track.clone(), scale_track],
2726        });
2727        assert_eq!(
2728            validate_document_shape(&document),
2729            Err(DocumentShapeError::DuplicateClipTrack {
2730                clip_index: 1,
2731                node: 0,
2732                property: Property::Scale,
2733            })
2734        );
2735
2736        let mut document = track_document(valid_track());
2737        document.clips.push(Clip {
2738            name: "later".into(),
2739            duration_s: 0.0,
2740            tracks: vec![Track {
2741                property: Property::Scale,
2742                times: Vec::new(),
2743                values: TrackValues::Vec3s(Vec::new()),
2744                ..valid_track()
2745            }],
2746        });
2747        assert_eq!(
2748            validate_document_shape(&document),
2749            Err(DocumentShapeError::TrackShape {
2750                clip_index: 1,
2751                node: 0,
2752                violation: TrackShapeViolation::EmptyTimes,
2753            })
2754        );
2755
2756        let mut columns = Mat4::IDENTITY.to_cols_array();
2757        columns[15] = f32::NAN;
2758        let non_finite_inverse_bind = Mat4::from_cols_array(&columns);
2759
2760        let mut document = instance_document();
2761        document.skeleton.bones.push(bone(Some(0)));
2762        document.assets.instances[0].skin_joints = vec![0, 1];
2763        document.assets.instances[0].skin_ibms = vec![Mat4::IDENTITY, non_finite_inverse_bind];
2764        assert_eq!(
2765            validate_document_shape(&document),
2766            Err(DocumentShapeError::MeshInstanceShape {
2767                instance_index: 0,
2768                violation: MeshInstanceShapeViolation::NonFiniteSkinInverseBind,
2769            })
2770        );
2771
2772        let mut document = instance_document();
2773        document.assets.instances.push(MeshInstance {
2774            node: 0,
2775            mesh: 0,
2776            skin_joints: vec![0],
2777            skin_ibms: vec![non_finite_inverse_bind],
2778            ..MeshInstance::default()
2779        });
2780        assert_eq!(
2781            validate_document_shape(&document),
2782            Err(DocumentShapeError::MeshInstanceShape {
2783                instance_index: 1,
2784                violation: MeshInstanceShapeViolation::NonFiniteSkinInverseBind,
2785            })
2786        );
2787
2788        let mut document = instance_document();
2789        document.assets.instances.push(MeshInstance {
2790            node: 0,
2791            mesh: 0,
2792            skin_joints: vec![0],
2793            skin_ibms: vec![Mat4::IDENTITY, Mat4::IDENTITY],
2794            ..MeshInstance::default()
2795        });
2796        assert_eq!(
2797            validate_document_shape(&document),
2798            Err(DocumentShapeError::MeshInstanceShape {
2799                instance_index: 1,
2800                violation: MeshInstanceShapeViolation::SkinInverseBindCountMismatch,
2801            })
2802        );
2803
2804        let mut document = one_bone_document();
2805        document.skeleton.bones.push(Bone {
2806            inverse_bind: Some(non_finite_inverse_bind),
2807            ..bone(Some(0))
2808        });
2809        assert_eq!(
2810            validate_document_shape(&document),
2811            Err(DocumentShapeError::NonFiniteBoneInverseBind { node: 1 })
2812        );
2813    }
2814
2815    #[test]
2816    fn document_shape_violation_names_remain_machine_stable() {
2817        let source_projection = [
2818            (
2819                SourceProjectionViolation::ProjectedBoneOutOfRange,
2820                "projected_bone_out_of_range",
2821            ),
2822            (
2823                SourceProjectionViolation::TwoSourceNodesProjectToOneBone,
2824                "two_source_nodes_project_to_one_bone",
2825            ),
2826            (
2827                SourceProjectionViolation::ParentSourceNodeMissing,
2828                "parent_source_node_is_missing",
2829            ),
2830            (
2831                SourceProjectionViolation::CyclicUnprojectedSourceParentChain,
2832                "cyclic_unprojected_source_parent_chain",
2833            ),
2834            (
2835                SourceProjectionViolation::NearestProjectedParentMismatch,
2836                "projection_and_skeleton_parents_differ",
2837            ),
2838            (
2839                SourceProjectionViolation::ProjectedBoneHasUnprojectedSkeletonChild,
2840                "projected_bone_has_an_unprojected_skeleton_child",
2841            ),
2842        ];
2843        for (violation, expected) in source_projection {
2844            assert_eq!(violation.to_string(), expected);
2845        }
2846
2847        let track = [
2848            (
2849                TrackShapeViolation::BoneIndexOutOfRange,
2850                "bone_index_out_of_range",
2851            ),
2852            (TrackShapeViolation::EmptyTimes, "empty_times"),
2853            (TrackShapeViolation::NonFiniteTime, "non_finite_time"),
2854            (
2855                TrackShapeViolation::TimesNotStrictlyIncreasing,
2856                "times_not_strictly_increasing",
2857            ),
2858            (
2859                TrackShapeViolation::ValueCountMismatch,
2860                "value_count_mismatch",
2861            ),
2862            (
2863                TrackShapeViolation::ValueTypeMismatchesProperty,
2864                "value_type_mismatches_property",
2865            ),
2866            (TrackShapeViolation::NonFiniteValue, "non_finite_value"),
2867        ];
2868        for (violation, expected) in track {
2869            assert_eq!(violation.to_string(), expected);
2870        }
2871
2872        let instance = [
2873            (
2874                MeshInstanceShapeViolation::NodeIndexOutOfRange,
2875                "node_index_out_of_range",
2876            ),
2877            (
2878                MeshInstanceShapeViolation::MeshIndexOutOfRange,
2879                "mesh_index_out_of_range",
2880            ),
2881            (
2882                MeshInstanceShapeViolation::SkinJointOutOfRange,
2883                "skin_joint_out_of_range",
2884            ),
2885            (
2886                MeshInstanceShapeViolation::SkinInverseBindCountMismatch,
2887                "skin_ibm_count_mismatch",
2888            ),
2889            (
2890                MeshInstanceShapeViolation::NonFiniteSkinInverseBind,
2891                "non_finite_inverse_bind",
2892            ),
2893        ];
2894        for (violation, expected) in instance {
2895            assert_eq!(violation.to_string(), expected);
2896        }
2897    }
2898
2899    #[test]
2900    fn tolerant_world_rests_keep_unrelated_partial_evidence() {
2901        let skeleton = Skeleton {
2902            bones: vec![
2903                bone(None),
2904                bone(Some(99)),
2905                Bone {
2906                    rest: Transform {
2907                        translation: Vec3::X,
2908                        ..Transform::IDENTITY
2909                    },
2910                    ..bone(None)
2911                },
2912                Bone {
2913                    rest: Transform {
2914                        translation: Vec3::Y,
2915                        ..Transform::IDENTITY
2916                    },
2917                    ..bone(Some(2))
2918                },
2919                bone(Some(1)),
2920            ],
2921        };
2922
2923        let worlds = tolerant_world_rest_matrices(&skeleton);
2924        assert_eq!(worlds.len(), 5);
2925        assert_eq!(worlds[0], Some(Mat4::IDENTITY));
2926        assert_eq!(worlds[1], None, "the malformed parent is unavailable");
2927        assert_eq!(worlds[2], Some(Mat4::from_translation(Vec3::X)));
2928        assert_eq!(
2929            worlds[3],
2930            Some(Mat4::from_translation(Vec3::new(1.0, 1.0, 0.0))),
2931            "a finite independent chain remains measurable"
2932        );
2933        assert_eq!(
2934            worlds[4], None,
2935            "a child of unavailable evidence is unavailable"
2936        );
2937    }
2938
2939    #[test]
2940    fn shared_affine_classifier_respects_distinct_caller_tolerances() {
2941        let equal_axis_basis = affine_test_fixtures::tolerance_divergence_basis();
2942        let strict = PositiveUniformAffineTolerance {
2943            equal_axis: 1.0e-5,
2944            relative_orthogonality: 1.0e-5,
2945            singular_determinant_relative: 1.0e-6,
2946        };
2947        let loose = PositiveUniformAffineTolerance {
2948            equal_axis: 1.0e-4,
2949            relative_orthogonality: 1.0e-4,
2950            singular_determinant_relative: 0.0,
2951        };
2952
2953        assert_eq!(
2954            classify_positive_uniform_affine(equal_axis_basis, strict),
2955            Err(AffineDomainViolation::NonUniformScale),
2956            "the stricter caller rejects this equal-axis difference"
2957        );
2958        assert!(
2959            classify_positive_uniform_affine(equal_axis_basis, loose).is_ok(),
2960            "the looser caller accepts this equal-axis difference"
2961        );
2962
2963        let orthogonality_basis = affine_test_fixtures::orthogonality_tolerance_divergence_basis();
2964        assert_eq!(
2965            classify_positive_uniform_affine(orthogonality_basis, strict),
2966            Err(AffineDomainViolation::Sheared),
2967            "the stricter caller rejects this cross-axis dot product"
2968        );
2969        assert!(
2970            classify_positive_uniform_affine(orthogonality_basis, loose).is_ok(),
2971            "the looser caller accepts this cross-axis dot product"
2972        );
2973    }
2974
2975    #[test]
2976    fn shared_affine_classifier_pins_its_symmetric_f64_formula() {
2977        let policy = PositiveUniformAffineTolerance {
2978            equal_axis: 1.0e-5,
2979            relative_orthogonality: 1.0e-5,
2980            singular_determinant_relative: 1.0e-6,
2981        };
2982
2983        // Exact binary32 lengths whose mean is exactly 99_999 in binary64.
2984        // The longest-axis deviation is exactly 1: accepted only when the
2985        // relative base is max(mean, axis), then refused one binary32 ulp
2986        // farther out.
2987        let on_long_edge = Mat3::from_diagonal(Vec3::new(99_998.5, 99_998.5, 100_000.0));
2988        assert_eq!(
2989            classify_positive_uniform_affine(on_long_edge, policy),
2990            Ok(99_999.0)
2991        );
2992        let short = 99_998.5;
2993        let long = 100_000.0 + 0.007_812_5;
2994        for diagonal in [
2995            Vec3::new(long, short, short),
2996            Vec3::new(short, long, short),
2997            Vec3::new(short, short, long),
2998        ] {
2999            assert_eq!(
3000                classify_positive_uniform_affine(Mat3::from_diagonal(diagonal), policy),
3001                Err(AffineDomainViolation::NonUniformScale)
3002            );
3003        }
3004
3005        // A one-sided comparison would miss the uniquely short axis. At this
3006        // exact binary32 step the short-axis deviation is outside the band,
3007        // while each longer axis remains inside it.
3008        let short = 1.0 - 2.0_f32.powi(-16);
3009        for diagonal in [
3010            Vec3::new(short, 1.0, 1.0),
3011            Vec3::new(1.0, short, 1.0),
3012            Vec3::new(1.0, 1.0, short),
3013        ] {
3014            assert_eq!(
3015                classify_positive_uniform_affine(Mat3::from_diagonal(diagonal), policy),
3016                Err(AffineDomainViolation::NonUniformScale)
3017            );
3018        }
3019
3020        // Only binary64 dot-product arithmetic places this basis outside the
3021        // orthogonality band; binary32 rounds the deciding dot back inside.
3022        let c0 = Vec3::new(0.12792248, -0.99066633, -0.047073245);
3023        let c1 = Vec3::new(-0.34637994, -0.00016034879, -0.93809813);
3024        let c2 = Vec3::new(0.92933476, 0.13630849, -0.3431568);
3025        assert!((c1.dot(c2) as f64).abs() < 1.0e-5);
3026        assert!(c1.as_dvec3().dot(c2.as_dvec3()).abs() > 1.0e-5);
3027        assert_eq!(
3028            classify_positive_uniform_affine(Mat3::from_cols(c0, c1, c2), policy),
3029            Err(AffineDomainViolation::Sheared)
3030        );
3031
3032        // Orthogonality is sign-independent; dropping abs() accepts the
3033        // negative case while leaving the positive fixture green.
3034        for shear in [2.0_f32.powi(-15), -2.0_f32.powi(-15)] {
3035            let basis = Mat3::from_cols(Vec3::X, Vec3::new(shear, 1.0, 0.0), Vec3::Z);
3036            assert_eq!(
3037                classify_positive_uniform_affine(basis, policy),
3038                Err(AffineDomainViolation::Sheared)
3039            );
3040        }
3041    }
3042
3043    #[test]
3044    fn affine_axis_mean_is_ascending_and_column_order_invariant() {
3045        // This is the audited counterexample. These are the widened lengths
3046        // of three exact binary32 columns; their authored-order sum changes
3047        // by one binary64 ulp when the columns are cycled. The canonical
3048        // ascending association is the lower result.
3049        let lengths = [
3050            f64::from_bits(0x3ff1_09e7_e000_022c),
3051            f64::from_bits(0x3ff1_09ec_6000_0eb5),
3052            f64::from_bits(0x3ff1_09fa_e000_3cde),
3053        ];
3054        let expected = f64::from_bits(0x3ff1_09ef_b555_6f3f);
3055        let ascending = (lengths[0] + lengths[1] + lengths[2]) / 3.0;
3056        let descending = (lengths[2] + lengths[1] + lengths[0]) / 3.0;
3057        assert_eq!(expected.to_bits(), 0x3ff1_09ef_b555_6f3f);
3058        assert_eq!(ascending.to_bits(), expected.to_bits());
3059        assert_eq!(descending.to_bits(), 0x3ff1_09ef_b555_6f40);
3060        for order in [
3061            [0, 1, 2],
3062            [0, 2, 1],
3063            [1, 0, 2],
3064            [1, 2, 0],
3065            [2, 0, 1],
3066            [2, 1, 0],
3067        ] {
3068            assert_eq!(
3069                average_affine_axis_length(order.map(|index| lengths[index])),
3070                expected,
3071                "axis order {order:?}"
3072            );
3073        }
3074
3075        // The association is observable even for exactly representable
3076        // dyadic inputs: adding the two small terms first retains them,
3077        // while adding either to `2^53` loses both. This catches replacing
3078        // the canonical sort with a different fixed input order.
3079        let dyadic = [2.0_f64.powi(53), 1.0, 1.0];
3080        let ascending = (dyadic[1] + dyadic[2] + dyadic[0]) / 3.0;
3081        let descending = (dyadic[0] + dyadic[1] + dyadic[2]) / 3.0;
3082        assert_ne!(ascending, descending);
3083        assert_eq!(average_affine_axis_length(dyadic), ascending);
3084
3085        // The same exact binary32 columns exercise the classifier. Every
3086        // permutation below preserves orientation: odd column permutations
3087        // negate one column, which leaves lengths unchanged and restores the
3088        // determinant's sign. The equal-axis boundary must consequently
3089        // reject the same geometry in all six authored axis orders.
3090        let permutations = affine_test_fixtures::appendix_d_v6_mean_permutations();
3091        let expected_length_bits = lengths.map(f64::to_bits);
3092        assert_eq!(
3093            affine_axis_lengths(permutations[0]).map(f64::to_bits),
3094            expected_length_bits
3095        );
3096        let tolerance = PositiveUniformAffineTolerance {
3097            equal_axis: 1.0e-5,
3098            relative_orthogonality: 1.0e-5,
3099            singular_determinant_relative: 1.0e-6,
3100        };
3101        for (permutation, linear) in permutations.into_iter().enumerate() {
3102            assert!(
3103                linear
3104                    .x_axis
3105                    .as_dvec3()
3106                    .cross(linear.y_axis.as_dvec3())
3107                    .dot(linear.z_axis.as_dvec3())
3108                    > 0.0,
3109                "orientation for permutation {permutation}"
3110            );
3111            assert_eq!(
3112                average_affine_axis_length(affine_axis_lengths(linear)).to_bits(),
3113                expected.to_bits(),
3114                "mean for permutation {permutation}"
3115            );
3116            assert_eq!(
3117                classify_positive_uniform_affine(linear, tolerance),
3118                Err(AffineDomainViolation::NonUniformScale),
3119                "classification for permutation {permutation}"
3120            );
3121        }
3122    }
3123
3124    #[test]
3125    fn shared_affine_classifier_pins_f64_determinant_arithmetic() {
3126        // These exact binary32 columns make the f32 scalar triple product
3127        // land above the same threshold that the product of widened columns
3128        // lands below. The remaining bands are deliberately loose so only
3129        // singularity arithmetic decides the result.
3130        let linear = Mat3::from_cols(
3131            Vec3::new(
3132                f32::from_bits(0x3ff3_5574),
3133                f32::from_bits(0x3f0e_fa3c),
3134                0.0,
3135            ),
3136            Vec3::new(
3137                f32::from_bits(0x3ff5_5e17),
3138                f32::from_bits(0x3f10_2c31),
3139                0.0,
3140            ),
3141            Vec3::Z,
3142        );
3143        let columns = [
3144            linear.x_axis.as_dvec3(),
3145            linear.y_axis.as_dvec3(),
3146            linear.z_axis.as_dvec3(),
3147        ];
3148        let determinant_f64 = columns[2].dot(columns[0].cross(columns[1]));
3149        let determinant_f32 = f64::from(linear.determinant());
3150        let lengths = affine_axis_lengths(linear);
3151        let threshold = (determinant_f64 + determinant_f32) / 2.0;
3152        assert!(determinant_f64 < threshold);
3153        assert!(determinant_f32 > threshold);
3154
3155        assert_eq!(
3156            classify_positive_uniform_affine(
3157                linear,
3158                PositiveUniformAffineTolerance {
3159                    equal_axis: 10.0,
3160                    relative_orthogonality: 10.0,
3161                    singular_determinant_relative: threshold
3162                        / (lengths[0] * lengths[1] * lengths[2]),
3163                },
3164            ),
3165            Err(AffineDomainViolation::Singular)
3166        );
3167
3168        // Every derived determinant operand must stay widened as well. A
3169        // binary32 axis-product intermediate overflows on this otherwise
3170        // finite, positive, exactly uniform basis and falsely calls it
3171        // singular under Appendix D's non-zero relative threshold.
3172        let large_uniform = 2.0e19_f32;
3173        assert_eq!(
3174            classify_positive_uniform_affine(
3175                Mat3::from_diagonal(Vec3::splat(large_uniform)),
3176                PositiveUniformAffineTolerance {
3177                    equal_axis: 1.0e-5,
3178                    relative_orthogonality: 1.0e-5,
3179                    singular_determinant_relative: 1.0e-6,
3180                },
3181            ),
3182            Ok(f64::from(large_uniform))
3183        );
3184    }
3185
3186    #[test]
3187    fn affine_geometry_facts_pin_every_widened_field_and_slot() {
3188        let linear = Mat3::from_cols(
3189            Vec3::new(1.0, 2.0, 3.0),
3190            Vec3::new(4.0, 5.0, 6.0),
3191            Vec3::new(7.0, 8.0, 10.0),
3192        );
3193
3194        let facts = AffineGeometryFacts::from_linear(linear).expect("finite widened facts");
3195        assert_eq!(
3196            facts.axis_lengths.map(f64::to_bits),
3197            [
3198                0x400d_eeea_1168_3f49,
3199                0x4021_8cc8_21d6_d3e3,
3200                0x402d_3064_dcc8_ae67,
3201            ]
3202        );
3203        assert_eq!(facts.mean_axis_length.to_bits(), 0x4022_12f7_d653_30b4);
3204        assert_eq!(facts.determinant.to_bits(), 0xc008_0000_0000_0000);
3205        assert_eq!(facts.axis_length_product.to_bits(), 0x407d_f2e3_88f2_1b01);
3206        assert_eq!(
3207            facts.cross_axis_dots.map(f64::to_bits),
3208            [
3209                0x4040_0000_0000_0000,
3210                0x404a_8000_0000_0000,
3211                0x4060_0000_0000_0000,
3212            ],
3213            "cross-axis slots are XY, XZ, YZ"
3214        );
3215    }
3216
3217    #[test]
3218    fn affine_geometry_facts_widen_every_dot_product_before_multiplying() {
3219        let x = Vec3::new(
3220            f32::from_bits(0x3ff3_5574),
3221            f32::from_bits(0x3f0e_fa3c),
3222            0.0,
3223        );
3224        let y = Vec3::new(
3225            f32::from_bits(0x3ff5_5e17),
3226            f32::from_bits(0x3f10_2c31),
3227            0.0,
3228        );
3229        let widened_dot = x.as_dvec3().dot(y.as_dvec3());
3230        let f32_then_widened = f64::from(x.dot(y));
3231
3232        for (slot, linear) in [
3233            (0, Mat3::from_cols(x, y, Vec3::Z)),
3234            (1, Mat3::from_cols(x, Vec3::Z, y)),
3235            (2, Mat3::from_cols(Vec3::Z, x, y)),
3236        ] {
3237            let facts = AffineGeometryFacts::from_linear(linear).expect("finite widened facts");
3238            assert_eq!(facts.cross_axis_dots[slot], widened_dot);
3239            assert_ne!(
3240                facts.cross_axis_dots[slot], f32_then_widened,
3241                "dot slot {slot} must multiply and add in f64, not widen an f32 result"
3242            );
3243        }
3244    }
3245
3246    #[test]
3247    fn weld_preserves_uv_seams_at_shared_positions() {
3248        let mut primitive = Primitive {
3249            positions: vec![Vec3::ZERO, Vec3::ZERO, Vec3::ZERO],
3250            uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 0.0]],
3251            ..Primitive::default()
3252        };
3253
3254        primitive.weld();
3255
3256        assert_eq!(primitive.positions.len(), 2);
3257        let reconstructed_corners = primitive
3258            .indices
3259            .iter()
3260            .map(|&index| {
3261                let index = index as usize;
3262                (primitive.positions[index], primitive.uvs[index])
3263            })
3264            .collect::<Vec<_>>();
3265        assert_eq!(
3266            reconstructed_corners,
3267            vec![
3268                (Vec3::ZERO, [0.0, 0.0]),
3269                (Vec3::ZERO, [1.0, 0.0]),
3270                (Vec3::ZERO, [0.0, 0.0]),
3271            ]
3272        );
3273    }
3274}