concinnity_world/schema/character_model.rs
1//! Character-model schema: a body conforming to a CharacterSchema.
2
3use concinnity_core::components::CharacterCapsule;
4use concinnity_core::ecs::MaterialHandle;
5use concinnity_core::ecs::de_opt_material_handle;
6
7/// A character body that conforms to a [CharacterSchema](#characterschema).
8///
9/// The build validates the source against the schema (joint names and
10/// parentage, complete `+` / `-` pairs for bipolar keys), imports it,
11/// generates the schema's synthesized targets, and emits one
12/// [SkinnedMesh](#skinnedmesh) under this asset's name. A
13/// [CharacterShape](#charactershape) or [Animation](#animation) targets the
14/// model by this name exactly as it would a `SkinnedMesh`. Lower levels of
15/// detail come from `lod_levels`, as on a `SkinnedMesh`.
16///
17/// The source's extra shape keys (ones the schema does not list) are
18/// imported and appear under the editor panel's "Other" section.
19///
20/// ```rust
21/// # use concinnity_world::registry::build_only::CharacterModel;
22/// CharacterModel {
23/// schema: "builtin:humanoid".into(),
24/// source: "./base_humanoid.glb".into(),
25/// lod_levels: 3,
26/// ..Default::default()
27/// };
28/// ```
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30#[serde(default)]
31pub struct CharacterModel {
32 /// The [CharacterSchema](#characterschema) the source conforms to, by
33 /// asset name or the reserved `builtin:humanoid`.
34 pub schema: String,
35 /// Path to the `.glb` / `.gltf` body.
36 pub source: String,
37 /// Which skinned mesh of `source` to import, in file order.
38 pub skin_index: u32,
39 /// [Material](#material) of the emitted mesh.
40 #[serde(deserialize_with = "de_opt_material_handle")]
41 pub material: Option<MaterialHandle>,
42 /// World-space position.
43 pub position: [f32; 3],
44 /// World rotation, Euler degrees [pitch, yaw, roll], YXZ order.
45 pub rotation_deg: [f32; 3],
46 /// World scale.
47 pub scale: [f32; 3],
48 /// Number of level-of-detail versions to generate, including the
49 /// original. `1` (the default) generates none; values are clamped to
50 /// `[1, 8]`.
51 pub lod_levels: u32,
52 /// Camera distances at which to switch to each lower-detail version.
53 /// When non-empty, must have exactly `lod_levels - 1` entries; empty
54 /// lets the build choose defaults.
55 pub lod_distances: Vec<f32>,
56 /// Runtime copies the mesh may spawn beyond the authored one.
57 pub max_instances: u32,
58 /// Character capsule of the emitted mesh.
59 pub capsule: Option<CharacterCapsule>,
60}
61
62impl Default for CharacterModel {
63 fn default() -> Self {
64 Self {
65 schema: String::from("builtin:humanoid"),
66 source: String::new(),
67 skin_index: 0,
68 material: None,
69 position: [0.0, 0.0, 0.0],
70 rotation_deg: [0.0, 0.0, 0.0],
71 scale: [1.0, 1.0, 1.0],
72 lod_levels: 1,
73 lod_distances: Vec::new(),
74 max_instances: 0,
75 capsule: None,
76 }
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83
84 #[test]
85 fn a_blank_model_names_the_builtin_schema_and_no_source() {
86 let m = CharacterModel::default();
87 assert_eq!(m.schema, "builtin:humanoid");
88 assert!(m.source.is_empty());
89 assert_eq!(m.lod_levels, 1);
90 assert_eq!(m.scale, [1.0, 1.0, 1.0]);
91 assert!(m.capsule.is_none());
92 }
93
94 // The resolver seam is process-global and install-once, so the stand-in goes
95 // in behind a `Once`: a name resolves to its own byte length, which is what
96 // lets a named reference deserialize to a predictable handle.
97 fn install_len_resolvers() {
98 static ONCE: std::sync::Once = std::sync::Once::new();
99 ONCE.call_once(|| {
100 concinnity_core::ecs::resolver::set_material_handle_resolver(|n| Some(n.len() as u32));
101 });
102 }
103
104 #[test]
105 fn a_model_round_trips_through_postcard() {
106 install_len_resolvers();
107 let m: CharacterModel = serde_json::from_str(
108 r#"{"schema":"humanoid","material":"skin","source":"hero.glb","skin_index":1,
109 "position":[0,0.1,0],"lod_levels":3,"lod_distances":[6,12],"max_instances":2,
110 "capsule":{"half_height":0.9,"radius":0.3}}"#,
111 )
112 .unwrap();
113 assert_eq!(m.material, Some(MaterialHandle(4)));
114 let bytes = postcard::to_allocvec(&m).unwrap();
115 let back: CharacterModel = postcard::from_bytes(&bytes).unwrap();
116 assert_eq!(back.schema, "humanoid");
117 assert_eq!(back.source, "hero.glb");
118 assert_eq!(back.skin_index, 1);
119 assert_eq!(back.lod_levels, 3);
120 assert_eq!(back.lod_distances, [6.0, 12.0]);
121 assert_eq!(back.max_instances, 2);
122 assert_eq!(back.capsule.unwrap().half_height, 0.9);
123 }
124}