Skip to main content

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}