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