roxlap_scene/lib.rs
1//! roxlap scene-graph layer — many independent chunked voxel
2//! grids in a single 3D scene.
3//!
4//! See `PORTING-SCENE.md` at the workspace root for the substage
5//! roadmap. This crate is the layer **above** voxlap's per-chunk
6//! renderer (`roxlap-core`): a [`Scene`] holds a sparse set of
7//! [`Grid`]s, each with its own f64 world position + arbitrary 3D
8//! rotation. Future stages will add per-grid raycast composition
9//! (S3), cross-chunk gline within a grid (S4), per-grid rotation
10//! (S5), far-LOD billboards / planet proxies (S6), and streaming +
11//! procedural generation (S7).
12//!
13//! S2.0 lands the **type skeleton + grid registration only**.
14//! S2.1 adds the [`addr`] module — world ↔ grid-local ↔ chunk +
15//! voxel-in-chunk decomposition, the canonical f64↔i32 boundary
16//! helper called out by risk R5 in `PORTING-SCENE.md`. S2.2 adds
17//! the [`chunks`] module (sparse storage with on-demand chunk
18//! allocation) and the [`Grid`] edit API ([`Grid::set_voxel`],
19//! [`Grid::set_rect`], [`Grid::set_sphere`]) which decompose
20//! multi-chunk operations and delegate to
21//! [`roxlap_formats::edit`]. S2.3 adds the [`snapshot`] module —
22//! a serde-friendly view of the scene that round-trips through
23//! `Serialize` + `Deserialize` (chunks encode via
24//! [`roxlap_formats::vxl::serialize`] / [`parse`]). Rendering
25//! composition is still owed (S3+).
26//!
27//! [`parse`]: roxlap_formats::vxl::parse
28
29pub mod addr;
30pub mod chunks;
31pub mod edit;
32pub mod render;
33pub mod snapshot;
34
35use std::collections::HashMap;
36
37use glam::{DQuat, DVec3, IVec3, UVec3};
38use roxlap_formats::vxl::Vxl;
39use serde::{Deserialize, Serialize};
40
41pub use addr::{grid_local_to_world, voxel_global, voxel_split, world_to_grid_local, GridLocalPos};
42
43/// XY size of one chunk in voxels. The plan locks 128 — keeps
44/// chunks compact (~2 MB worst-case dense-slab footprint inside
45/// each `Vxl`) and divides cleanly into voxlap's 2048 reference
46/// world size.
47pub const CHUNK_SIZE_XY: u32 = 128;
48
49/// Z size of one chunk in voxels. Locked at 256 to preserve
50/// voxlap's existing slab byte format unchanged inside each chunk
51/// — the per-chunk renderer doesn't need to know it's living
52/// inside a scene-graph.
53pub const CHUNK_SIZE_Z: u32 = 256;
54
55/// Stable identifier for a grid registered in a [`Scene`]. Issued
56/// by [`Scene::add_grid`]; persists across edits but a removed
57/// grid's id is not reissued.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
59pub struct GridId(u32);
60
61impl GridId {
62 /// The integer wire form. Useful for serde / debug output.
63 #[must_use]
64 pub const fn raw(self) -> u32 {
65 self.0
66 }
67}
68
69/// f64 world placement of one grid: position + orientation.
70///
71/// `origin` is the grid's local-space origin in world coords —
72/// chunk `(0, 0, 0)`'s `(0, 0, 0)` voxel maps to
73/// `origin + rotation * vec3(0, 0, 0)` (i.e. just `origin`).
74/// Voxel size is fixed at 1 world unit / voxel for v1.
75#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
76pub struct GridTransform {
77 pub origin: DVec3,
78 pub rotation: DQuat,
79}
80
81impl GridTransform {
82 /// Identity transform at world origin. Useful as a default for
83 /// the first grid added to an otherwise empty scene.
84 #[must_use]
85 pub fn identity() -> Self {
86 Self {
87 origin: DVec3::ZERO,
88 rotation: DQuat::IDENTITY,
89 }
90 }
91
92 /// Axis-aligned grid placed at `origin` with no rotation.
93 #[must_use]
94 pub fn at(origin: DVec3) -> Self {
95 Self {
96 origin,
97 rotation: DQuat::IDENTITY,
98 }
99 }
100}
101
102impl Default for GridTransform {
103 fn default() -> Self {
104 Self::identity()
105 }
106}
107
108/// Address of one voxel inside a scene: which grid it belongs to,
109/// which chunk within that grid, and the voxel's offset inside
110/// that chunk.
111///
112/// `chunk` is signed (`IVec3`) because chunks are centred on the
113/// grid's local origin and may extend in either direction. `voxel`
114/// is unsigned and must satisfy
115/// `(voxel.x, voxel.y) < CHUNK_SIZE_XY` and `voxel.z < CHUNK_SIZE_Z`.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
117pub struct GridAddr {
118 pub grid: GridId,
119 pub chunk: IVec3,
120 pub voxel: UVec3,
121}
122
123/// One independent voxel grid in a scene. Holds its world placement
124/// and a sparse map of populated chunks. Empty chunk slots are
125/// implicit air and skipped during rendering / raycasts.
126///
127/// Each chunk is internally a [`Vxl`] with `vsid = CHUNK_SIZE_XY`
128/// — the existing per-chunk renderer (opticast + grouscan +
129/// sprites + lighting in `roxlap-core`) runs on each chunk
130/// unchanged. Vertical worlds are built by stacking chunks along
131/// grid-local `+z`.
132#[derive(Debug)]
133pub struct Grid {
134 /// World placement (origin + rotation).
135 pub transform: GridTransform,
136 /// Sparse chunk storage keyed by `(chx, chy, chz)` chunk
137 /// coordinates. A missing entry means the chunk is fully air.
138 pub chunks: HashMap<IVec3, Vxl>,
139 /// Whether sky pixels rendered for this grid should be
140 /// composited into the final framebuffer. `true` is the
141 /// historical "grid owns its own sky" behaviour: ray misses
142 /// inside this grid's frustum paint sky_color into the temp
143 /// buffer. Set `false` for grids that are a foreground object
144 /// (e.g. a ship) — the sky is owned by a single "world" grid
145 /// (the ground) and other grids should not contribute sky
146 /// pixels, otherwise their grid-local-frame sky lookup
147 /// rotates with the grid and visibly fights the world's sky
148 /// during compose. See [`crate::render::render_scene_composed`]
149 /// for the masking implementation.
150 pub render_sky: bool,
151 /// Override [`roxlap_core::opticast::OpticastSettings::mip_levels`]
152 /// for this grid. `None` ⇒ use the caller's value. `Some(n)`
153 /// ⇒ cap at `n` (clamped to `[1, settings.mip_levels]`). Use
154 /// to disable multi-mip on a per-grid basis — small grids
155 /// (rotating ships, billboards) don't benefit from deep mips
156 /// and CAN trigger the
157 /// `[[project_axis_aligned_mip_beams]]`-style cf-cancellation
158 /// artifact when near-axis-aligned rays hit the rotated grid.
159 /// `Some(1)` = mip-0 only, byte-stable to single-mip.
160 pub mip_levels_override: Option<u32>,
161}
162
163impl Grid {
164 /// New empty grid at the given transform — no chunks populated,
165 /// `render_sky = true`.
166 #[must_use]
167 pub fn new(transform: GridTransform) -> Self {
168 Self {
169 transform,
170 chunks: HashMap::new(),
171 render_sky: true,
172 mip_levels_override: None,
173 }
174 }
175}
176
177/// Top-level scene container. Holds a flat collection of grids
178/// keyed by [`GridId`].
179///
180/// S2.0 only exposes registration / removal / lookup. Address math
181/// helpers (S2.x), edit API (S2.x), and rendering composition (S3)
182/// land in later sub-substages.
183#[derive(Debug, Default)]
184pub struct Scene {
185 grids: HashMap<GridId, Grid>,
186 next_grid_id: u32,
187}
188
189impl Scene {
190 /// New empty scene — no grids.
191 #[must_use]
192 pub fn new() -> Self {
193 Self::default()
194 }
195
196 /// Number of grids currently registered.
197 #[must_use]
198 pub fn grid_count(&self) -> usize {
199 self.grids.len()
200 }
201
202 /// Register a new grid. Returns its fresh, unique [`GridId`].
203 pub fn add_grid(&mut self, transform: GridTransform) -> GridId {
204 let id = GridId(self.next_grid_id);
205 self.next_grid_id += 1;
206 self.grids.insert(id, Grid::new(transform));
207 id
208 }
209
210 /// Remove a grid by id. Returns the removed [`Grid`] (so the
211 /// caller can reclaim its chunks) or `None` if the id wasn't
212 /// registered. Removed ids are not reissued.
213 pub fn remove_grid(&mut self, id: GridId) -> Option<Grid> {
214 self.grids.remove(&id)
215 }
216
217 /// Borrow a registered grid.
218 #[must_use]
219 pub fn grid(&self, id: GridId) -> Option<&Grid> {
220 self.grids.get(&id)
221 }
222
223 /// Mutably borrow a registered grid.
224 pub fn grid_mut(&mut self, id: GridId) -> Option<&mut Grid> {
225 self.grids.get_mut(&id)
226 }
227
228 /// Iterator over all `(id, grid)` pairs in registration order
229 /// is **not** guaranteed — the underlying map is a `HashMap`.
230 /// Callers that need a stable order must sort by [`GridId`].
231 pub fn grids(&self) -> impl Iterator<Item = (GridId, &Grid)> {
232 self.grids.iter().map(|(id, g)| (*id, g))
233 }
234
235 /// Mutable iterator over all `(id, grid)` pairs. Yield order
236 /// is not guaranteed (HashMap-backed).
237 pub fn grids_mut(&mut self) -> impl Iterator<Item = (GridId, &mut Grid)> {
238 self.grids.iter_mut().map(|(id, g)| (*id, g))
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn empty_scene_has_no_grids() {
248 let scene = Scene::new();
249 assert_eq!(scene.grid_count(), 0);
250 assert!(scene.grids().next().is_none());
251 }
252
253 #[test]
254 fn add_grid_returns_fresh_ids() {
255 let mut scene = Scene::new();
256 let a = scene.add_grid(GridTransform::identity());
257 let b = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
258 assert_ne!(a, b);
259 assert_eq!(a.raw(), 0);
260 assert_eq!(b.raw(), 1);
261 assert_eq!(scene.grid_count(), 2);
262 }
263
264 #[test]
265 fn grid_lookup_round_trips() {
266 let mut scene = Scene::new();
267 let id = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
268 let g = scene.grid(id).expect("grid registered");
269 assert_eq!(g.transform.origin, DVec3::new(10.0, 20.0, 30.0));
270 assert_eq!(g.transform.rotation, DQuat::IDENTITY);
271 assert!(g.chunks.is_empty());
272 }
273
274 #[test]
275 fn remove_grid_drops_it_from_scene() {
276 let mut scene = Scene::new();
277 let id = scene.add_grid(GridTransform::identity());
278 let removed = scene.remove_grid(id);
279 assert!(removed.is_some());
280 assert_eq!(scene.grid_count(), 0);
281 assert!(scene.grid(id).is_none());
282 // Re-adding does NOT reuse the dropped id.
283 let id2 = scene.add_grid(GridTransform::identity());
284 assert_ne!(id, id2);
285 assert_eq!(id2.raw(), 1);
286 }
287
288 #[test]
289 fn remove_unknown_grid_is_none() {
290 let mut scene = Scene::new();
291 let bogus = GridId(999);
292 assert!(scene.remove_grid(bogus).is_none());
293 }
294
295 #[test]
296 fn grid_mut_can_modify_transform() {
297 let mut scene = Scene::new();
298 let id = scene.add_grid(GridTransform::identity());
299 scene.grid_mut(id).unwrap().transform.origin = DVec3::new(1.0, 2.0, 3.0);
300 assert_eq!(
301 scene.grid(id).unwrap().transform.origin,
302 DVec3::new(1.0, 2.0, 3.0)
303 );
304 }
305
306 #[test]
307 fn chunk_size_constants_match_plan() {
308 // Plan locks these values; bumping either breaks the slab
309 // byte format (Z) or the worst-case chunk footprint budget
310 // (XY). Pin them so a future refactor that drifts them
311 // shows up in CI.
312 assert_eq!(CHUNK_SIZE_XY, 128);
313 assert_eq!(CHUNK_SIZE_Z, 256);
314 }
315}