concinnity_world/schema/prefab.rs
1//! Prefab schema: a reusable template of props / lights / nested prefabs.
2
3use concinnity_core::components::PropCollider;
4
5/// A reusable template of [Prop](#prop)s, [PointLight](#pointlight)s, and nested
6/// prefabs.
7///
8/// Placed as a unit at a world-space transform. Add a `prefab` field to a
9/// [Prop](#prop) to instantiate it; each instance expands into concrete assets
10/// positioned relative to the instance's transform.
11///
12/// **Expanded asset names:** `<instance_name>_<entry_name>` (nested:
13/// `<instance>_<outer>_<inner>`).
14///
15/// **Instantiation:** add a `prefab` field to a [Prop](#prop). The prop's other
16/// fields (`position`, `rotation_deg`, `scale`) act as the instance's world
17/// transform.
18///
19/// **Library presets** (JSON files in `assets/prefabs/`):
20#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
21#[serde(default)]
22pub struct Prefab {
23 /// Ordered list of entries. Each is a prop, a point light, or a nested
24 /// prefab (selected by `kind`), placed relative to the instance transform.
25 pub props: Vec<PrefabEntry>,
26}
27
28/// Which kind of asset a [PrefabEntry] expands into.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30#[serde(rename_all = "snake_case")]
31#[derive(Default)]
32pub enum PrefabKind {
33 /// A [Prop](#prop) built from the entry's `model` / `mesh` / `material` /
34 /// `texture` and transform fields.
35 #[default]
36 Prop,
37 /// A [PointLight](#pointlight) built from the entry's `light_*` fields at the
38 /// entry's `position`.
39 PointLight,
40 /// A nested prefab named by the entry's `prefab` field, expanded relative to
41 /// this entry's transform.
42 Prefab,
43}
44
45/// One entry in a [Prefab]'s `props` list. The fields consulted depend on
46/// `kind`: a `prop` uses the render / collision / transform fields, a
47/// `point_light` uses the `light_*` fields, and a `prefab` uses `prefab`. Names
48/// in `model` / `mesh` / `material` / `texture` / `parent` / `prefab` are
49/// unresolved references to other assets, resolved when the entry expands.
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51#[serde(default)]
52pub struct PrefabEntry {
53 /// Entry name; the expanded asset is named `<instance>_<name>`.
54 pub name: String,
55 /// Which asset this entry expands into.
56 pub kind: PrefabKind,
57 /// Local position relative to the instance transform.
58 pub position: [f32; 3],
59 /// Local rotation, Euler degrees [pitch, yaw, roll], YXZ order.
60 pub rotation_deg: [f32; 3],
61 /// Local scale.
62 pub scale: [f32; 3],
63 /// `prop`: [Model](#model) name.
64 #[serde(skip_serializing_if = "String::is_empty")]
65 pub model: String,
66 /// `prop`: [Mesh](#mesh) / [ProceduralMesh](#proceduralmesh) name.
67 #[serde(skip_serializing_if = "String::is_empty")]
68 pub mesh: String,
69 /// `prop`: [Material](#material) name.
70 #[serde(skip_serializing_if = "String::is_empty")]
71 pub material: String,
72 /// `prop`: [Texture](#texture) name (older path; `material` takes priority).
73 #[serde(skip_serializing_if = "String::is_empty")]
74 pub texture: String,
75 /// `prop`: parent asset name for the expanded prop.
76 #[serde(skip_serializing_if = "String::is_empty")]
77 pub parent: String,
78 /// `prop`: optional collision shape for the expanded prop.
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub collider: Option<PropCollider>,
81 /// `prop`: whether the expanded prop is interactable.
82 pub interactable: bool,
83 /// `prop`: whether the expanded prop is a pickup.
84 pub pickup: bool,
85 /// `point_light`: linear-space RGB colour.
86 pub light_color: [f32; 3],
87 /// `point_light`: intensity multiplier.
88 pub light_intensity: f32,
89 /// `point_light`: maximum reach in world units.
90 pub light_range: f32,
91 /// `prefab`: name of another [Prefab] to expand at this entry's transform.
92 #[serde(skip_serializing_if = "String::is_empty")]
93 pub prefab: String,
94}
95
96impl Default for PrefabEntry {
97 fn default() -> Self {
98 Self {
99 name: String::new(),
100 kind: PrefabKind::Prop,
101 position: [0.0, 0.0, 0.0],
102 rotation_deg: [0.0, 0.0, 0.0],
103 scale: [1.0, 1.0, 1.0],
104 model: String::new(),
105 mesh: String::new(),
106 material: String::new(),
107 texture: String::new(),
108 parent: String::new(),
109 collider: None,
110 interactable: false,
111 pickup: false,
112 light_color: [1.0, 1.0, 1.0],
113 light_intensity: 8.0,
114 light_range: 6.0,
115 prefab: String::new(),
116 }
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn each_kind_deserialises_with_its_fields() {
126 let prop: PrefabEntry = serde_json::from_str(
127 r#"{"name":"table","kind":"prop","model":"model_table","position":[1.0,0.0,2.0]}"#,
128 )
129 .unwrap();
130 assert_eq!(prop.kind, PrefabKind::Prop);
131 assert_eq!(prop.name, "table");
132 assert_eq!(prop.model, "model_table");
133 assert_eq!(prop.position, [1.0, 0.0, 2.0]);
134 // Omitted scale falls back to unit.
135 assert_eq!(prop.scale, [1.0, 1.0, 1.0]);
136
137 let light: PrefabEntry =
138 serde_json::from_str(r#"{"name":"lamp","kind":"point_light","light_intensity":5.0}"#)
139 .unwrap();
140 assert_eq!(light.kind, PrefabKind::PointLight);
141 assert_eq!(light.light_intensity, 5.0);
142 // Omitted light fields fall back to the point-light defaults.
143 assert_eq!(light.light_range, 6.0);
144 assert_eq!(light.light_color, [1.0, 1.0, 1.0]);
145
146 let nested: PrefabEntry =
147 serde_json::from_str(r#"{"name":"inner","kind":"prefab","prefab":"other"}"#).unwrap();
148 assert_eq!(nested.kind, PrefabKind::Prefab);
149 assert_eq!(nested.prefab, "other");
150 }
151
152 #[test]
153 fn kind_defaults_to_prop_when_omitted() {
154 let e: PrefabEntry = serde_json::from_str(r#"{"name":"x","mesh":"box"}"#).unwrap();
155 assert_eq!(e.kind, PrefabKind::Prop);
156 assert_eq!(e.mesh, "box");
157 }
158}