Skip to main content

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}