Skip to main content

animsmith_core/
skinned_canonical.rs

1//! Bind-pose canonicalization for skinned character geometry.
2//!
3//! This module deliberately works on an unanimated base document. It turns
4//! every skinned mesh into world-space bind geometry, moves the skeleton by the
5//! same declared coordinate transform, and regenerates inverse bind matrices.
6
7use crate::model::{
8    AffineDomainViolation, Bone, Document, MeshAsset, MeshInstance, PositiveUniformAffineTolerance,
9    SceneAsset, SceneAssets, Skeleton, SourceSkeletonAssets, Transform, WorldMatrixError,
10    affine_axis_lengths, classify_positive_uniform_affine, world_rest_matrices,
11};
12use glam::{Mat3, Mat4, Vec3};
13use std::collections::BTreeSet;
14
15const MATRIX_EPSILON: f32 = 1.0e-4;
16const MIN_RELATIVE_DETERMINANT: f32 = 1.0e-6;
17const COORDINATE_AFFINE_TOLERANCE: PositiveUniformAffineTolerance =
18    PositiveUniformAffineTolerance {
19        equal_axis: MATRIX_EPSILON as f64,
20        relative_orthogonality: MATRIX_EPSILON as f64,
21        singular_determinant_relative: 0.0,
22    };
23
24/// Deterministic placement applied after the declared coordinate conversion.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum SkinnedBindPosePlacement {
27    /// Retain converted bind-pose coordinates.
28    #[default]
29    Preserve,
30    /// Place the bind-pose bounds on `Y = 0` and centre their X/Z midpoint.
31    GroundAndCenter,
32}
33
34/// Input for [`canonicalize_skinned_bind_pose`].
35#[derive(Debug, Clone, Copy)]
36pub struct SkinnedBindPoseCanonicalizationOptions {
37    /// Affine, orientation-preserving uniform-scale transform from input space
38    /// into right-handed, Y-up metres. Identity explicitly declares that the
39    /// document is already in that space.
40    pub source_to_meters_y_up: Mat4,
41    /// Optional deterministic placement in converted bind-pose space.
42    pub placement: SkinnedBindPosePlacement,
43}
44
45impl Default for SkinnedBindPoseCanonicalizationOptions {
46    fn default() -> Self {
47        Self {
48            source_to_meters_y_up: Mat4::IDENTITY,
49            placement: SkinnedBindPosePlacement::Preserve,
50        }
51    }
52}
53
54/// Result of [`canonicalize_skinned_bind_pose`].
55#[derive(Debug, Clone)]
56#[non_exhaustive]
57pub struct SkinnedBindPoseCanonicalization {
58    /// Canonical document with one identity scene root and explicit skin IBMs.
59    pub document: Document,
60    /// Complete transform applied to source-world bind geometry.
61    pub source_world_to_canonical: Mat4,
62    /// Converted bind-pose bounds before optional placement.
63    pub converted_bounds_min: Vec3,
64    /// Converted bind-pose bounds before optional placement.
65    pub converted_bounds_max: Vec3,
66}
67
68/// Rejection reason returned by [`canonicalize_skinned_bind_pose`].
69#[derive(Debug, thiserror::Error)]
70#[non_exhaustive]
71pub enum SkinnedBindPoseCanonicalizationError {
72    /// The document has no mesh attachments to canonicalize.
73    #[error("cannot canonicalize skinned bind pose: document has no mesh instances")]
74    NoInstances,
75    /// The operation accepts an unanimated base scene only.
76    #[error(
77        "cannot canonicalize skinned bind pose: clip {clip:?} animates node {node} ({property})"
78    )]
79    AnimationTrack {
80        /// Clip name.
81        clip: String,
82        /// Target node.
83        node: usize,
84        /// Animated property name.
85        property: &'static str,
86    },
87    /// The caller supplied an unsafe coordinate conversion.
88    #[error("cannot canonicalize skinned bind pose: source_to_meters_y_up is invalid ({reason})")]
89    InvalidCoordinateTransform {
90        /// Stable machine-readable rejection reason.
91        reason: &'static str,
92    },
93    /// The skeleton is not in parent-before-child order.
94    #[error(
95        "cannot canonicalize skinned bind pose: skeleton node {node} has invalid parent {parent}"
96    )]
97    InvalidParent {
98        /// Node ordinal.
99        node: usize,
100        /// Invalid parent ordinal.
101        parent: usize,
102    },
103    /// A skeleton rest transform or accumulated world matrix is non-finite.
104    #[error(
105        "cannot canonicalize skinned bind pose: skeleton node {node} has a non-finite transform"
106    )]
107    NonFiniteTransform {
108        /// Node ordinal.
109        node: usize,
110    },
111    /// A converted skeleton root cannot be represented as node-local TRS.
112    #[error(
113        "cannot canonicalize skinned bind pose: skeleton root {node} cannot be represented as TRS"
114    )]
115    NonTrsRoot {
116        /// Node ordinal.
117        node: usize,
118    },
119    /// A mesh attachment references missing model data.
120    #[error(
121        "cannot canonicalize skinned bind pose: source node {source_node_index} references missing {kind} {index}"
122    )]
123    MissingReference {
124        /// Source node identity supplied by the loader.
125        source_node_index: usize,
126        /// Referenced model collection.
127        kind: &'static str,
128        /// Missing ordinal.
129        index: usize,
130    },
131    /// A mesh attachment is not skinned. Select/remove props first.
132    #[error(
133        "cannot canonicalize skinned bind pose: source node {source_node_index} is not skinned"
134    )]
135    UnskinnedInstance {
136        /// Source node identity supplied by the loader.
137        source_node_index: usize,
138    },
139    /// An explicit IBM array does not parallel its joint list.
140    #[error(
141        "cannot canonicalize skinned bind pose: source node {source_node_index} has {ibms} inverse bind matrices for {joints} joints"
142    )]
143    InverseBindCount {
144        /// Source node identity supplied by the loader.
145        source_node_index: usize,
146        /// Number of inverse bind matrices.
147        ibms: usize,
148        /// Number of skin joints.
149        joints: usize,
150    },
151    /// A skin relies on a bone-level inverse bind that is absent.
152    #[error(
153        "cannot canonicalize skinned bind pose: source node {source_node_index} joint {joint} has no inverse bind matrix"
154    )]
155    MissingInverseBind {
156        /// Source node identity supplied by the loader.
157        source_node_index: usize,
158        /// Joint node ordinal.
159        joint: usize,
160    },
161    /// An IBM disagrees with `joint_bind_world^-1 * geometry_bind_world`.
162    #[error(
163        "cannot canonicalize skinned bind pose: source node {source_node_index} joint {joint} has an inconsistent inverse bind matrix"
164    )]
165    InconsistentInverseBind {
166        /// Source node identity supplied by the loader.
167        source_node_index: usize,
168        /// Joint node ordinal.
169        joint: usize,
170    },
171    /// A joint's rest-world matrix cannot be inverted reliably.
172    #[error(
173        "cannot canonicalize skinned bind pose: joint {joint} has a singular or near-singular rest transform"
174    )]
175    SingularJoint {
176        /// Joint node ordinal.
177        joint: usize,
178    },
179    /// A mesh bind-world transform cannot safely be baked while preserving
180    /// triangle indices and winding.
181    #[error(
182        "cannot canonicalize skinned bind pose: source node {source_node_index} has {reason} geometry transform"
183    )]
184    InvalidGeometryTransform {
185        /// Source node identity supplied by the loader.
186        source_node_index: usize,
187        /// Stable machine-readable rejection reason.
188        reason: &'static str,
189    },
190    /// A primitive is malformed for a skinned triangle mesh.
191    #[error(
192        "cannot canonicalize skinned bind pose: mesh {mesh} primitive {primitive} is invalid ({reason})"
193    )]
194    InvalidPrimitive {
195        /// Mesh ordinal.
196        mesh: usize,
197        /// Primitive ordinal within the mesh.
198        primitive: usize,
199        /// Stable machine-readable rejection reason.
200        reason: &'static str,
201    },
202    /// A vertex attribute is non-finite.
203    #[error(
204        "cannot canonicalize skinned bind pose: mesh {mesh} primitive {primitive} has non-finite {attribute} at vertex {vertex}"
205    )]
206    NonFiniteAttribute {
207        /// Mesh ordinal.
208        mesh: usize,
209        /// Primitive ordinal.
210        primitive: usize,
211        /// Attribute name.
212        attribute: &'static str,
213        /// Vertex ordinal.
214        vertex: usize,
215    },
216    /// A normal cannot be normalized after the geometry transform.
217    #[error(
218        "cannot canonicalize skinned bind pose: mesh {mesh} primitive {primitive} has zero normal at vertex {vertex}"
219    )]
220    ZeroNormal {
221        /// Mesh ordinal.
222        mesh: usize,
223        /// Primitive ordinal.
224        primitive: usize,
225        /// Vertex ordinal.
226        vertex: usize,
227    },
228}
229
230#[derive(Debug, Clone, Copy)]
231struct Plan {
232    instance: usize,
233    geometry_world: Mat4,
234}
235
236/// Canonicalize an unanimated skinned base document into right-handed Y-up metres.
237///
238/// The caller supplies a finite affine, positive-determinant, uniform-scale
239/// `source_to_meters_y_up` matrix. Input skins must satisfy
240/// `IBM = joint_bind_world^-1 * geometry_bind_world`. The output has one
241/// identity scene root; each private output mesh holds canonical bind-world
242/// positions and inverse-transpose normalized normals, while its IBMs are
243/// regenerated as `canonical_joint_bind_world^-1`. This makes vertex data,
244/// joints, rest pose, and inverse binds mutually consistent.
245///
246/// Animation tracks, unskinned instances, malformed skin attributes,
247/// non-finite data, reflections or singular geometry transforms, non-TRS
248/// coordinate conversions, singular joints, and inconsistent inverse bind
249/// matrices are rejected. The function does not infer source axes: identity
250/// explicitly asserts the source is already right-handed Y-up metres.
251pub fn canonicalize_skinned_bind_pose(
252    doc: &Document,
253    options: SkinnedBindPoseCanonicalizationOptions,
254) -> Result<SkinnedBindPoseCanonicalization, SkinnedBindPoseCanonicalizationError> {
255    validate_coordinate_transform(options.source_to_meters_y_up)?;
256    let source_worlds = world_matrices(&doc.skeleton)?;
257    let plans = validate(doc, &source_worlds)?;
258    let (bounds_min, bounds_max) = converted_bounds(doc, &plans, options.source_to_meters_y_up)?;
259    let placement = placement_transform(options.placement, bounds_min, bounds_max);
260    let source_world_to_canonical = placement * options.source_to_meters_y_up;
261    let mut skeleton = canonical_skeleton(&doc.skeleton, source_world_to_canonical)?;
262    let canonical_worlds = world_matrices(&skeleton)?;
263    let mut meshes = Vec::with_capacity(plans.len());
264    let mut instances = Vec::with_capacity(plans.len());
265    let mut joints_with_skins = BTreeSet::new();
266
267    for plan in plans {
268        let input = &doc.assets.instances[plan.instance];
269        let output_mesh = meshes.len();
270        let geometry_transform = source_world_to_canonical * plan.geometry_world;
271        meshes.push(transform_mesh(
272            &doc.assets.meshes[input.mesh],
273            input.mesh,
274            geometry_transform,
275        )?);
276        let skin_joints: Vec<usize> = input.skin_joints.iter().map(|joint| joint + 1).collect();
277        let skin_ibms = skin_joints
278            .iter()
279            .map(|&joint| {
280                let inverse = inverse_joint_matrix(canonical_worlds[joint], joint)?;
281                joints_with_skins.insert(joint);
282                Ok(inverse)
283            })
284            .collect::<Result<Vec<_>, _>>()?;
285        instances.push(MeshInstance {
286            source_node_index: input.source_node_index,
287            node: 0,
288            mesh: output_mesh,
289            skin_joints,
290            skin_ibms,
291        });
292    }
293    for joint in joints_with_skins {
294        skeleton.bones[joint].inverse_bind =
295            Some(inverse_joint_matrix(canonical_worlds[joint], joint)?);
296    }
297    Ok(SkinnedBindPoseCanonicalization {
298        document: Document {
299            skeleton,
300            clips: doc.clips.clone(),
301            assets: SceneAssets {
302                meshes,
303                instances,
304                materials: doc.assets.materials.clone(),
305                material_resources: doc.assets.material_resources.clone(),
306                scenes: vec![SceneAsset {
307                    source_scene_index: 0,
308                    name: None,
309                    roots: vec![0],
310                }],
311                default_scene: Some(0),
312                source_skeleton: SourceSkeletonAssets::default(),
313            },
314            source: doc.source.clone(),
315        },
316        source_world_to_canonical,
317        converted_bounds_min: bounds_min,
318        converted_bounds_max: bounds_max,
319    })
320}
321
322fn validate_coordinate_transform(
323    transform: Mat4,
324) -> Result<(), SkinnedBindPoseCanonicalizationError> {
325    if !matrix4_is_finite(transform) {
326        return Err(
327            SkinnedBindPoseCanonicalizationError::InvalidCoordinateTransform {
328                reason: "non_finite",
329            },
330        );
331    }
332    if !transform.w_axis.abs_diff_eq(glam::Vec4::W, MATRIX_EPSILON) {
333        return Err(
334            SkinnedBindPoseCanonicalizationError::InvalidCoordinateTransform {
335                reason: "non_affine",
336            },
337        );
338    }
339    let linear = Mat3::from_mat4(transform);
340    let scale = affine_axis_lengths(linear)[0];
341    if !scale.is_finite() || scale <= f64::from(MATRIX_EPSILON) {
342        return Err(
343            SkinnedBindPoseCanonicalizationError::InvalidCoordinateTransform {
344                reason: "zero_scale",
345            },
346        );
347    }
348    classify_positive_uniform_affine(linear, COORDINATE_AFFINE_TOLERANCE)
349        .map(|_| ())
350        .map_err(
351            |violation| SkinnedBindPoseCanonicalizationError::InvalidCoordinateTransform {
352                reason: coordinate_transform_violation_reason(violation),
353            },
354        )
355}
356
357/// Preserve skinned canonicalization's established machine-readable reasons
358/// while sharing the typed Mat3 classifier with scale planning.
359fn coordinate_transform_violation_reason(violation: AffineDomainViolation) -> &'static str {
360    match violation {
361        AffineDomainViolation::NonFinite => "non_finite",
362        AffineDomainViolation::NonUniformScale | AffineDomainViolation::Sheared => {
363            "non_uniform_or_sheared"
364        }
365        AffineDomainViolation::Reflected | AffineDomainViolation::Singular => {
366            "reflection_or_singular"
367        }
368    }
369}
370
371fn validate(
372    doc: &Document,
373    worlds: &[Mat4],
374) -> Result<Vec<Plan>, SkinnedBindPoseCanonicalizationError> {
375    if doc.assets.instances.is_empty() {
376        return Err(SkinnedBindPoseCanonicalizationError::NoInstances);
377    }
378    for clip in &doc.clips {
379        if let Some(track) = clip.tracks.first() {
380            return Err(SkinnedBindPoseCanonicalizationError::AnimationTrack {
381                clip: clip.name.clone(),
382                node: track.bone,
383                property: track.property.as_str(),
384            });
385        }
386    }
387    let mut plans = Vec::with_capacity(doc.assets.instances.len());
388    for (instance_ordinal, instance) in doc.assets.instances.iter().enumerate() {
389        let mesh = doc.assets.meshes.get(instance.mesh).ok_or(
390            SkinnedBindPoseCanonicalizationError::MissingReference {
391                source_node_index: instance.source_node_index,
392                kind: "mesh",
393                index: instance.mesh,
394            },
395        )?;
396        // Keep the attachment-node reference structurally valid, but derive
397        // geometry bind space from the skin equation itself. FBX geometric
398        // transform helper nodes can make the attachment's ordinary rest
399        // world differ from the geometry-to-world matrix encoded by every
400        // inverse bind.
401        worlds.get(instance.node).ok_or(
402            SkinnedBindPoseCanonicalizationError::MissingReference {
403                source_node_index: instance.source_node_index,
404                kind: "node",
405                index: instance.node,
406            },
407        )?;
408        if instance.skin_joints.is_empty() {
409            return Err(SkinnedBindPoseCanonicalizationError::UnskinnedInstance {
410                source_node_index: instance.source_node_index,
411            });
412        }
413        if !instance.skin_ibms.is_empty() && instance.skin_ibms.len() != instance.skin_joints.len()
414        {
415            return Err(SkinnedBindPoseCanonicalizationError::InverseBindCount {
416                source_node_index: instance.source_node_index,
417                ibms: instance.skin_ibms.len(),
418                joints: instance.skin_joints.len(),
419            });
420        }
421        let mut geometry_world = None;
422        for (slot, &joint) in instance.skin_joints.iter().enumerate() {
423            let joint_world = *worlds.get(joint).ok_or(
424                SkinnedBindPoseCanonicalizationError::MissingReference {
425                    source_node_index: instance.source_node_index,
426                    kind: "joint",
427                    index: joint,
428                },
429            )?;
430            let actual = if instance.skin_ibms.is_empty() {
431                doc.skeleton.bones[joint].inverse_bind.ok_or(
432                    SkinnedBindPoseCanonicalizationError::MissingInverseBind {
433                        source_node_index: instance.source_node_index,
434                        joint,
435                    },
436                )?
437            } else {
438                instance.skin_ibms[slot]
439            };
440            let candidate = joint_world * actual;
441            if !matrix4_is_finite(actual)
442                || !matrix4_is_finite(candidate)
443                || geometry_world
444                    .is_some_and(|geometry| !matrix_approximately_equal(candidate, geometry))
445            {
446                return Err(
447                    SkinnedBindPoseCanonicalizationError::InconsistentInverseBind {
448                        source_node_index: instance.source_node_index,
449                        joint,
450                    },
451                );
452            }
453            geometry_world.get_or_insert(candidate);
454        }
455        let Some(geometry_world) = geometry_world else {
456            return Err(SkinnedBindPoseCanonicalizationError::UnskinnedInstance {
457                source_node_index: instance.source_node_index,
458            });
459        };
460        validate_geometry_transform(geometry_world, instance.source_node_index)?;
461        validate_mesh(mesh, instance.mesh, instance)?;
462        plans.push(Plan {
463            instance: instance_ordinal,
464            geometry_world,
465        });
466    }
467    plans.sort_unstable_by_key(|plan| {
468        let instance = &doc.assets.instances[plan.instance];
469        (instance.source_node_index, plan.instance)
470    });
471    Ok(plans)
472}
473
474fn world_matrices(skeleton: &Skeleton) -> Result<Vec<Mat4>, SkinnedBindPoseCanonicalizationError> {
475    world_rest_matrices(skeleton).map_err(|error| match error {
476        WorldMatrixError::NonFiniteTransform { node } => {
477            SkinnedBindPoseCanonicalizationError::NonFiniteTransform { node }
478        }
479        WorldMatrixError::InvalidParent { node, parent } => {
480            SkinnedBindPoseCanonicalizationError::InvalidParent { node, parent }
481        }
482    })
483}
484
485fn inverse_joint_matrix(
486    world: Mat4,
487    joint: usize,
488) -> Result<Mat4, SkinnedBindPoseCanonicalizationError> {
489    let linear = Mat3::from_mat4(world);
490    let scale = linear
491        .to_cols_array()
492        .into_iter()
493        .fold(0.0_f32, |largest, component| largest.max(component.abs()));
494    let determinant = linear.determinant();
495    if !determinant.is_finite()
496        || scale == 0.0
497        || determinant.abs() <= MIN_RELATIVE_DETERMINANT * scale * scale * scale
498    {
499        return Err(SkinnedBindPoseCanonicalizationError::SingularJoint { joint });
500    }
501    let inverse = world.inverse();
502    if !matrix4_is_finite(inverse) {
503        return Err(SkinnedBindPoseCanonicalizationError::SingularJoint { joint });
504    }
505    Ok(inverse)
506}
507
508fn validate_geometry_transform(
509    world: Mat4,
510    source_node_index: usize,
511) -> Result<(), SkinnedBindPoseCanonicalizationError> {
512    let linear = Mat3::from_mat4(world);
513    let scale = linear
514        .to_cols_array()
515        .into_iter()
516        .fold(0.0_f32, |largest, component| largest.max(component.abs()));
517    let determinant = linear.determinant();
518    if !determinant.is_finite()
519        || scale == 0.0
520        || determinant.abs() <= MIN_RELATIVE_DETERMINANT * scale * scale * scale
521    {
522        return Err(
523            SkinnedBindPoseCanonicalizationError::InvalidGeometryTransform {
524                source_node_index,
525                reason: "singular_or_near_singular",
526            },
527        );
528    }
529    if determinant < 0.0 {
530        return Err(
531            SkinnedBindPoseCanonicalizationError::InvalidGeometryTransform {
532                source_node_index,
533                reason: "reflection",
534            },
535        );
536    }
537    Ok(())
538}
539
540fn validate_mesh(
541    mesh: &MeshAsset,
542    mesh_ordinal: usize,
543    instance: &MeshInstance,
544) -> Result<(), SkinnedBindPoseCanonicalizationError> {
545    if mesh.primitives.is_empty() {
546        return Err(invalid_primitive(mesh_ordinal, 0, "empty_primitives"));
547    }
548    for (primitive_ordinal, primitive) in mesh.primitives.iter().enumerate() {
549        let invalid = |reason| invalid_primitive(mesh_ordinal, primitive_ordinal, reason);
550        if primitive.positions.is_empty() {
551            return Err(invalid("empty_positions"));
552        }
553        if !primitive.normals.is_empty() && primitive.normals.len() != primitive.positions.len() {
554            return Err(invalid("normal_count"));
555        }
556        if primitive.joints.len() != primitive.positions.len()
557            || primitive.weights.len() != primitive.positions.len()
558        {
559            return Err(invalid("skin_attribute_count"));
560        }
561        if primitive.indices.is_empty() {
562            if !primitive.positions.len().is_multiple_of(3) {
563                return Err(invalid("unindexed_triangle_count"));
564            }
565        } else if !primitive.indices.len().is_multiple_of(3)
566            || primitive
567                .indices
568                .iter()
569                .any(|&index| index as usize >= primitive.positions.len())
570        {
571            return Err(invalid("triangle_indices"));
572        }
573        for (vertex, position) in primitive.positions.iter().enumerate() {
574            if !position.is_finite() {
575                return Err(non_finite(
576                    mesh_ordinal,
577                    primitive_ordinal,
578                    "position",
579                    vertex,
580                ));
581            }
582        }
583        for (vertex, normal) in primitive.normals.iter().enumerate() {
584            if !normal.is_finite() {
585                return Err(non_finite(
586                    mesh_ordinal,
587                    primitive_ordinal,
588                    "normal",
589                    vertex,
590                ));
591            }
592            if normal.length_squared() == 0.0 {
593                return Err(SkinnedBindPoseCanonicalizationError::ZeroNormal {
594                    mesh: mesh_ordinal,
595                    primitive: primitive_ordinal,
596                    vertex,
597                });
598            }
599        }
600        for (vertex, weights) in primitive.weights.iter().enumerate() {
601            if weights.iter().any(|weight| !weight.is_finite()) {
602                return Err(non_finite(
603                    mesh_ordinal,
604                    primitive_ordinal,
605                    "weight",
606                    vertex,
607                ));
608            }
609            if weights.iter().any(|weight| *weight < 0.0)
610                || (weights.iter().sum::<f32>() - 1.0).abs() > MATRIX_EPSILON
611            {
612                return Err(invalid("skin_weights"));
613            }
614            if primitive.joints[vertex]
615                .iter()
616                .zip(weights)
617                .any(|(&joint_slot, &weight)| {
618                    weight != 0.0 && joint_slot as usize >= instance.skin_joints.len()
619                })
620            {
621                return Err(invalid("joint_index"));
622            }
623        }
624    }
625    Ok(())
626}
627
628fn converted_bounds(
629    doc: &Document,
630    plans: &[Plan],
631    source_to_meters_y_up: Mat4,
632) -> Result<(Vec3, Vec3), SkinnedBindPoseCanonicalizationError> {
633    let mut min = Vec3::splat(f32::INFINITY);
634    let mut max = Vec3::splat(f32::NEG_INFINITY);
635    for plan in plans {
636        let instance = &doc.assets.instances[plan.instance];
637        let transform = source_to_meters_y_up * plan.geometry_world;
638        for (primitive_ordinal, primitive) in doc.assets.meshes[instance.mesh]
639            .primitives
640            .iter()
641            .enumerate()
642        {
643            for (vertex, position) in primitive.positions.iter().enumerate() {
644                let converted = transform.transform_point3(*position);
645                if !converted.is_finite() {
646                    return Err(non_finite(
647                        instance.mesh,
648                        primitive_ordinal,
649                        "converted_position",
650                        vertex,
651                    ));
652                }
653                min = min.min(converted);
654                max = max.max(converted);
655            }
656        }
657    }
658    Ok((min, max))
659}
660
661fn placement_transform(placement: SkinnedBindPosePlacement, min: Vec3, max: Vec3) -> Mat4 {
662    match placement {
663        SkinnedBindPosePlacement::Preserve => Mat4::IDENTITY,
664        SkinnedBindPosePlacement::GroundAndCenter => Mat4::from_translation(Vec3::new(
665            -(min.x + max.x) * 0.5,
666            -min.y,
667            -(min.z + max.z) * 0.5,
668        )),
669    }
670}
671
672fn canonical_skeleton(
673    source: &Skeleton,
674    transform: Mat4,
675) -> Result<Skeleton, SkinnedBindPoseCanonicalizationError> {
676    let mut bones = Vec::with_capacity(source.bones.len() + 1);
677    bones.push(Bone {
678        name: "animsmith-canonical-root".to_string(),
679        parent: None,
680        rest: Transform::IDENTITY,
681        inverse_bind: None,
682    });
683    for (node, source_bone) in source.bones.iter().enumerate() {
684        let (parent, rest) = match source_bone.parent {
685            Some(parent) => (Some(parent + 1), source_bone.rest),
686            None => (
687                Some(0),
688                transform_to_trs(transform * source_bone.rest.to_mat4())
689                    .ok_or(SkinnedBindPoseCanonicalizationError::NonTrsRoot { node })?,
690            ),
691        };
692        bones.push(Bone {
693            name: source_bone.name.clone(),
694            parent,
695            rest,
696            inverse_bind: None,
697        });
698    }
699    Ok(Skeleton { bones })
700}
701
702fn transform_mesh(
703    mesh: &MeshAsset,
704    mesh_ordinal: usize,
705    transform: Mat4,
706) -> Result<MeshAsset, SkinnedBindPoseCanonicalizationError> {
707    let normal_transform = Mat3::from_mat4(transform).inverse().transpose();
708    if !matrix3_is_finite(normal_transform) {
709        return Err(
710            SkinnedBindPoseCanonicalizationError::InvalidCoordinateTransform {
711                reason: "normal_matrix",
712            },
713        );
714    }
715    let mut output = mesh.clone();
716    for (primitive_ordinal, primitive) in output.primitives.iter_mut().enumerate() {
717        for (vertex, position) in primitive.positions.iter_mut().enumerate() {
718            *position = transform.transform_point3(*position);
719            if !position.is_finite() {
720                return Err(non_finite(
721                    mesh_ordinal,
722                    primitive_ordinal,
723                    "canonical_position",
724                    vertex,
725                ));
726            }
727        }
728        for (vertex, normal) in primitive.normals.iter_mut().enumerate() {
729            let Some(normalized) = (normal_transform * *normal).try_normalize() else {
730                return Err(SkinnedBindPoseCanonicalizationError::ZeroNormal {
731                    mesh: mesh_ordinal,
732                    primitive: primitive_ordinal,
733                    vertex,
734                });
735            };
736            *normal = normalized;
737        }
738    }
739    Ok(output)
740}
741
742fn transform_to_trs(matrix: Mat4) -> Option<Transform> {
743    if !matrix4_is_finite(matrix) {
744        return None;
745    }
746    let (scale, rotation, translation) = matrix.to_scale_rotation_translation();
747    if !scale.is_finite() || !rotation.is_finite() || !translation.is_finite() {
748        return None;
749    }
750    let transform = Transform {
751        translation,
752        rotation,
753        scale,
754    };
755    matrix_approximately_equal(matrix, transform.to_mat4()).then_some(transform)
756}
757
758fn invalid_primitive(
759    mesh: usize,
760    primitive: usize,
761    reason: &'static str,
762) -> SkinnedBindPoseCanonicalizationError {
763    SkinnedBindPoseCanonicalizationError::InvalidPrimitive {
764        mesh,
765        primitive,
766        reason,
767    }
768}
769
770fn non_finite(
771    mesh: usize,
772    primitive: usize,
773    attribute: &'static str,
774    vertex: usize,
775) -> SkinnedBindPoseCanonicalizationError {
776    SkinnedBindPoseCanonicalizationError::NonFiniteAttribute {
777        mesh,
778        primitive,
779        attribute,
780        vertex,
781    }
782}
783
784fn matrix_approximately_equal(left: Mat4, right: Mat4) -> bool {
785    left.to_cols_array()
786        .into_iter()
787        .zip(right.to_cols_array())
788        .all(|(left, right)| {
789            (left - right).abs() <= MATRIX_EPSILON * left.abs().max(right.abs()).max(1.0)
790        })
791}
792
793fn matrix4_is_finite(matrix: Mat4) -> bool {
794    matrix
795        .to_cols_array()
796        .into_iter()
797        .all(|component| component.is_finite())
798}
799
800fn matrix3_is_finite(matrix: Mat3) -> bool {
801    matrix
802        .to_cols_array()
803        .into_iter()
804        .all(|component| component.is_finite())
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810    use crate::model::affine_test_fixtures;
811
812    #[test]
813    fn coordinate_transform_accepts_the_shared_fixtures_inside_its_1e4_bands() {
814        assert_eq!(
815            COORDINATE_AFFINE_TOLERANCE,
816            PositiveUniformAffineTolerance {
817                equal_axis: f64::from(1.0e-4_f32),
818                relative_orthogonality: f64::from(1.0e-4_f32),
819                singular_determinant_relative: 0.0,
820            },
821            "the canonicalization policy tuple is an explicit contract"
822        );
823        for basis in [
824            affine_test_fixtures::tolerance_divergence_basis(),
825            affine_test_fixtures::orthogonality_tolerance_divergence_basis(),
826        ] {
827            validate_coordinate_transform(Mat4::from_mat3(basis))
828                .expect("skinned canonicalization keeps its declared 1e-4 policy");
829        }
830    }
831}