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