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