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//! New to roxlap? **[The roxlap book](https://ncrashed.github.io/roxlap/)**
5//! is the guide — its scene-graph chapter walks this crate end to end;
6//! this page is the API reference.
7//!
8//! This crate is the layer **above** the per-chunk renderer
9//! (`roxlap-core`): a [`Scene`] holds a sparse set of [`Grid`]s, each
10//! with its own f64 world position + arbitrary 3D rotation
11//! ([`GridTransform`]). Around that core sit:
12//!
13//! - [`addr`] — world ↔ grid-local ↔ chunk + voxel-in-chunk
14//!   decomposition, the canonical f64↔i32 boundary helpers;
15//! - [`chunks`] — sparse chunk storage with on-demand materialisation,
16//!   plus the [`Grid`] edit API ([`Grid::set_voxel`],
17//!   [`Grid::set_rect`], [`Grid::set_sphere`], …) which decomposes
18//!   multi-chunk operations and delegates to [`roxlap_formats::edit`];
19//! - [`render`] — multi-grid raycast composition for the CPU renderer;
20//! - [`snapshot`] — save/load: the versioned wire format
21//!   ([`Scene::save_snapshot`] / [`Scene::load_snapshot`], QE.5b)
22//!   plus the underlying serde-friendly [`snapshot::SceneSnapshot`]
23//!   value (chunks encode via [`roxlap_formats::vxl::serialize`] /
24//!   [`parse`]);
25//! - [`streaming`] — chunk streaming + procedural generation
26//!   ([`streaming::ChunkGenerator`], radius-driven install/evict,
27//!   async generation on a rayon pool) and persistence for edited
28//!   chunks ([`streaming::ChunkStore`], QE.5a);
29//! - [`lod`] / [`billboard`] / [`occluder`] — far-LOD billboards and
30//!   render culling helpers;
31//! - world queries — [`Scene::raycast`], [`Scene::resolve_voxel`],
32//!   [`Grid::voxel_solid`] / [`Grid::voxel_color`].
33//!
34//! `docs/porting/PORTING-SCENE.md` in the repository records the
35//! original substage roadmap (S1..S7, all landed).
36//!
37//! [`parse`]: roxlap_formats::vxl::parse
38
39pub mod addr;
40pub mod billboard;
41pub mod cavegen;
42/// Character controller (stage CC) — a walking body over the scene;
43/// see `docs/porting/PORTING-CONTROLLER.md`.
44pub mod character;
45pub mod chunks;
46/// Collision query layer (stage CC) — box-vs-voxel overlap over a
47/// scene; see `docs/porting/PORTING-CONTROLLER.md`.
48pub mod collide;
49pub mod edit;
50/// Fog-of-war mask core (stage FW) — SS13-style knowledge visibility;
51/// see `docs/porting/PORTING-FOW.md`.
52pub mod fow;
53pub mod islands;
54pub mod lod;
55pub mod occluder;
56pub mod render;
57pub mod snapshot;
58pub mod streaming;
59/// Water volumes (stage WT) — the physics representation of water;
60/// see `docs/porting/PORTING-WATER.md`.
61pub mod water;
62
63use std::collections::{HashMap, HashSet};
64use std::sync::Arc;
65
66use glam::{DQuat, DVec3, IVec3, UVec3};
67use roxlap_formats::vxl::Vxl;
68use serde::{Deserialize, Serialize};
69
70pub use addr::{grid_local_to_world, voxel_global, voxel_split, world_to_grid_local, GridLocalPos};
71pub use billboard::{canonical_viewpoints, BillboardCache, BillboardSnapshot};
72pub use character::{CharacterBody, CharacterDef, MoveMode, WalkInput};
73pub use chunks::{BakeLight, BakeMode};
74pub use collide::{box_overlaps_solid, grid_box_overlaps_solid, point_overlaps_solid, Solidity};
75pub use edit::SpanOp;
76pub use fow::{
77    CellState, DeckBand, FogOfWar, FowObserver, FowRender, FowTwin, GpuFowMask, LightGate,
78    VisionConfig,
79};
80pub use islands::{detect_islands, FracturePattern, Island, DEFAULT_ISLAND_BUDGET};
81pub use lod::{select_lod, Lod, LodThresholds};
82pub use roxlap_core::AoParams;
83pub use roxlap_formats::color::{OverlayColor, Rgb, VoxColor};
84pub use streaming::{ChunkGenerator, ChunkStore, StreamRadius};
85pub use water::WaterVolume;
86
87/// XY size of one chunk in voxels. The plan locks 128 — keeps
88/// chunks compact (~2 MB worst-case dense-slab footprint inside
89/// each `Vxl`) and divides cleanly into voxlap's 2048 reference
90/// world size.
91pub const CHUNK_SIZE_XY: u32 = 128;
92
93/// Z size of one chunk in voxels. Locked at 256 to preserve
94/// voxlap's existing slab byte format unchanged inside each chunk
95/// — the per-chunk renderer doesn't need to know it's living
96/// inside a scene-graph.
97pub const CHUNK_SIZE_Z: u32 = 256;
98
99/// Stable identifier for a grid registered in a [`Scene`]. Issued
100/// by [`Scene::add_grid`]; persists across edits but a removed
101/// grid's id is not reissued.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
103pub struct GridId(u32);
104
105impl GridId {
106    /// The integer wire form. Useful for serde / debug output.
107    #[must_use]
108    pub const fn raw(self) -> u32 {
109        self.0
110    }
111}
112
113/// A solid-voxel hit from [`Scene::raycast`].
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub struct RayHit {
116    /// The grid the ray hit.
117    pub grid: GridId,
118    /// Grid-local integer voxel coordinate of the hit cell.
119    pub voxel: IVec3,
120    /// World-space hit point (`origin + t · normalize(dir)`).
121    pub world: DVec3,
122    /// World distance from the ray origin to the hit.
123    pub t: f64,
124    /// Packed colour of the hit voxel, or `None` if it's an untextured
125    /// (bedrock / interior) cell. See [`Grid::voxel_color`].
126    pub color: Option<VoxColor>,
127}
128
129/// Ray/AABB slab intersection in f64 (`[lo, hi)` box). Returns the
130/// entry/exit ray parameters, or `None` on a miss. PF.6 helper for the
131/// raycast pre-clip + absent-chunk exit.
132fn ray_box(o: DVec3, d: DVec3, blo: DVec3, bhi: DVec3) -> Option<(f64, f64)> {
133    let mut tmin = f64::NEG_INFINITY;
134    let mut tmax = f64::INFINITY;
135    for a in 0..3 {
136        if d[a].abs() < 1e-12 {
137            if o[a] < blo[a] || o[a] > bhi[a] {
138                return None;
139            }
140        } else {
141            let inv = 1.0 / d[a];
142            let (t0, t1) = ((blo[a] - o[a]) * inv, (bhi[a] - o[a]) * inv);
143            tmin = tmin.max(t0.min(t1));
144            tmax = tmax.min(t0.max(t1));
145            if tmin > tmax {
146                return None;
147            }
148        }
149    }
150    Some((tmin, tmax))
151}
152
153/// The populated chunk extent of `grid` as a grid-local voxel-space AABB
154/// `[lo, hi)`, or `None` for an empty grid. O(chunks) HashMap key walk —
155/// fine for per-call raycast use.
156fn grid_voxel_aabb_f64(grid: &Grid) -> Option<(DVec3, DVec3)> {
157    let mut min = IVec3::splat(i32::MAX);
158    let mut max = IVec3::splat(i32::MIN);
159    let mut any = false;
160    for idx in grid.chunks.keys() {
161        any = true;
162        min = min.min(*idx);
163        max = max.max(*idx);
164    }
165    if !any {
166        return None;
167    }
168    let (cs_xy, cs_z) = (
169        i64::from(CHUNK_SIZE_XY as i32),
170        i64::from(CHUNK_SIZE_Z as i32),
171    );
172    #[allow(clippy::cast_precision_loss)]
173    let v = |i: IVec3, add: i64| {
174        DVec3::new(
175            ((i64::from(i.x) + add) * cs_xy) as f64,
176            ((i64::from(i.y) + add) * cs_xy) as f64,
177            ((i64::from(i.z) + add) * cs_z) as f64,
178        )
179    };
180    Some((v(min, 0), v(max, 1)))
181}
182
183/// CA.4 — a grid's cutaway volume, precomputed for the **footprint
184/// rule**: the grid's world→local transform + its materialised XY
185/// chunk footprint + the clip plane, hoisted out of the per-point test
186/// so one frame can test many points (sprites, particles, lights)
187/// cheaply. Build via [`Grid::cutaway_volume`]; the one-shot
188/// convenience is [`Grid::cutaway_hides_point`]. Both renderers hide
189/// sprite instances by exactly this volume, so host-side culls (the
190/// deck-light pattern) stay in perfect agreement with the render.
191#[derive(Clone, Copy, Debug)]
192pub struct CutawayVolume {
193    origin: DVec3,
194    rot_inv: DQuat,
195    inv_vws: f64,
196    xy_lo: [f64; 2],
197    xy_hi: [f64; 2],
198    z_clip: f64,
199}
200
201impl CutawayVolume {
202    /// Whether the footprint rule hides the world-space point: mapped
203    /// into the grid's local voxel frame, it lands inside the XY
204    /// footprint with `z < z_clip` (z-down).
205    #[must_use]
206    pub fn hides_point(&self, world: DVec3) -> bool {
207        let local = (self.rot_inv * (world - self.origin)) * self.inv_vws;
208        local.x >= self.xy_lo[0]
209            && local.x < self.xy_hi[0]
210            && local.y >= self.xy_lo[1]
211            && local.y < self.xy_hi[1]
212            && local.z < self.z_clip
213    }
214
215    /// The materialised XY chunk footprint `[lo, hi)` in grid-local
216    /// voxel coords — what the GPU facade forwards to the sprite cull
217    /// so both backends test the SAME footprint (the scene's
218    /// materialised chunks, not the GPU-resident subset).
219    #[must_use]
220    pub fn footprint_xy(&self) -> ([f64; 2], [f64; 2]) {
221        (self.xy_lo, self.xy_hi)
222    }
223}
224
225/// Voxel DDA (Amanatides-Woo) in a grid's local space. `lo` / `ld` are
226/// the ray origin + unit direction already transformed into grid-local
227/// coords. Returns the first [`Grid::voxel_solid`] cell and its world-
228/// equal distance `t`, or `None` past `max_t`.
229///
230/// PF.6 — three upgrades over the naive from-origin march:
231/// - the ray is pre-clipped to the grid's populated chunk AABB (a miss
232///   costs one slab test; a distant grid is marched AT the box, not from
233///   the origin);
234/// - chunk lookups go through the chunk-cached [`SolidSampler`]-style
235///   probe (one HashMap hit per chunk crossing) and the solid test walks
236///   the slab chain in place (no per-step allocation);
237/// - a voxel in an absent (all-air) chunk fast-forwards the march to
238///   that chunk's exit face instead of stepping its up-to-128 voxels.
239///
240/// CA.4 — `z_clip` in grid-local absolute voxel z: cells with
241/// `z < z_clip` read as air (the cutaway rule). `i32::MIN` = disabled
242/// (no real cell lies below it — the same sentinel the renderers use).
243#[allow(clippy::cast_possible_truncation)]
244fn voxel_dda(grid: &Grid, lo: DVec3, ld: DVec3, max_t: f64, z_clip: i32) -> Option<(IVec3, f64)> {
245    // Clip to the populated chunk AABB: everything outside is air.
246    let (blo, bhi) = grid_voxel_aabb_f64(grid)?;
247    let (t0, t1) = ray_box(lo, ld, blo, bhi)?;
248    let t_enter = t0.max(0.0);
249    let t_exit = t1.min(max_t);
250    if t_enter > t_exit {
251        return None;
252    }
253
254    let step = IVec3::new(
255        i32::from(ld.x > 0.0) - i32::from(ld.x < 0.0),
256        i32::from(ld.y > 0.0) - i32::from(ld.y < 0.0),
257        i32::from(ld.z > 0.0) - i32::from(ld.z < 0.0),
258    );
259    // Distance to advance one whole voxel along each axis (∞ if parallel).
260    let inv_abs = |d: f64| {
261        if d == 0.0 {
262            f64::INFINITY
263        } else {
264            (1.0 / d).abs()
265        }
266    };
267    let t_delta = DVec3::new(inv_abs(ld.x), inv_abs(ld.y), inv_abs(ld.z));
268    // Absolute-`t` of the next voxel boundary from cell `p`, per axis
269    // (also the re-seed after an absent-chunk jump).
270    let seed_t_max = |p: IVec3| -> DVec3 {
271        let axis = |pa: i32, oa: f64, da: f64| -> f64 {
272            if da > 0.0 {
273                (f64::from(pa) + 1.0 - oa) / da
274            } else if da < 0.0 {
275                (f64::from(pa) - oa) / da
276            } else {
277                f64::INFINITY
278            }
279        };
280        DVec3::new(
281            axis(p.x, lo.x, ld.x),
282            axis(p.y, lo.y, ld.y),
283            axis(p.z, lo.z, ld.z),
284        )
285    };
286
287    // Start at the AABB entry (t stays measured from the true origin;
288    // t_enter == 0 ⇒ the original from-origin start, bit-identical).
289    let start = lo + ld * t_enter;
290    let mut p = IVec3::new(
291        start.x.floor() as i32,
292        start.y.floor() as i32,
293        start.z.floor() as i32,
294    );
295    let mut t_max = seed_t_max(p);
296    let mut t_curr = t_enter;
297    let mut sampler = grid.solid_sampler();
298
299    #[allow(clippy::cast_sign_loss)]
300    let max_steps = (max_t * 3.0) as u64 + 8;
301    let (cs_xy, cs_z) = (
302        f64::from(CHUNK_SIZE_XY as i32),
303        f64::from(CHUNK_SIZE_Z as i32),
304    );
305    for _ in 0..max_steps {
306        let (chunk_idx, in_chunk) = voxel_split(p);
307        if let Some(vxl) = sampler.chunk_at(chunk_idx) {
308            // CA.4 — clipped-away cells (z < z_clip) read as air.
309            if p.z >= z_clip && chunks::vxl_voxel_solid(vxl, in_chunk.x, in_chunk.y, in_chunk.z) {
310                return Some((p, t_curr));
311            }
312            // Advance across the nearest voxel boundary.
313            let t = if t_max.x <= t_max.y && t_max.x <= t_max.z {
314                p.x += step.x;
315                let t = t_max.x;
316                t_max.x += t_delta.x;
317                t
318            } else if t_max.y <= t_max.z {
319                p.y += step.y;
320                let t = t_max.y;
321                t_max.y += t_delta.y;
322                t
323            } else {
324                p.z += step.z;
325                let t = t_max.z;
326                t_max.z += t_delta.z;
327                t
328            };
329            if t > t_exit {
330                return None;
331            }
332            t_curr = t;
333        } else {
334            // Absent chunk ⇒ guaranteed air: jump to its exit face.
335            let clo = DVec3::new(
336                f64::from(chunk_idx.x) * cs_xy,
337                f64::from(chunk_idx.y) * cs_xy,
338                f64::from(chunk_idx.z) * cs_z,
339            );
340            let chi = clo + DVec3::new(cs_xy, cs_xy, cs_z);
341            let exit = match ray_box(lo, ld, clo, chi) {
342                // Nudge past the face so the floor lands in the next chunk.
343                Some((_, t1)) => t1.max(t_curr) + 1e-4,
344                None => return None, // degenerate (shouldn't happen: p is inside)
345            };
346            if exit > t_exit {
347                return None;
348            }
349            let q = lo + ld * exit;
350            p = IVec3::new(q.x.floor() as i32, q.y.floor() as i32, q.z.floor() as i32);
351            t_max = seed_t_max(p);
352            t_curr = exit;
353        }
354    }
355    None
356}
357
358/// f64 world placement of one grid: position + orientation.
359///
360/// `origin` is the grid's local-space origin in world coords — a
361/// grid-local point `p` (in voxel units) maps to
362/// `origin + rotation * (p * voxel_world_size)`.
363///
364/// SC — `voxel_world_size` is the grid's **world units per voxel**
365/// (`1.0` = the classic 1:1). A coarse planet grid might use `4.0`
366/// (big voxels) and a finely detailed ship `0.25`, so they coexist at
367/// the right relative sizes in one scene. The scale enters ONLY at the
368/// world↔grid-local boundary (`crate::world_to_grid_local` /
369/// `crate::grid_local_to_world`, `Scene::raycast`, shadows); the
370/// per-grid voxel storage, marchers and bakes are scale-agnostic. Only
371/// a uniform scalar is supported (anisotropic scale would break the
372/// ray-length invariant the raycast/shadow `t` conversion relies on).
373#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
374pub struct GridTransform {
375    /// The grid's local-space origin, in world coordinates.
376    pub origin: DVec3,
377    /// The grid's orientation about `origin`.
378    pub rotation: DQuat,
379    /// SC — world units per voxel (`1.0` = 1:1). See the type docs.
380    /// **`#[serde(skip)]`**: this field is NOT part of `GridTransform`'s
381    /// wire form (which stays frozen for snapshot back-compat). The
382    /// snapshot persists it as a trailing field on
383    /// [`crate::snapshot::GridSnapshot`] instead, so old saves — which
384    /// predate it — still load (defaulting to `1.0`). A standalone
385    /// `GridTransform` deserialize also defaults it to `1.0`.
386    #[serde(skip, default = "one_f64")]
387    pub voxel_world_size: f64,
388}
389
390/// serde default for [`GridTransform::voxel_world_size`].
391fn one_f64() -> f64 {
392    1.0
393}
394
395impl GridTransform {
396    /// Identity transform at world origin, 1 world unit per voxel.
397    /// Useful as a default for the first grid added to an otherwise
398    /// empty scene.
399    #[must_use]
400    pub fn identity() -> Self {
401        Self {
402            origin: DVec3::ZERO,
403            rotation: DQuat::IDENTITY,
404            voxel_world_size: 1.0,
405        }
406    }
407
408    /// Axis-aligned grid placed at `origin` with no rotation, 1 world
409    /// unit per voxel.
410    #[must_use]
411    pub fn at(origin: DVec3) -> Self {
412        Self {
413            origin,
414            rotation: DQuat::IDENTITY,
415            voxel_world_size: 1.0,
416        }
417    }
418
419    /// SC — axis-aligned grid at `origin` with `voxel_world_size` world
420    /// units per voxel (no rotation). `1.0` = the classic 1:1.
421    ///
422    /// `voxel_world_size` must be finite and `> 0`: it scales every
423    /// world↔grid boundary, and a `0` collapses the GPU marcher's
424    /// `chunk_dim` to zero → `floor(origin / 0)` = NaN (garbage geometry),
425    /// worse than the CPU's divide. Debug-asserted here; release builds
426    /// trust the caller.
427    #[must_use]
428    pub fn at_scale(origin: DVec3, voxel_world_size: f64) -> Self {
429        debug_assert!(
430            voxel_world_size.is_finite() && voxel_world_size > 0.0,
431            "voxel_world_size must be finite and > 0, got {voxel_world_size}"
432        );
433        Self {
434            origin,
435            rotation: DQuat::IDENTITY,
436            voxel_world_size,
437        }
438    }
439}
440
441impl Default for GridTransform {
442    fn default() -> Self {
443        Self::identity()
444    }
445}
446
447/// Address of one voxel inside a scene: which grid it belongs to,
448/// which chunk within that grid, and the voxel's offset inside
449/// that chunk.
450///
451/// `chunk` is signed (`IVec3`) because chunks are centred on the
452/// grid's local origin and may extend in either direction. `voxel`
453/// is unsigned and must satisfy
454/// `(voxel.x, voxel.y) < CHUNK_SIZE_XY` and `voxel.z < CHUNK_SIZE_Z`.
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
456pub struct GridAddr {
457    /// The owning grid.
458    pub grid: GridId,
459    /// Signed chunk index within the grid.
460    pub chunk: IVec3,
461    /// Voxel offset inside `chunk` (see the bounds above).
462    pub voxel: UVec3,
463}
464
465/// One independent voxel grid in a scene. Holds its world placement
466/// and a sparse map of populated chunks. Empty chunk slots are
467/// implicit air and skipped during rendering / raycasts.
468///
469/// Each chunk is internally a [`Vxl`] with `vsid = CHUNK_SIZE_XY`
470/// — the existing per-chunk renderer (opticast + grouscan +
471/// sprites + lighting in `roxlap-core`) runs on each chunk
472/// unchanged. Vertical worlds are built by stacking chunks along
473/// grid-local `+z`.
474#[derive(Debug)]
475pub struct Grid {
476    /// World placement (origin + rotation).
477    pub transform: GridTransform,
478    /// Sparse chunk storage keyed by `(chx, chy, chz)` chunk
479    /// coordinates. A missing entry means the chunk is fully air.
480    pub chunks: HashMap<IVec3, Vxl>,
481    /// Whether sky pixels rendered for this grid should be
482    /// composited into the final framebuffer. `true` is the
483    /// historical "grid owns its own sky" behaviour: ray misses
484    /// inside this grid's frustum paint sky_color into the temp
485    /// buffer. Set `false` for grids that are a foreground object
486    /// (e.g. a ship) — the sky is owned by a single "world" grid
487    /// (the ground) and other grids should not contribute sky
488    /// pixels, otherwise their grid-local-frame sky lookup
489    /// rotates with the grid and visibly fights the world's sky
490    /// during compose. See [`crate::render::render_scene_composed`]
491    /// for the masking implementation.
492    pub render_sky: bool,
493    /// Override [`roxlap_core::opticast::OpticastSettings::mip_levels`]
494    /// for this grid. `None` ⇒ use the caller's value. `Some(n)`
495    /// ⇒ cap at `n` (clamped to `[1, settings.mip_levels]`). Use
496    /// to disable multi-mip on a per-grid basis — small grids
497    /// (rotating ships, billboards) don't benefit from deep mips
498    /// and CAN trigger the
499    /// `[[project_axis_aligned_mip_beams]]`-style cf-cancellation
500    /// artifact when near-axis-aligned rays hit the rotated grid.
501    /// `Some(1)` = mip-0 only, byte-stable to single-mip.
502    pub mip_levels_override: Option<u32>,
503    /// CA.0 — cutaway deck clip: grid-local **absolute** voxel z
504    /// (z-down, spanning stacked chz chunks — the same space
505    /// [`Self::set_voxel`] addresses). `Some(z)` makes both renderers
506    /// treat every voxel with `z < z_clip` as air, exposing the
507    /// interior for an isometric "deck view" (SS13-style); `z_clip`
508    /// itself is the first visible layer and its top face renders as
509    /// the cut surface. Stays glued to a rotated/moving grid (the clip
510    /// is grid-local). **Render-only**: simulation, collision, audio
511    /// occlusion and pathing still see the full grid — a hidden deck
512    /// keeps blocking sound and movement. Point lights are NOT
513    /// filtered by the engine; cull lights on hidden decks game-side
514    /// when setting the clip. Persisted in snapshots (wire v4+; older
515    /// saves load with `None`). `None` (the default) = no clipping,
516    /// byte-identical to pre-CA renders. Set directly or via
517    /// [`Scene::set_grid_z_clip`].
518    pub z_clip: Option<i32>,
519    /// World-distance thresholds for per-grid LOD tier selection
520    /// (S6.0). Defaults to [`LodThresholds::always_near`], so a
521    /// freshly-constructed grid always renders at full voxel (the
522    /// S5-and-earlier byte-stable behaviour). S6.1 plugs `Mid` into
523    /// the existing multi-mip path; S6.3 plugs `Far` into the
524    /// billboard impostor cache. See [`crate::lod`].
525    pub lod_thresholds: LodThresholds,
526    /// Lazy [`BillboardCache`] for the `Lod::Far` tier (S6.2).
527    /// `None` until the first time S6.3's render dispatch needs
528    /// it; populated then via [`BillboardCache::build`] and
529    /// cleared by edits ([`Self::set_voxel`] / [`Self::set_rect`]
530    /// / [`Self::set_sphere`]) to force a rebuild on next Far use.
531    /// Callers may also force-invalidate via direct assignment.
532    pub billboards: Option<BillboardCache>,
533    /// Optional procedural generator (S7.0). When set,
534    /// [`Self::ensure_chunk_generated`] uses it to materialise
535    /// chunks that are still absent from [`Self::chunks`].
536    ///
537    /// Streaming layers (S7.1+) walk the active radius around the
538    /// camera and call `ensure_chunk_generated` for missing chunks;
539    /// later stages dispatch this onto a background rayon pool. The
540    /// trait bound is `Send + Sync` (needed for S7.3 async
541    /// dispatch) + `Debug` (needed so [`Grid`] keeps deriving
542    /// `Debug`).
543    ///
544    /// `None` is the default — a grid without a generator behaves
545    /// exactly like the pre-S7 grids: absent chunks stay absent.
546    ///
547    /// `Arc` (not `Box`) so S7.3's async dispatch can clone the
548    /// generator into background rayon tasks without moving it out
549    /// of the grid. Trait bound `Send + Sync` (required at S7.0)
550    /// already makes `Arc<dyn ChunkGenerator>` `Send + Sync`.
551    pub generator: Option<Arc<dyn ChunkGenerator>>,
552    /// QE.5b - optional host-assigned tag, carried through snapshots.
553    /// Grid ids are runtime-opaque, so a save/load cycle gives hosts
554    /// nothing to rebind their own per-grid data (generators, stores,
555    /// gameplay state) against; a stable name closes that gap. Not
556    /// interpreted by the engine.
557    pub name: Option<String>,
558    /// QE.5a - optional persistence for edited streamed chunks: the
559    /// eviction pass hands every `chunk_version != 0` chunk to
560    /// [`ChunkStore::store`] before dropping it, and stream-in asks
561    /// [`ChunkStore::load`] before running the generator. `None` (the
562    /// default) keeps the pre-QE.5 behaviour: evicting an edited
563    /// chunk discards the edits.
564    pub store: Option<Arc<dyn ChunkStore>>,
565    /// Streaming activity / eviction radii used by
566    /// [`Scene::pump_streaming_sync`] (S7.1). Defaults to
567    /// [`StreamRadius::DISABLED`] so existing grids see no change
568    /// in behaviour until the caller opts in.
569    pub stream_radius: StreamRadius,
570    /// EV.3 — baked point lights ([`BakeLight`], grid-local voxel
571    /// coords) consumed by [`BakeMode::PointLights`]: [`Grid::bake`]
572    /// and [`Grid::bake_bbox`] write each light's Lambertian pool
573    /// into the brightness bytes, so incremental carve relights keep
574    /// their glow. Authoring state only — editing this list does
575    /// **not** rebake by itself (call [`Grid::bake`] after) and it is
576    /// not carried through snapshots (the baked bytes are; re-set the
577    /// list after a load if you keep editing). Ignored by the other
578    /// bake modes and by the dynamic `LightRig`.
579    pub bake_lights: Vec<BakeLight>,
580    /// WT.0 — the grid's water volumes ([`WaterVolume`], grid-local
581    /// voxel AABBs; surface = each volume's `lo.z`, z-down). The
582    /// PHYSICS representation of water — [`Scene::water_depth_at`]
583    /// and the swim state read these; the *visual* water is ordinary
584    /// (typically volumetric-material) voxels the host fills
585    /// separately. Authoring state like [`Self::bake_lights`], but —
586    /// unlike them — persisted in snapshots (wire v3+; older saves
587    /// load with no water).
588    pub water_volumes: Vec<WaterVolume>,
589    /// Per-chunk edit version counter (S7.2). Each user edit
590    /// through [`Self::set_voxel`] / [`Self::set_rect`] /
591    /// [`Self::set_sphere`] bumps the counter for every chunk it
592    /// actually wrote to. [`Self::ensure_chunk_generated`] does
593    /// NOT bump — a freshly generated chunk has no edits and
594    /// reads as version 0.
595    ///
596    /// Wired up here so the S7.3 async dispatch can detect "an
597    /// edit happened while a chunk was being generated in the
598    /// background" and discard the now-stale result: each
599    /// background task captures the dispatch-time version and
600    /// only installs its result iff the current version still
601    /// matches.
602    ///
603    /// Missing entries read as `0` via [`Self::chunk_version`].
604    /// Evictions in [`Scene::pump_streaming_sync`] drop the
605    /// corresponding entry so the map stays bounded.
606    ///
607    /// QE.3b — private: every mutation goes through
608    /// [`Self::bump_chunk_version`] / [`Self::bump_chunk_version_bbox`]
609    /// / the crate-internal tracking helpers, so the
610    /// version/extent/counter triple can never desync. Read via
611    /// [`Self::chunk_version`] / [`Self::chunk_versions`].
612    chunk_versions: HashMap<IVec3, u64>,
613    /// In-flight background generation tasks (S7.3).
614    ///
615    /// Populated by [`Scene::pump_streaming`] when it dispatches a
616    /// generator call onto the streaming rayon pool, drained when
617    /// the corresponding `ChunkResult` is received and processed
618    /// (either installed or discarded). The set is consulted to
619    /// avoid re-dispatching the same chunk while a previous task
620    /// is still running.
621    ///
622    /// Stays empty when only the synchronous
623    /// [`Scene::pump_streaming_sync`] is used — that path generates
624    /// inline on the calling thread.
625    pub pending_gen: HashSet<IVec3>,
626    /// Cross-frame DDA brick-occupancy cache (Substage DDA.7 perf).
627    /// Keyed by `(chunk, mip)` + the chunk's edit version, so a static
628    /// chunk's brick map is built once and reused every frame. Skipped
629    /// entirely on the voxlap render path. Not serialised.
630    pub dda_brick_cache: roxlap_core::BrickCache,
631    /// PF.12 — per-chunk change extent accumulated since a consumer
632    /// last [`Self::take_chunk_dirty`]'d it: the partial-refresh /
633    /// incremental-remip companion to [`Self::chunk_versions`]. Bounded
634    /// by the chunk count (one merged entry per chunk). Not serialised.
635    chunk_dirty: HashMap<IVec3, DirtyExtent>,
636    /// PF.13 (H9) — monotonic counter bumped by EVERY chunk-set /
637    /// chunk-content mutation (edits, installs, evictions). Per-frame
638    /// consumers (the GPU dirty poll, the brick-cache sweep) compare it
639    /// against their last-seen value and skip their O(all chunks) scans
640    /// outright on quiet frames. Not serialised.
641    mutations: u64,
642    /// PF.13 (H9) — `(mutation counter, requested mip, effective mip)`
643    /// of the last [`Self::ensure_dda_bricks`] sweep; a matching pair
644    /// skips the whole per-chunk ensure + retain pass.
645    last_bricks: Option<(u64, u32, u32)>,
646    /// FW.1 — fog-of-war exclusion. `render_excluded` marks the *real*
647    /// simulated grid whose visuals are shown through a known twin
648    /// instead: every render path (primary AND shadow) skips it, so its
649    /// live geometry — and any shadow it would cast — never reaches the
650    /// screen (a shadow from unseen geometry is an information leak).
651    /// It is still fully present to simulation, collision, audio and
652    /// pathing. `false` (the default) = normal grid, byte-identical to
653    /// pre-FW renders. See [`crate::fow`].
654    pub render_excluded: bool,
655    /// FW.1 — fog-of-war exclusion. `presentation_only` marks the known
656    /// *twin* grid: it is drawn like any other grid but excluded from
657    /// EVERY gameplay query — raycasts, collision, `resolve_voxel`,
658    /// cutaway hit-tests, audio occlusion, streaming and LOD
659    /// bookkeeping — and from snapshots (it is derived state rebuilt
660    /// from the real grid). `false` (the default) = normal grid. See
661    /// [`crate::fow`].
662    pub presentation_only: bool,
663    /// FW.1 — GPU residency sizing hint `(origin_chunk, chunks_dims)`
664    /// in chunk coordinates. A fog-of-war twin gains chunks over its
665    /// lifetime as the observer explores; without a hint the GPU
666    /// backend sizes the twin's chunk-space region AND its modular slot
667    /// pool from whatever chunks were resident at the first upload, so a
668    /// later-explored chunk outside that box is unaddressable (the
669    /// marcher never looks there) or aliases an occupied slot (rooms
670    /// flicker). [`crate::FowTwin`] sets this to the REAL grid's full
671    /// chunk bounding box, so the twin's region + pool cover the whole
672    /// ship from the first frame. `None` (the default) = size from the
673    /// grid's own chunks, the pre-FW behaviour. Not serialised
674    /// (transient render state).
675    pub gpu_residency_hint: Option<([i32; 3], [u32; 3])>,
676}
677
678/// PF.12 — how much of a chunk changed since a consumer last synced it.
679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
680pub enum DirtyExtent {
681    /// Unknown / whole-chunk change (installs, wholesale replaces,
682    /// extent-less [`Grid::bump_chunk_version`] calls).
683    Full,
684    /// Inclusive CHUNK-LOCAL voxel bbox covering every change.
685    Bbox(IVec3, IVec3),
686}
687
688impl Grid {
689    /// New empty grid at the given transform — no chunks populated,
690    /// `render_sky = true`, LOD thresholds default to
691    /// [`LodThresholds::always_near`], no billboard cache.
692    #[must_use]
693    pub fn new(transform: GridTransform) -> Self {
694        Self {
695            transform,
696            chunks: HashMap::new(),
697            render_sky: true,
698            mip_levels_override: None,
699            z_clip: None,
700            lod_thresholds: LodThresholds::always_near(),
701            billboards: None,
702            generator: None,
703            name: None,
704            store: None,
705            stream_radius: StreamRadius::DISABLED,
706            bake_lights: Vec::new(),
707            water_volumes: Vec::new(),
708            chunk_versions: HashMap::new(),
709            pending_gen: HashSet::new(),
710            dda_brick_cache: roxlap_core::BrickCache::new(),
711            chunk_dirty: HashMap::new(),
712            mutations: 0,
713            last_bricks: None,
714            render_excluded: false,
715            presentation_only: false,
716            gpu_residency_hint: None,
717        }
718    }
719
720    /// FW.1 — the render predicate behind [`Scene::render_grids`]: a
721    /// grid is drawn (primary + shadow) unless it is the
722    /// [`Self::render_excluded`] real fog-of-war grid. Single source of
723    /// the rule so it can't drift across the render sites.
724    #[must_use]
725    pub fn renderable(&self) -> bool {
726        !self.render_excluded
727    }
728
729    /// FW.1 — the query predicate behind [`Scene::query_grids`]: a grid
730    /// answers gameplay queries (raycast, collision, audio, streaming,
731    /// snapshot) unless it is a [`Self::presentation_only`] twin.
732    /// Single source of the rule.
733    #[must_use]
734    pub fn queryable(&self) -> bool {
735        !self.presentation_only
736    }
737
738    /// Ensure the DDA brick cache holds current mip-`requested_mip`
739    /// occupancy maps for every populated chunk, rebuilding only chunks
740    /// whose edit version changed (Substage DDA.7). Clamps the mip to a
741    /// level every chunk has built (so coarse rendering never holes) and
742    /// returns that effective mip. Evicts cache entries for chunks no
743    /// longer present. Call once per frame before the DDA render.
744    pub fn ensure_dda_bricks(&mut self, requested_mip: u32) -> u32 {
745        // Split-borrow disjoint fields so the cache mutates while the
746        // chunks + versions are read.
747        // PF.13 (H9) — quiet frame at the same requested mip ⇒ nothing
748        // in the cache can be stale: skip the whole O(chunks) sweep.
749        if let Some((counter, req, eff)) = self.last_bricks {
750            if counter == self.mutations && req == requested_mip {
751                return eff;
752            }
753        }
754        let mutations = self.mutations;
755        let Self {
756            chunks,
757            chunk_versions,
758            dda_brick_cache,
759            ..
760        } = self;
761        // Effective uniform mip: min built mip across chunks, capped.
762        let mut mip = requested_mip;
763        if requested_mip > 0 {
764            for vxl in chunks.values() {
765                mip = mip.min(vxl.mip_count().saturating_sub(1));
766            }
767        }
768        for (idx, vxl) in chunks.iter() {
769            let version = chunk_versions.get(idx).copied().unwrap_or(0);
770            let view = roxlap_core::GridView::from_single_vxl(vxl);
771            dda_brick_cache.ensure([idx.x, idx.y, idx.z], mip, version, &view);
772        }
773        dda_brick_cache.retain_chunks(|c| chunks.contains_key(&IVec3::new(c[0], c[1], c[2])));
774        self.last_bricks = Some((mutations, requested_mip, mip));
775        mip
776    }
777
778    /// CA.4 — this grid's precomputed [`CutawayVolume`], or `None`
779    /// when no [`Self::z_clip`] is set or the grid is empty. The
780    /// hoisted form of [`Self::cutaway_hides_point`]: build once per
781    /// frame, then test many points (sprites, particles, lights)
782    /// without re-walking the chunk map or re-inverting the rotation
783    /// per point. Both renderers' sprite passes use exactly this.
784    #[must_use]
785    pub fn cutaway_volume(&self) -> Option<CutawayVolume> {
786        let zc = self.z_clip?;
787        let (blo, bhi) = grid_voxel_aabb_f64(self)?;
788        Some(CutawayVolume {
789            origin: self.transform.origin,
790            rot_inv: self.transform.rotation.inverse(),
791            inv_vws: 1.0 / self.transform.voxel_world_size,
792            xy_lo: [blo.x, blo.y],
793            xy_hi: [bhi.x, bhi.y],
794            z_clip: f64::from(zc),
795        })
796    }
797
798    /// CA.4 — the cutaway **footprint rule** for world-positioned
799    /// objects (sprite instances, actors, particles): `true` iff this
800    /// grid's [`Self::z_clip`] hides the world-space point — the
801    /// point, mapped into the grid's local frame, lands inside the
802    /// grid's materialised **XY chunk footprint** with local voxel
803    /// `z < z_clip`. Points outside the footprint are NEVER affected
804    /// (clipping the ship's upper decks must not hide a character
805    /// standing on the ground beside the ship, even when they share a
806    /// world height). `false` when no clip is set or the grid is
807    /// empty. Testing many points per frame? Hoist the volume once via
808    /// [`Self::cutaway_volume`].
809    #[must_use]
810    pub fn cutaway_hides_point(&self, world: DVec3) -> bool {
811        self.cutaway_volume().is_some_and(|v| v.hides_point(world))
812    }
813
814    /// Current per-chunk edit version (S7.2). Returns `0` for any
815    /// chunk that hasn't been edited yet (including absent chunks
816    /// and chunks materialised only via
817    /// [`Self::ensure_chunk_generated`]).
818    ///
819    /// Used by S7.3's async generation dispatch to detect "edit
820    /// happened while we were generating" — the dispatcher
821    /// snapshots this value, the background task carries it, and
822    /// the result is discarded on install if the live counter has
823    /// since moved.
824    #[must_use]
825    pub fn chunk_version(&self, chunk_idx: IVec3) -> u64 {
826        self.chunk_versions.get(&chunk_idx).copied().unwrap_or(0)
827    }
828
829    /// Bump the edit version of `chunk_idx` (S7.2). Saturating add
830    /// at `u64::MAX` — a chunk would need 10^11 edits per second
831    /// for ~5 years to wrap, so saturation is a defensive cap, not
832    /// a realistic concern.
833    ///
834    /// Called by the edit API ([`Self::set_voxel`] /
835    /// [`Self::set_rect`] / [`Self::set_sphere`]) after a chunk
836    /// has actually been written to. Pure no-op edit paths
837    /// (carving from an air chunk that doesn't exist yet) skip
838    /// the bump.
839    ///
840    /// Exposed as `pub` (vs the historical `pub(crate)`) so hosts
841    /// that mutate `grid.chunks` directly — e.g.
842    /// `roxlap-scene-demo`'s `StreamingBakeTracker` writing
843    /// lightmode-1 alphas via `apply_lighting_with_cache` — can
844    /// signal "this chunk's slab changed" to downstream consumers
845    /// like the GPU dirty-chunk poller.
846    pub fn bump_chunk_version(&mut self, chunk_idx: IVec3) {
847        let entry = self.chunk_versions.entry(chunk_idx).or_insert(0);
848        *entry = entry.saturating_add(1);
849        // PF.12 — no extent information ⇒ the whole chunk must be
850        // treated as changed by partial-refresh consumers.
851        self.chunk_dirty.insert(chunk_idx, DirtyExtent::Full);
852        self.mutations = self.mutations.wrapping_add(1);
853    }
854
855    /// PF.13 (H9) — the grid's monotonic mutation counter: bumped by
856    /// every chunk edit, install, and eviction. Per-frame consumers
857    /// snapshot it to skip their whole-grid scans on quiet frames.
858    #[must_use]
859    pub fn mutation_counter(&self) -> u64 {
860        self.mutations
861    }
862
863    /// PF.12 — [`Self::bump_chunk_version`] with the edit's CHUNK-LOCAL
864    /// voxel extent (inclusive), so partial-refresh consumers (the GPU
865    /// facade) and incremental re-mip know how little actually changed.
866    /// Extents accumulate (bbox union) until a consumer
867    /// [`Self::take_chunk_dirty`]s them; an extent-less bump upgrades
868    /// the entry to [`DirtyExtent::Full`].
869    pub fn bump_chunk_version_bbox(&mut self, chunk_idx: IVec3, lo: IVec3, hi: IVec3) {
870        let entry = self.chunk_versions.entry(chunk_idx).or_insert(0);
871        *entry = entry.saturating_add(1);
872        let merged = match self.chunk_dirty.get(&chunk_idx) {
873            Some(DirtyExtent::Full) => DirtyExtent::Full,
874            Some(DirtyExtent::Bbox(l, h)) => DirtyExtent::Bbox(l.min(lo), h.max(hi)),
875            None => DirtyExtent::Bbox(lo, hi),
876        };
877        self.chunk_dirty.insert(chunk_idx, merged);
878        self.mutations = self.mutations.wrapping_add(1);
879    }
880
881    /// PF.12 — take (and clear) the extent accumulated for `chunk_idx`
882    /// since the last take. `None` ⇒ no recorded change (a consumer that
883    /// still observed a version bump should treat that as
884    /// [`DirtyExtent::Full`] — e.g. a change recorded before the
885    /// consumer first synced the chunk).
886    pub fn take_chunk_dirty(&mut self, chunk_idx: IVec3) -> Option<DirtyExtent> {
887        self.chunk_dirty.remove(&chunk_idx)
888    }
889
890    /// The full per-chunk edit-version map (QE.3b — the read half of
891    /// the previously `pub` field). Consumers seeding a sync tracker
892    /// iterate this; per-chunk reads go through
893    /// [`Self::chunk_version`].
894    #[must_use]
895    pub fn chunk_versions(&self) -> &HashMap<IVec3, u64> {
896        &self.chunk_versions
897    }
898
899    /// QE.3b — record a chunk-**set** mutation (materialise / install /
900    /// evict) on the PF.13 quiet-frame counter. The single entry point
901    /// for the counter besides the version bumps above; per-frame
902    /// consumers compare [`Self::mutation_counter`] snapshots.
903    pub(crate) fn note_chunk_set_changed(&mut self) {
904        self.mutations = self.mutations.wrapping_add(1);
905    }
906
907    /// QE.3b — drop `chunk_idx`'s per-chunk tracking on eviction, so
908    /// both maps stay bounded by the live chunk count. (Also clears any
909    /// accumulated [`DirtyExtent`] — pre-QE.3b that entry leaked until
910    /// a consumer happened to take it.) A future re-stream of the same
911    /// index restarts at version 0.
912    pub(crate) fn forget_chunk_tracking(&mut self, chunk_idx: IVec3) {
913        self.chunk_versions.remove(&chunk_idx);
914        self.chunk_dirty.remove(&chunk_idx);
915    }
916
917    /// QE.3b — seat a restored per-chunk version verbatim (the
918    /// [`snapshot`] load path; not an edit, so no extent / counter
919    /// side-effects).
920    pub(crate) fn restore_chunk_version(&mut self, chunk_idx: IVec3, version: u64) {
921        self.chunk_versions.insert(chunk_idx, version);
922    }
923
924    /// Attach (or detach) the procedural generator used by
925    /// [`Self::ensure_chunk_generated`] (S7.0).
926    ///
927    /// Pass `Some(Arc::new(generator))` to enable on-demand chunk
928    /// generation; pass `None` to revert to the "absent stays
929    /// absent" behaviour. Replacing an existing generator drops the
930    /// previous `Arc` clone without touching already-materialised
931    /// chunks. Any background tasks dispatched by a prior
932    /// [`Scene::pump_streaming`] hold their own clones of the old
933    /// generator and finish naturally.
934    pub fn set_generator(&mut self, generator: Option<Arc<dyn ChunkGenerator>>) {
935        self.generator = generator;
936    }
937
938    /// Attach (or detach) the persistence store for edited streamed
939    /// chunks (QE.5a; see [`ChunkStore`]). Without one, evicting an
940    /// edited chunk **discards the edits** — the pre-QE.5 default.
941    pub fn set_chunk_store(&mut self, store: Option<Arc<dyn ChunkStore>>) {
942        self.store = store;
943    }
944
945    /// Materialise the chunk at `chunk_idx` by running [`Self::generator`]
946    /// if (a) the chunk is not already present and (b) a generator
947    /// is attached. Returns `true` iff a chunk was newly generated.
948    ///
949    /// No-ops in all other cases:
950    /// - chunk already present (caller edits / a previous
951    ///   `ensure_chunk_generated` call already populated it),
952    /// - no generator attached (the chunk stays implicit-air per
953    ///   the existing convention — does NOT fall through to
954    ///   [`Self::ensure_chunk`]'s empty-chunk constructor).
955    ///
956    /// This is the synchronous S7.0 path; [`Scene::pump_streaming`]
957    /// is the async counterpart (generation + store loads on a
958    /// dedicated rayon pool).
959    ///
960    /// QE.5a: with a [`ChunkStore`] attached, a stored chunk is
961    /// installed (with its persisted edit version) **before** the
962    /// generator is consulted — including for indices the generator's
963    /// [`ChunkGenerator::should_generate`] declines, since edits can
964    /// materialise chunks the generator never would.
965    pub fn ensure_chunk_generated(&mut self, chunk_idx: IVec3) -> bool {
966        if self.chunks.contains_key(&chunk_idx) {
967            return false;
968        }
969        // QE.5a — persisted edits win over regeneration.
970        if let Some((vxl, version)) = self.store.as_ref().and_then(|store| store.load(chunk_idx)) {
971            self.chunks.insert(chunk_idx, vxl);
972            self.restore_chunk_version(chunk_idx, version);
973            self.note_chunk_set_changed();
974            self.billboards = None; // same invalidation as below
975            return true;
976        }
977        let Some(generator) = self.generator.as_ref() else {
978            return false;
979        };
980        // S7.6+: generator may decline specific indices (e.g. a
981        // single-z-layer generator skipping placeholder bedrock
982        // chunks at chz != 0). Respect the filter so we don't
983        // materialise an unwanted chunk.
984        if !generator.should_generate(chunk_idx) {
985            return false;
986        }
987        let chunk = generator.generate(chunk_idx);
988        self.chunks.insert(chunk_idx, chunk);
989        self.note_chunk_set_changed();
990        // S7.4: a fresh chunk grows the populated AABB → the
991        // bounding sphere shifts/expands → existing impostor
992        // projections become wrong. Match the eviction (S7.1) +
993        // edit (S6.2) invalidation contract and drop the cache.
994        // Next Far-tier render rebuilds lazily.
995        self.billboards = None;
996        true
997    }
998
999    /// FW.4 — the grid's XY FOOTPRINT as grid-local cell (mip-0 voxel)
1000    /// bounds `[lo, hi)`: the whole ship's extent, so a fog point can be
1001    /// classified as "over the grid" vs "open space" (review #1, hazard
1002    /// 3). Prefers [`Self::gpu_residency_hint`] (a fog twin's full-ship
1003    /// bbox — bigger than its copy-on-seen chunk subset); falls back to
1004    /// the materialised chunk XY bbox. `None` for an empty grid with no
1005    /// hint.
1006    #[must_use]
1007    pub fn footprint_cells(&self) -> Option<(glam::IVec2, glam::IVec2)> {
1008        let cs = CHUNK_SIZE_XY as i32;
1009        if let Some((oc, dims)) = self.gpu_residency_hint {
1010            let lo = glam::IVec2::new(oc[0] * cs, oc[1] * cs);
1011            let hi = glam::IVec2::new((oc[0] + dims[0] as i32) * cs, (oc[1] + dims[1] as i32) * cs);
1012            return Some((lo, hi));
1013        }
1014        if self.chunks.is_empty() {
1015            return None;
1016        }
1017        let (mut min, mut max) = (glam::IVec2::splat(i32::MAX), glam::IVec2::splat(i32::MIN));
1018        for idx in self.chunks.keys() {
1019            min = min.min(idx.truncate());
1020            max = max.max(idx.truncate());
1021        }
1022        Some((min * cs, (max + glam::IVec2::ONE) * cs))
1023    }
1024
1025    /// Bounding-sphere radius of the populated chunk set in
1026    /// **world** space (SC.3 — the voxel half-extent is scaled by
1027    /// `voxel_world_size`, so it pairs directly with the world-distance
1028    /// LOD thresholds in [`crate::LodThresholds::from_radius`]).
1029    ///
1030    /// Walks the sparse chunk map once, computes the chunk-index
1031    /// AABB, converts to voxel-space half-extent, returns its
1032    /// Euclidean length. Empty grid → `0.0`.
1033    ///
1034    /// Conservative — bounds the full chunk volume, not just its
1035    /// populated voxels (a chunk containing one voxel still
1036    /// contributes `CHUNK_SIZE_XY × CHUNK_SIZE_XY × CHUNK_SIZE_Z`
1037    /// to the bbox). For LOD picking that's fine: an over-bound
1038    /// sphere errs on the side of `Near`.
1039    ///
1040    /// Cost: `O(chunks.len())`; recomputed on every call. Callers
1041    /// who need this every frame should memoize at the
1042    /// [`Scene`]-level cache (added when S6.2 needs it).
1043    #[must_use]
1044    pub fn bounding_radius(&self) -> f64 {
1045        if self.chunks.is_empty() {
1046            return 0.0;
1047        }
1048        let mut min = IVec3::splat(i32::MAX);
1049        let mut max = IVec3::splat(i32::MIN);
1050        for &idx in self.chunks.keys() {
1051            min = min.min(idx);
1052            max = max.max(idx);
1053        }
1054        // Chunk-index bbox → voxel-space half-extent. `+1` on max
1055        // converts inclusive chunk index to exclusive voxel upper
1056        // bound (chunk `idx` covers voxels `[idx*size, (idx+1)*size)`).
1057        let sx = f64::from(CHUNK_SIZE_XY);
1058        let sz = f64::from(CHUNK_SIZE_Z);
1059        let lo = DVec3::new(
1060            f64::from(min.x) * sx,
1061            f64::from(min.y) * sx,
1062            f64::from(min.z) * sz,
1063        );
1064        let hi = DVec3::new(
1065            f64::from(max.x + 1) * sx,
1066            f64::from(max.y + 1) * sx,
1067            f64::from(max.z + 1) * sz,
1068        );
1069        let half_extent = (hi - lo) * 0.5;
1070        // SC.3 — voxel half-extent → world radius (byte-identical at vws==1).
1071        half_extent.length() * self.transform.voxel_world_size
1072    }
1073
1074    /// Pick this grid's LOD tier for the given world-space camera
1075    /// position. Convenience wrapper around [`crate::select_lod`]
1076    /// that pulls [`Self::lod_thresholds`] from the grid.
1077    #[must_use]
1078    pub fn select_lod(&self, camera_world_pos: DVec3) -> Lod {
1079        select_lod(camera_world_pos, &self.transform, self.lod_thresholds)
1080    }
1081}
1082
1083/// Top-level scene container. Holds a flat collection of grids
1084/// keyed by [`GridId`].
1085///
1086/// S2.0 only exposes registration / removal / lookup. Address math
1087/// helpers (S2.x), edit API (S2.x), and rendering composition (S3)
1088/// land in later sub-substages.
1089#[derive(Debug, Default)]
1090pub struct Scene {
1091    grids: HashMap<GridId, Grid>,
1092    next_grid_id: u32,
1093    /// S7.3: per-scene streaming pool + result channel. Stored on
1094    /// the `Scene` so `pump_streaming` can dispatch background
1095    /// tasks and drain results across pump calls. `cfg`-gated out
1096    /// on wasm32 where `pump_streaming` short-circuits to
1097    /// `pump_streaming_sync` (no rayon pool there).
1098    #[cfg(not(target_arch = "wasm32"))]
1099    streaming: streaming::StreamingState,
1100}
1101
1102impl Scene {
1103    /// New empty scene — no grids.
1104    #[must_use]
1105    pub fn new() -> Self {
1106        Self::default()
1107    }
1108
1109    /// Number of grids currently registered.
1110    #[must_use]
1111    pub fn grid_count(&self) -> usize {
1112        self.grids.len()
1113    }
1114
1115    /// Register a new grid. Returns its fresh, unique [`GridId`].
1116    ///
1117    /// SC — debug-asserts `transform.voxel_world_size` is finite and `> 0`
1118    /// (catches a direct `GridTransform { .. }` literal that bypasses
1119    /// [`GridTransform::at_scale`]); a `0` breaks the scaled marcher.
1120    pub fn add_grid(&mut self, transform: GridTransform) -> GridId {
1121        debug_assert!(
1122            transform.voxel_world_size.is_finite() && transform.voxel_world_size > 0.0,
1123            "grid voxel_world_size must be finite and > 0, got {}",
1124            transform.voxel_world_size
1125        );
1126        let id = GridId(self.next_grid_id);
1127        self.next_grid_id += 1;
1128        self.grids.insert(id, Grid::new(transform));
1129        id
1130    }
1131
1132    /// Remove a grid by id. Returns the removed [`Grid`] (so the
1133    /// caller can reclaim its chunks) or `None` if the id wasn't
1134    /// registered. Removed ids are not reissued.
1135    pub fn remove_grid(&mut self, id: GridId) -> Option<Grid> {
1136        self.grids.remove(&id)
1137    }
1138
1139    /// Borrow a registered grid.
1140    #[must_use]
1141    pub fn grid(&self, id: GridId) -> Option<&Grid> {
1142        self.grids.get(&id)
1143    }
1144
1145    /// Mutably borrow a registered grid.
1146    pub fn grid_mut(&mut self, id: GridId) -> Option<&mut Grid> {
1147        self.grids.get_mut(&id)
1148    }
1149
1150    /// CA.0 — set (or clear, with `None`) a grid's cutaway clip
1151    /// ([`Grid::z_clip`]): both renderers treat voxels with
1152    /// `z < z_clip` (grid-local absolute voxel z, z-down) as air,
1153    /// exposing the interior below for an isometric "deck view".
1154    /// Render-only — collision, audio and simulation still see the
1155    /// full grid. Returns `false` if `id` is not a registered grid.
1156    pub fn set_grid_z_clip(&mut self, id: GridId, z_clip: Option<i32>) -> bool {
1157        match self.grids.get_mut(&id) {
1158            Some(g) => {
1159                // CA — Far-tier impostors bake the clip into their
1160                // snapshots; drop a cache built under a different one
1161                // (the Far dispatch also self-heals via
1162                // `BillboardCache::built_z_clip` for direct field
1163                // writes — this just frees the memory sooner).
1164                if g.z_clip != z_clip {
1165                    g.billboards = None;
1166                }
1167                g.z_clip = z_clip;
1168                true
1169            }
1170            None => false,
1171        }
1172    }
1173
1174    /// CA.4 — whether ANY grid's cutaway clip hides the world-space
1175    /// point under the footprint rule ([`Grid::cutaway_hides_point`]).
1176    /// The per-instance test both renderers apply to sprites; cheap
1177    /// when no grid has a clip set (each unclipped grid early-outs).
1178    #[must_use]
1179    pub fn cutaway_hides_point(&self, world: DVec3) -> bool {
1180        // FW.1 — CA.4 is a RENDER rule: it must iterate the SAME grid
1181        // set the sprite cull collects cutaway volumes from
1182        // (`render_grids` — the drawn twin, not the excluded real grid),
1183        // or a lamp culled by the host would sit under a still-drawn
1184        // sprite where fog is active. Both sites use `render_grids`.
1185        self.render_grids()
1186            .any(|(_, g)| g.cutaway_hides_point(world))
1187    }
1188
1189    /// Iterator over all `(id, grid)` pairs in registration order
1190    /// is **not** guaranteed — the underlying map is a `HashMap`.
1191    /// Callers that need a stable order must sort by [`GridId`].
1192    pub fn grids(&self) -> impl Iterator<Item = (GridId, &Grid)> {
1193        self.grids.iter().map(|(id, g)| (*id, g))
1194    }
1195
1196    /// Mutable iterator over all `(id, grid)` pairs. Yield order
1197    /// is not guaranteed (HashMap-backed).
1198    pub fn grids_mut(&mut self) -> impl Iterator<Item = (GridId, &mut Grid)> {
1199        self.grids.iter_mut().map(|(id, g)| (*id, g))
1200    }
1201
1202    /// FW.1 — grids the renderers draw: every grid EXCEPT one flagged
1203    /// [`Grid::render_excluded`] (the real fog-of-war grid, shown via
1204    /// its known twin instead). Both render passes — primary AND the
1205    /// shadow occluder — iterate this, so unseen live geometry casts no
1206    /// shadow either. With no FW grids this yields every grid, so
1207    /// renders stay byte-identical.
1208    pub fn render_grids(&self) -> impl Iterator<Item = (GridId, &Grid)> {
1209        self.grids
1210            .iter()
1211            .filter(|(_, g)| g.renderable())
1212            .map(|(id, g)| (*id, g))
1213    }
1214
1215    /// FW.1 — mutable [`Self::render_grids`] (the render caches / LOD
1216    /// bookkeeping phase).
1217    pub fn render_grids_mut(&mut self) -> impl Iterator<Item = (GridId, &mut Grid)> {
1218        self.grids
1219            .iter_mut()
1220            .filter(|(_, g)| g.renderable())
1221            .map(|(id, g)| (*id, g))
1222    }
1223
1224    /// FW.1 — grids visible to gameplay: every grid EXCEPT one flagged
1225    /// [`Grid::presentation_only`] (a fog-of-war twin — derived visual
1226    /// state that must never answer a query). Raycasts, `resolve_voxel`,
1227    /// collision, cutaway hit-tests, water, audio occlusion and
1228    /// streaming iterate this, never [`Self::grids`]. With no FW grids
1229    /// it yields every grid, so query results are unchanged.
1230    pub fn query_grids(&self) -> impl Iterator<Item = (GridId, &Grid)> {
1231        self.grids
1232            .iter()
1233            .filter(|(_, g)| g.queryable())
1234            .map(|(id, g)| (*id, g))
1235    }
1236
1237    /// FW.1 — mutable [`Self::query_grids`] (streaming / eviction).
1238    pub fn query_grids_mut(&mut self) -> impl Iterator<Item = (GridId, &mut Grid)> {
1239        self.grids
1240            .iter_mut()
1241            .filter(|(_, g)| g.queryable())
1242            .map(|(id, g)| (*id, g))
1243    }
1244
1245    /// Resolve a world-space surface hit to the owning grid + its
1246    /// grid-local voxel — the picking back half. `ray_dir` is the view
1247    /// direction the hit was found along (need not be normalised); the
1248    /// point is nudged half a voxel along it, past the surface and into
1249    /// the solid cell, before each grid's [`Grid::voxel_solid`] test.
1250    /// Returns the first grid that is solid there (transform-correct
1251    /// for rotated/translated grids), or `None` if none claims it.
1252    ///
1253    /// Backend-agnostic: pair with a renderer depth read to turn a
1254    /// click into a voxel — `world = cam.pos + t · normalize(ray_dir)`,
1255    /// then `resolve_voxel(world, ray_dir)`. `roxlap-render`'s
1256    /// `SceneRenderer::pick` wires exactly that.
1257    #[must_use]
1258    pub fn resolve_voxel(&self, world: DVec3, ray_dir: DVec3) -> Option<(GridId, IVec3)> {
1259        let len = ray_dir.length();
1260        if len < 1e-9 {
1261            return None;
1262        }
1263        let inside = world + ray_dir * (0.5 / len); // half a voxel inward
1264        for (id, grid) in self.query_grids() {
1265            let glp = addr::world_to_grid_local(inside, &grid.transform);
1266            let v = addr::voxel_global(glp.chunk, glp.voxel);
1267            if grid.voxel_solid(v) {
1268                return Some((id, v));
1269            }
1270        }
1271        None
1272    }
1273
1274    /// Cast a world-space ray and return the nearest solid voxel hit
1275    /// across all grids, or `None` if nothing solid lies within
1276    /// `max_dist`. Renderer-independent (no depth buffer, no camera) —
1277    /// the primitive for line-of-sight, projectiles, AI probing, and
1278    /// off-screen / backend-agnostic picking.
1279    ///
1280    /// `dir` need not be normalised. Each grid's ray is transformed
1281    /// into the grid's local frame (so rotated / translated grids are
1282    /// handled exactly) and marched with a voxel DDA against
1283    /// [`Grid::voxel_solid`]; the closest hit by world distance `t`
1284    /// wins. The step budget is bounded by `max_dist`, so empty space
1285    /// is safe but not free — a chunk-level skip is a future
1286    /// optimisation if hot.
1287    #[must_use]
1288    pub fn raycast(&self, origin: DVec3, dir: DVec3, max_dist: f64) -> Option<RayHit> {
1289        self.raycast_impl(origin, dir, max_dist, false)
1290    }
1291
1292    /// CA.4 — clip-aware [`Self::raycast`] variant for click-to-select
1293    /// on decks: voxels each grid's [`Grid::z_clip`] hides read as air,
1294    /// so the ray lands on what the CUT render shows (the exposed
1295    /// interior) instead of the hidden hull above it. Grids without a
1296    /// clip behave exactly as in `raycast`. Use the plain `raycast`
1297    /// for gameplay traces — hidden decks still block projectiles and
1298    /// line-of-sight (the clip is render/picking-only).
1299    #[must_use]
1300    pub fn raycast_clipped(&self, origin: DVec3, dir: DVec3, max_dist: f64) -> Option<RayHit> {
1301        self.raycast_impl(origin, dir, max_dist, true)
1302    }
1303
1304    fn raycast_impl(
1305        &self,
1306        origin: DVec3,
1307        dir: DVec3,
1308        max_dist: f64,
1309        apply_clip: bool,
1310    ) -> Option<RayHit> {
1311        let len = dir.length();
1312        if len < 1e-12 || max_dist <= 0.0 {
1313            return None;
1314        }
1315        let dn = dir / len; // unit world direction → t is world distance
1316        let mut best: Option<RayHit> = None;
1317        for (id, grid) in self.query_grids() {
1318            // World ray → grid-local voxel space. `ld = inv * dn` stays
1319            // UNIT (rotation preserves length), so `voxel_dda` marches
1320            // in voxel units and returns a voxel-distance `t`. SC — the
1321            // grid's `voxel_world_size` scales the boundary both ways:
1322            // the origin divides into voxel coords, the world march cap
1323            // divides into voxel units, and the returned voxel `t`
1324            // multiplies back to a WORLD distance so it stays
1325            // comparable across grids of different scale. `vws == 1.0`
1326            // is the pre-SC path, bit-for-bit.
1327            let inv = grid.transform.rotation.inverse();
1328            let vws = grid.transform.voxel_world_size;
1329            let lo = (inv * (origin - grid.transform.origin)) / vws;
1330            let ld = inv * dn;
1331            // CA.4 — the clipped variant applies each grid's OWN clip.
1332            let z_clip = if apply_clip {
1333                grid.z_clip.unwrap_or(i32::MIN)
1334            } else {
1335                i32::MIN
1336            };
1337            if let Some((voxel, t_local)) = voxel_dda(grid, lo, ld, max_dist / vws, z_clip) {
1338                let t = t_local * vws;
1339                if best.as_ref().is_none_or(|b| t < b.t) {
1340                    best = Some(RayHit {
1341                        grid: id,
1342                        voxel,
1343                        world: origin + dn * t,
1344                        t,
1345                        color: grid.voxel_color(voxel),
1346                    });
1347                }
1348            }
1349        }
1350        best
1351    }
1352
1353    /// Configure the number of worker threads in the dedicated
1354    /// streaming pool (S7.3).
1355    ///
1356    /// Lazily applied — the pool itself is constructed on the first
1357    /// [`Self::pump_streaming`] call. If the pool was already built
1358    /// (i.e. a previous `pump_streaming` already dispatched at
1359    /// least one task), it gets dropped and rebuilt. Dropping the
1360    /// old pool blocks until all of its in-flight tasks finish
1361    /// (rayon's contract); any results those tasks sent are still
1362    /// drained by the next `pump_streaming` because the channel
1363    /// survives the rebuild.
1364    ///
1365    /// The streaming pool is separate from rayon's global pool
1366    /// (which R12 multicore rendering uses), so chunk generation
1367    /// doesn't compete with render threads. Sensible values are 1
1368    /// to ~4 — generation work is CPU-bound but should leave most
1369    /// of the box for everything else.
1370    ///
1371    /// On wasm32 this is a no-op (no rayon pool available);
1372    /// `pump_streaming` runs synchronously there.
1373    ///
1374    /// # Panics
1375    /// Panics on native if `n == 0` (zero-thread pools are not
1376    /// supported; the scene crate's S7.1 helper already disallows
1377    /// the equivalent for `StreamRadius::r_active < 0`).
1378    #[cfg(not(target_arch = "wasm32"))]
1379    pub fn set_streaming_threads(&mut self, n: usize) {
1380        self.streaming.set_thread_count(n);
1381    }
1382
1383    /// wasm32 no-op companion of [`Self::set_streaming_threads`].
1384    /// Lets cross-target code call this unconditionally.
1385    #[cfg(target_arch = "wasm32")]
1386    pub fn set_streaming_threads(&mut self, _n: usize) {
1387        // No streaming pool on wasm32 — see `pump_streaming` docs.
1388    }
1389
1390    /// Asynchronous streaming pump (S7.3).
1391    ///
1392    /// On native, dispatches missing-chunk generations onto a
1393    /// dedicated rayon pool, drains any results that arrived since
1394    /// the last pump, runs the eviction pass, and tracks in-flight
1395    /// tasks in each grid's [`Grid::pending_gen`] set. The drain
1396    /// uses the per-chunk version counter from S7.2 to discard
1397    /// results whose chunk was edited mid-generation.
1398    ///
1399    /// On wasm32 this short-circuits to [`Self::pump_streaming_sync`]
1400    /// — no thread pool is available there, but the same per-grid
1401    /// stream-in / evict semantics apply.
1402    ///
1403    /// Call once per frame from the render thread. Cheap when
1404    /// nothing changed (early-exit on disabled grids, try_recv
1405    /// loops empty fast).
1406    pub fn pump_streaming(&mut self, camera_world_pos: DVec3) {
1407        #[cfg(target_arch = "wasm32")]
1408        {
1409            self.pump_streaming_sync(camera_world_pos);
1410        }
1411        #[cfg(not(target_arch = "wasm32"))]
1412        {
1413            self.pump_streaming_native(camera_world_pos);
1414        }
1415    }
1416
1417    /// Native implementation of [`Self::pump_streaming`].
1418    #[cfg(not(target_arch = "wasm32"))]
1419    fn pump_streaming_native(&mut self, camera_world_pos: DVec3) {
1420        // 1. Drain inbox — install fresh results, drop stale.
1421        while let Ok(result) = self.streaming.rx.try_recv() {
1422            let Some(grid) = self.grids.get_mut(&result.grid_id) else {
1423                // Grid was removed while a generation task was
1424                // in-flight. Drop silently.
1425                continue;
1426            };
1427            // Clearing pending_gen here both for "result delivered"
1428            // and "we shouldn't try to re-dispatch this chunk just
1429            // because it's missing".
1430            let was_pending = grid.pending_gen.remove(&result.chunk_idx);
1431            if !was_pending {
1432                // Either the chunk was evicted (pending cleared in
1433                // the eviction pass below in some prior call), or a
1434                // duplicate result for an already-handled chunk.
1435                continue;
1436            }
1437            if grid.chunks.contains_key(&result.chunk_idx) {
1438                // Some other path (e.g. `ensure_chunk_generated`
1439                // sync helper, or a manual edit's `ensure_chunk`)
1440                // already populated the slot. Don't overwrite.
1441                continue;
1442            }
1443            if grid.chunk_version(result.chunk_idx) != result.version_at_dispatch {
1444                // S7.2 stale-result discard: chunk was edited mid-
1445                // generation.
1446                continue;
1447            }
1448            let Some(vxl) = result.vxl else {
1449                // QE.5a — nothing to install (store miss + generator
1450                // declined); the pending entry is already cleared.
1451                continue;
1452            };
1453            grid.chunks.insert(result.chunk_idx, vxl);
1454            if let Some(version) = result.restored_version {
1455                // QE.5a — a store-restored chunk keeps its persisted
1456                // edit version (consumers see it as edited content).
1457                grid.restore_chunk_version(result.chunk_idx, version);
1458            }
1459            grid.note_chunk_set_changed();
1460            // S7.4: same invalidation contract as the sync
1461            // `ensure_chunk_generated` path — installing a new
1462            // chunk can grow the bounding sphere, so the
1463            // billboard impostor cache must be rebuilt on next
1464            // Far entry. Lazy: only one cache wipe per drain
1465            // batch, the Far render rebuilds afterwards.
1466            grid.billboards = None;
1467        }
1468
1469        // 2. Per-grid: eviction first, then dispatch. Doing evict
1470        //    before dispatch means a chunk that's just left
1471        //    r_active doesn't get re-dispatched on the same pump.
1472        self.streaming.ensure_pool();
1473        // Disjoint sub-field borrows: pool/tx via `&self.streaming.*`,
1474        // grids via `&mut self.grids`. Hold both at once.
1475        let pool: &rayon::ThreadPool = self.streaming.pool.as_ref().expect("ensure_pool just ran");
1476        let tx_template = &self.streaming.tx;
1477        // FW.1 — never stream a presentation-only twin (derived state).
1478        // Inline here (not `query_grids_mut`) because the streaming
1479        // pool/tx borrow of `self.streaming.*` must coexist with the
1480        // `&mut self.grids` borrow; the predicate is shared via
1481        // `Grid::queryable` so the rule stays single-sourced.
1482        for (grid_id, grid) in self.grids.iter_mut().filter(|(_, g)| g.queryable()) {
1483            evict_grid_chunks(grid, camera_world_pos);
1484            dispatch_grid_async(*grid_id, grid, camera_world_pos, pool, tx_template);
1485        }
1486    }
1487
1488    /// Synchronous streaming pump (S7.1).
1489    ///
1490    /// For each grid with a non-[`StreamRadius::DISABLED`] policy:
1491    /// 1. Project the world-space camera into grid-local coords
1492    ///    (inverse rotation + origin subtract).
1493    /// 2. Stream in any chunk whose AABB-to-camera distance is
1494    ///    `<= r_active`, calling [`Grid::ensure_chunk_generated`].
1495    ///    No-ops gracefully if the grid has no generator attached
1496    ///    (so callers can use the eviction half of streaming on a
1497    ///    purely-edited grid).
1498    /// 3. Evict any chunk whose AABB-to-camera distance exceeds
1499    ///    `r_evict` from the grid's chunk map. Eviction also
1500    ///    clears the cached [`BillboardCache`] (the bounding sphere
1501    ///    may shrink, invalidating impostor projections; the next
1502    ///    Far-tier render rebuilds lazily).
1503    ///
1504    /// Both passes use the f64 grid-local position so rotation +
1505    /// non-axis-aligned grids stream and evict correctly. The
1506    /// generate path is blocking — S7.3 will move it to a
1507    /// background rayon pool with `pump_streaming` (non-blocking).
1508    /// Callers that want the async variant in S7.0/S7.1 stages
1509    /// should keep `r_active` small.
1510    pub fn pump_streaming_sync(&mut self, camera_world_pos: DVec3) {
1511        // FW.1 — skip presentation-only twins (derived state).
1512        for (_, grid) in self.query_grids_mut() {
1513            pump_grid_streaming_sync(grid, camera_world_pos);
1514        }
1515    }
1516}
1517
1518/// S7.1 helper — drives one grid's synchronous streaming pass.
1519/// Stream-in pass uses [`Grid::ensure_chunk_generated`] (blocking
1520/// inline generation); eviction pass shared with the S7.3 async
1521/// path through [`evict_grid_chunks`].
1522fn pump_grid_streaming_sync(grid: &mut Grid, camera_world_pos: DVec3) {
1523    let radius = grid.stream_radius;
1524    if radius.is_disabled() {
1525        return;
1526    }
1527    let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
1528
1529    // --- Pass 1: stream in active chunks (sync) ---------------
1530    // QE.5a — a ChunkStore alone (no generator) still restores
1531    // persisted edited chunks.
1532    if radius.r_active > 0.0 && (grid.generator.is_some() || grid.store.is_some()) {
1533        for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
1534            grid.ensure_chunk_generated(idx);
1535        });
1536    }
1537
1538    // --- Pass 2: evict chunks past r_evict --------------------
1539    evict_grid_chunks_with_cam(grid, cam_local);
1540}
1541
1542/// Eviction pass shared by [`pump_grid_streaming_sync`] and the
1543/// S7.3 async path. Public-ish so the async driver can call it
1544/// before dispatching to avoid generating chunks that are about
1545/// to be evicted. `cfg`-gated to native: on wasm32 the only
1546/// caller (`pump_streaming_native`) doesn't compile, so this
1547/// helper would warn as dead code.
1548#[cfg(not(target_arch = "wasm32"))]
1549fn evict_grid_chunks(grid: &mut Grid, camera_world_pos: DVec3) {
1550    let radius = grid.stream_radius;
1551    if radius.is_disabled() {
1552        return;
1553    }
1554    let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
1555    evict_grid_chunks_with_cam(grid, cam_local);
1556}
1557
1558/// Eviction inner — assumes `cam_local` is already computed (the
1559/// dispatcher and sync pump both have it on hand).
1560fn evict_grid_chunks_with_cam(grid: &mut Grid, cam_local: DVec3) {
1561    let radius = grid.stream_radius;
1562    if !radius.r_evict.is_finite() {
1563        return;
1564    }
1565    let r_sq = radius.r_evict * radius.r_evict;
1566    let to_evict: Vec<IVec3> = grid
1567        .chunks
1568        .keys()
1569        .filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
1570        .copied()
1571        .collect();
1572    // S7.3: also evict pending in-flight tasks past r_evict so the
1573    // drain pass doesn't install a chunk that's no longer wanted.
1574    // We don't have a way to cancel the rayon task, but we can
1575    // drop the pending_gen entry so the result is dropped on
1576    // arrival.
1577    let to_evict_pending: Vec<IVec3> = grid
1578        .pending_gen
1579        .iter()
1580        .filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
1581        .copied()
1582        .collect();
1583    if to_evict.is_empty() && to_evict_pending.is_empty() {
1584        return;
1585    }
1586    for idx in &to_evict {
1587        // QE.5a — an edited chunk (version != 0) is handed to the
1588        // ChunkStore before it drops, so the edits survive evict +
1589        // re-stream. Pristine generator output (version 0) is
1590        // regenerable and skips the store.
1591        if let Some(store) = grid.store.as_ref() {
1592            let version = grid.chunk_version(*idx);
1593            if version != 0 {
1594                if let Some(vxl) = grid.chunks.get(idx) {
1595                    store.store(*idx, vxl, version);
1596                }
1597            }
1598        }
1599        grid.chunks.remove(idx);
1600        grid.note_chunk_set_changed();
1601        // S7.2/QE.3b: drop the chunk's version + dirty-extent tracking
1602        // so both maps stay bounded. A future re-stream of the same idx
1603        // restarts at 0 — that's fine because any in-flight gen-result
1604        // tagged with the pre-eviction version is unreachable (no chunk
1605        // to install onto) and gets discarded by the new "version
1606        // still 0" check anyway. (A stored edited chunk re-enters with
1607        // its persisted version via the QE.5a load path instead.)
1608        grid.forget_chunk_tracking(*idx);
1609        // S7.3: drop pending entry for the same chunk too. If a
1610        // background task is still running, its result will be
1611        // dropped on arrival (was_pending = false).
1612        grid.pending_gen.remove(idx);
1613    }
1614    for idx in &to_evict_pending {
1615        grid.pending_gen.remove(idx);
1616    }
1617    if !to_evict.is_empty() {
1618        // Bounding sphere can shrink → impostor projections would
1619        // be wrong on next Far render. Clear lazily; the next
1620        // Far-tier pass repopulates via BillboardCache::build.
1621        grid.billboards = None;
1622    }
1623}
1624
1625/// Walk every chunk index whose AABB falls within `r_active` of
1626/// `cam_local` and invoke `f` on it. Shared between the S7.1 sync
1627/// stream-in and the S7.3 async dispatch.
1628fn for_each_chunk_in_radius<F>(cam_local: DVec3, r_active: f64, mut f: F)
1629where
1630    F: FnMut(IVec3),
1631{
1632    let r_sq = r_active * r_active;
1633    let sxy = f64::from(CHUNK_SIZE_XY);
1634    let sz = f64::from(CHUNK_SIZE_Z);
1635    // Half-extent in chunk units; ceil to be conservative so any
1636    // chunk whose AABB clips the radius gets considered. `+1`
1637    // covers the half-open chunk-AABB upper edge plus the case
1638    // where the camera sits exactly on a chunk boundary and the
1639    // closest chunk is one index off.
1640    #[allow(clippy::cast_possible_truncation)]
1641    let r_chunks_xy = (r_active / sxy).ceil() as i32 + 1;
1642    #[allow(clippy::cast_possible_truncation)]
1643    let r_chunks_z = (r_active / sz).ceil() as i32 + 1;
1644    #[allow(clippy::cast_possible_truncation)]
1645    let cx_chunk = (cam_local.x / sxy).floor() as i32;
1646    #[allow(clippy::cast_possible_truncation)]
1647    let cy_chunk = (cam_local.y / sxy).floor() as i32;
1648    #[allow(clippy::cast_possible_truncation)]
1649    let cz_chunk = (cam_local.z / sz).floor() as i32;
1650    for chz in (cz_chunk - r_chunks_z)..=(cz_chunk + r_chunks_z) {
1651        for chy in (cy_chunk - r_chunks_xy)..=(cy_chunk + r_chunks_xy) {
1652            for chx in (cx_chunk - r_chunks_xy)..=(cx_chunk + r_chunks_xy) {
1653                let idx = IVec3::new(chx, chy, chz);
1654                if streaming::chunk_aabb_dist_sq(cam_local, idx) <= r_sq {
1655                    f(idx);
1656                }
1657            }
1658        }
1659    }
1660}
1661
1662/// S7.3 async dispatch — schedule generation for every chunk in
1663/// `r_active` that's not already present and not already in
1664/// flight. Each dispatch clones the grid's generator `Arc` and a
1665/// sender clone, then spawns the closure on the streaming rayon
1666/// pool. The closure does the generate + send; the main thread
1667/// drains results on the next pump.
1668#[cfg(not(target_arch = "wasm32"))]
1669fn dispatch_grid_async(
1670    grid_id: GridId,
1671    grid: &mut Grid,
1672    camera_world_pos: DVec3,
1673    pool: &rayon::ThreadPool,
1674    tx: &crossbeam_channel::Sender<streaming::ChunkResult>,
1675) {
1676    let radius = grid.stream_radius;
1677    if radius.is_disabled() || radius.r_active <= 0.0 {
1678        return;
1679    }
1680    // QE.5a — a ChunkStore alone (no generator) still restores
1681    // persisted edited chunks on the background pool.
1682    let generator = grid.generator.as_ref().map(Arc::clone);
1683    let store = grid.store.as_ref().map(Arc::clone);
1684    if generator.is_none() && store.is_none() {
1685        return;
1686    }
1687    let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
1688    for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
1689        if grid.chunks.contains_key(&idx) {
1690            return; // already present
1691        }
1692        if grid.pending_gen.contains(&idx) {
1693            return; // already in flight
1694        }
1695        // S7.6+: respect the generator's per-chunk filter — same
1696        // contract as `Grid::ensure_chunk_generated` (sync helper).
1697        // Lets a generator decline to materialise specific indices
1698        // (e.g. `HillsChunkGenerator` skipping placeholder bedrock
1699        // chunks at chz != 0 so the camera-above-grid path doesn't
1700        // create chz < 0 entries that would shift `origin_chunk_z`
1701        // and trigger the S4B.6.j cross-chunk look-down bug).
1702        // QE.5a — with a store attached the chunk is still
1703        // dispatched: a persisted edit wins over the decline (edits
1704        // can materialise chunks the generator never would); the
1705        // background task falls back to "nothing" on a store miss.
1706        let declined = !generator.as_ref().is_some_and(|g| g.should_generate(idx));
1707        if declined && store.is_none() {
1708            return;
1709        }
1710        grid.pending_gen.insert(idx);
1711        let version_at_dispatch = grid.chunk_version(idx);
1712        let tx_clone = tx.clone();
1713        let gen_clone = generator.clone();
1714        let store_clone = store.clone();
1715        pool.spawn(move || {
1716            // QE.5a — persisted edits win over regeneration; the
1717            // store load runs here on the pool so blocking IO never
1718            // stalls the render thread.
1719            let (vxl, restored_version) = match store_clone.as_ref().and_then(|s| s.load(idx)) {
1720                Some((vxl, version)) => (Some(vxl), Some(version)),
1721                None if declined => (None, None),
1722                None => (gen_clone.map(|g| g.generate(idx)), None),
1723            };
1724            // Send is non-blocking on unbounded channel; if the
1725            // receiver was dropped (Scene drop), the send fails
1726            // silently — that's fine.
1727            let _ = tx_clone.send(streaming::ChunkResult {
1728                grid_id,
1729                chunk_idx: idx,
1730                version_at_dispatch,
1731                vxl,
1732                restored_version,
1733            });
1734        });
1735    });
1736}
1737
1738#[cfg(test)]
1739mod tests {
1740    use super::*;
1741
1742    #[test]
1743    fn empty_scene_has_no_grids() {
1744        let scene = Scene::new();
1745        assert_eq!(scene.grid_count(), 0);
1746        assert!(scene.grids().next().is_none());
1747    }
1748
1749    #[test]
1750    fn raycast_hits_axis_aligned_voxel() {
1751        let mut scene = Scene::new();
1752        let id = scene.add_grid(GridTransform::identity());
1753        scene
1754            .grid_mut(id)
1755            .unwrap()
1756            .set_voxel(IVec3::new(5, 5, 10), Some(VoxColor(0x80_aa_bb_cc)));
1757
1758        // Straight down the +z column through (5,5): hits z=10 at t≈10.
1759        let hit = scene
1760            .raycast(DVec3::new(5.5, 5.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1761            .expect("ray hits the voxel");
1762        assert_eq!(hit.grid, id);
1763        assert_eq!(hit.voxel, IVec3::new(5, 5, 10));
1764        assert!((hit.t - 10.0).abs() < 1e-6, "t≈10, got {}", hit.t);
1765        assert!(hit.color.is_some(), "textured voxel has a colour");
1766
1767        // A column with no voxel misses.
1768        assert!(
1769            scene
1770                .raycast(DVec3::new(0.5, 0.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1771                .is_none(),
1772            "empty column → no hit",
1773        );
1774    }
1775
1776    #[test]
1777    fn raycast_respects_grid_transform() {
1778        // A translated grid: the hit voxel is reported in GRID-LOCAL
1779        // coords, and the world hit point is back in world space — so a
1780        // host gets the true voxel regardless of where the grid sits.
1781        let mut scene = Scene::new();
1782        let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
1783        scene
1784            .grid_mut(id)
1785            .unwrap()
1786            .set_voxel(IVec3::new(5, 5, 10), Some(VoxColor(0x80_11_22_33)));
1787
1788        let hit = scene
1789            .raycast(DVec3::new(105.5, 5.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1790            .expect("ray hits the translated voxel");
1791        assert_eq!(hit.voxel, IVec3::new(5, 5, 10), "grid-local voxel");
1792        assert!((hit.world.x - 105.5).abs() < 1e-6, "world x preserved");
1793        assert!((hit.t - 10.0).abs() < 1e-6, "t≈10, got {}", hit.t);
1794    }
1795
1796    /// CA.4 — the footprint rule: a clipped grid hides world points
1797    /// that map inside its materialised XY chunk footprint with local
1798    /// `z < z_clip`; points below the plane or outside the footprint
1799    /// are never affected, and an unclipped grid hides nothing.
1800    #[test]
1801    fn cutaway_footprint_rule_hides_points() {
1802        let mut scene = Scene::new();
1803        let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
1804        // Materialise chunk (0,0,0) → XY footprint [0, 128)².
1805        scene
1806            .grid_mut(id)
1807            .unwrap()
1808            .set_voxel(IVec3::new(5, 5, 200), Some(VoxColor(0x80_10_20_30)));
1809        // No clip yet: nothing is hidden.
1810        assert!(!scene.cutaway_hides_point(DVec3::new(116.0, 16.0, 50.0)));
1811
1812        assert!(scene.set_grid_z_clip(id, Some(120)));
1813        // Inside the footprint, above the plane (local z 50 < 120).
1814        assert!(scene.cutaway_hides_point(DVec3::new(116.0, 16.0, 50.0)));
1815        // Below the plane stays.
1816        assert!(!scene.cutaway_hides_point(DVec3::new(116.0, 16.0, 200.0)));
1817        // Above the plane but OUTSIDE the XY footprint (local x = 300):
1818        // a character beside the ship keeps rendering.
1819        assert!(!scene.cutaway_hides_point(DVec3::new(400.0, 16.0, 50.0)));
1820        // Behind the grid origin (local x < 0) is outside too.
1821        assert!(!scene.cutaway_hides_point(DVec3::new(50.0, 16.0, 50.0)));
1822    }
1823
1824    /// CA.4 — `raycast_clipped` lands on what the cut render shows (the
1825    /// exposed interior), while the plain `raycast` keeps hitting the
1826    /// hidden hull — gameplay traces ignore the render-only clip.
1827    #[test]
1828    fn raycast_clipped_lands_on_exposed_interior() {
1829        let mut scene = Scene::new();
1830        let id = scene.add_grid(GridTransform::identity());
1831        {
1832            let g = scene.grid_mut(id).unwrap();
1833            // Roof plate at z=40 over a floor plate at z=100.
1834            g.set_rect(
1835                IVec3::new(0, 0, 40),
1836                IVec3::new(31, 31, 40),
1837                Some(VoxColor(0x80_aa_00_00)),
1838            );
1839            g.set_rect(
1840                IVec3::new(0, 0, 100),
1841                IVec3::new(31, 31, 100),
1842                Some(VoxColor(0x80_00_aa_00)),
1843            );
1844            g.z_clip = Some(80);
1845        }
1846        let (o, d) = (DVec3::new(10.5, 10.5, 0.0), DVec3::new(0.0, 0.0, 1.0));
1847        // Plain raycast: the roof still blocks (clip is render-only).
1848        let solid = scene.raycast(o, d, 256.0).expect("roof blocks");
1849        assert_eq!(solid.voxel, IVec3::new(10, 10, 40));
1850        // Clipped raycast: the roof reads as air; the click lands on
1851        // the exposed floor — exactly what the cut render shows.
1852        let cut = scene.raycast_clipped(o, d, 256.0).expect("floor visible");
1853        assert_eq!(cut.voxel, IVec3::new(10, 10, 100));
1854        assert!((cut.t - 100.0).abs() < 1e-6, "t≈100, got {}", cut.t);
1855        // With the clip removed both variants agree again.
1856        scene.set_grid_z_clip(id, None);
1857        let back = scene.raycast_clipped(o, d, 256.0).expect("roof again");
1858        assert_eq!(back.voxel, IVec3::new(10, 10, 40));
1859    }
1860
1861    #[test]
1862    fn raycast_picks_nearest_grid() {
1863        // Two grids with a voxel each along the same world column; the
1864        // raycast must return the closer one.
1865        let mut scene = Scene::new();
1866        let near = scene.add_grid(GridTransform::identity());
1867        let far = scene.add_grid(GridTransform::identity());
1868        scene
1869            .grid_mut(near)
1870            .unwrap()
1871            .set_voxel(IVec3::new(1, 1, 20), Some(VoxColor(0x80_00_ff_00)));
1872        scene
1873            .grid_mut(far)
1874            .unwrap()
1875            .set_voxel(IVec3::new(1, 1, 40), Some(VoxColor(0x80_ff_00_00)));
1876
1877        let hit = scene
1878            .raycast(DVec3::new(1.5, 1.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1879            .expect("hits the nearer voxel");
1880        assert_eq!(hit.grid, near);
1881        assert_eq!(hit.voxel, IVec3::new(1, 1, 20));
1882    }
1883
1884    #[test]
1885    fn raycast_into_scaled_grid_returns_world_t() {
1886        // SC — a grid at voxel_world_size 2.0: the voxel at grid-local
1887        // (5,5,10) sits at WORLD z = 20 (10 voxels × 2 units). The ray
1888        // must hit at world t ≈ 20 and report the grid-local voxel.
1889        let mut scene = Scene::new();
1890        let id = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 2.0));
1891        scene
1892            .grid_mut(id)
1893            .unwrap()
1894            .set_voxel(IVec3::new(5, 5, 10), Some(VoxColor(0x80_aa_bb_cc)));
1895        // World column through grid-local (5,5): world x/y = 11 (5.5 vox × 2).
1896        let hit = scene
1897            .raycast(DVec3::new(11.0, 11.0, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1898            .expect("ray hits the scaled voxel");
1899        assert_eq!(hit.voxel, IVec3::new(5, 5, 10), "grid-local voxel");
1900        assert!((hit.t - 20.0).abs() < 1e-4, "world t≈20, got {}", hit.t);
1901        assert!((hit.world.z - 20.0).abs() < 1e-4, "world hit z≈20");
1902    }
1903
1904    #[test]
1905    fn raycast_nearest_is_by_world_distance_across_scales() {
1906        // SC — the two metrics DISAGREE, so a missing `* vws` on `t`
1907        // flips the winner. Both voxels on the world column at xy = 1.0:
1908        //  - grid A (vws 2.0): voxel (0,0,6) → WORLD z 12, voxel-LOCAL 6.
1909        //  - grid B (vws 0.5): voxel (2,2,20) → WORLD z 10, voxel-LOCAL 20.
1910        // Correct (world t): B(10) < A(12) → hit B.
1911        // Broken (voxel-local t, no `* vws`): A(6) < B(20) → hit A.
1912        // So `hit.grid == B` bites iff the conversion is applied.
1913        let mut scene = Scene::new();
1914        let a = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 2.0));
1915        let b = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 0.5));
1916        scene
1917            .grid_mut(a)
1918            .unwrap()
1919            .set_voxel(IVec3::new(0, 0, 6), Some(VoxColor(0x80_ff_00_00)));
1920        scene
1921            .grid_mut(b)
1922            .unwrap()
1923            .set_voxel(IVec3::new(2, 2, 20), Some(VoxColor(0x80_00_ff_00)));
1924        let hit = scene
1925            .raycast(DVec3::new(1.0, 1.0, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
1926            .expect("hits the world-nearer voxel");
1927        assert_eq!(
1928            hit.grid, b,
1929            "grid B is nearer in WORLD units (10 < 12) though FARTHER in \
1930             voxel-local units (20 > 6) — the t-conversion must decide by world"
1931        );
1932        assert!((hit.t - 10.0).abs() < 1e-4, "world t≈10, got {}", hit.t);
1933    }
1934
1935    #[test]
1936    fn add_grid_returns_fresh_ids() {
1937        let mut scene = Scene::new();
1938        let a = scene.add_grid(GridTransform::identity());
1939        let b = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
1940        assert_ne!(a, b);
1941        assert_eq!(a.raw(), 0);
1942        assert_eq!(b.raw(), 1);
1943        assert_eq!(scene.grid_count(), 2);
1944    }
1945
1946    #[test]
1947    fn grid_lookup_round_trips() {
1948        let mut scene = Scene::new();
1949        let id = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
1950        let g = scene.grid(id).expect("grid registered");
1951        assert_eq!(g.transform.origin, DVec3::new(10.0, 20.0, 30.0));
1952        assert_eq!(g.transform.rotation, DQuat::IDENTITY);
1953        assert!(g.chunks.is_empty());
1954    }
1955
1956    #[test]
1957    fn remove_grid_drops_it_from_scene() {
1958        let mut scene = Scene::new();
1959        let id = scene.add_grid(GridTransform::identity());
1960        let removed = scene.remove_grid(id);
1961        assert!(removed.is_some());
1962        assert_eq!(scene.grid_count(), 0);
1963        assert!(scene.grid(id).is_none());
1964        // Re-adding does NOT reuse the dropped id.
1965        let id2 = scene.add_grid(GridTransform::identity());
1966        assert_ne!(id, id2);
1967        assert_eq!(id2.raw(), 1);
1968    }
1969
1970    #[test]
1971    fn remove_unknown_grid_is_none() {
1972        let mut scene = Scene::new();
1973        let bogus = GridId(999);
1974        assert!(scene.remove_grid(bogus).is_none());
1975    }
1976
1977    #[test]
1978    fn grid_mut_can_modify_transform() {
1979        let mut scene = Scene::new();
1980        let id = scene.add_grid(GridTransform::identity());
1981        scene.grid_mut(id).unwrap().transform.origin = DVec3::new(1.0, 2.0, 3.0);
1982        assert_eq!(
1983            scene.grid(id).unwrap().transform.origin,
1984            DVec3::new(1.0, 2.0, 3.0)
1985        );
1986    }
1987
1988    #[test]
1989    fn chunk_size_constants_match_plan() {
1990        // Plan locks these values; bumping either breaks the slab
1991        // byte format (Z) or the worst-case chunk footprint budget
1992        // (XY). Pin them so a future refactor that drifts them
1993        // shows up in CI.
1994        assert_eq!(CHUNK_SIZE_XY, 128);
1995        assert_eq!(CHUNK_SIZE_Z, 256);
1996    }
1997
1998    // ---- S6.0: bounding_radius + Grid::select_lod ----
1999
2000    #[test]
2001    fn new_grid_defaults_to_always_near_lod() {
2002        // Byte-identity contract for the staged S6 rollout: a
2003        // grid built through `new` must never trigger the Mid/Far
2004        // branches by accident, even when bounding_radius would
2005        // imply otherwise.
2006        let g = Grid::new(GridTransform::identity());
2007        assert_eq!(g.lod_thresholds.r_near, f64::INFINITY);
2008        assert_eq!(g.lod_thresholds.r_mid, f64::INFINITY);
2009        assert_eq!(g.select_lod(DVec3::new(1e9, 0.0, 0.0)), Lod::Near);
2010    }
2011
2012    #[test]
2013    fn bounding_radius_empty_grid_is_zero() {
2014        let g = Grid::new(GridTransform::identity());
2015        assert_eq!(g.bounding_radius(), 0.0);
2016    }
2017
2018    #[test]
2019    fn bounding_radius_single_chunk_at_origin() {
2020        // One chunk at (0, 0, 0): bbox is [0, 128) × [0, 128) × [0, 256).
2021        // Half-extent = (64, 64, 128); length = sqrt(64² + 64² + 128²)
2022        //   = sqrt(4096 + 4096 + 16384) = sqrt(24576) ≈ 156.7747...
2023        let mut scene = Scene::new();
2024        let id = scene.add_grid(GridTransform::identity());
2025        let g = scene.grid_mut(id).unwrap();
2026        // Populate chunk (0, 0, 0) via the edit API.
2027        g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_88_88_88)));
2028        let r = g.bounding_radius();
2029        let expected = ((64.0_f64).powi(2) * 2.0 + (128.0_f64).powi(2)).sqrt();
2030        assert!(
2031            (r - expected).abs() < 1e-9,
2032            "bounding_radius={r} expected={expected}"
2033        );
2034    }
2035
2036    #[test]
2037    fn sc3_bounding_radius_is_world_scaled() {
2038        // SC.3 — bounding_radius returns WORLD units, so a vws=3.0 grid's
2039        // radius is 3× the voxel half-extent. This pairs with the
2040        // world-distance LOD thresholds so a scaled grid picks the right tier.
2041        let mut scene = Scene::new();
2042        let id = scene.add_grid(crate::GridTransform::at_scale(DVec3::ZERO, 3.0));
2043        let g = scene.grid_mut(id).unwrap();
2044        g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_88_88_88)));
2045        let voxel_half = ((64.0_f64).powi(2) * 2.0 + (128.0_f64).powi(2)).sqrt();
2046        let r = g.bounding_radius();
2047        assert!(
2048            (r - voxel_half * 3.0).abs() < 1e-9,
2049            "world radius must be voxel half-extent × vws: got {r}"
2050        );
2051        // select_lod uses world distance vs world thresholds. from_radius(r)
2052        // sets r_near = r; a camera just inside/outside it flips Near↔Mid.
2053        g.lod_thresholds = crate::LodThresholds::from_radius(r);
2054        assert_eq!(
2055            g.select_lod(DVec3::new(r * 0.5, 0.0, 0.0)),
2056            crate::Lod::Near
2057        );
2058        assert_eq!(
2059            g.select_lod(DVec3::new(r * 2.0, 0.0, 0.0)),
2060            crate::Lod::Mid,
2061            "tier must flip at the WORLD (scaled) distance"
2062        );
2063    }
2064
2065    #[test]
2066    fn bounding_radius_grows_with_chunk_extent() {
2067        // Two chunks at (0,0,0) and (3,0,0): x extent is 4 chunks =
2068        // 512 voxels; y/z are 1 chunk each. Half-extent = (256, 64, 128);
2069        // length = sqrt(256² + 64² + 128²) = sqrt(65536+4096+16384)
2070        //        = sqrt(86016) ≈ 293.2848.
2071        let mut scene = Scene::new();
2072        let id = scene.add_grid(GridTransform::identity());
2073        let g = scene.grid_mut(id).unwrap();
2074        // Stamp one voxel in chunk (0,0,0).
2075        g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_88_88_88)));
2076        // Stamp one voxel in chunk (3,0,0): grid-local x = 3*128 = 384.
2077        g.set_voxel(IVec3::new(384, 0, 0), Some(VoxColor(0x80_88_88_88)));
2078        assert_eq!(g.chunks.len(), 2);
2079        let r = g.bounding_radius();
2080        let expected = (256.0_f64.powi(2) + 64.0_f64.powi(2) + 128.0_f64.powi(2)).sqrt();
2081        assert!(
2082            (r - expected).abs() < 1e-9,
2083            "bounding_radius={r} expected={expected}"
2084        );
2085    }
2086
2087    #[test]
2088    fn grid_select_lod_respects_lod_thresholds_field() {
2089        // Set a non-default threshold and verify the helper picks
2090        // the right tier for known distances.
2091        let mut scene = Scene::new();
2092        let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
2093        let g = scene.grid_mut(id).unwrap();
2094        g.lod_thresholds = LodThresholds {
2095            r_near: 50.0,
2096            r_mid: 200.0,
2097            ..LodThresholds::always_near()
2098        };
2099        // Camera 25 units from grid origin → Near.
2100        assert_eq!(g.select_lod(DVec3::new(125.0, 0.0, 0.0)), Lod::Near);
2101        // 100 units → Mid.
2102        assert_eq!(g.select_lod(DVec3::new(200.0, 0.0, 0.0)), Lod::Mid);
2103        // 500 units → Far.
2104        assert_eq!(g.select_lod(DVec3::new(600.0, 0.0, 0.0)), Lod::Far);
2105    }
2106}