Skip to main content

animsmith_core/
model.rs

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