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