use concinnity_core::components::JointProportion;
use concinnity_core::components::ShapeSlider;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KeyPolarity {
#[default]
Unipolar,
Bipolar,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SchemaJoint {
pub name: String,
pub parent: String,
pub optional: bool,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SchemaKey {
pub name: String,
pub polarity: KeyPolarity,
pub caption: String,
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 {
pub fn caption(&self) -> &str {
if self.caption.is_empty() {
&self.name
} else {
&self.caption
}
}
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SchemaRegion {
pub name: String,
pub joints: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ProportionGroup {
pub name: String,
pub caption: String,
pub region: String,
pub joints: Vec<String>,
pub scale: f32,
pub length: f32,
}
impl ProportionGroup {
pub fn caption(&self) -> &str {
if self.caption.is_empty() {
&self.name
} else {
&self.caption
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SynthParams {
pub amplitude: f32,
pub along: f32,
pub sigma: f32,
pub direction: [f32; 3],
pub reverse: bool,
pub source: String,
pub span: [f32; 2],
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,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SynthesizedTarget {
pub name: String,
pub generator: String,
pub region: String,
pub polarity: KeyPolarity,
pub caption: String,
pub params: SynthParams,
}
impl SynthesizedTarget {
pub(crate) fn key(&self) -> SchemaKey {
SchemaKey {
name: self.name.clone(),
polarity: self.polarity,
caption: self.caption.clone(),
region: self.region.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PanelSection {
pub caption: String,
pub regions: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ShapePreset {
pub name: String,
pub sliders: Vec<ShapeSlider>,
pub proportions: Vec<JointProportion>,
}
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct CharacterSchema {
pub joints: Vec<SchemaJoint>,
pub keys: Vec<SchemaKey>,
pub regions: Vec<SchemaRegion>,
pub proportion_groups: Vec<ProportionGroup>,
pub synthesized: Vec<SynthesizedTarget>,
pub panel: Vec<PanelSection>,
pub presets: Vec<ShapePreset>,
}
impl CharacterSchema {
pub(crate) fn region(&self, name: &str) -> Option<&SchemaRegion> {
self.regions.iter().find(|r| r.name == name)
}
pub fn all_keys(&self) -> Vec<SchemaKey> {
self.keys
.iter()
.cloned()
.chain(self.synthesized.iter().map(SynthesizedTarget::key))
.collect()
}
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
}
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 ®ion.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 §ion.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]);
}
}