1use concinnity_core::components::JointProportion;
5use concinnity_core::components::ShapeSlider;
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum KeyPolarity {
11 #[default]
13 Unipolar,
14 Bipolar,
16}
17
18impl KeyPolarity {
19 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#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
30#[serde(default)]
31pub struct SchemaJoint {
32 pub name: String,
34 pub parent: String,
36 pub optional: bool,
38}
39
40#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
42#[serde(default)]
43pub struct SchemaKey {
44 pub name: String,
46 pub polarity: KeyPolarity,
48 pub caption: String,
50 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 pub fn caption(&self) -> &str {
68 if self.caption.is_empty() {
69 &self.name
70 } else {
71 &self.caption
72 }
73 }
74}
75
76#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
79#[serde(default)]
80pub struct SchemaRegion {
81 pub name: String,
83 pub joints: Vec<String>,
85}
86
87#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
90#[serde(default)]
91pub struct ProportionGroup {
92 pub name: String,
94 pub caption: String,
96 pub region: String,
98 pub joints: Vec<String>,
100 pub scale: f32,
102 pub length: f32,
104}
105
106impl ProportionGroup {
107 pub fn caption(&self) -> &str {
109 if self.caption.is_empty() {
110 &self.name
111 } else {
112 &self.caption
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
120#[serde(default)]
121pub struct SynthParams {
122 pub amplitude: f32,
124 pub along: f32,
126 pub sigma: f32,
128 pub direction: [f32; 3],
131 pub reverse: bool,
133 pub source: String,
135 pub span: [f32; 2],
138 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#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
160#[serde(default)]
161pub struct SynthesizedTarget {
162 pub name: String,
164 pub generator: String,
167 pub region: String,
169 pub polarity: KeyPolarity,
171 pub caption: String,
173 pub params: SynthParams,
175}
176
177impl SynthesizedTarget {
178 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#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
191#[serde(default)]
192pub struct PanelSection {
193 pub caption: String,
195 pub regions: Vec<String>,
197}
198
199#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
201#[serde(default)]
202pub struct ShapePreset {
203 pub name: String,
205 pub sliders: Vec<ShapeSlider>,
207 pub proportions: Vec<JointProportion>,
209}
210
211#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
251#[serde(default)]
252pub struct CharacterSchema {
253 pub joints: Vec<SchemaJoint>,
255 pub keys: Vec<SchemaKey>,
257 pub regions: Vec<SchemaRegion>,
259 pub proportion_groups: Vec<ProportionGroup>,
261 pub synthesized: Vec<SynthesizedTarget>,
263 pub panel: Vec<PanelSection>,
266 pub presets: Vec<ShapePreset>,
268}
269
270impl CharacterSchema {
271 pub fn region(&self, name: &str) -> Option<&SchemaRegion> {
273 self.regions.iter().find(|r| r.name == name)
274 }
275
276 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 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 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 ®ion.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 §ion.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}