Skip to main content

concinnity_core/components/
room.rs

1// src/components/room.rs
2//
3// The `Room` asset: the authored args a world declares, and the runtime
4// component they bake into.
5
6use crate::ecs::TextureHandle;
7use crate::ecs::asset_id::AssetId;
8use crate::ecs::de_opt_texture_handle;
9use crate::ecs::{Component, PayloadLocator};
10use alloc::vec::Vec;
11
12/// Authored fields of a `Room`; the resolved dimensions and payload locator are
13/// runtime state.
14///
15/// ```rust
16/// # use concinnity_core::components::cook::Room as RoomArgs;
17/// RoomArgs {
18///     size: Some([16.0, 20.0, 3.5]),
19///     ..Default::default()
20/// };
21/// ```
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23#[serde(default)]
24pub struct RoomArgs {
25    /// Half the room's width along X, in world units. Ignored when `size` is set.
26    pub half_width: f32,
27    /// Half the room's depth along Z, in world units. Ignored when `size` is set.
28    pub half_depth: f32,
29    /// Floor-to-ceiling height in world units. Ignored when `size` is set.
30    pub ceiling_height: f32,
31    /// Shorthand for the full dimensions `[width, depth, height]`. When set, it
32    /// overrides `half_width`, `half_depth`, and `ceiling_height`.
33    pub size: Option<[f32; 3]>,
34    /// [Texture](#texture) applied to all surfaces. Falls back to `wall_texture`
35    /// when unset. Generator names such as `"brick"` or `"concrete"` resolve to
36    /// a matching texture at build time.
37    #[serde(deserialize_with = "de_opt_texture_handle")]
38    pub texture: Option<TextureHandle>,
39    /// [Texture](#texture) for the walls. Currently all surfaces share one
40    /// texture; per-surface texturing is reserved for a future update.
41    #[serde(deserialize_with = "de_opt_texture_handle")]
42    pub wall_texture: Option<TextureHandle>,
43    /// [Texture](#texture) for the floor (see `wall_texture`).
44    #[serde(deserialize_with = "de_opt_texture_handle")]
45    pub floor_texture: Option<TextureHandle>,
46    /// [Texture](#texture) for the ceiling (see `wall_texture`).
47    #[serde(deserialize_with = "de_opt_texture_handle")]
48    pub ceiling_texture: Option<TextureHandle>,
49    /// Number of level-of-detail versions to generate, including the original.
50    /// `1` (the default) generates no alternates.
51    pub lod_levels: u32,
52    /// Camera distances at which to switch to each lower-detail version. Empty
53    /// lets the build choose defaults.
54    #[serde(default)]
55    pub lod_distances: Vec<f32>,
56}
57
58impl Default for RoomArgs {
59    fn default() -> Self {
60        Self {
61            half_width: 8.0,
62            half_depth: 10.0,
63            ceiling_height: 3.5,
64            size: None,
65            texture: None,
66            wall_texture: None,
67            floor_texture: None,
68            ceiling_texture: None,
69            lod_levels: 1,
70            lod_distances: Vec::new(),
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn a_blank_room_is_an_untextured_box_at_the_default_dimensions() {
81        let r = RoomArgs::default();
82        assert_eq!(r.half_width, 8.0);
83        assert_eq!(r.half_depth, 10.0);
84        assert_eq!(r.ceiling_height, 3.5);
85        // `size` overrides the three dimensions above when set.
86        assert_eq!(r.size, None);
87        assert!(r.texture.is_none());
88        assert!(r.wall_texture.is_none());
89        assert!(r.floor_texture.is_none());
90        assert!(r.ceiling_texture.is_none());
91        assert_eq!(r.lod_levels, 1);
92        assert!(r.lod_distances.is_empty());
93    }
94
95    #[test]
96    fn each_surface_takes_its_own_texture_and_falls_back_to_the_shared_one() {
97        crate::test_support::install_resolvers();
98        let r: RoomArgs = serde_json::from_str(
99            r#"{"texture":"tex_base","wall_texture":"tex_brick","floor_texture":"tex_stone"}"#,
100        )
101        .unwrap();
102        assert_eq!(r.texture, Some(TextureHandle(8)));
103        assert_eq!(r.wall_texture, Some(TextureHandle(9)));
104        assert_eq!(r.floor_texture, Some(TextureHandle(9)));
105        // The ceiling was not named, so it falls back to the shared texture.
106        assert_eq!(r.ceiling_texture, None);
107    }
108
109    #[test]
110    fn an_authored_room_round_trips_through_postcard() {
111        let r: RoomArgs =
112            serde_json::from_str(r#"{"size":[20,4,30],"lod_levels":2,"lod_distances":[25]}"#)
113                .unwrap();
114        assert_eq!(r.size, Some([20.0, 4.0, 30.0]));
115
116        let bytes = postcard::to_allocvec(&r).unwrap();
117        let back: RoomArgs = postcard::from_bytes(&bytes).unwrap();
118        assert_eq!(back.size, Some([20.0, 4.0, 30.0]));
119        assert_eq!(back.lod_levels, 2);
120        assert_eq!(back.lod_distances, [25.0]);
121        // The half-extent fields keep their defaults; `size` takes precedence.
122        assert_eq!(back.half_width, 8.0);
123    }
124}
125
126/// A self-contained room (floor, ceiling, four walls), with optional texturing.
127///
128/// Prefer `Room` over a [ProceduralMesh](#proceduralmesh) (generator `"room"`) +
129/// [Prop](#prop) pair for a shorter declaration. The room is placed at the world
130/// origin.
131///
132/// Dimensions can be given as `size: [width, depth, height]` (full extents) or
133/// as `half_width`, `half_depth`, and `ceiling_height` individually.
134///
135/// `texture`, `wall_texture`, `floor_texture`, and `ceiling_texture` are checked
136/// in that order; the first set value wins. Generator names such as `"brick"` or
137/// `"concrete"` resolve to a matching [Texture](#texture) at build time.
138#[derive(Debug, serde::Serialize, serde::Deserialize)]
139pub struct Room {
140    /// Assigned by the loader; not authored.
141    pub asset_id: AssetId,
142    /// Half the room's width in world units.
143    pub half_width: f32,
144    /// Half the room's depth in world units.
145    pub half_depth: f32,
146    /// Floor-to-ceiling height in world units.
147    pub ceiling_height: f32,
148    /// Texture applied to every surface unless a surface overrides it.
149    pub texture: Option<TextureHandle>,
150    /// Texture for the four walls.
151    pub wall_texture: Option<TextureHandle>,
152    /// Texture for the floor.
153    pub floor_texture: Option<TextureHandle>,
154    /// Texture for the ceiling.
155    pub ceiling_texture: Option<TextureHandle>,
156    /// The generated geometry's place in the blob, injected at load.
157    pub locator: Option<PayloadLocator>,
158}
159
160impl Room {
161    /// Returns the first set texture reference across all texture fields.
162    pub fn effective_texture(&self) -> Option<TextureHandle> {
163        [
164            self.texture,
165            self.wall_texture,
166            self.floor_texture,
167            self.ceiling_texture,
168        ]
169        .into_iter()
170        .flatten()
171        .next()
172    }
173}
174
175impl Room {
176    /// Translate the authored args into the runtime room: resolve the `size`
177    /// shorthand into half extents. Run by cook at build time (the baked blob
178    /// record carries the result).
179    pub fn bake(args: RoomArgs) -> Self {
180        let (half_width, half_depth, ceiling_height) = if let Some([w, d, h]) = args.size {
181            (w / 2.0, d / 2.0, h)
182        } else {
183            (args.half_width, args.half_depth, args.ceiling_height)
184        };
185        Self {
186            asset_id: AssetId::default(),
187            half_width,
188            half_depth,
189            ceiling_height,
190            texture: args.texture,
191            wall_texture: args.wall_texture,
192            floor_texture: args.floor_texture,
193            ceiling_texture: args.ceiling_texture,
194            locator: None,
195        }
196    }
197}
198
199impl Component for Room {
200    const NAME: &'static str = "Room";
201
202    fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
203        Ok(crate::blob::decode_exact(bytes)?)
204    }
205
206    fn inject_locator(&mut self, locator: PayloadLocator) {
207        self.locator = Some(locator);
208    }
209
210    fn inject_name(&mut self, id: AssetId) {
211        self.asset_id = id;
212    }
213}
214
215#[cfg(test)]
216mod runtime_tests {
217    use super::*;
218
219    #[test]
220    fn effective_texture_returns_texture_field_first() {
221        let room = Room {
222            asset_id: AssetId::default(),
223            half_width: 8.0,
224            half_depth: 10.0,
225            ceiling_height: 3.5,
226            texture: Some(TextureHandle(1)),
227            wall_texture: Some(TextureHandle(2)),
228            floor_texture: None,
229            ceiling_texture: None,
230            locator: None,
231        };
232        assert_eq!(room.effective_texture(), Some(TextureHandle(1)));
233    }
234
235    #[test]
236    fn effective_texture_falls_back_to_wall_texture() {
237        let room = Room {
238            asset_id: AssetId::default(),
239            half_width: 8.0,
240            half_depth: 10.0,
241            ceiling_height: 3.5,
242            texture: None,
243            wall_texture: Some(TextureHandle(7)),
244            floor_texture: None,
245            ceiling_texture: None,
246            locator: None,
247        };
248        assert_eq!(room.effective_texture(), Some(TextureHandle(7)));
249    }
250
251    #[test]
252    fn effective_texture_returns_none_when_all_unset() {
253        let room = Room::bake(RoomArgs::default());
254        assert_eq!(room.effective_texture(), None);
255    }
256
257    #[test]
258    fn from_args_resolves_size_shorthand() {
259        let args = RoomArgs {
260            size: Some([16.0, 20.0, 3.5]),
261            ..RoomArgs::default()
262        };
263        let room = Room::bake(args);
264        assert_eq!(room.half_width, 8.0);
265        assert_eq!(room.half_depth, 10.0);
266        assert_eq!(room.ceiling_height, 3.5);
267    }
268
269    #[test]
270    fn from_args_uses_explicit_half_extents_when_no_size() {
271        let args = RoomArgs {
272            half_width: 5.0,
273            half_depth: 7.0,
274            ceiling_height: 4.0,
275            size: None,
276            ..RoomArgs::default()
277        };
278        let room = Room::bake(args);
279        assert_eq!(room.half_width, 5.0);
280        assert_eq!(room.half_depth, 7.0);
281        assert_eq!(room.ceiling_height, 4.0);
282    }
283}