concinnity-cook 0.19.1

Authored world model, validation, and the asset cook pipeline that bakes a Concinnity world into a blob
Documentation
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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
//! Character-schema: the declarative contract a body conforms to, read by the
//! cook (validation, synthesized targets) and the editor (panel layout).

use concinnity_core::components::JointProportion;
use concinnity_core::components::ShapeSlider;

/// Whether a shape key is one target or a `+` / `-` pair.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KeyPolarity {
    /// One target named exactly `name`; the slider runs `[0, 1]`.
    #[default]
    Unipolar,
    /// Two targets `name+` / `name-`; the slider runs `[-1, 1]`.
    Bipolar,
}

/// One joint the schema expects in a conforming skeleton.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SchemaJoint {
    /// Joint name.
    pub name: String,
    /// Parent joint name; empty for a root.
    pub parent: String,
    /// A source may omit this joint.
    pub optional: bool,
}

/// One shape key the schema knows, authored on the source or synthesized.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SchemaKey {
    /// Slider name; the target is `name` or the `name+` / `name-` pair.
    pub name: String,
    /// One target or a pair.
    pub polarity: KeyPolarity,
    /// Panel caption; the name when empty.
    pub caption: String,
    /// The region the key belongs to (panel grouping).
    pub region: String,
}

impl Default for SchemaKey {
    fn default() -> Self {
        Self {
            name: String::new(),
            polarity: KeyPolarity::Unipolar,
            caption: String::new(),
            region: String::new(),
        }
    }
}

impl SchemaKey {
    /// The caption, falling back to the name.
    pub fn caption(&self) -> &str {
        if self.caption.is_empty() {
            &self.name
        } else {
            &self.caption
        }
    }
}

/// A named group of joints. A vertex belongs to a region by the skin weight
/// it gives the region's joints.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SchemaRegion {
    /// Region name.
    pub name: String,
    /// Member joints.
    pub joints: Vec<String>,
}

/// A proportion slider: one value in `[-1, 1]` written as a scale and / or
/// length change on every listed joint.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ProportionGroup {
    /// Group name (the panel row).
    pub name: String,
    /// Panel caption; the name when empty.
    pub caption: String,
    /// The region the row belongs to (panel grouping).
    pub region: String,
    /// Joints the row writes; only those the skeleton has are written.
    pub joints: Vec<String>,
    /// Scale change at full deflection (`0` leaves scale alone).
    pub scale: f32,
    /// Length change at full deflection, in model units (`0` leaves it alone).
    pub length: f32,
}

impl ProportionGroup {
    /// The caption, falling back to the name.
    pub fn caption(&self) -> &str {
        if self.caption.is_empty() {
            &self.name
        } else {
            &self.caption
        }
    }
}

/// Generator parameters for a synthesized target. Each generator reads the
/// fields it needs and ignores the rest.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SynthParams {
    /// Displacement at full weight, in model units.
    pub amplitude: f32,
    /// `bulge`: centre along the bone, as a fraction of its length.
    pub along: f32,
    /// `bulge`: width of the lobe along the bone, as a fraction of its length.
    pub sigma: f32,
    /// `bulge`: model-space direction of the lobe; zero means radially away
    /// from the bone.
    pub direction: [f32; 3],
    /// `taper`: ramp from the distal end toward the proximal end instead.
    pub reverse: bool,
    /// `mirror` / `blend_mask`: the authored target to derive from.
    pub source: String,
    /// `surface_offset`: the window along the region's first bone, as
    /// fractions of its length, outside which the offset fades to nothing.
    pub span: [f32; 2],
    /// `surface_offset`: width of the fade at each end of `span`.
    pub falloff: f32,
}

impl Default for SynthParams {
    fn default() -> Self {
        Self {
            amplitude: 0.02,
            along: 0.5,
            sigma: 0.15,
            direction: [0.0, 0.0, 0.0],
            reverse: false,
            source: String::new(),
            span: [0.0, 1.0],
            falloff: 0.1,
        }
    }
}

/// A morph target the build generates from the mesh instead of reading from
/// the source.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SynthesizedTarget {
    /// Slider name. A bipolar target emits `name+` and its negation `name-`.
    pub name: String,
    /// Generator: `girth`, `taper`, `bulge`, `mirror`, `blend_mask`, or
    /// `surface_offset`.
    pub generator: String,
    /// The region the generator works in and the key is grouped under.
    pub region: String,
    /// One target or a pair.
    pub polarity: KeyPolarity,
    /// Panel caption; the name when empty.
    pub caption: String,
    /// Generator parameters.
    pub params: SynthParams,
}

impl SynthesizedTarget {
    /// The key entry this target presents to the panel.
    pub(crate) fn key(&self) -> SchemaKey {
        SchemaKey {
            name: self.name.clone(),
            polarity: self.polarity,
            caption: self.caption.clone(),
            region: self.region.clone(),
        }
    }
}

/// One panel section: a caption over the rows of the listed regions.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PanelSection {
    /// Section caption.
    pub caption: String,
    /// Regions whose keys and proportion groups the section shows, in order.
    pub regions: Vec<String>,
}

/// A named slider vector the panel offers as a button.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ShapePreset {
    /// Preset name (the button caption).
    pub name: String,
    /// Slider values the preset sets; every other slider resets to 0.
    pub sliders: Vec<ShapeSlider>,
    /// Proportions the preset sets; every other joint resets to identity.
    pub proportions: Vec<JointProportion>,
}

/// The contract between a character body and everything that uses it.
///
/// A schema names the joints a conforming skeleton must have (with their
/// parents), the shape keys a conforming mesh carries, the regions those keys
/// and the editor group by, the proportion rows the editor offers, the morph
/// targets the build synthesizes from the mesh, the panel's section order,
/// and the presets it offers. A [CharacterModel](#charactermodel) names one
/// schema and is validated against it at build time, so any conforming body
/// gets the same sliders, panel, and animations.
///
/// **Regions** are joint groups. A vertex belongs to a region by the skin
/// weight it gives the region's joints, which needs no authoring and holds
/// at any vertex count. Regions scope every synthesized target and group the
/// panel.
///
/// **Synthesized targets** are ordinary morph targets the build generates:
/// `girth` pushes a region's vertices away from its bone axes, `taper` ramps
/// that push along each bone, `bulge` raises a gaussian lobe at a point along
/// a bone, `mirror` reflects an authored target across X, `blend_mask`
/// restricts an authored whole-body target to a region, and `surface_offset`
/// pushes along the vertex normal. Normals are recomputed from the displaced
/// mesh. At runtime they are indistinguishable from sculpted keys.
///
/// The reserved name `builtin:humanoid` is the schema of the humanoid body
/// the `customize_character` example ships (`base_humanoid.glb`), bundled
/// with the build so any body with the same 25 joints and 21 shape keys
/// conforms to it.
///
/// ```rust
/// # use concinnity_cook::authoring::registry::build_only::{CharacterSchema, SchemaJoint, SchemaRegion};
/// CharacterSchema {
///     joints: vec![
///         SchemaJoint { name: "root".into(), ..Default::default() },
///         SchemaJoint { name: "spine".into(), parent: "root".into(), optional: false },
///     ],
///     regions: vec![SchemaRegion { name: "torso".into(), joints: vec!["spine".into()] }],
///     ..Default::default()
/// };
/// ```
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct CharacterSchema {
    /// Required (and optional) joints with their parents.
    pub joints: Vec<SchemaJoint>,
    /// Shape keys a conforming source carries.
    pub keys: Vec<SchemaKey>,
    /// Named joint groups.
    pub regions: Vec<SchemaRegion>,
    /// Proportion rows.
    pub proportion_groups: Vec<ProportionGroup>,
    /// Targets the build generates from the mesh.
    pub synthesized: Vec<SynthesizedTarget>,
    /// Panel sections in display order. Regions no section lists, and keys
    /// the schema does not know, show under a trailing "Other" section.
    pub panel: Vec<PanelSection>,
    /// Named slider vectors offered as buttons.
    pub presets: Vec<ShapePreset>,
}

impl CharacterSchema {
    /// The region named `name`.
    pub(crate) fn region(&self, name: &str) -> Option<&SchemaRegion> {
        self.regions.iter().find(|r| r.name == name)
    }

    /// Every key the panel shows: the authored keys followed by the
    /// synthesized ones.
    pub fn all_keys(&self) -> Vec<SchemaKey> {
        self.keys
            .iter()
            .cloned()
            .chain(self.synthesized.iter().map(SynthesizedTarget::key))
            .collect()
    }

    /// The morph-target names a conforming source must carry: one per
    /// unipolar key, `name+` / `name-` per bipolar key.
    pub fn required_target_names(&self) -> Vec<String> {
        let mut out = Vec::new();
        for key in &self.keys {
            match key.polarity {
                KeyPolarity::Unipolar => out.push(key.name.clone()),
                KeyPolarity::Bipolar => {
                    out.push(std::format!("{}+", key.name));
                    out.push(std::format!("{}-", key.name));
                }
            }
        }
        out
    }

    /// Problems in the schema itself: regions naming unknown joints, keys
    /// and groups naming unknown regions, generators naming unknown
    /// sources, duplicate names. Empty when the schema is consistent.
    pub(crate) fn consistency_errors(&self) -> Vec<String> {
        let mut errors = Vec::new();
        let joint_known = |name: &str| self.joints.iter().any(|j| j.name == name);
        let region_known = |name: &str| self.regions.iter().any(|r| r.name == name);
        for joint in &self.joints {
            if !joint.parent.is_empty() && !joint_known(&joint.parent) {
                errors.push(std::format!(
                    "joint '{}' names unknown parent '{}'",
                    joint.name,
                    joint.parent
                ));
            }
        }
        for region in &self.regions {
            for joint in &region.joints {
                if !joint_known(joint) {
                    errors.push(std::format!(
                        "region '{}' lists unknown joint '{}'",
                        region.name,
                        joint
                    ));
                }
            }
        }
        let mut seen: Vec<String> = Vec::new();
        for key in self.all_keys() {
            if !key.region.is_empty() && !region_known(&key.region) {
                errors.push(std::format!(
                    "key '{}' names unknown region '{}'",
                    key.name,
                    key.region
                ));
            }
            if seen.contains(&key.name) {
                errors.push(std::format!("key '{}' is declared twice", key.name));
            }
            seen.push(key.name.clone());
        }
        for group in &self.proportion_groups {
            if !group.region.is_empty() && !region_known(&group.region) {
                errors.push(std::format!(
                    "proportion group '{}' names unknown region '{}'",
                    group.name,
                    group.region
                ));
            }
            for joint in &group.joints {
                if !joint_known(joint) {
                    errors.push(std::format!(
                        "proportion group '{}' lists unknown joint '{}'",
                        group.name,
                        joint
                    ));
                }
            }
        }
        for target in &self.synthesized {
            if !region_known(&target.region) {
                errors.push(std::format!(
                    "synthesized '{}' names unknown region '{}'",
                    target.name,
                    target.region
                ));
            }
            let needs_source = matches!(target.generator.as_str(), "mirror" | "blend_mask");
            if needs_source && target.params.source.is_empty() {
                errors.push(std::format!(
                    "synthesized '{}': generator '{}' needs a source key",
                    target.name,
                    target.generator
                ));
            }
        }
        for section in &self.panel {
            for region in &section.regions {
                if !region_known(region) {
                    errors.push(std::format!(
                        "panel section '{}' lists unknown region '{}'",
                        section.caption,
                        region
                    ));
                }
            }
        }
        errors
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::vec;

    fn schema() -> CharacterSchema {
        serde_json::from_str(
            r#"{
            "joints": [{"name": "root"}, {"name": "spine", "parent": "root"},
                       {"name": "head", "parent": "spine"}, {"name": "tail", "parent": "root", "optional": true}],
            "keys": [{"name": "weight", "polarity": "bipolar", "region": "torso"},
                     {"name": "brow", "caption": "Brow ridge", "region": "face"}],
            "regions": [{"name": "torso", "joints": ["spine"]}, {"name": "face", "joints": ["head"]}],
            "proportion_groups": [{"name": "height", "region": "torso", "joints": ["spine"], "scale": 0.08}],
            "synthesized": [{"name": "neck_girth", "generator": "girth", "region": "torso",
                             "polarity": "bipolar", "params": {"amplitude": 0.03}}],
            "panel": [{"caption": "Face", "regions": ["face"]}, {"caption": "Body", "regions": ["torso"]}],
            "presets": [{"name": "heavy", "sliders": [{"name": "weight", "value": 0.8}]}]
        }"#,
        )
        .unwrap()
    }

    #[test]
    fn polarity_sets_the_required_targets() {
        let s = schema();
        assert_eq!(s.required_target_names(), ["weight+", "weight-", "brow"]);
    }

    #[test]
    fn regions_resolve_by_name_and_captions_fall_back_to_names() {
        let s = schema();
        assert_eq!(s.region("torso").unwrap().joints, ["spine"]);
        let keys = s.all_keys();
        assert_eq!(keys.len(), 3, "authored keys then synthesized");
        assert_eq!(keys[0].caption(), "weight");
        assert_eq!(keys[1].caption(), "Brow ridge");
        assert_eq!(keys[2].name, "neck_girth");
        assert_eq!(keys[2].polarity, KeyPolarity::Bipolar);
        assert_eq!(s.proportion_groups[0].caption(), "height");
        assert_eq!(s.synthesized[0].params.amplitude, 0.03);
        assert_eq!(
            s.synthesized[0].params.sigma, 0.15,
            "unset params keep their defaults"
        );
    }

    #[test]
    fn a_consistent_schema_reports_nothing() {
        assert!(schema().consistency_errors().is_empty());
    }

    #[test]
    fn inconsistencies_are_all_reported() {
        let mut s = schema();
        s.joints[1].parent = "pelvis".into();
        s.regions[0].joints.push("wing".into());
        s.keys[0].region = "arms".into();
        s.keys.push(s.keys[1].clone());
        s.proportion_groups[0].joints.push("wing".into());
        s.synthesized.push(SynthesizedTarget {
            name: "brow_r".into(),
            generator: "mirror".into(),
            region: "face".into(),
            ..Default::default()
        });
        s.panel[0].regions.push("hair".into());
        let errors = s.consistency_errors();
        let has = |needle: &str| errors.iter().any(|e| e.contains(needle));
        assert!(has("unknown parent 'pelvis'"), "{errors:?}");
        assert!(
            has("region 'torso' lists unknown joint 'wing'"),
            "{errors:?}"
        );
        assert!(
            has("key 'weight' names unknown region 'arms'"),
            "{errors:?}"
        );
        assert!(has("key 'brow' is declared twice"), "{errors:?}");
        assert!(
            has("proportion group 'height' lists unknown joint 'wing'"),
            "{errors:?}"
        );
        assert!(has("generator 'mirror' needs a source key"), "{errors:?}");
        assert!(
            has("panel section 'Face' lists unknown region 'hair'"),
            "{errors:?}"
        );
    }

    #[test]
    fn a_schema_round_trips_through_postcard() {
        let s = schema();
        let bytes = postcard::to_allocvec(&s).unwrap();
        let back: CharacterSchema = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(back, s);
        assert_eq!(back.presets[0].sliders[0].value, 0.8);
        let blank = CharacterSchema::default();
        assert!(blank.joints.is_empty() && blank.panel.is_empty());
        assert_eq!(SynthParams::default().span, [0.0, 1.0]);
        assert_eq!(vec![SchemaKey::default().polarity], [KeyPolarity::Unipolar]);
    }
}