Skip to main content

concinnity_core/components/
skinned_mesh.rs

1// src/components/skinned_mesh.rs
2//
3// Runtime behavior for the SkinnedMesh asset. The authored schema (SkinnedMesh,
4// its SkinnedVertexData / SkeletonJoint / CharacterCapsule, and their Defaults) lives
5// in concinnity-asset; SkinnedMesh is a resource now (compiled by cook into the
6// blob's resource stream, no `Component` impl), so this file keeps only the
7// skeleton builder and the `SkinnedMeshGeometry` extension trait that needs
8// `gfx::skeleton`.
9
10use crate::components::{SkeletonJoint, SkinnedMesh};
11
12/// Build a runtime `Skeleton` from authored joint definitions. Mirrors the
13/// conversion `GraphicsSystem::init` does at world load time: each
14/// `SkeletonJoint.parent` becomes `Some(usize)` for valid indices (negative values
15/// mark roots), and each `SkeletonJoint`'s translation / rotation / scale becomes the
16/// joint's bind `JointPose`. Used at init and by the asset hot-reload's
17/// skeleton-shape change path.
18pub fn build_skeleton_from_joint_defs(defs: &[SkeletonJoint]) -> crate::gfx::skeleton::Skeleton {
19    use crate::gfx::skeleton as skinning;
20    let joints = defs
21        .iter()
22        .map(|jd| skinning::Joint {
23            name: jd.name.clone(),
24            parent: (jd.parent >= 0).then_some(jd.parent as usize),
25            bind: skinning::JointPose {
26                translation: jd.translation,
27                rotation_deg: jd.rotation_deg,
28                scale: jd.scale,
29            },
30        })
31        .collect();
32    skinning::Skeleton::new(joints)
33}
34
35/// Column-major world matrix from a SkinnedMesh's transform. Kept in core (not
36/// the schema crate) because the matrix build goes through `gfx::skeleton`, which
37/// needs std transcendentals. Exposed as an extension trait so call sites keep
38/// method syntax (`sm.model_matrix()`), matching `geometry.rs`.
39pub trait SkinnedMeshGeometry {
40    /// Column-major world matrix built from the mesh's transform.
41    fn model_matrix(&self) -> [[f32; 4]; 4];
42}
43
44impl SkinnedMeshGeometry for SkinnedMesh {
45    // Same construction order (scale, YXZ rotation, translate) as
46    // `Prop::model_matrix`.
47    fn model_matrix(&self) -> [[f32; 4]; 4] {
48        crate::gfx::skeleton::JointPose {
49            translation: self.position,
50            rotation_deg: self.rotation_deg,
51            scale: self.scale,
52        }
53        .to_matrix()
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::components::{CharacterCapsule, SkinnedVertexData};
61    use alloc::vec;
62
63    #[test]
64    fn build_skeleton_from_joint_defs_preserves_count_and_parent_links() {
65        let defs = vec![
66            SkeletonJoint {
67                name: "root".into(),
68                parent: -1,
69                translation: [0.0, 0.0, 0.0],
70                rotation_deg: [0.0, 0.0, 0.0],
71                scale: [1.0, 1.0, 1.0],
72            },
73            SkeletonJoint {
74                name: "tip".into(),
75                parent: 0,
76                translation: [0.0, 1.0, 0.0],
77                rotation_deg: [0.0, 0.0, 0.0],
78                scale: [1.0, 1.0, 1.0],
79            },
80            SkeletonJoint {
81                name: "tail".into(),
82                parent: 1,
83                translation: [0.0, 1.0, 0.0],
84                rotation_deg: [0.0, 0.0, 0.0],
85                scale: [1.0, 1.0, 1.0],
86            },
87        ];
88        let skel = build_skeleton_from_joint_defs(&defs);
89        assert_eq!(skel.len(), 3);
90        let joints = skel.joints();
91        assert_eq!(joints[0].parent, None);
92        assert_eq!(joints[1].parent, Some(0));
93        assert_eq!(joints[2].parent, Some(1));
94    }
95
96    #[test]
97    fn build_skeleton_from_joint_defs_treats_negative_parent_as_root() {
98        // Any negative parent (not just -1) collapses to None; mirrors the
99        // init-time semantics so a hot-reload from the same SkeletonJoint shape
100        // produces the same Skeleton.
101        let defs = vec![SkeletonJoint {
102            name: "root".into(),
103            parent: -42,
104            translation: [1.0, 2.0, 3.0],
105            rotation_deg: [0.0, 0.0, 0.0],
106            scale: [1.0, 1.0, 1.0],
107        }];
108        let skel = build_skeleton_from_joint_defs(&defs);
109        assert_eq!(skel.joints()[0].parent, None);
110    }
111
112    #[test]
113    fn model_matrix_places_translation_in_last_column() {
114        let mesh = SkinnedMesh {
115            position: [2.0, 3.0, 4.0],
116            scale: [1.0, 1.0, 1.0],
117            ..SkinnedMesh::default()
118        };
119        let m = mesh.model_matrix();
120        // Column-major: the translation lives in the last column, identity
121        // scale keeps the diagonal at 1.
122        assert_eq!([m[3][0], m[3][1], m[3][2]], [2.0, 3.0, 4.0]);
123        assert_eq!(m[3][3], 1.0);
124        assert_eq!(m[0][0], 1.0);
125    }
126
127    #[test]
128    fn skinned_vertex_defaults_fill_color_uv_and_weights() {
129        // A vertex authored with only a position picks up the serde defaults:
130        // white colour, zero uv, and full weight on joint 0.
131        let v: SkinnedVertexData =
132            serde_json::from_value(serde_json::json!({"pos": [0.0, 0.0, 0.0]})).unwrap();
133        assert_eq!(v.color, [1.0, 1.0, 1.0]);
134        assert_eq!(v.uv, [0.0, 0.0]);
135        assert_eq!(v.weights, [1.0, 0.0, 0.0, 0.0]);
136        assert_eq!(v.joints, [0, 0, 0, 0]);
137    }
138
139    #[test]
140    fn capsule_joint_defaults() {
141        let cap = CharacterCapsule::default();
142        assert_eq!(cap.half_height, 0.5);
143        assert_eq!(cap.radius, 0.3);
144
145        let jd: SkeletonJoint = serde_json::from_value(serde_json::json!({})).unwrap();
146        assert_eq!(jd.parent, -1);
147        assert_eq!(jd.scale, [1.0, 1.0, 1.0]);
148    }
149}