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