Skip to main content

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}