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