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