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 billboard;
31pub mod cavegen;
32pub mod chunks;
33pub mod edit;
34pub mod lod;
35pub mod occluder;
36pub mod render;
37pub mod snapshot;
38pub mod streaming;
39
40use std::collections::{HashMap, HashSet};
41use std::sync::Arc;
42
43use glam::{DQuat, DVec3, IVec3, UVec3};
44use roxlap_formats::vxl::Vxl;
45use serde::{Deserialize, Serialize};
46
47pub use addr::{grid_local_to_world, voxel_global, voxel_split, world_to_grid_local, GridLocalPos};
48pub use billboard::{canonical_viewpoints, BillboardCache, BillboardSnapshot};
49pub use edit::SpanOp;
50pub use lod::{select_lod, Lod, LodThresholds};
51pub use streaming::{ChunkGenerator, StreamRadius};
52
53/// XY size of one chunk in voxels. The plan locks 128 — keeps
54/// chunks compact (~2 MB worst-case dense-slab footprint inside
55/// each `Vxl`) and divides cleanly into voxlap's 2048 reference
56/// world size.
57pub const CHUNK_SIZE_XY: u32 = 128;
58
59/// Z size of one chunk in voxels. Locked at 256 to preserve
60/// voxlap's existing slab byte format unchanged inside each chunk
61/// — the per-chunk renderer doesn't need to know it's living
62/// inside a scene-graph.
63pub const CHUNK_SIZE_Z: u32 = 256;
64
65/// Stable identifier for a grid registered in a [`Scene`]. Issued
66/// by [`Scene::add_grid`]; persists across edits but a removed
67/// grid's id is not reissued.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
69pub struct GridId(u32);
70
71impl GridId {
72 /// The integer wire form. Useful for serde / debug output.
73 #[must_use]
74 pub const fn raw(self) -> u32 {
75 self.0
76 }
77}
78
79/// A solid-voxel hit from [`Scene::raycast`].
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct RayHit {
82 /// The grid the ray hit.
83 pub grid: GridId,
84 /// Grid-local integer voxel coordinate of the hit cell.
85 pub voxel: IVec3,
86 /// World-space hit point (`origin + t · normalize(dir)`).
87 pub world: DVec3,
88 /// World distance from the ray origin to the hit.
89 pub t: f64,
90 /// Packed colour of the hit voxel, or `None` if it's an untextured
91 /// (bedrock / interior) cell. See [`Grid::voxel_color`].
92 pub color: Option<u32>,
93}
94
95/// Voxel DDA (Amanatides-Woo) in a grid's local space. `lo` / `ld` are
96/// the ray origin + unit direction already transformed into grid-local
97/// coords. Returns the first [`Grid::voxel_solid`] cell and its world-
98/// equal distance `t`, or `None` past `max_t`. The step budget is
99/// `~3·max_t` so a near-axis ray through empty space still terminates.
100fn voxel_dda(grid: &Grid, lo: DVec3, ld: DVec3, max_t: f64) -> Option<(IVec3, f64)> {
101 #[allow(clippy::cast_possible_truncation)]
102 let mut p = IVec3::new(
103 lo.x.floor() as i32,
104 lo.y.floor() as i32,
105 lo.z.floor() as i32,
106 );
107 if grid.voxel_solid(p) {
108 return Some((p, 0.0)); // origin already inside a solid voxel
109 }
110 let sign = |d: f64| -> i32 {
111 if d > 0.0 {
112 1
113 } else if d < 0.0 {
114 -1
115 } else {
116 0
117 }
118 };
119 let step = IVec3::new(sign(ld.x), sign(ld.y), sign(ld.z));
120 // Distance to advance one whole voxel along each axis (∞ if parallel).
121 let t_delta = DVec3::new(
122 if ld.x == 0.0 {
123 f64::INFINITY
124 } else {
125 (1.0 / ld.x).abs()
126 },
127 if ld.y == 0.0 {
128 f64::INFINITY
129 } else {
130 (1.0 / ld.y).abs()
131 },
132 if ld.z == 0.0 {
133 f64::INFINITY
134 } else {
135 (1.0 / ld.z).abs()
136 },
137 );
138 // Distance to the first voxel boundary on each axis.
139 let boundary = |o: f64, d: f64| -> f64 {
140 if d > 0.0 {
141 (o.floor() + 1.0 - o) / d
142 } else if d < 0.0 {
143 (o - o.floor()) / -d
144 } else {
145 f64::INFINITY
146 }
147 };
148 let mut t_max = DVec3::new(
149 boundary(lo.x, ld.x),
150 boundary(lo.y, ld.y),
151 boundary(lo.z, ld.z),
152 );
153 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
154 let max_steps = (max_t * 3.0) as u64 + 8;
155 for _ in 0..max_steps {
156 // Advance across the nearest voxel boundary.
157 let t = if t_max.x <= t_max.y && t_max.x <= t_max.z {
158 p.x += step.x;
159 let t = t_max.x;
160 t_max.x += t_delta.x;
161 t
162 } else if t_max.y <= t_max.z {
163 p.y += step.y;
164 let t = t_max.y;
165 t_max.y += t_delta.y;
166 t
167 } else {
168 p.z += step.z;
169 let t = t_max.z;
170 t_max.z += t_delta.z;
171 t
172 };
173 if t > max_t {
174 return None;
175 }
176 if grid.voxel_solid(p) {
177 return Some((p, t));
178 }
179 }
180 None
181}
182
183/// f64 world placement of one grid: position + orientation.
184///
185/// `origin` is the grid's local-space origin in world coords —
186/// chunk `(0, 0, 0)`'s `(0, 0, 0)` voxel maps to
187/// `origin + rotation * vec3(0, 0, 0)` (i.e. just `origin`).
188/// Voxel size is fixed at 1 world unit / voxel for v1.
189#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
190pub struct GridTransform {
191 pub origin: DVec3,
192 pub rotation: DQuat,
193}
194
195impl GridTransform {
196 /// Identity transform at world origin. Useful as a default for
197 /// the first grid added to an otherwise empty scene.
198 #[must_use]
199 pub fn identity() -> Self {
200 Self {
201 origin: DVec3::ZERO,
202 rotation: DQuat::IDENTITY,
203 }
204 }
205
206 /// Axis-aligned grid placed at `origin` with no rotation.
207 #[must_use]
208 pub fn at(origin: DVec3) -> Self {
209 Self {
210 origin,
211 rotation: DQuat::IDENTITY,
212 }
213 }
214}
215
216impl Default for GridTransform {
217 fn default() -> Self {
218 Self::identity()
219 }
220}
221
222/// Address of one voxel inside a scene: which grid it belongs to,
223/// which chunk within that grid, and the voxel's offset inside
224/// that chunk.
225///
226/// `chunk` is signed (`IVec3`) because chunks are centred on the
227/// grid's local origin and may extend in either direction. `voxel`
228/// is unsigned and must satisfy
229/// `(voxel.x, voxel.y) < CHUNK_SIZE_XY` and `voxel.z < CHUNK_SIZE_Z`.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
231pub struct GridAddr {
232 pub grid: GridId,
233 pub chunk: IVec3,
234 pub voxel: UVec3,
235}
236
237/// One independent voxel grid in a scene. Holds its world placement
238/// and a sparse map of populated chunks. Empty chunk slots are
239/// implicit air and skipped during rendering / raycasts.
240///
241/// Each chunk is internally a [`Vxl`] with `vsid = CHUNK_SIZE_XY`
242/// — the existing per-chunk renderer (opticast + grouscan +
243/// sprites + lighting in `roxlap-core`) runs on each chunk
244/// unchanged. Vertical worlds are built by stacking chunks along
245/// grid-local `+z`.
246#[derive(Debug)]
247pub struct Grid {
248 /// World placement (origin + rotation).
249 pub transform: GridTransform,
250 /// Sparse chunk storage keyed by `(chx, chy, chz)` chunk
251 /// coordinates. A missing entry means the chunk is fully air.
252 pub chunks: HashMap<IVec3, Vxl>,
253 /// Whether sky pixels rendered for this grid should be
254 /// composited into the final framebuffer. `true` is the
255 /// historical "grid owns its own sky" behaviour: ray misses
256 /// inside this grid's frustum paint sky_color into the temp
257 /// buffer. Set `false` for grids that are a foreground object
258 /// (e.g. a ship) — the sky is owned by a single "world" grid
259 /// (the ground) and other grids should not contribute sky
260 /// pixels, otherwise their grid-local-frame sky lookup
261 /// rotates with the grid and visibly fights the world's sky
262 /// during compose. See [`crate::render::render_scene_composed`]
263 /// for the masking implementation.
264 pub render_sky: bool,
265 /// Override [`roxlap_core::opticast::OpticastSettings::mip_levels`]
266 /// for this grid. `None` ⇒ use the caller's value. `Some(n)`
267 /// ⇒ cap at `n` (clamped to `[1, settings.mip_levels]`). Use
268 /// to disable multi-mip on a per-grid basis — small grids
269 /// (rotating ships, billboards) don't benefit from deep mips
270 /// and CAN trigger the
271 /// `[[project_axis_aligned_mip_beams]]`-style cf-cancellation
272 /// artifact when near-axis-aligned rays hit the rotated grid.
273 /// `Some(1)` = mip-0 only, byte-stable to single-mip.
274 pub mip_levels_override: Option<u32>,
275 /// World-distance thresholds for per-grid LOD tier selection
276 /// (S6.0). Defaults to [`LodThresholds::always_near`], so a
277 /// freshly-constructed grid always renders at full voxel (the
278 /// S5-and-earlier byte-stable behaviour). S6.1 plugs `Mid` into
279 /// the existing multi-mip path; S6.3 plugs `Far` into the
280 /// billboard impostor cache. See [`crate::lod`].
281 pub lod_thresholds: LodThresholds,
282 /// Lazy [`BillboardCache`] for the `Lod::Far` tier (S6.2).
283 /// `None` until the first time S6.3's render dispatch needs
284 /// it; populated then via [`BillboardCache::build`] and
285 /// cleared by edits ([`Self::set_voxel`] / [`Self::set_rect`]
286 /// / [`Self::set_sphere`]) to force a rebuild on next Far use.
287 /// Callers may also force-invalidate via direct assignment.
288 pub billboards: Option<BillboardCache>,
289 /// Optional procedural generator (S7.0). When set,
290 /// [`Self::ensure_chunk_generated`] uses it to materialise
291 /// chunks that are still absent from [`Self::chunks`].
292 ///
293 /// Streaming layers (S7.1+) walk the active radius around the
294 /// camera and call `ensure_chunk_generated` for missing chunks;
295 /// later stages dispatch this onto a background rayon pool. The
296 /// trait bound is `Send + Sync` (needed for S7.3 async
297 /// dispatch) + `Debug` (needed so [`Grid`] keeps deriving
298 /// `Debug`).
299 ///
300 /// `None` is the default — a grid without a generator behaves
301 /// exactly like the pre-S7 grids: absent chunks stay absent.
302 ///
303 /// `Arc` (not `Box`) so S7.3's async dispatch can clone the
304 /// generator into background rayon tasks without moving it out
305 /// of the grid. Trait bound `Send + Sync` (required at S7.0)
306 /// already makes `Arc<dyn ChunkGenerator>` `Send + Sync`.
307 pub generator: Option<Arc<dyn ChunkGenerator>>,
308 /// Streaming activity / eviction radii used by
309 /// [`Scene::pump_streaming_sync`] (S7.1). Defaults to
310 /// [`StreamRadius::DISABLED`] so existing grids see no change
311 /// in behaviour until the caller opts in.
312 pub stream_radius: StreamRadius,
313 /// Per-chunk edit version counter (S7.2). Each user edit
314 /// through [`Self::set_voxel`] / [`Self::set_rect`] /
315 /// [`Self::set_sphere`] bumps the counter for every chunk it
316 /// actually wrote to. [`Self::ensure_chunk_generated`] does
317 /// NOT bump — a freshly generated chunk has no edits and
318 /// reads as version 0.
319 ///
320 /// Wired up here so the S7.3 async dispatch can detect "an
321 /// edit happened while a chunk was being generated in the
322 /// background" and discard the now-stale result: each
323 /// background task captures the dispatch-time version and
324 /// only installs its result iff the current version still
325 /// matches.
326 ///
327 /// Missing entries read as `0` via [`Self::chunk_version`].
328 /// Evictions in [`Scene::pump_streaming_sync`] drop the
329 /// corresponding entry so the map stays bounded.
330 pub chunk_versions: HashMap<IVec3, u64>,
331 /// In-flight background generation tasks (S7.3).
332 ///
333 /// Populated by [`Scene::pump_streaming`] when it dispatches a
334 /// generator call onto the streaming rayon pool, drained when
335 /// the corresponding [`ChunkResult`] is received and processed
336 /// (either installed or discarded). The set is consulted to
337 /// avoid re-dispatching the same chunk while a previous task
338 /// is still running.
339 ///
340 /// Stays empty when only the synchronous
341 /// [`Scene::pump_streaming_sync`] is used — that path generates
342 /// inline on the calling thread.
343 ///
344 /// [`ChunkResult`]: streaming::ChunkResult
345 pub pending_gen: HashSet<IVec3>,
346 /// Cross-frame DDA brick-occupancy cache (Substage DDA.7 perf).
347 /// Keyed by `(chunk, mip)` + the chunk's edit version, so a static
348 /// chunk's brick map is built once and reused every frame. Skipped
349 /// entirely on the voxlap render path. Not serialised.
350 pub dda_brick_cache: roxlap_core::BrickCache,
351}
352
353impl Grid {
354 /// New empty grid at the given transform — no chunks populated,
355 /// `render_sky = true`, LOD thresholds default to
356 /// [`LodThresholds::always_near`], no billboard cache.
357 #[must_use]
358 pub fn new(transform: GridTransform) -> Self {
359 Self {
360 transform,
361 chunks: HashMap::new(),
362 render_sky: true,
363 mip_levels_override: None,
364 lod_thresholds: LodThresholds::always_near(),
365 billboards: None,
366 generator: None,
367 stream_radius: StreamRadius::DISABLED,
368 chunk_versions: HashMap::new(),
369 pending_gen: HashSet::new(),
370 dda_brick_cache: roxlap_core::BrickCache::new(),
371 }
372 }
373
374 /// Ensure the DDA brick cache holds current mip-`requested_mip`
375 /// occupancy maps for every populated chunk, rebuilding only chunks
376 /// whose edit version changed (Substage DDA.7). Clamps the mip to a
377 /// level every chunk has built (so coarse rendering never holes) and
378 /// returns that effective mip. Evicts cache entries for chunks no
379 /// longer present. Call once per frame before the DDA render.
380 pub fn ensure_dda_bricks(&mut self, requested_mip: u32) -> u32 {
381 // Split-borrow disjoint fields so the cache mutates while the
382 // chunks + versions are read.
383 let Self {
384 chunks,
385 chunk_versions,
386 dda_brick_cache,
387 ..
388 } = self;
389 // Effective uniform mip: min built mip across chunks, capped.
390 let mut mip = requested_mip;
391 if requested_mip > 0 {
392 for vxl in chunks.values() {
393 mip = mip.min(vxl.mip_count().saturating_sub(1));
394 }
395 }
396 for (idx, vxl) in chunks.iter() {
397 let version = chunk_versions.get(idx).copied().unwrap_or(0);
398 let view = roxlap_core::GridView::from_single_vxl(vxl);
399 dda_brick_cache.ensure([idx.x, idx.y, idx.z], mip, version, &view);
400 }
401 dda_brick_cache.retain_chunks(|c| chunks.contains_key(&IVec3::new(c[0], c[1], c[2])));
402 mip
403 }
404
405 /// Current per-chunk edit version (S7.2). Returns `0` for any
406 /// chunk that hasn't been edited yet (including absent chunks
407 /// and chunks materialised only via
408 /// [`Self::ensure_chunk_generated`]).
409 ///
410 /// Used by S7.3's async generation dispatch to detect "edit
411 /// happened while we were generating" — the dispatcher
412 /// snapshots this value, the background task carries it, and
413 /// the result is discarded on install if the live counter has
414 /// since moved.
415 #[must_use]
416 pub fn chunk_version(&self, chunk_idx: IVec3) -> u64 {
417 self.chunk_versions.get(&chunk_idx).copied().unwrap_or(0)
418 }
419
420 /// Bump the edit version of `chunk_idx` (S7.2). Saturating add
421 /// at `u64::MAX` — a chunk would need 10^11 edits per second
422 /// for ~5 years to wrap, so saturation is a defensive cap, not
423 /// a realistic concern.
424 ///
425 /// Called by the edit API ([`Self::set_voxel`] /
426 /// [`Self::set_rect`] / [`Self::set_sphere`]) after a chunk
427 /// has actually been written to. Pure no-op edit paths
428 /// (carving from an air chunk that doesn't exist yet) skip
429 /// the bump.
430 ///
431 /// Exposed as `pub` (vs the historical `pub(crate)`) so hosts
432 /// that mutate `grid.chunks` directly — e.g.
433 /// `roxlap-scene-demo`'s `StreamingBakeTracker` writing
434 /// lightmode-1 alphas via `apply_lighting_with_cache` — can
435 /// signal "this chunk's slab changed" to downstream consumers
436 /// like the GPU dirty-chunk poller.
437 pub fn bump_chunk_version(&mut self, chunk_idx: IVec3) {
438 let entry = self.chunk_versions.entry(chunk_idx).or_insert(0);
439 *entry = entry.saturating_add(1);
440 }
441
442 /// Attach (or detach) the procedural generator used by
443 /// [`Self::ensure_chunk_generated`] (S7.0).
444 ///
445 /// Pass `Some(Arc::new(generator))` to enable on-demand chunk
446 /// generation; pass `None` to revert to the "absent stays
447 /// absent" behaviour. Replacing an existing generator drops the
448 /// previous `Arc` clone without touching already-materialised
449 /// chunks. Any background tasks dispatched by a prior
450 /// [`Scene::pump_streaming`] hold their own clones of the old
451 /// generator and finish naturally.
452 pub fn set_generator(&mut self, generator: Option<Arc<dyn ChunkGenerator>>) {
453 self.generator = generator;
454 }
455
456 /// Materialise the chunk at `chunk_idx` by running [`Self::generator`]
457 /// if (a) the chunk is not already present and (b) a generator
458 /// is attached. Returns `true` iff a chunk was newly generated.
459 ///
460 /// No-ops in all other cases:
461 /// - chunk already present (caller edits / a previous
462 /// `ensure_chunk_generated` call already populated it),
463 /// - no generator attached (the chunk stays implicit-air per
464 /// the existing convention — does NOT fall through to
465 /// [`Self::ensure_chunk`]'s empty-chunk constructor).
466 ///
467 /// This is the synchronous S7.0 path. S7.3 will add an async
468 /// counterpart that dispatches the generator call to a
469 /// dedicated rayon pool and installs the result on the next
470 /// `pump_streaming` call.
471 pub fn ensure_chunk_generated(&mut self, chunk_idx: IVec3) -> bool {
472 if self.chunks.contains_key(&chunk_idx) {
473 return false;
474 }
475 let Some(generator) = self.generator.as_ref() else {
476 return false;
477 };
478 // S7.6+: generator may decline specific indices (e.g. a
479 // single-z-layer generator skipping placeholder bedrock
480 // chunks at chz != 0). Respect the filter so we don't
481 // materialise an unwanted chunk.
482 if !generator.should_generate(chunk_idx) {
483 return false;
484 }
485 let chunk = generator.generate(chunk_idx);
486 self.chunks.insert(chunk_idx, chunk);
487 // S7.4: a fresh chunk grows the populated AABB → the
488 // bounding sphere shifts/expands → existing impostor
489 // projections become wrong. Match the eviction (S7.1) +
490 // edit (S6.2) invalidation contract and drop the cache.
491 // Next Far-tier render rebuilds lazily.
492 self.billboards = None;
493 true
494 }
495
496 /// Bounding-sphere radius of the populated chunk set in
497 /// grid-local space.
498 ///
499 /// Walks the sparse chunk map once, computes the chunk-index
500 /// AABB, converts to voxel-space half-extent, returns its
501 /// Euclidean length. Empty grid → `0.0`.
502 ///
503 /// Conservative — bounds the full chunk volume, not just its
504 /// populated voxels (a chunk containing one voxel still
505 /// contributes `CHUNK_SIZE_XY × CHUNK_SIZE_XY × CHUNK_SIZE_Z`
506 /// to the bbox). For LOD picking that's fine: an over-bound
507 /// sphere errs on the side of `Near`.
508 ///
509 /// Cost: `O(chunks.len())`; recomputed on every call. Callers
510 /// who need this every frame should memoize at the
511 /// [`Scene`]-level cache (added when S6.2 needs it).
512 #[must_use]
513 pub fn bounding_radius(&self) -> f64 {
514 if self.chunks.is_empty() {
515 return 0.0;
516 }
517 let mut min = IVec3::splat(i32::MAX);
518 let mut max = IVec3::splat(i32::MIN);
519 for &idx in self.chunks.keys() {
520 min = min.min(idx);
521 max = max.max(idx);
522 }
523 // Chunk-index bbox → voxel-space half-extent. `+1` on max
524 // converts inclusive chunk index to exclusive voxel upper
525 // bound (chunk `idx` covers voxels `[idx*size, (idx+1)*size)`).
526 let sx = f64::from(CHUNK_SIZE_XY);
527 let sz = f64::from(CHUNK_SIZE_Z);
528 let lo = DVec3::new(
529 f64::from(min.x) * sx,
530 f64::from(min.y) * sx,
531 f64::from(min.z) * sz,
532 );
533 let hi = DVec3::new(
534 f64::from(max.x + 1) * sx,
535 f64::from(max.y + 1) * sx,
536 f64::from(max.z + 1) * sz,
537 );
538 let half_extent = (hi - lo) * 0.5;
539 half_extent.length()
540 }
541
542 /// Pick this grid's LOD tier for the given world-space camera
543 /// position. Convenience wrapper around [`crate::select_lod`]
544 /// that pulls [`Self::lod_thresholds`] from the grid.
545 #[must_use]
546 pub fn select_lod(&self, camera_world_pos: DVec3) -> Lod {
547 select_lod(camera_world_pos, &self.transform, self.lod_thresholds)
548 }
549}
550
551/// Top-level scene container. Holds a flat collection of grids
552/// keyed by [`GridId`].
553///
554/// S2.0 only exposes registration / removal / lookup. Address math
555/// helpers (S2.x), edit API (S2.x), and rendering composition (S3)
556/// land in later sub-substages.
557#[derive(Debug, Default)]
558pub struct Scene {
559 grids: HashMap<GridId, Grid>,
560 next_grid_id: u32,
561 /// S7.3: per-scene streaming pool + result channel. Stored on
562 /// the `Scene` so `pump_streaming` can dispatch background
563 /// tasks and drain results across pump calls. `cfg`-gated out
564 /// on wasm32 where `pump_streaming` short-circuits to
565 /// `pump_streaming_sync` (no rayon pool there).
566 #[cfg(not(target_arch = "wasm32"))]
567 streaming: streaming::StreamingState,
568}
569
570impl Scene {
571 /// New empty scene — no grids.
572 #[must_use]
573 pub fn new() -> Self {
574 Self::default()
575 }
576
577 /// Number of grids currently registered.
578 #[must_use]
579 pub fn grid_count(&self) -> usize {
580 self.grids.len()
581 }
582
583 /// Register a new grid. Returns its fresh, unique [`GridId`].
584 pub fn add_grid(&mut self, transform: GridTransform) -> GridId {
585 let id = GridId(self.next_grid_id);
586 self.next_grid_id += 1;
587 self.grids.insert(id, Grid::new(transform));
588 id
589 }
590
591 /// Remove a grid by id. Returns the removed [`Grid`] (so the
592 /// caller can reclaim its chunks) or `None` if the id wasn't
593 /// registered. Removed ids are not reissued.
594 pub fn remove_grid(&mut self, id: GridId) -> Option<Grid> {
595 self.grids.remove(&id)
596 }
597
598 /// Borrow a registered grid.
599 #[must_use]
600 pub fn grid(&self, id: GridId) -> Option<&Grid> {
601 self.grids.get(&id)
602 }
603
604 /// Mutably borrow a registered grid.
605 pub fn grid_mut(&mut self, id: GridId) -> Option<&mut Grid> {
606 self.grids.get_mut(&id)
607 }
608
609 /// Iterator over all `(id, grid)` pairs in registration order
610 /// is **not** guaranteed — the underlying map is a `HashMap`.
611 /// Callers that need a stable order must sort by [`GridId`].
612 pub fn grids(&self) -> impl Iterator<Item = (GridId, &Grid)> {
613 self.grids.iter().map(|(id, g)| (*id, g))
614 }
615
616 /// Mutable iterator over all `(id, grid)` pairs. Yield order
617 /// is not guaranteed (HashMap-backed).
618 pub fn grids_mut(&mut self) -> impl Iterator<Item = (GridId, &mut Grid)> {
619 self.grids.iter_mut().map(|(id, g)| (*id, g))
620 }
621
622 /// Resolve a world-space surface hit to the owning grid + its
623 /// grid-local voxel — the picking back half. `ray_dir` is the view
624 /// direction the hit was found along (need not be normalised); the
625 /// point is nudged half a voxel along it, past the surface and into
626 /// the solid cell, before each grid's [`Grid::voxel_solid`] test.
627 /// Returns the first grid that is solid there (transform-correct
628 /// for rotated/translated grids), or `None` if none claims it.
629 ///
630 /// Backend-agnostic: pair with a renderer depth read to turn a
631 /// click into a voxel — `world = cam.pos + t · normalize(ray_dir)`,
632 /// then `resolve_voxel(world, ray_dir)`. `roxlap-render`'s
633 /// `SceneRenderer::pick` wires exactly that.
634 #[must_use]
635 pub fn resolve_voxel(&self, world: DVec3, ray_dir: DVec3) -> Option<(GridId, IVec3)> {
636 let len = ray_dir.length();
637 if len < 1e-9 {
638 return None;
639 }
640 let inside = world + ray_dir * (0.5 / len); // half a voxel inward
641 for (id, grid) in self.grids() {
642 let glp = addr::world_to_grid_local(inside, &grid.transform);
643 let v = addr::voxel_global(glp.chunk, glp.voxel);
644 if grid.voxel_solid(v) {
645 return Some((id, v));
646 }
647 }
648 None
649 }
650
651 /// Cast a world-space ray and return the nearest solid voxel hit
652 /// across all grids, or `None` if nothing solid lies within
653 /// `max_dist`. Renderer-independent (no depth buffer, no camera) —
654 /// the primitive for line-of-sight, projectiles, AI probing, and
655 /// off-screen / backend-agnostic picking.
656 ///
657 /// `dir` need not be normalised. Each grid's ray is transformed
658 /// into the grid's local frame (so rotated / translated grids are
659 /// handled exactly) and marched with a voxel DDA against
660 /// [`Grid::voxel_solid`]; the closest hit by world distance `t`
661 /// wins. The step budget is bounded by `max_dist`, so empty space
662 /// is safe but not free — a chunk-level skip is a future
663 /// optimisation if hot.
664 #[must_use]
665 pub fn raycast(&self, origin: DVec3, dir: DVec3, max_dist: f64) -> Option<RayHit> {
666 let len = dir.length();
667 if len < 1e-12 || max_dist <= 0.0 {
668 return None;
669 }
670 let dn = dir / len; // unit world direction → t is world distance
671 let mut best: Option<RayHit> = None;
672 for (id, grid) in self.grids() {
673 // World ray → grid-local: undo translation + rotation. The
674 // inverse rotation preserves length, so `t` stays in world
675 // units and is comparable across grids.
676 let inv = grid.transform.rotation.inverse();
677 let lo = inv * (origin - grid.transform.origin);
678 let ld = inv * dn;
679 if let Some((voxel, t)) = voxel_dda(grid, lo, ld, max_dist) {
680 if best.as_ref().map_or(true, |b| t < b.t) {
681 best = Some(RayHit {
682 grid: id,
683 voxel,
684 world: origin + dn * t,
685 t,
686 color: grid.voxel_color(voxel),
687 });
688 }
689 }
690 }
691 best
692 }
693
694 /// Configure the number of worker threads in the dedicated
695 /// streaming pool (S7.3).
696 ///
697 /// Lazily applied — the pool itself is constructed on the first
698 /// [`Self::pump_streaming`] call. If the pool was already built
699 /// (i.e. a previous `pump_streaming` already dispatched at
700 /// least one task), it gets dropped and rebuilt. Dropping the
701 /// old pool blocks until all of its in-flight tasks finish
702 /// (rayon's contract); any results those tasks sent are still
703 /// drained by the next `pump_streaming` because the channel
704 /// survives the rebuild.
705 ///
706 /// The streaming pool is separate from rayon's global pool
707 /// (which R12 multicore rendering uses), so chunk generation
708 /// doesn't compete with render threads. Sensible values are 1
709 /// to ~4 — generation work is CPU-bound but should leave most
710 /// of the box for everything else.
711 ///
712 /// On wasm32 this is a no-op (no rayon pool available);
713 /// `pump_streaming` runs synchronously there.
714 ///
715 /// # Panics
716 /// Panics on native if `n == 0` (zero-thread pools are not
717 /// supported; the scene crate's S7.1 helper already disallows
718 /// the equivalent for `StreamRadius::r_active < 0`).
719 #[cfg(not(target_arch = "wasm32"))]
720 pub fn set_streaming_threads(&mut self, n: usize) {
721 self.streaming.set_thread_count(n);
722 }
723
724 /// wasm32 no-op companion of [`Self::set_streaming_threads`].
725 /// Lets cross-target code call this unconditionally.
726 #[cfg(target_arch = "wasm32")]
727 pub fn set_streaming_threads(&mut self, _n: usize) {
728 // No streaming pool on wasm32 — see `pump_streaming` docs.
729 }
730
731 /// Asynchronous streaming pump (S7.3).
732 ///
733 /// On native, dispatches missing-chunk generations onto a
734 /// dedicated rayon pool, drains any results that arrived since
735 /// the last pump, runs the eviction pass, and tracks in-flight
736 /// tasks in each grid's [`Grid::pending_gen`] set. The drain
737 /// uses the per-chunk version counter from S7.2 to discard
738 /// results whose chunk was edited mid-generation.
739 ///
740 /// On wasm32 this short-circuits to [`Self::pump_streaming_sync`]
741 /// — no thread pool is available there, but the same per-grid
742 /// stream-in / evict semantics apply.
743 ///
744 /// Call once per frame from the render thread. Cheap when
745 /// nothing changed (early-exit on disabled grids, try_recv
746 /// loops empty fast).
747 pub fn pump_streaming(&mut self, camera_world_pos: DVec3) {
748 #[cfg(target_arch = "wasm32")]
749 {
750 self.pump_streaming_sync(camera_world_pos);
751 }
752 #[cfg(not(target_arch = "wasm32"))]
753 {
754 self.pump_streaming_native(camera_world_pos);
755 }
756 }
757
758 /// Native implementation of [`Self::pump_streaming`].
759 #[cfg(not(target_arch = "wasm32"))]
760 fn pump_streaming_native(&mut self, camera_world_pos: DVec3) {
761 // 1. Drain inbox — install fresh results, drop stale.
762 while let Ok(result) = self.streaming.rx.try_recv() {
763 let Some(grid) = self.grids.get_mut(&result.grid_id) else {
764 // Grid was removed while a generation task was
765 // in-flight. Drop silently.
766 continue;
767 };
768 // Clearing pending_gen here both for "result delivered"
769 // and "we shouldn't try to re-dispatch this chunk just
770 // because it's missing".
771 let was_pending = grid.pending_gen.remove(&result.chunk_idx);
772 if !was_pending {
773 // Either the chunk was evicted (pending cleared in
774 // the eviction pass below in some prior call), or a
775 // duplicate result for an already-handled chunk.
776 continue;
777 }
778 if grid.chunks.contains_key(&result.chunk_idx) {
779 // Some other path (e.g. `ensure_chunk_generated`
780 // sync helper, or a manual edit's `ensure_chunk`)
781 // already populated the slot. Don't overwrite.
782 continue;
783 }
784 if grid.chunk_version(result.chunk_idx) != result.version_at_dispatch {
785 // S7.2 stale-result discard: chunk was edited mid-
786 // generation.
787 continue;
788 }
789 grid.chunks.insert(result.chunk_idx, result.vxl);
790 // S7.4: same invalidation contract as the sync
791 // `ensure_chunk_generated` path — installing a new
792 // chunk can grow the bounding sphere, so the
793 // billboard impostor cache must be rebuilt on next
794 // Far entry. Lazy: only one cache wipe per drain
795 // batch, the Far render rebuilds afterwards.
796 grid.billboards = None;
797 }
798
799 // 2. Per-grid: eviction first, then dispatch. Doing evict
800 // before dispatch means a chunk that's just left
801 // r_active doesn't get re-dispatched on the same pump.
802 self.streaming.ensure_pool();
803 // Disjoint sub-field borrows: pool/tx via `&self.streaming.*`,
804 // grids via `&mut self.grids`. Hold both at once.
805 let pool: &rayon::ThreadPool = self.streaming.pool.as_ref().expect("ensure_pool just ran");
806 let tx_template = &self.streaming.tx;
807 for (grid_id, grid) in &mut self.grids {
808 evict_grid_chunks(grid, camera_world_pos);
809 dispatch_grid_async(*grid_id, grid, camera_world_pos, pool, tx_template);
810 }
811 }
812
813 /// Synchronous streaming pump (S7.1).
814 ///
815 /// For each grid with a non-[`StreamRadius::DISABLED`] policy:
816 /// 1. Project the world-space camera into grid-local coords
817 /// (inverse rotation + origin subtract).
818 /// 2. Stream in any chunk whose AABB-to-camera distance is
819 /// `<= r_active`, calling [`Grid::ensure_chunk_generated`].
820 /// No-ops gracefully if the grid has no generator attached
821 /// (so callers can use the eviction half of streaming on a
822 /// purely-edited grid).
823 /// 3. Evict any chunk whose AABB-to-camera distance exceeds
824 /// `r_evict` from the grid's chunk map. Eviction also
825 /// clears the cached [`BillboardCache`] (the bounding sphere
826 /// may shrink, invalidating impostor projections; the next
827 /// Far-tier render rebuilds lazily).
828 ///
829 /// Both passes use the f64 grid-local position so rotation +
830 /// non-axis-aligned grids stream and evict correctly. The
831 /// generate path is blocking — S7.3 will move it to a
832 /// background rayon pool with `pump_streaming` (non-blocking).
833 /// Callers that want the async variant in S7.0/S7.1 stages
834 /// should keep `r_active` small.
835 pub fn pump_streaming_sync(&mut self, camera_world_pos: DVec3) {
836 for grid in self.grids.values_mut() {
837 pump_grid_streaming_sync(grid, camera_world_pos);
838 }
839 }
840}
841
842/// S7.1 helper — drives one grid's synchronous streaming pass.
843/// Stream-in pass uses [`Grid::ensure_chunk_generated`] (blocking
844/// inline generation); eviction pass shared with the S7.3 async
845/// path through [`evict_grid_chunks`].
846fn pump_grid_streaming_sync(grid: &mut Grid, camera_world_pos: DVec3) {
847 let radius = grid.stream_radius;
848 if radius.is_disabled() {
849 return;
850 }
851 let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
852
853 // --- Pass 1: stream in active chunks (sync) ---------------
854 if radius.r_active > 0.0 && grid.generator.is_some() {
855 for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
856 grid.ensure_chunk_generated(idx);
857 });
858 }
859
860 // --- Pass 2: evict chunks past r_evict --------------------
861 evict_grid_chunks_with_cam(grid, cam_local);
862}
863
864/// Eviction pass shared by [`pump_grid_streaming_sync`] and the
865/// S7.3 async path. Public-ish so the async driver can call it
866/// before dispatching to avoid generating chunks that are about
867/// to be evicted. `cfg`-gated to native: on wasm32 the only
868/// caller (`pump_streaming_native`) doesn't compile, so this
869/// helper would warn as dead code.
870#[cfg(not(target_arch = "wasm32"))]
871fn evict_grid_chunks(grid: &mut Grid, camera_world_pos: DVec3) {
872 let radius = grid.stream_radius;
873 if radius.is_disabled() {
874 return;
875 }
876 let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
877 evict_grid_chunks_with_cam(grid, cam_local);
878}
879
880/// Eviction inner — assumes `cam_local` is already computed (the
881/// dispatcher and sync pump both have it on hand).
882fn evict_grid_chunks_with_cam(grid: &mut Grid, cam_local: DVec3) {
883 let radius = grid.stream_radius;
884 if !radius.r_evict.is_finite() {
885 return;
886 }
887 let r_sq = radius.r_evict * radius.r_evict;
888 let to_evict: Vec<IVec3> = grid
889 .chunks
890 .keys()
891 .filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
892 .copied()
893 .collect();
894 // S7.3: also evict pending in-flight tasks past r_evict so the
895 // drain pass doesn't install a chunk that's no longer wanted.
896 // We don't have a way to cancel the rayon task, but we can
897 // drop the pending_gen entry so the result is dropped on
898 // arrival.
899 let to_evict_pending: Vec<IVec3> = grid
900 .pending_gen
901 .iter()
902 .filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
903 .copied()
904 .collect();
905 if to_evict.is_empty() && to_evict_pending.is_empty() {
906 return;
907 }
908 for idx in &to_evict {
909 grid.chunks.remove(idx);
910 // S7.2: keep chunk_versions in sync with chunks so the
911 // map stays bounded. A future re-stream of the same idx
912 // restarts at 0 — that's fine because any in-flight
913 // gen-result tagged with the pre-eviction version is
914 // unreachable (no chunk to install onto) and gets
915 // discarded by the new "version still 0" check anyway.
916 grid.chunk_versions.remove(idx);
917 // S7.3: drop pending entry for the same chunk too. If a
918 // background task is still running, its result will be
919 // dropped on arrival (was_pending = false).
920 grid.pending_gen.remove(idx);
921 }
922 for idx in &to_evict_pending {
923 grid.pending_gen.remove(idx);
924 }
925 if !to_evict.is_empty() {
926 // Bounding sphere can shrink → impostor projections would
927 // be wrong on next Far render. Clear lazily; the next
928 // Far-tier pass repopulates via BillboardCache::build.
929 grid.billboards = None;
930 }
931}
932
933/// Walk every chunk index whose AABB falls within `r_active` of
934/// `cam_local` and invoke `f` on it. Shared between the S7.1 sync
935/// stream-in and the S7.3 async dispatch.
936fn for_each_chunk_in_radius<F>(cam_local: DVec3, r_active: f64, mut f: F)
937where
938 F: FnMut(IVec3),
939{
940 let r_sq = r_active * r_active;
941 let sxy = f64::from(CHUNK_SIZE_XY);
942 let sz = f64::from(CHUNK_SIZE_Z);
943 // Half-extent in chunk units; ceil to be conservative so any
944 // chunk whose AABB clips the radius gets considered. `+1`
945 // covers the half-open chunk-AABB upper edge plus the case
946 // where the camera sits exactly on a chunk boundary and the
947 // closest chunk is one index off.
948 #[allow(clippy::cast_possible_truncation)]
949 let r_chunks_xy = (r_active / sxy).ceil() as i32 + 1;
950 #[allow(clippy::cast_possible_truncation)]
951 let r_chunks_z = (r_active / sz).ceil() as i32 + 1;
952 #[allow(clippy::cast_possible_truncation)]
953 let cx_chunk = (cam_local.x / sxy).floor() as i32;
954 #[allow(clippy::cast_possible_truncation)]
955 let cy_chunk = (cam_local.y / sxy).floor() as i32;
956 #[allow(clippy::cast_possible_truncation)]
957 let cz_chunk = (cam_local.z / sz).floor() as i32;
958 for chz in (cz_chunk - r_chunks_z)..=(cz_chunk + r_chunks_z) {
959 for chy in (cy_chunk - r_chunks_xy)..=(cy_chunk + r_chunks_xy) {
960 for chx in (cx_chunk - r_chunks_xy)..=(cx_chunk + r_chunks_xy) {
961 let idx = IVec3::new(chx, chy, chz);
962 if streaming::chunk_aabb_dist_sq(cam_local, idx) <= r_sq {
963 f(idx);
964 }
965 }
966 }
967 }
968}
969
970/// S7.3 async dispatch — schedule generation for every chunk in
971/// `r_active` that's not already present and not already in
972/// flight. Each dispatch clones the grid's generator `Arc` and a
973/// sender clone, then spawns the closure on the streaming rayon
974/// pool. The closure does the generate + send; the main thread
975/// drains results on the next pump.
976#[cfg(not(target_arch = "wasm32"))]
977fn dispatch_grid_async(
978 grid_id: GridId,
979 grid: &mut Grid,
980 camera_world_pos: DVec3,
981 pool: &rayon::ThreadPool,
982 tx: &crossbeam_channel::Sender<streaming::ChunkResult>,
983) {
984 let radius = grid.stream_radius;
985 if radius.is_disabled() || radius.r_active <= 0.0 {
986 return;
987 }
988 let Some(generator) = grid.generator.as_ref().map(Arc::clone) else {
989 return;
990 };
991 let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
992 for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
993 if grid.chunks.contains_key(&idx) {
994 return; // already present
995 }
996 if grid.pending_gen.contains(&idx) {
997 return; // already in flight
998 }
999 // S7.6+: respect the generator's per-chunk filter — same
1000 // contract as `Grid::ensure_chunk_generated` (sync helper).
1001 // Lets a generator decline to materialise specific indices
1002 // (e.g. `HillsChunkGenerator` skipping placeholder bedrock
1003 // chunks at chz != 0 so the camera-above-grid path doesn't
1004 // create chz < 0 entries that would shift `origin_chunk_z`
1005 // and trigger the S4B.6.j cross-chunk look-down bug).
1006 if !generator.should_generate(idx) {
1007 return;
1008 }
1009 grid.pending_gen.insert(idx);
1010 let version_at_dispatch = grid.chunk_version(idx);
1011 let tx_clone = tx.clone();
1012 let gen_clone = Arc::clone(&generator);
1013 pool.spawn(move || {
1014 let vxl = gen_clone.generate(idx);
1015 // Send is non-blocking on unbounded channel; if the
1016 // receiver was dropped (Scene drop), the send fails
1017 // silently — that's fine.
1018 let _ = tx_clone.send(streaming::ChunkResult {
1019 grid_id,
1020 chunk_idx: idx,
1021 version_at_dispatch,
1022 vxl,
1023 });
1024 });
1025 });
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 #[test]
1033 fn empty_scene_has_no_grids() {
1034 let scene = Scene::new();
1035 assert_eq!(scene.grid_count(), 0);
1036 assert!(scene.grids().next().is_none());
1037 }
1038
1039 #[test]
1040 fn raycast_hits_axis_aligned_voxel() {
1041 let mut scene = Scene::new();
1042 let id = scene.add_grid(GridTransform::identity());
1043 scene
1044 .grid_mut(id)
1045 .unwrap()
1046 .set_voxel(IVec3::new(5, 5, 10), Some(0x80_aa_bb_cc));
1047
1048 // Straight down the +z column through (5,5): hits z=10 at t≈10.
1049 let hit = scene
1050 .raycast(DVec3::new(5.5, 5.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1051 .expect("ray hits the voxel");
1052 assert_eq!(hit.grid, id);
1053 assert_eq!(hit.voxel, IVec3::new(5, 5, 10));
1054 assert!((hit.t - 10.0).abs() < 1e-6, "t≈10, got {}", hit.t);
1055 assert!(hit.color.is_some(), "textured voxel has a colour");
1056
1057 // A column with no voxel misses.
1058 assert!(
1059 scene
1060 .raycast(DVec3::new(0.5, 0.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1061 .is_none(),
1062 "empty column → no hit",
1063 );
1064 }
1065
1066 #[test]
1067 fn raycast_respects_grid_transform() {
1068 // A translated grid: the hit voxel is reported in GRID-LOCAL
1069 // coords, and the world hit point is back in world space — so a
1070 // host gets the true voxel regardless of where the grid sits.
1071 let mut scene = Scene::new();
1072 let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
1073 scene
1074 .grid_mut(id)
1075 .unwrap()
1076 .set_voxel(IVec3::new(5, 5, 10), Some(0x80_11_22_33));
1077
1078 let hit = scene
1079 .raycast(DVec3::new(105.5, 5.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1080 .expect("ray hits the translated voxel");
1081 assert_eq!(hit.voxel, IVec3::new(5, 5, 10), "grid-local voxel");
1082 assert!((hit.world.x - 105.5).abs() < 1e-6, "world x preserved");
1083 assert!((hit.t - 10.0).abs() < 1e-6, "t≈10, got {}", hit.t);
1084 }
1085
1086 #[test]
1087 fn raycast_picks_nearest_grid() {
1088 // Two grids with a voxel each along the same world column; the
1089 // raycast must return the closer one.
1090 let mut scene = Scene::new();
1091 let near = scene.add_grid(GridTransform::identity());
1092 let far = scene.add_grid(GridTransform::identity());
1093 scene
1094 .grid_mut(near)
1095 .unwrap()
1096 .set_voxel(IVec3::new(1, 1, 20), Some(0x80_00_ff_00));
1097 scene
1098 .grid_mut(far)
1099 .unwrap()
1100 .set_voxel(IVec3::new(1, 1, 40), Some(0x80_ff_00_00));
1101
1102 let hit = scene
1103 .raycast(DVec3::new(1.5, 1.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1104 .expect("hits the nearer voxel");
1105 assert_eq!(hit.grid, near);
1106 assert_eq!(hit.voxel, IVec3::new(1, 1, 20));
1107 }
1108
1109 #[test]
1110 fn add_grid_returns_fresh_ids() {
1111 let mut scene = Scene::new();
1112 let a = scene.add_grid(GridTransform::identity());
1113 let b = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
1114 assert_ne!(a, b);
1115 assert_eq!(a.raw(), 0);
1116 assert_eq!(b.raw(), 1);
1117 assert_eq!(scene.grid_count(), 2);
1118 }
1119
1120 #[test]
1121 fn grid_lookup_round_trips() {
1122 let mut scene = Scene::new();
1123 let id = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
1124 let g = scene.grid(id).expect("grid registered");
1125 assert_eq!(g.transform.origin, DVec3::new(10.0, 20.0, 30.0));
1126 assert_eq!(g.transform.rotation, DQuat::IDENTITY);
1127 assert!(g.chunks.is_empty());
1128 }
1129
1130 #[test]
1131 fn remove_grid_drops_it_from_scene() {
1132 let mut scene = Scene::new();
1133 let id = scene.add_grid(GridTransform::identity());
1134 let removed = scene.remove_grid(id);
1135 assert!(removed.is_some());
1136 assert_eq!(scene.grid_count(), 0);
1137 assert!(scene.grid(id).is_none());
1138 // Re-adding does NOT reuse the dropped id.
1139 let id2 = scene.add_grid(GridTransform::identity());
1140 assert_ne!(id, id2);
1141 assert_eq!(id2.raw(), 1);
1142 }
1143
1144 #[test]
1145 fn remove_unknown_grid_is_none() {
1146 let mut scene = Scene::new();
1147 let bogus = GridId(999);
1148 assert!(scene.remove_grid(bogus).is_none());
1149 }
1150
1151 #[test]
1152 fn grid_mut_can_modify_transform() {
1153 let mut scene = Scene::new();
1154 let id = scene.add_grid(GridTransform::identity());
1155 scene.grid_mut(id).unwrap().transform.origin = DVec3::new(1.0, 2.0, 3.0);
1156 assert_eq!(
1157 scene.grid(id).unwrap().transform.origin,
1158 DVec3::new(1.0, 2.0, 3.0)
1159 );
1160 }
1161
1162 #[test]
1163 fn chunk_size_constants_match_plan() {
1164 // Plan locks these values; bumping either breaks the slab
1165 // byte format (Z) or the worst-case chunk footprint budget
1166 // (XY). Pin them so a future refactor that drifts them
1167 // shows up in CI.
1168 assert_eq!(CHUNK_SIZE_XY, 128);
1169 assert_eq!(CHUNK_SIZE_Z, 256);
1170 }
1171
1172 // ---- S6.0: bounding_radius + Grid::select_lod ----
1173
1174 #[test]
1175 fn new_grid_defaults_to_always_near_lod() {
1176 // Byte-identity contract for the staged S6 rollout: a
1177 // grid built through `new` must never trigger the Mid/Far
1178 // branches by accident, even when bounding_radius would
1179 // imply otherwise.
1180 let g = Grid::new(GridTransform::identity());
1181 assert_eq!(g.lod_thresholds.r_near, f64::INFINITY);
1182 assert_eq!(g.lod_thresholds.r_mid, f64::INFINITY);
1183 assert_eq!(g.select_lod(DVec3::new(1e9, 0.0, 0.0)), Lod::Near);
1184 }
1185
1186 #[test]
1187 fn bounding_radius_empty_grid_is_zero() {
1188 let g = Grid::new(GridTransform::identity());
1189 assert_eq!(g.bounding_radius(), 0.0);
1190 }
1191
1192 #[test]
1193 fn bounding_radius_single_chunk_at_origin() {
1194 // One chunk at (0, 0, 0): bbox is [0, 128) × [0, 128) × [0, 256).
1195 // Half-extent = (64, 64, 128); length = sqrt(64² + 64² + 128²)
1196 // = sqrt(4096 + 4096 + 16384) = sqrt(24576) ≈ 156.7747...
1197 let mut scene = Scene::new();
1198 let id = scene.add_grid(GridTransform::identity());
1199 let g = scene.grid_mut(id).unwrap();
1200 // Populate chunk (0, 0, 0) via the edit API.
1201 g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_88_88_88));
1202 let r = g.bounding_radius();
1203 let expected = ((64.0_f64).powi(2) * 2.0 + (128.0_f64).powi(2)).sqrt();
1204 assert!(
1205 (r - expected).abs() < 1e-9,
1206 "bounding_radius={r} expected={expected}"
1207 );
1208 }
1209
1210 #[test]
1211 fn bounding_radius_grows_with_chunk_extent() {
1212 // Two chunks at (0,0,0) and (3,0,0): x extent is 4 chunks =
1213 // 512 voxels; y/z are 1 chunk each. Half-extent = (256, 64, 128);
1214 // length = sqrt(256² + 64² + 128²) = sqrt(65536+4096+16384)
1215 // = sqrt(86016) ≈ 293.2848.
1216 let mut scene = Scene::new();
1217 let id = scene.add_grid(GridTransform::identity());
1218 let g = scene.grid_mut(id).unwrap();
1219 // Stamp one voxel in chunk (0,0,0).
1220 g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_88_88_88));
1221 // Stamp one voxel in chunk (3,0,0): grid-local x = 3*128 = 384.
1222 g.set_voxel(IVec3::new(384, 0, 0), Some(0x80_88_88_88));
1223 assert_eq!(g.chunks.len(), 2);
1224 let r = g.bounding_radius();
1225 let expected = (256.0_f64.powi(2) + 64.0_f64.powi(2) + 128.0_f64.powi(2)).sqrt();
1226 assert!(
1227 (r - expected).abs() < 1e-9,
1228 "bounding_radius={r} expected={expected}"
1229 );
1230 }
1231
1232 #[test]
1233 fn grid_select_lod_respects_lod_thresholds_field() {
1234 // Set a non-default threshold and verify the helper picks
1235 // the right tier for known distances.
1236 let mut scene = Scene::new();
1237 let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
1238 let g = scene.grid_mut(id).unwrap();
1239 g.lod_thresholds = LodThresholds {
1240 r_near: 50.0,
1241 r_mid: 200.0,
1242 ..LodThresholds::always_near()
1243 };
1244 // Camera 25 units from grid origin → Near.
1245 assert_eq!(g.select_lod(DVec3::new(125.0, 0.0, 0.0)), Lod::Near);
1246 // 100 units → Mid.
1247 assert_eq!(g.select_lod(DVec3::new(200.0, 0.0, 0.0)), Lod::Mid);
1248 // 500 units → Far.
1249 assert_eq!(g.select_lod(DVec3::new(600.0, 0.0, 0.0)), Lod::Far);
1250 }
1251}