concinnity_core/components/prop.rs
1// Scene-object prop schema.
2
3use crate::ecs::MaterialHandle;
4use crate::ecs::MeshHandle;
5use crate::ecs::TextureHandle;
6use crate::ecs::asset_id::AssetId;
7use crate::ecs::asset_id::de_opt_asset_ref;
8use crate::ecs::de_opt_material_handle;
9use crate::ecs::de_opt_mesh_handle;
10use crate::ecs::de_opt_texture_handle;
11use alloc::string::{String, ToString};
12
13/// Collision volume attached to a [Prop](#prop).
14///
15/// The shape dimensions are in the prop's local space and are scaled by the
16/// prop's `scale`. `ball` and `capsule` use the X scale component (they assume
17/// uniform scaling).
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19#[serde(default)]
20pub struct PropCollider {
21 /// Collision shape: "aabb" (alias "cuboid"), "ball", or "capsule".
22 pub shape: String,
23 /// Box half-extents in local space [x, y, z]. Used by cuboid shapes.
24 pub half_extents: [f32; 3],
25 /// Radius in local space. Used by ball and capsule shapes.
26 pub radius: f32,
27 /// Half the cylinder height in local space. Used by capsule shapes.
28 pub half_height: f32,
29 /// Collision layer name. Built-in layers are `world`, `prop`, `character`,
30 /// and `trigger`; extra names come from [PhysicsConfig](#physicsconfig)
31 /// `layers`. Empty derives the layer from the body kind: `world` for a
32 /// static prop, `prop` when a [PropBody](#propbody) makes it dynamic.
33 pub layer: String,
34}
35
36impl Default for PropCollider {
37 fn default() -> Self {
38 Self {
39 shape: "cuboid".to_string(),
40 half_extents: [0.5, 0.5, 0.5],
41 radius: 0.5,
42 half_height: 0.5,
43 layer: String::new(),
44 }
45 }
46}
47
48/// A scene object: places geometry at a world-space transform.
49///
50/// Reference either a [Model](#model) (multi-mesh) or a single
51/// [Mesh](#mesh)/[ProceduralMesh](#proceduralmesh). `model` takes precedence
52/// when both are set.
53///
54/// Rotation notes:
55/// - `rotation_deg[0]` = pitch (tilt forward/back)
56/// - `rotation_deg[1]` = yaw (spin on vertical axis), most common
57/// - `rotation_deg[2]` = roll (tilt side-to-side)
58///
59/// ```rust
60/// # use concinnity_core::components::Prop;
61/// Prop {
62/// position: [4.0, 0.4, -8.0],
63/// ..Default::default()
64/// };
65/// ```
66#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67#[serde(default)]
68pub struct Prop {
69 /// Asset identity; injected via `inject_name`. Not part of `args`.
70 #[serde(skip)]
71 pub asset_id: AssetId,
72 /// A [Model](#model) asset. When set, the prop renders all sub-meshes of
73 /// that model (each with its own material) sharing this prop's transform.
74 /// Takes precedence over `mesh` and `material`.
75 #[serde(deserialize_with = "de_opt_asset_ref")]
76 pub model: Option<AssetId>,
77 /// A [Mesh](#mesh) or [ProceduralMesh](#proceduralmesh) asset this prop
78 /// renders. Used when `model` is unset.
79 #[serde(deserialize_with = "de_opt_mesh_handle")]
80 pub mesh: Option<MeshHandle>,
81 /// A [Material](#material) to use for this prop. When set it takes
82 /// precedence over `texture` and provides the albedo texture plus the
83 /// lighting parameters (roughness, metallic, tint, emissive). Used when
84 /// `model` is unset.
85 #[serde(deserialize_with = "de_opt_material_handle")]
86 pub material: Option<MaterialHandle>,
87 /// A [Texture](#texture) to use for this prop. Older field: ignored when
88 /// `material` is set. Unset uses the first declared texture (or a white
89 /// fallback).
90 #[serde(deserialize_with = "de_opt_texture_handle")]
91 pub texture: Option<TextureHandle>,
92 /// World-space position [x, y, z].
93 pub position: [f32; 3],
94 /// Euler rotation in degrees [pitch, yaw, roll], applied in YXZ order
95 /// (yaw first so that rotating around the vertical axis is intuitive).
96 pub rotation_deg: [f32; 3],
97 /// Non-uniform scale [x, y, z]. Defaults to [1, 1, 1].
98 pub scale: [f32; 3],
99 /// Optional collision volume. When present, the prop blocks the player; when
100 /// absent the prop is non-solid.
101 pub collider: Option<PropCollider>,
102 /// When true, the player can interact with this prop: pressing the interact
103 /// key (E) while close and facing it triggers its rotation behaviour.
104 pub interactable: bool,
105 /// When true, the player can pick up and carry this prop with the interact
106 /// key (E). A companion [PropBody](#propbody) must also be declared so the
107 /// prop falls correctly after being dropped.
108 pub pickup: bool,
109 /// Another [Prop](#prop) whose world transform this prop inherits. When set,
110 /// `position`, `rotation_deg`, and `scale` are relative to the parent's
111 /// world transform. The parent must be declared in the same world; circular
112 /// chains are treated as an error.
113 #[serde(deserialize_with = "de_opt_asset_ref")]
114 pub parent: Option<AssetId>,
115 /// [Scene](#scene) this prop belongs to. Resolved automatically from the
116 /// naming convention (a prop named `<scene>_*` belongs to scene `<scene>`);
117 /// you don't set this directly. `None` means the prop is visible in every
118 /// scene. Used by scene switches for per-scene visibility.
119 #[serde(default, deserialize_with = "de_opt_asset_ref")]
120 pub scene: Option<AssetId>,
121 /// Name of a [Prefab](#prefab) to instantiate at this prop's transform. When
122 /// set, it expands into concrete child props and lights, replacing this
123 /// prop. Cannot be combined with `model` or `mesh`.
124 pub prefab: String,
125 /// Optional view-distance cutoff in world units. When > 0 the prop is hidden
126 /// once the camera is further than this from it. 0 (default) keeps the prop
127 /// visible at any distance.
128 pub cull_distance: f32,
129 /// Set at runtime while the prop is being carried. Not serialised.
130 /// While true, PhysicsSystem drives the prop as a kinematic body that
131 /// follows the camera instead of simulating it dynamically.
132 #[serde(skip)]
133 pub is_held: bool,
134}
135
136impl Default for Prop {
137 fn default() -> Self {
138 Self {
139 asset_id: AssetId::default(),
140 model: None,
141 mesh: None,
142 material: None,
143 texture: None,
144 position: [0.0, 0.0, 0.0],
145 rotation_deg: [0.0, 0.0, 0.0],
146 scale: [1.0, 1.0, 1.0],
147 collider: None,
148 interactable: false,
149 pickup: false,
150 parent: None,
151 scene: None,
152 prefab: String::new(),
153 cull_distance: 0.0,
154 is_held: false,
155 }
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn a_blank_collider_is_a_unit_cuboid() {
165 let c = PropCollider::default();
166 assert_eq!(c.shape, "cuboid");
167 assert_eq!(c.half_extents, [0.5, 0.5, 0.5]);
168 assert_eq!(c.radius, 0.5);
169 assert_eq!(c.half_height, 0.5);
170 }
171
172 #[test]
173 fn a_blank_prop_is_an_unscaled_non_interactive_placement() {
174 let p = Prop::default();
175 assert_eq!(p.position, [0.0, 0.0, 0.0]);
176 assert_eq!(p.rotation_deg, [0.0, 0.0, 0.0]);
177 assert_eq!(p.scale, [1.0, 1.0, 1.0]);
178 // No collider means the prop is decoration: physics ignores it.
179 assert!(p.collider.is_none());
180 assert!(!p.interactable);
181 assert!(!p.pickup);
182 assert!(!p.is_held);
183 assert_eq!(p.cull_distance, 0.0);
184 assert!(p.prefab.is_empty());
185 assert!(p.model.is_none());
186 assert!(p.mesh.is_none());
187 assert!(p.material.is_none());
188 assert!(p.texture.is_none());
189 assert!(p.parent.is_none());
190 assert!(p.scene.is_none());
191 }
192
193 #[test]
194 fn every_reference_resolves_through_its_own_seam() {
195 crate::test_support::install_resolvers();
196 let p: Prop = serde_json::from_str(
197 r#"{"model":"crate_model","mesh":"crate_mesh","material":"wood","texture":"tex_wood",
198 "parent":"shelf","scene":"vault"}"#,
199 )
200 .unwrap();
201 // A Model is still an interned name; the resource kinds are handles.
202 assert_eq!(p.model, Some(AssetId(11)));
203 assert_eq!(p.mesh, Some(MeshHandle(10)));
204 assert_eq!(p.material, Some(MaterialHandle(4)));
205 assert_eq!(p.texture, Some(TextureHandle(8)));
206 assert_eq!(p.parent, Some(AssetId(5)));
207 assert_eq!(p.scene, Some(AssetId(5)));
208 }
209
210 #[test]
211 fn a_pickup_with_a_ball_collider_round_trips_through_postcard() {
212 let p: Prop = serde_json::from_str(
213 r#"{"position":[1,2,3],"rotation_deg":[0,90,0],"scale":[2,2,2],
214 "collider":{"shape":"ball","radius":0.25},
215 "interactable":true,"pickup":true,"prefab":"lantern","cull_distance":60}"#,
216 )
217 .unwrap();
218 let collider = p.collider.as_ref().expect("collider");
219 assert_eq!(collider.shape, "ball");
220 assert_eq!(collider.radius, 0.25);
221 // Unmentioned collider dimensions keep the schema defaults.
222 assert_eq!(collider.half_height, 0.5);
223
224 let bytes = postcard::to_allocvec(&p).unwrap();
225 let back: Prop = postcard::from_bytes(&bytes).unwrap();
226 assert_eq!(back.position, [1.0, 2.0, 3.0]);
227 assert_eq!(back.rotation_deg, [0.0, 90.0, 0.0]);
228 assert_eq!(back.scale, [2.0, 2.0, 2.0]);
229 assert_eq!(back.collider.expect("collider").shape, "ball");
230 assert!(back.interactable);
231 assert!(back.pickup);
232 assert_eq!(back.prefab, "lantern");
233 assert_eq!(back.cull_distance, 60.0);
234 // Held state is runtime-only, so it never rides the wire.
235 assert!(!back.is_held);
236 assert_eq!(back.asset_id, AssetId::default());
237 }
238}