concinnity_asset/character_shape.rs
1// Character-shape schema: slider-driven morph weights and per-joint proportions
2// that deform a SkinnedMesh at runtime.
3
4use crate::{AssetId, SkinnedMeshHandle, de_opt_skinned_mesh_handle};
5use alloc::string::String;
6use alloc::vec::Vec;
7
8/// One named shape value in `[-1, 1]`.
9#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
10#[serde(default)]
11pub struct ShapeSlider {
12 /// Slider name; matched against the target mesh's morph-target names.
13 pub name: String,
14 /// Slider value, clamped to `[-1, 1]`.
15 pub value: f32,
16}
17
18/// One joint's proportion change.
19#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
20#[serde(default)]
21pub struct JointProportion {
22 /// Name of the joint in the target mesh's `skeleton`.
23 pub joint: String,
24 /// Uniform scale applied to the joint (and, through the hierarchy,
25 /// everything below it). `1` leaves it alone.
26 pub scale: f32,
27 /// Extra length along the bone, in model units: every child joint is
28 /// pushed that far along its bind direction from this joint. `0` leaves
29 /// it alone.
30 pub length: f32,
31}
32
33impl Default for JointProportion {
34 fn default() -> Self {
35 Self {
36 joint: String::new(),
37 scale: 1.0,
38 length: 0.0,
39 }
40 }
41}
42
43/// Shape sliders and joint proportions applied to one [SkinnedMesh](#skinnedmesh).
44///
45/// Every characteristic of the shape is data on the mesh, not code: a slider
46/// drives one or two of the mesh's morph targets, and a proportion scales or
47/// lengthens one joint of its skeleton. The deformation is static and sits
48/// under any [Animation](#animation) playing on the same mesh: clip morph
49/// tracks are added on top of the slider weights, and clip poses are
50/// re-proportioned every frame.
51///
52/// **Sliders** resolve to morph targets by name. A target named exactly
53/// `name` is unipolar and receives the slider value clamped to `[0, 1]`. A
54/// pair named `name+` / `name-` is bipolar: a positive value drives `name+`,
55/// a negative value drives `name-` by its magnitude. A slider with no matching
56/// target is reported as a build warning and ignored.
57///
58/// **Proportions** resolve to joints by name. `scale` is uniform (the
59/// skinning shaders transform normals with the plain joint matrix, so a
60/// non-uniform scale would shade incorrectly) and propagates to the joint's
61/// descendants; `length` moves only the joint's children along the bone, so a
62/// longer thigh does not also stretch the shin. Proportions change the posed
63/// skeleton, not the bind pose, so clips with translation tracks on the
64/// affected joints fight them; keep such rigs rotation-only. When the mesh
65/// declares a `capsule`, the capsule's half-height follows the skeleton's
66/// height change and its radius follows the root joint's scale.
67///
68/// `target` may name a [CharacterModel](#charactermodel) as well as a
69/// `SkinnedMesh`; the model's emitted mesh is what the shape deforms.
70///
71/// **Baking.** With `bake` set, the build flattens the shape into its target:
72/// the sliders' deformation is applied to the vertices and the morph targets
73/// dropped, the bind pose is rewritten through the proportions, the capsule
74/// is resized, and this asset is consumed. The result is a plain `SkinnedMesh`
75/// with no per-frame shape work, for characters that never change shape.
76///
77/// ```rust
78/// # use concinnity_asset::{CharacterShape, JointProportion, ShapeSlider};
79/// CharacterShape {
80/// sliders: vec![ShapeSlider { name: "weight".into(), value: 0.5 }],
81/// proportions: vec![JointProportion { joint: "spine".into(), scale: 1.1, length: 0.0 }],
82/// ..Default::default()
83/// };
84/// ```
85#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
86#[serde(default)]
87pub struct CharacterShape {
88 /// Asset identity; injected via `inject_name`. Not part of `args`.
89 #[serde(skip)]
90 pub asset_id: AssetId,
91 /// The [SkinnedMesh](#skinnedmesh) this shape deforms.
92 #[serde(deserialize_with = "de_opt_skinned_mesh_handle")]
93 pub target: Option<SkinnedMeshHandle>,
94 /// Named shape values, each resolved to the mesh's morph targets.
95 pub sliders: Vec<ShapeSlider>,
96 /// Per-joint scale and length changes.
97 pub proportions: Vec<JointProportion>,
98 /// Flatten the shape into the target mesh at build time and drop this
99 /// asset, instead of deforming at runtime.
100 pub bake: bool,
101}
102
103/// Morph weights resolved from a shape's sliders against a mesh's morph-target
104/// names, plus the slider names that matched nothing.
105#[derive(Debug, Clone, Default, PartialEq)]
106pub struct ResolvedSliders {
107 /// One weight per morph target, in target order.
108 pub weights: Vec<f32>,
109 /// Slider names with neither a unipolar nor a bipolar target.
110 pub unresolved: Vec<String>,
111}
112
113impl CharacterShape {
114 /// Resolve `sliders` against `target_names` (the mesh's morph targets in
115 /// target order). Several sliders naming the same target accumulate; the
116 /// result is clamped to `[0, 1]` per target.
117 pub fn resolve_sliders(&self, target_names: &[String]) -> ResolvedSliders {
118 let mut out = ResolvedSliders {
119 weights: alloc::vec![0.0; target_names.len()],
120 unresolved: Vec::new(),
121 };
122 let find = |name: &str, suffix: &str| {
123 target_names
124 .iter()
125 .position(|t| t.strip_suffix(suffix).is_some_and(|base| base == name))
126 };
127 for slider in &self.sliders {
128 let value = slider.value.clamp(-1.0, 1.0);
129 let plus = find(&slider.name, "+");
130 let minus = find(&slider.name, "-");
131 if plus.is_some() || minus.is_some() {
132 if let Some(i) = plus {
133 out.weights[i] += value.max(0.0);
134 }
135 if let Some(i) = minus {
136 out.weights[i] += (-value).max(0.0);
137 }
138 } else if let Some(i) = find(&slider.name, "") {
139 out.weights[i] += value.max(0.0);
140 } else {
141 out.unresolved.push(slider.name.clone());
142 }
143 }
144 for w in &mut out.weights {
145 *w = w.clamp(0.0, 1.0);
146 }
147 out
148 }
149
150 /// The proportion joint names that `has_joint` does not know.
151 pub fn unresolved_joints(&self, has_joint: impl Fn(&str) -> bool) -> Vec<String> {
152 self.proportions
153 .iter()
154 .filter(|p| !has_joint(&p.joint))
155 .map(|p| p.joint.clone())
156 .collect()
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use alloc::vec;
164
165 fn names(list: &[&str]) -> Vec<String> {
166 list.iter().map(|s| String::from(*s)).collect()
167 }
168
169 fn shape(sliders: &[(&str, f32)]) -> CharacterShape {
170 CharacterShape {
171 sliders: sliders
172 .iter()
173 .map(|(n, v)| ShapeSlider {
174 name: String::from(*n),
175 value: *v,
176 })
177 .collect(),
178 ..Default::default()
179 }
180 }
181
182 #[test]
183 fn unipolar_slider_drives_the_target_of_the_same_name() {
184 let r = shape(&[("weight", 0.6)]).resolve_sliders(&names(&["height", "weight"]));
185 assert_eq!(r.weights, [0.0, 0.6]);
186 assert!(r.unresolved.is_empty());
187 // A negative value on a unipolar target contributes nothing.
188 let r = shape(&[("weight", -0.6)]).resolve_sliders(&names(&["weight"]));
189 assert_eq!(r.weights, [0.0]);
190 }
191
192 #[test]
193 fn bipolar_slider_splits_by_sign() {
194 let targets = names(&["jaw-", "jaw+"]);
195 let r = shape(&[("jaw", 0.25)]).resolve_sliders(&targets);
196 assert_eq!(r.weights, [0.0, 0.25]);
197 let r = shape(&[("jaw", -0.75)]).resolve_sliders(&targets);
198 assert_eq!(r.weights, [0.75, 0.0]);
199 // The pair takes precedence over a same-named unipolar target.
200 let r = shape(&[("jaw", 0.5)]).resolve_sliders(&names(&["jaw", "jaw+"]));
201 assert_eq!(r.weights, [0.0, 0.5]);
202 }
203
204 #[test]
205 fn unresolved_sliders_are_reported_not_fatal() {
206 let r = shape(&[("nose", 1.0), ("weight", 2.0)]).resolve_sliders(&names(&["weight"]));
207 assert_eq!(r.unresolved, names(&["nose"]));
208 // Values clamp to the slider range before resolving.
209 assert_eq!(r.weights, [1.0]);
210 }
211
212 #[test]
213 fn repeated_sliders_accumulate_and_clamp() {
214 let r = shape(&[("w", 0.7), ("w", 0.7)]).resolve_sliders(&names(&["w"]));
215 assert_eq!(r.weights, [1.0]);
216 }
217
218 #[test]
219 fn unresolved_joints_are_listed() {
220 let s = CharacterShape {
221 proportions: vec![
222 JointProportion {
223 joint: "spine".into(),
224 ..Default::default()
225 },
226 JointProportion {
227 joint: "tail".into(),
228 ..Default::default()
229 },
230 ],
231 ..Default::default()
232 };
233 assert_eq!(s.unresolved_joints(|j| j == "spine"), names(&["tail"]));
234 }
235
236 #[test]
237 fn a_shape_round_trips_through_postcard() {
238 crate::test_support::install_resolvers();
239 let s: CharacterShape = serde_json::from_str(
240 r#"{"target":"hero","sliders":[{"name":"jaw","value":-0.5}],
241 "proportions":[{"joint":"thigh.L","scale":1.05,"length":0.1}]}"#,
242 )
243 .unwrap();
244 assert_eq!(s.target, Some(SkinnedMeshHandle(4)));
245 let bytes = postcard::to_allocvec(&s).unwrap();
246 let back: CharacterShape = postcard::from_bytes(&bytes).unwrap();
247 assert_eq!(back.target, Some(SkinnedMeshHandle(4)));
248 assert_eq!(back.sliders, s.sliders);
249 assert_eq!(back.proportions, s.proportions);
250 assert_eq!(back.proportions[0].scale, 1.05);
251 assert!(!back.bake, "runtime deformation is the default");
252 assert_eq!(back.asset_id, AssetId::default());
253 // A blank proportion is the identity.
254 let p = JointProportion::default();
255 assert_eq!((p.scale, p.length), (1.0, 0.0));
256 }
257}