Skip to main content

concinnity_world/schema/
character_schema.rs

1//! Character-schema: the declarative contract a body conforms to, read by the
2//! cook (validation, synthesized targets) and the editor (panel layout).
3
4use concinnity_core::components::JointProportion;
5use concinnity_core::components::ShapeSlider;
6
7/// Whether a shape key is one target or a `+` / `-` pair.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum KeyPolarity {
11    /// One target named exactly `name`; the slider runs `[0, 1]`.
12    #[default]
13    Unipolar,
14    /// Two targets `name+` / `name-`; the slider runs `[-1, 1]`.
15    Bipolar,
16}
17
18impl KeyPolarity {
19    /// The slider range the polarity implies.
20    pub fn range(self) -> [f32; 2] {
21        match self {
22            KeyPolarity::Unipolar => [0.0, 1.0],
23            KeyPolarity::Bipolar => [-1.0, 1.0],
24        }
25    }
26}
27
28/// One joint the schema expects in a conforming skeleton.
29#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
30#[serde(default)]
31pub struct SchemaJoint {
32    /// Joint name.
33    pub name: String,
34    /// Parent joint name; empty for a root.
35    pub parent: String,
36    /// A source may omit this joint.
37    pub optional: bool,
38}
39
40/// One shape key the schema knows, authored on the source or synthesized.
41#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
42#[serde(default)]
43pub struct SchemaKey {
44    /// Slider name; the target is `name` or the `name+` / `name-` pair.
45    pub name: String,
46    /// One target or a pair.
47    pub polarity: KeyPolarity,
48    /// Panel caption; the name when empty.
49    pub caption: String,
50    /// The region the key belongs to (panel grouping).
51    pub region: String,
52}
53
54impl Default for SchemaKey {
55    fn default() -> Self {
56        Self {
57            name: String::new(),
58            polarity: KeyPolarity::Unipolar,
59            caption: String::new(),
60            region: String::new(),
61        }
62    }
63}
64
65impl SchemaKey {
66    /// The caption, falling back to the name.
67    pub fn caption(&self) -> &str {
68        if self.caption.is_empty() {
69            &self.name
70        } else {
71            &self.caption
72        }
73    }
74}
75
76/// A named group of joints. A vertex belongs to a region by the skin weight
77/// it gives the region's joints.
78#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
79#[serde(default)]
80pub struct SchemaRegion {
81    /// Region name.
82    pub name: String,
83    /// Member joints.
84    pub joints: Vec<String>,
85}
86
87/// A proportion slider: one value in `[-1, 1]` written as a scale and / or
88/// length change on every listed joint.
89#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
90#[serde(default)]
91pub struct ProportionGroup {
92    /// Group name (the panel row).
93    pub name: String,
94    /// Panel caption; the name when empty.
95    pub caption: String,
96    /// The region the row belongs to (panel grouping).
97    pub region: String,
98    /// Joints the row writes; only those the skeleton has are written.
99    pub joints: Vec<String>,
100    /// Scale change at full deflection (`0` leaves scale alone).
101    pub scale: f32,
102    /// Length change at full deflection, in model units (`0` leaves it alone).
103    pub length: f32,
104}
105
106impl ProportionGroup {
107    /// The caption, falling back to the name.
108    pub fn caption(&self) -> &str {
109        if self.caption.is_empty() {
110            &self.name
111        } else {
112            &self.caption
113        }
114    }
115}
116
117/// Generator parameters for a synthesized target. Each generator reads the
118/// fields it needs and ignores the rest.
119#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
120#[serde(default)]
121pub struct SynthParams {
122    /// Displacement at full weight, in model units.
123    pub amplitude: f32,
124    /// `bulge`: centre along the bone, as a fraction of its length.
125    pub along: f32,
126    /// `bulge`: width of the lobe along the bone, as a fraction of its length.
127    pub sigma: f32,
128    /// `bulge`: model-space direction of the lobe; zero means radially away
129    /// from the bone.
130    pub direction: [f32; 3],
131    /// `taper`: ramp from the distal end toward the proximal end instead.
132    pub reverse: bool,
133    /// `mirror` / `blend_mask`: the authored target to derive from.
134    pub source: String,
135    /// `surface_offset`: the window along the region's first bone, as
136    /// fractions of its length, outside which the offset fades to nothing.
137    pub span: [f32; 2],
138    /// `surface_offset`: width of the fade at each end of `span`.
139    pub falloff: f32,
140}
141
142impl Default for SynthParams {
143    fn default() -> Self {
144        Self {
145            amplitude: 0.02,
146            along: 0.5,
147            sigma: 0.15,
148            direction: [0.0, 0.0, 0.0],
149            reverse: false,
150            source: String::new(),
151            span: [0.0, 1.0],
152            falloff: 0.1,
153        }
154    }
155}
156
157/// A morph target the build generates from the mesh instead of reading from
158/// the source.
159#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
160#[serde(default)]
161pub struct SynthesizedTarget {
162    /// Slider name. A bipolar target emits `name+` and its negation `name-`.
163    pub name: String,
164    /// Generator: `girth`, `taper`, `bulge`, `mirror`, `blend_mask`, or
165    /// `surface_offset`.
166    pub generator: String,
167    /// The region the generator works in and the key is grouped under.
168    pub region: String,
169    /// One target or a pair.
170    pub polarity: KeyPolarity,
171    /// Panel caption; the name when empty.
172    pub caption: String,
173    /// Generator parameters.
174    pub params: SynthParams,
175}
176
177impl SynthesizedTarget {
178    /// The key entry this target presents to the panel.
179    pub fn key(&self) -> SchemaKey {
180        SchemaKey {
181            name: self.name.clone(),
182            polarity: self.polarity,
183            caption: self.caption.clone(),
184            region: self.region.clone(),
185        }
186    }
187}
188
189/// One panel section: a caption over the rows of the listed regions.
190#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
191#[serde(default)]
192pub struct PanelSection {
193    /// Section caption.
194    pub caption: String,
195    /// Regions whose keys and proportion groups the section shows, in order.
196    pub regions: Vec<String>,
197}
198
199/// A named slider vector the panel offers as a button.
200#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
201#[serde(default)]
202pub struct ShapePreset {
203    /// Preset name (the button caption).
204    pub name: String,
205    /// Slider values the preset sets; every other slider resets to 0.
206    pub sliders: Vec<ShapeSlider>,
207    /// Proportions the preset sets; every other joint resets to identity.
208    pub proportions: Vec<JointProportion>,
209}
210
211/// The contract between a character body and everything that uses it.
212///
213/// A schema names the joints a conforming skeleton must have (with their
214/// parents), the shape keys a conforming mesh carries, the regions those keys
215/// and the editor group by, the proportion rows the editor offers, the morph
216/// targets the build synthesizes from the mesh, the panel's section order,
217/// and the presets it offers. A [CharacterModel](#charactermodel) names one
218/// schema and is validated against it at build time, so any conforming body
219/// gets the same sliders, panel, and animations.
220///
221/// **Regions** are joint groups. A vertex belongs to a region by the skin
222/// weight it gives the region's joints, which needs no authoring and holds
223/// at any vertex count. Regions scope every synthesized target and group the
224/// panel.
225///
226/// **Synthesized targets** are ordinary morph targets the build generates:
227/// `girth` pushes a region's vertices away from its bone axes, `taper` ramps
228/// that push along each bone, `bulge` raises a gaussian lobe at a point along
229/// a bone, `mirror` reflects an authored target across X, `blend_mask`
230/// restricts an authored whole-body target to a region, and `surface_offset`
231/// pushes along the vertex normal. Normals are recomputed from the displaced
232/// mesh. At runtime they are indistinguishable from sculpted keys.
233///
234/// The reserved name `builtin:humanoid` is the schema of the humanoid body
235/// the `customize_character` example ships (`base_humanoid.glb`), bundled
236/// with the build so any body with the same 25 joints and 21 shape keys
237/// conforms to it.
238///
239/// ```rust
240/// # use concinnity_world::registry::build_only::{CharacterSchema, SchemaJoint, SchemaRegion};
241/// CharacterSchema {
242///     joints: vec![
243///         SchemaJoint { name: "root".into(), ..Default::default() },
244///         SchemaJoint { name: "spine".into(), parent: "root".into(), optional: false },
245///     ],
246///     regions: vec![SchemaRegion { name: "torso".into(), joints: vec!["spine".into()] }],
247///     ..Default::default()
248/// };
249/// ```
250#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
251#[serde(default)]
252pub struct CharacterSchema {
253    /// Required (and optional) joints with their parents.
254    pub joints: Vec<SchemaJoint>,
255    /// Shape keys a conforming source carries.
256    pub keys: Vec<SchemaKey>,
257    /// Named joint groups.
258    pub regions: Vec<SchemaRegion>,
259    /// Proportion rows.
260    pub proportion_groups: Vec<ProportionGroup>,
261    /// Targets the build generates from the mesh.
262    pub synthesized: Vec<SynthesizedTarget>,
263    /// Panel sections in display order. Regions no section lists, and keys
264    /// the schema does not know, show under a trailing "Other" section.
265    pub panel: Vec<PanelSection>,
266    /// Named slider vectors offered as buttons.
267    pub presets: Vec<ShapePreset>,
268}
269
270impl CharacterSchema {
271    /// The region named `name`.
272    pub fn region(&self, name: &str) -> Option<&SchemaRegion> {
273        self.regions.iter().find(|r| r.name == name)
274    }
275
276    /// Every key the panel shows: the authored keys followed by the
277    /// synthesized ones.
278    pub fn all_keys(&self) -> Vec<SchemaKey> {
279        self.keys
280            .iter()
281            .cloned()
282            .chain(self.synthesized.iter().map(SynthesizedTarget::key))
283            .collect()
284    }
285
286    /// The morph-target names a conforming source must carry: one per
287    /// unipolar key, `name+` / `name-` per bipolar key.
288    pub fn required_target_names(&self) -> Vec<String> {
289        let mut out = Vec::new();
290        for key in &self.keys {
291            match key.polarity {
292                KeyPolarity::Unipolar => out.push(key.name.clone()),
293                KeyPolarity::Bipolar => {
294                    out.push(std::format!("{}+", key.name));
295                    out.push(std::format!("{}-", key.name));
296                }
297            }
298        }
299        out
300    }
301
302    /// Problems in the schema itself: regions naming unknown joints, keys
303    /// and groups naming unknown regions, generators naming unknown
304    /// sources, duplicate names. Empty when the schema is consistent.
305    pub fn consistency_errors(&self) -> Vec<String> {
306        let mut errors = Vec::new();
307        let joint_known = |name: &str| self.joints.iter().any(|j| j.name == name);
308        let region_known = |name: &str| self.regions.iter().any(|r| r.name == name);
309        for joint in &self.joints {
310            if !joint.parent.is_empty() && !joint_known(&joint.parent) {
311                errors.push(std::format!(
312                    "joint '{}' names unknown parent '{}'",
313                    joint.name,
314                    joint.parent
315                ));
316            }
317        }
318        for region in &self.regions {
319            for joint in &region.joints {
320                if !joint_known(joint) {
321                    errors.push(std::format!(
322                        "region '{}' lists unknown joint '{}'",
323                        region.name,
324                        joint
325                    ));
326                }
327            }
328        }
329        let mut seen: Vec<String> = Vec::new();
330        for key in self.all_keys() {
331            if !key.region.is_empty() && !region_known(&key.region) {
332                errors.push(std::format!(
333                    "key '{}' names unknown region '{}'",
334                    key.name,
335                    key.region
336                ));
337            }
338            if seen.contains(&key.name) {
339                errors.push(std::format!("key '{}' is declared twice", key.name));
340            }
341            seen.push(key.name.clone());
342        }
343        for group in &self.proportion_groups {
344            if !group.region.is_empty() && !region_known(&group.region) {
345                errors.push(std::format!(
346                    "proportion group '{}' names unknown region '{}'",
347                    group.name,
348                    group.region
349                ));
350            }
351            for joint in &group.joints {
352                if !joint_known(joint) {
353                    errors.push(std::format!(
354                        "proportion group '{}' lists unknown joint '{}'",
355                        group.name,
356                        joint
357                    ));
358                }
359            }
360        }
361        for target in &self.synthesized {
362            if !region_known(&target.region) {
363                errors.push(std::format!(
364                    "synthesized '{}' names unknown region '{}'",
365                    target.name,
366                    target.region
367                ));
368            }
369            let needs_source = matches!(target.generator.as_str(), "mirror" | "blend_mask");
370            if needs_source && target.params.source.is_empty() {
371                errors.push(std::format!(
372                    "synthesized '{}': generator '{}' needs a source key",
373                    target.name,
374                    target.generator
375                ));
376            }
377        }
378        for section in &self.panel {
379            for region in &section.regions {
380                if !region_known(region) {
381                    errors.push(std::format!(
382                        "panel section '{}' lists unknown region '{}'",
383                        section.caption,
384                        region
385                    ));
386                }
387            }
388        }
389        errors
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use std::vec;
397
398    fn schema() -> CharacterSchema {
399        serde_json::from_str(
400            r#"{
401            "joints": [{"name": "root"}, {"name": "spine", "parent": "root"},
402                       {"name": "head", "parent": "spine"}, {"name": "tail", "parent": "root", "optional": true}],
403            "keys": [{"name": "weight", "polarity": "bipolar", "region": "torso"},
404                     {"name": "brow", "caption": "Brow ridge", "region": "face"}],
405            "regions": [{"name": "torso", "joints": ["spine"]}, {"name": "face", "joints": ["head"]}],
406            "proportion_groups": [{"name": "height", "region": "torso", "joints": ["spine"], "scale": 0.08}],
407            "synthesized": [{"name": "neck_girth", "generator": "girth", "region": "torso",
408                             "polarity": "bipolar", "params": {"amplitude": 0.03}}],
409            "panel": [{"caption": "Face", "regions": ["face"]}, {"caption": "Body", "regions": ["torso"]}],
410            "presets": [{"name": "heavy", "sliders": [{"name": "weight", "value": 0.8}]}]
411        }"#,
412        )
413        .unwrap()
414    }
415
416    #[test]
417    fn polarity_sets_the_range_and_the_required_targets() {
418        assert_eq!(KeyPolarity::Unipolar.range(), [0.0, 1.0]);
419        assert_eq!(KeyPolarity::Bipolar.range(), [-1.0, 1.0]);
420        let s = schema();
421        assert_eq!(s.required_target_names(), ["weight+", "weight-", "brow"]);
422    }
423
424    #[test]
425    fn regions_resolve_by_name_and_captions_fall_back_to_names() {
426        let s = schema();
427        assert_eq!(s.region("torso").unwrap().joints, ["spine"]);
428        let keys = s.all_keys();
429        assert_eq!(keys.len(), 3, "authored keys then synthesized");
430        assert_eq!(keys[0].caption(), "weight");
431        assert_eq!(keys[1].caption(), "Brow ridge");
432        assert_eq!(keys[2].name, "neck_girth");
433        assert_eq!(keys[2].polarity, KeyPolarity::Bipolar);
434        assert_eq!(s.proportion_groups[0].caption(), "height");
435        assert_eq!(s.synthesized[0].params.amplitude, 0.03);
436        assert_eq!(
437            s.synthesized[0].params.sigma, 0.15,
438            "unset params keep their defaults"
439        );
440    }
441
442    #[test]
443    fn a_consistent_schema_reports_nothing() {
444        assert!(schema().consistency_errors().is_empty());
445    }
446
447    #[test]
448    fn inconsistencies_are_all_reported() {
449        let mut s = schema();
450        s.joints[1].parent = "pelvis".into();
451        s.regions[0].joints.push("wing".into());
452        s.keys[0].region = "arms".into();
453        s.keys.push(s.keys[1].clone());
454        s.proportion_groups[0].joints.push("wing".into());
455        s.synthesized.push(SynthesizedTarget {
456            name: "brow_r".into(),
457            generator: "mirror".into(),
458            region: "face".into(),
459            ..Default::default()
460        });
461        s.panel[0].regions.push("hair".into());
462        let errors = s.consistency_errors();
463        let has = |needle: &str| errors.iter().any(|e| e.contains(needle));
464        assert!(has("unknown parent 'pelvis'"), "{errors:?}");
465        assert!(
466            has("region 'torso' lists unknown joint 'wing'"),
467            "{errors:?}"
468        );
469        assert!(
470            has("key 'weight' names unknown region 'arms'"),
471            "{errors:?}"
472        );
473        assert!(has("key 'brow' is declared twice"), "{errors:?}");
474        assert!(
475            has("proportion group 'height' lists unknown joint 'wing'"),
476            "{errors:?}"
477        );
478        assert!(has("generator 'mirror' needs a source key"), "{errors:?}");
479        assert!(
480            has("panel section 'Face' lists unknown region 'hair'"),
481            "{errors:?}"
482        );
483    }
484
485    #[test]
486    fn a_schema_round_trips_through_postcard() {
487        let s = schema();
488        let bytes = postcard::to_allocvec(&s).unwrap();
489        let back: CharacterSchema = postcard::from_bytes(&bytes).unwrap();
490        assert_eq!(back, s);
491        assert_eq!(back.presets[0].sliders[0].value, 0.8);
492        let blank = CharacterSchema::default();
493        assert!(blank.joints.is_empty() && blank.panel.is_empty());
494        assert_eq!(SynthParams::default().span, [0.0, 1.0]);
495        assert_eq!(vec![SchemaKey::default().polarity], [KeyPolarity::Unipolar]);
496    }
497}