Skip to main content

roxlap_scene/
streaming.rs

1//! Streaming + procedural-generation hooks.
2//!
3//! S7 of the scene-graph port (see `PORTING-SCENE.md` § S7). This
4//! module lands incrementally:
5//!
6//! - **S7.0** (this commit): the [`ChunkGenerator`] trait and the
7//!   synchronous [`Grid::ensure_chunk_generated`](crate::Grid::ensure_chunk_generated) helper. Generators
8//!   are plain `Box<dyn ChunkGenerator>` — no rayon, no channels,
9//!   no async dispatch yet.
10//! - S7.1: per-grid `StreamRadius { r_active, r_evict }` policy and
11//!   `Scene::pump_streaming_sync(camera)`.
12//! - S7.2: per-chunk version counter for the edit-vs-generate race.
13//! - S7.3: async dispatch through a dedicated rayon pool +
14//!   `crossbeam_channel`.
15//! - S7.4: render integration (pending-chunk reads, billboard cache
16//!   invalidation on stream-in).
17//! - S7.5: `roxlap-cavegen` adapter as the first concrete generator.
18//! - S7.6: streaming demo.
19//!
20//! The `Send + Sync` bound on [`ChunkGenerator`] is needed by S7.3
21//! but is cheap to require now — generators are typically stateless
22//! noise configs that already satisfy it.
23
24use std::fmt;
25
26use glam::{DVec3, IVec3};
27use roxlap_formats::vxl::Vxl;
28
29use crate::{CHUNK_SIZE_XY, CHUNK_SIZE_Z};
30
31/// Pluggable per-chunk procedural generator.
32///
33/// `Grid` instances optionally carry a `Box<dyn ChunkGenerator>`.
34/// When the streaming layer (or a direct
35/// [`Grid::ensure_chunk_generated`](crate::Grid::ensure_chunk_generated)
36/// call) needs a chunk that is not yet materialised, it asks the
37/// generator to produce one. The returned [`Vxl`] is moved into the
38/// grid's sparse chunk map at the requested index.
39///
40/// Generators are expected to be deterministic functions of
41/// `chunk_idx` plus their own configuration: calling `generate` with
42/// the same index twice should return equivalent chunks. This is
43/// what makes "evict + re-stream" sound under [`crate::Grid`]'s
44/// no-persistence default (see S7 scope brief, decision 5).
45///
46/// `Send + Sync` is required so S7.3 can dispatch generation onto a
47/// background rayon pool without per-call locking. `Debug` is
48/// required so [`crate::Grid`] can derive `Debug` while holding a
49/// `Box<dyn ChunkGenerator>`.
50pub trait ChunkGenerator: fmt::Debug + Send + Sync {
51    /// Produce the chunk at `chunk_idx`. Implementations should not
52    /// allocate or touch any state outside their own configuration
53    /// — running this from a background thread must be safe.
54    fn generate(&self, chunk_idx: IVec3) -> Vxl;
55
56    /// Per-chunk filter consulted by [`crate::Scene::pump_streaming`]
57    /// (+ the synchronous [`crate::Grid::ensure_chunk_generated`]
58    /// helper) before dispatching `generate`. Returning `false`
59    /// skips the chunk entirely — it never enters the grid's chunk
60    /// map and `origin_chunk_z` (etc.) reflect only the indices the
61    /// generator actually materialises.
62    ///
63    /// Used to avoid creating placeholder bedrock-only chunks for
64    /// layers the generator has no real content for (e.g. the
65    /// streaming-hills demo's `HillsChunkGenerator` declines
66    /// `chunk_idx.z != 0` so the camera can fly above the world
67    /// without triggering the S4B.6.j cross-chunk look-down
68    /// limitation).
69    ///
70    /// Default returns `true` — pre-fix behaviour, every dispatched
71    /// chunk gets generated.
72    fn should_generate(&self, _chunk_idx: IVec3) -> bool {
73        true
74    }
75}
76
77/// Pluggable persistence for **edited** streamed chunks (QE.5a).
78///
79/// The [`ChunkGenerator`] contract makes evict + re-stream sound for
80/// *pristine* chunks (generation is deterministic), but an edited
81/// chunk (`chunk_version != 0` — the player dug, built, or a bake
82/// wrote into it) would silently revert to generator output when the
83/// camera walks away and comes back. A `ChunkStore` closes that hole:
84///
85/// - **Eviction** ([`crate::Scene::pump_streaming`] /
86///   [`pump_streaming_sync`](crate::Scene::pump_streaming_sync)):
87///   every evicted chunk whose version is non-zero is handed to
88///   [`store`](Self::store) *before* it is dropped.
89/// - **Stream-in**: before running the generator for a missing chunk,
90///   the streaming layer asks [`load`](Self::load); a hit installs
91///   the stored chunk (with its persisted edit version) and the
92///   generator is not called. A stored chunk wins even where
93///   [`ChunkGenerator::should_generate`] declines the index (edits
94///   can materialise chunks the generator never would).
95///
96/// Implementations own the actual storage — an in-memory map, a
97/// save-file region, a database. `load` runs on the streaming pool's
98/// background threads under [`crate::Scene::pump_streaming`] (so
99/// blocking IO is fine there) but **inline** under the synchronous
100/// paths ([`pump_streaming_sync`](crate::Scene::pump_streaming_sync)
101/// / [`Grid::ensure_chunk_generated`](crate::Grid::ensure_chunk_generated));
102/// `store` always runs inline during the eviction pass — keep it
103/// cheap (e.g. queue bytes for a writer thread).
104///
105/// Note [`crate::Scene::to_snapshot`] serialises only *materialised*
106/// chunks — chunks living solely in the store are the host's to save
107/// alongside the snapshot.
108pub trait ChunkStore: fmt::Debug + Send + Sync {
109    /// Persist an edited chunk that is about to be evicted. `version`
110    /// is its [`crate::Grid::chunk_version`] (always non-zero here).
111    fn store(&self, chunk_idx: IVec3, vxl: &Vxl, version: u64);
112
113    /// Load a previously stored chunk. `Some((vxl, version))` installs
114    /// it (restoring `version` as the chunk's edit version) instead of
115    /// generating; `None` falls through to the generator.
116    fn load(&self, chunk_idx: IVec3) -> Option<(Vxl, u64)>;
117}
118
119/// Per-grid streaming activity / eviction radii (S7.1).
120///
121/// Both values are in **grid-local voxel units** — the same scale
122/// as a `GridLocalPos::voxel` coordinate. The math falls out
123/// cleanly from there: chunks span fixed integer voxel extents and
124/// the camera's grid-local position is also expressed in voxels.
125///
126/// Semantics inside [`crate::Scene::pump_streaming_sync`]:
127///
128/// - A chunk whose AABB-to-camera distance is `≤ r_active` MUST be
129///   loaded; if absent + a generator is attached, it gets streamed
130///   in via [`crate::Grid::ensure_chunk_generated`].
131/// - A chunk whose AABB-to-camera distance is `> r_evict` is
132///   dropped from the chunk map.
133/// - Chunks in the hysteresis band `(r_active, r_evict]` are
134///   neither streamed in nor evicted — they're left as-is. The
135///   gap prevents a camera oscillating near a boundary from
136///   thrashing generation + eviction.
137///
138/// The [`Default`] / [`Self::DISABLED`] value is `r_active = 0`,
139/// `r_evict = ∞`: [`crate::Scene::pump_streaming_sync`] is a no-op.
140/// Existing grids keep the pre-S7 "absent stays absent, present
141/// stays present" behaviour until a caller opts in.
142#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
143pub struct StreamRadius {
144    /// Chunks closer than this (grid-local voxels, AABB distance)
145    /// are streamed in.
146    pub r_active: f64,
147    /// Chunks farther than this (grid-local voxels, AABB distance)
148    /// are evicted. Must be `≥ r_active`.
149    pub r_evict: f64,
150}
151
152impl StreamRadius {
153    /// `r_active = 0`, `r_evict = ∞` — `pump_streaming_sync`
154    /// never streams a chunk in or evicts one. The default for
155    /// pre-S7.1 grids.
156    pub const DISABLED: Self = Self {
157        r_active: 0.0,
158        r_evict: f64::INFINITY,
159    };
160
161    /// New radius pair. Requires `r_evict >= r_active` so the
162    /// hysteresis band is well-formed (or empty when `==`).
163    ///
164    /// # Panics
165    ///
166    /// Panics if `r_evict < r_active`, if `r_active` is `NaN` or
167    /// negative, or if `r_evict` is negative. NaN and negative
168    /// radii are policy bugs — failing loud at construction beats
169    /// silently degenerating chunk-AABB tests later.
170    #[must_use]
171    pub fn new(r_active: f64, r_evict: f64) -> Self {
172        assert!(
173            r_evict >= r_active,
174            "StreamRadius: r_evict ({r_evict}) must be >= r_active ({r_active})"
175        );
176        assert!(
177            r_active.is_finite() && r_active >= 0.0,
178            "StreamRadius: r_active must be finite and >= 0, got {r_active}"
179        );
180        assert!(
181            r_evict >= 0.0,
182            "StreamRadius: r_evict must be >= 0, got {r_evict}"
183        );
184        Self { r_active, r_evict }
185    }
186
187    /// `true` for the [`Self::DISABLED`] sentinel pair. Lets
188    /// `pump_streaming_sync` skip the per-grid pass cheaply when
189    /// streaming is off.
190    #[must_use]
191    pub fn is_disabled(self) -> bool {
192        self.r_active == 0.0 && self.r_evict == f64::INFINITY
193    }
194}
195
196impl Default for StreamRadius {
197    fn default() -> Self {
198        Self::DISABLED
199    }
200}
201
202/// Squared distance from `p_local` (grid-local f64) to the AABB of
203/// the chunk at `chunk_idx`, also in grid-local voxel units.
204///
205/// The chunk at `(chx, chy, chz)` covers voxel-space
206/// `[chx*XY, (chx+1)*XY) × [chy*XY, (chy+1)*XY) × [chz*Z, (chz+1)*Z)`.
207/// Standard "clamp point to AABB then subtract" gives the closest
208/// point on the box; squared length avoids a sqrt per chunk in the
209/// streaming inner loop.
210///
211/// Returns `0.0` if `p_local` is inside the chunk.
212#[must_use]
213pub(crate) fn chunk_aabb_dist_sq(p_local: DVec3, chunk_idx: IVec3) -> f64 {
214    let sxy = f64::from(CHUNK_SIZE_XY);
215    let sz = f64::from(CHUNK_SIZE_Z);
216    let lo = DVec3::new(
217        f64::from(chunk_idx.x) * sxy,
218        f64::from(chunk_idx.y) * sxy,
219        f64::from(chunk_idx.z) * sz,
220    );
221    let hi = DVec3::new(lo.x + sxy, lo.y + sxy, lo.z + sz);
222    let dx = (lo.x - p_local.x).max(0.0).max(p_local.x - hi.x);
223    let dy = (lo.y - p_local.y).max(0.0).max(p_local.y - hi.y);
224    let dz = (lo.z - p_local.z).max(0.0).max(p_local.z - hi.z);
225    dx * dx + dy * dy + dz * dz
226}
227
228/// World-to-grid-local f64 transform — the same inverse-rotation
229/// path as [`crate::addr::world_to_grid_local`], but skipping the
230/// chunk + voxel + fract decomposition. Used by
231/// [`crate::Scene::pump_streaming_sync`] which only needs the
232/// continuous grid-local position to test chunk-AABB distances.
233#[must_use]
234pub(crate) fn world_to_grid_local_pos(world_pos: DVec3, transform: &crate::GridTransform) -> DVec3 {
235    transform.rotation.inverse() * (world_pos - transform.origin)
236}
237
238// ===========================================================
239// S7.3 async dispatch — native only.
240// ===========================================================
241//
242// On wasm32 the streaming pool / channel infrastructure is
243// cfg'd out and `Scene::pump_streaming` short-circuits to
244// `pump_streaming_sync` — there's no rayon `ThreadPool::build`
245// on `wasm32-unknown-unknown` without the wasm-bindgen-rayon
246// adapter, which is a binary-side concern that the scene crate
247// doesn't pull in.
248
249#[cfg(not(target_arch = "wasm32"))]
250pub(crate) use native::{ChunkResult, StreamingState};
251
252#[cfg(not(target_arch = "wasm32"))]
253mod native {
254    use super::*;
255    use crate::GridId;
256
257    /// One generator result carried back over the channel from a
258    /// rayon worker to the main thread (S7.3). Kept `pub(crate)`
259    /// — callers don't construct or inspect these directly.
260    pub(crate) struct ChunkResult {
261        pub grid_id: GridId,
262        pub chunk_idx: IVec3,
263        /// `Grid::chunk_version(chunk_idx)` at dispatch time. The
264        /// main thread compares against the live counter on
265        /// install; mismatch ⇒ an edit happened mid-generation,
266        /// discard the result.
267        pub version_at_dispatch: u64,
268        /// The produced chunk, or `None` when the task had nothing
269        /// to install (QE.5a: store miss + generator declined the
270        /// index) — the drain then just clears the pending entry.
271        pub vxl: Option<Vxl>,
272        /// QE.5a — set when `vxl` came from the [`ChunkStore`]: the
273        /// persisted edit version to restore on install.
274        pub restored_version: Option<u64>,
275    }
276
277    /// Per-scene streaming state: dedicated rayon `ThreadPool` plus
278    /// the `crossbeam_channel` inbox the background tasks send
279    /// results into. One `StreamingState` lives on each
280    /// [`crate::Scene`].
281    ///
282    /// The pool is built lazily on first dispatch — a scene that
283    /// only ever uses [`crate::Scene::pump_streaming_sync`] pays
284    /// no thread-pool overhead.
285    pub(crate) struct StreamingState {
286        pub thread_count: usize,
287        pub pool: Option<rayon::ThreadPool>,
288        pub tx: crossbeam_channel::Sender<ChunkResult>,
289        pub rx: crossbeam_channel::Receiver<ChunkResult>,
290    }
291
292    impl Default for StreamingState {
293        fn default() -> Self {
294            // Unbounded so a slow drain (e.g. a frame stall in
295            // pump_streaming) doesn't block rayon workers on send.
296            // Inbox lifetime is bounded by pending_gen — there
297            // can't be more in-flight messages than there are
298            // chunks in pending_gen sets across all grids.
299            let (tx, rx) = crossbeam_channel::unbounded();
300            Self {
301                thread_count: 2,
302                pool: None,
303                tx,
304                rx,
305            }
306        }
307    }
308
309    impl std::fmt::Debug for StreamingState {
310        // Intentionally elides the channel + pool internals — they
311        // print noisily and add nothing over the summary booleans.
312        // `finish_non_exhaustive` signals that omission to clippy.
313        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314            f.debug_struct("StreamingState")
315                .field("thread_count", &self.thread_count)
316                .field("pool_built", &self.pool.is_some())
317                .finish_non_exhaustive()
318        }
319    }
320
321    impl StreamingState {
322        /// Lazily construct the pool with the current
323        /// `thread_count`. Idempotent — second call is a no-op
324        /// when the pool already exists.
325        ///
326        /// # Panics
327        /// Panics if rayon fails to build the pool (typically OS
328        /// thread-allocation failure).
329        pub fn ensure_pool(&mut self) {
330            if self.pool.is_none() {
331                let pool = rayon::ThreadPoolBuilder::new()
332                    .num_threads(self.thread_count)
333                    .thread_name(|i| format!("roxlap-stream-{i}"))
334                    .build()
335                    .expect("rayon ThreadPoolBuilder");
336                self.pool = Some(pool);
337            }
338        }
339
340        /// Update the desired thread count. If the pool was already
341        /// built, drops the old pool (which blocks until in-flight
342        /// tasks finish — rayon's standard contract) and rebuilds
343        /// with the new count on the next [`Self::ensure_pool`]
344        /// call. The channel survives the rebuild so any results
345        /// the old pool's tasks managed to send before drop are
346        /// still drained by the next pump.
347        ///
348        /// No-op when the new count matches the current one.
349        ///
350        /// # Panics
351        /// Panics if `n == 0`.
352        pub fn set_thread_count(&mut self, n: usize) {
353            assert!(n > 0, "streaming thread count must be >= 1");
354            if self.thread_count == n {
355                return;
356            }
357            self.thread_count = n;
358            self.pool = None;
359        }
360    }
361}
362
363#[cfg(test)]
364pub(crate) mod tests {
365    use super::*;
366    use crate::chunks::tests::voxel_is_solid;
367    use crate::{Grid, GridTransform, Scene, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
368    use glam::DQuat;
369    use roxlap_formats::color::VoxColor;
370    use roxlap_formats::edit::{set_spans, Vspan};
371    use std::sync::atomic::{AtomicUsize, Ordering};
372    use std::sync::Arc;
373
374    /// Test-only generator that stamps a chunk-idx-derived solid pad
375    /// (one voxel at local origin) into an otherwise air chunk, and
376    /// counts how many times `generate` was called.
377    ///
378    /// The count lets us assert idempotency: `ensure_chunk_generated`
379    /// must not invoke the generator a second time once the chunk is
380    /// materialised.
381    #[derive(Debug)]
382    pub(crate) struct StubGenerator {
383        pub call_count: Arc<AtomicUsize>,
384    }
385
386    impl StubGenerator {
387        pub(crate) fn new() -> Self {
388            Self {
389                call_count: Arc::new(AtomicUsize::new(0)),
390            }
391        }
392    }
393
394    impl ChunkGenerator for StubGenerator {
395        fn generate(&self, chunk_idx: IVec3) -> Vxl {
396            self.call_count.fetch_add(1, Ordering::Relaxed);
397            // Build a fresh all-air chunk by stamping one voxel via
398            // the same path as `chunks::empty_chunk_vxl`, then
399            // carving everything except `(0, 0, chunk_idx.x as u8)`
400            // — gives us a chunk-idx-distinguishable signature
401            // without duplicating the empty-chunk builder.
402            let mut g = Grid::new(GridTransform::identity());
403            let mark_z = (chunk_idx.x.rem_euclid(200) as u32) % CHUNK_SIZE_Z;
404            // ensure_chunk creates a stock all-air chunk; we then
405            // stamp one voxel and detach the chunk.
406            g.ensure_chunk(IVec3::ZERO);
407            let vxl = g.chunks.remove(&IVec3::ZERO).expect("just inserted");
408            let mut vxl = vxl;
409            // Stamp one voxel at (0, 0, mark_z) so each chunk has a
410            // unique geometric fingerprint.
411            set_spans(
412                &mut vxl,
413                &[Vspan {
414                    x: 0,
415                    y: 0,
416                    z0: u8::try_from(mark_z).unwrap_or(0),
417                    z1: u8::try_from(mark_z).unwrap_or(0),
418                }],
419                Some(VoxColor(0x80_aa_bb_cc)),
420            );
421            vxl
422        }
423    }
424
425    #[test]
426    fn stub_generator_emits_distinguishable_chunks() {
427        // Direct sanity check on the generator before we test the
428        // helper. Two different chunk indices must produce
429        // distinguishable voxel content.
430        let gen = StubGenerator::new();
431        let a = gen.generate(IVec3::new(0, 0, 0));
432        let b = gen.generate(IVec3::new(7, 0, 0));
433        assert_eq!(a.vsid, CHUNK_SIZE_XY);
434        assert_eq!(b.vsid, CHUNK_SIZE_XY);
435        assert!(voxel_is_solid(&a, 0, 0, 0), "chunk_idx.x=0 marks z=0");
436        assert!(voxel_is_solid(&b, 0, 0, 7), "chunk_idx.x=7 marks z=7");
437        assert_eq!(gen.call_count.load(Ordering::Relaxed), 2);
438    }
439
440    #[test]
441    fn ensure_chunk_generated_populates_via_generator() {
442        let mut g = Grid::new(GridTransform::identity());
443        let gen = StubGenerator::new();
444        let counter = Arc::clone(&gen.call_count);
445        g.set_generator(Some(Arc::new(gen)));
446
447        assert_eq!(g.chunk_count(), 0);
448        let idx = IVec3::new(3, 0, 0);
449        let produced = g.ensure_chunk_generated(idx);
450        assert!(
451            produced,
452            "ensure_chunk_generated returns true when it generates"
453        );
454        assert_eq!(g.chunk_count(), 1);
455        let chunk = g.chunk(idx).expect("chunk now present");
456        assert!(
457            voxel_is_solid(chunk, 0, 0, 3),
458            "stub generator's mark voxel for chunk_idx.x=3 missing"
459        );
460        assert_eq!(counter.load(Ordering::Relaxed), 1);
461    }
462
463    #[test]
464    fn ensure_chunk_generated_is_idempotent() {
465        // Re-calling on an already-materialised chunk must not invoke
466        // the generator again — the chunk's existing content stays,
467        // and the call count stays at 1.
468        let mut g = Grid::new(GridTransform::identity());
469        let gen = StubGenerator::new();
470        let counter = Arc::clone(&gen.call_count);
471        g.set_generator(Some(Arc::new(gen)));
472
473        let idx = IVec3::new(5, -2, 0);
474        assert!(g.ensure_chunk_generated(idx));
475        assert!(!g.ensure_chunk_generated(idx), "second call no-ops");
476        assert!(!g.ensure_chunk_generated(idx), "third call still no-ops");
477        assert_eq!(g.chunk_count(), 1);
478        assert_eq!(counter.load(Ordering::Relaxed), 1);
479    }
480
481    #[test]
482    fn ensure_chunk_generated_without_generator_is_noop() {
483        // A grid with no generator must leave a missing chunk
484        // missing — no implicit empty-chunk allocation, since that
485        // would conflict with the "implicit air" interpretation of
486        // absent chunk-map entries.
487        let mut g = Grid::new(GridTransform::identity());
488        let idx = IVec3::new(0, 0, 0);
489        assert!(g.generator.is_none());
490        let produced = g.ensure_chunk_generated(idx);
491        assert!(!produced, "no generator → no chunk generated");
492        assert_eq!(g.chunk_count(), 0);
493        assert!(g.chunk(idx).is_none());
494    }
495
496    #[test]
497    fn ensure_chunk_generated_on_already_present_chunk_skips_generator() {
498        // If the chunk was created via the edit API (ensure_chunk +
499        // set_voxel) before the generator was attached, a later
500        // ensure_chunk_generated call must not overwrite it with
501        // procedurally-generated content.
502        let mut g = Grid::new(GridTransform::identity());
503        let idx = IVec3::new(0, 0, 0);
504        // Stamp a manual voxel at chunk-local (10, 10, 10).
505        g.set_voxel(IVec3::new(10, 10, 10), Some(VoxColor(0x80_11_22_33)));
506        assert_eq!(g.chunk_count(), 1);
507
508        let gen = StubGenerator::new();
509        let counter = Arc::clone(&gen.call_count);
510        g.set_generator(Some(Arc::new(gen)));
511
512        let produced = g.ensure_chunk_generated(idx);
513        assert!(!produced, "existing chunk not regenerated");
514        assert_eq!(counter.load(Ordering::Relaxed), 0);
515        // Manual voxel still there; stub's signature voxel absent.
516        let chunk = g.chunk(idx).expect("manual chunk present");
517        assert!(voxel_is_solid(chunk, 10, 10, 10), "manual voxel survived");
518        assert!(
519            !voxel_is_solid(chunk, 0, 0, 0),
520            "generator's mark voxel must NOT appear"
521        );
522    }
523
524    // ---- S7.1: StreamRadius + Scene::pump_streaming_sync ----
525
526    #[test]
527    fn stream_radius_disabled_is_truly_zero_infty() {
528        let r = StreamRadius::DISABLED;
529        assert_eq!(r.r_active, 0.0);
530        assert!(r.r_evict.is_infinite() && r.r_evict.is_sign_positive());
531        assert!(r.is_disabled());
532        assert!(StreamRadius::default().is_disabled());
533    }
534
535    #[test]
536    fn stream_radius_new_rejects_evict_below_active() {
537        // Guard against accidental "r_evict < r_active" configs that
538        // would evict eagerly + re-stream the same chunk every pump.
539        let result = std::panic::catch_unwind(|| StreamRadius::new(200.0, 100.0));
540        assert!(result.is_err(), "r_evict < r_active must panic");
541    }
542
543    #[test]
544    fn chunk_aabb_dist_sq_inside_chunk_is_zero() {
545        // Camera at (10, 20, 30) — well inside chunk (0, 0, 0)
546        // which covers x,y in [0, 128) and z in [0, 256).
547        let d = chunk_aabb_dist_sq(DVec3::new(10.0, 20.0, 30.0), IVec3::new(0, 0, 0));
548        assert_eq!(d, 0.0);
549    }
550
551    #[test]
552    fn chunk_aabb_dist_sq_axis_aligned() {
553        // Camera at (0, 0, 0). Chunk (1, 0, 0) starts at x=128; nearest
554        // point on its AABB is (128, 0, 0); squared distance 128² = 16384.
555        let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(1, 0, 0));
556        let expected = 128.0_f64.powi(2);
557        assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
558        // Chunk (0, 0, 1) — nearest face at z=256.
559        let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(0, 0, 1));
560        let expected = 256.0_f64.powi(2);
561        assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
562    }
563
564    #[test]
565    fn pump_streaming_sync_with_disabled_radius_is_noop() {
566        // Disabled (default) → never generates, never evicts. This is
567        // the byte-stability guarantee for any pre-S7.1 caller.
568        let mut scene = Scene::new();
569        let id = scene.add_grid(GridTransform::identity());
570        let gen = StubGenerator::new();
571        let counter = Arc::clone(&gen.call_count);
572        scene
573            .grid_mut(id)
574            .unwrap()
575            .set_generator(Some(Arc::new(gen)));
576        // Stamp a far-away chunk that would be evicted under any
577        // finite r_evict.
578        scene
579            .grid_mut(id)
580            .unwrap()
581            .set_voxel(IVec3::new(10_000, 0, 0), Some(VoxColor(0x80_11_22_33)));
582        let baseline_chunks = scene.grid(id).unwrap().chunk_count();
583        scene.pump_streaming_sync(DVec3::ZERO);
584        assert_eq!(scene.grid(id).unwrap().chunk_count(), baseline_chunks);
585        assert_eq!(counter.load(Ordering::Relaxed), 0);
586    }
587
588    #[test]
589    fn pump_streaming_sync_streams_in_chunks_within_r_active() {
590        // r_active = 200 voxels covers chunk (0,0,0) (origin) plus the
591        // ring of XY neighbours whose nearest face lies within 200 of
592        // origin. Chunks at chx ±1 are 128 voxels away (within); chunks
593        // at chx ±2 are 256 voxels away (just outside).
594        let mut scene = Scene::new();
595        let id = scene.add_grid(GridTransform::identity());
596        let gen = StubGenerator::new();
597        let counter = Arc::clone(&gen.call_count);
598        let g = scene.grid_mut(id).unwrap();
599        g.set_generator(Some(Arc::new(gen)));
600        g.stream_radius = StreamRadius::new(200.0, 400.0);
601        scene.pump_streaming_sync(DVec3::ZERO);
602
603        // Camera at world origin → grid-local (0, 0, 0). chz coverage:
604        // chunk (0,0,0) Z-AABB is [0, 256); chunk (0,0,-1) Z-AABB is
605        // [-256, 0). Both touch the camera point → both must stream
606        // in. (0,0,1) is at z=256, more than 200 away → must NOT.
607        let g = scene.grid(id).unwrap();
608        let must_have = [
609            IVec3::new(0, 0, 0),
610            IVec3::new(1, 0, 0),
611            IVec3::new(-1, 0, 0),
612            IVec3::new(0, 1, 0),
613            IVec3::new(0, -1, 0),
614            IVec3::new(0, 0, -1),
615        ];
616        for idx in must_have {
617            assert!(
618                g.chunks.contains_key(&idx),
619                "chunk {idx:?} missing from streamed set"
620            );
621        }
622        // Diagonals at chunk (1,1,0): AABB nearest = (128,128,0);
623        // dist = sqrt(128² + 128²) ≈ 181.0 < 200 → must be streamed.
624        assert!(g.chunks.contains_key(&IVec3::new(1, 1, 0)));
625        // Chunk (2, 0, 0): nearest face at x=256, dist=256 > 200.
626        assert!(!g.chunks.contains_key(&IVec3::new(2, 0, 0)));
627        // Camera chz coverage: (0,0,1) at z=256 > 200 → out.
628        assert!(!g.chunks.contains_key(&IVec3::new(0, 0, 1)));
629
630        // Counter equals number of streamed chunks.
631        let streamed = g.chunk_count();
632        assert_eq!(counter.load(Ordering::Relaxed), streamed);
633    }
634
635    #[test]
636    fn pump_streaming_sync_idempotent_under_stationary_camera() {
637        // Second pump at the same position must NOT regenerate any
638        // already-loaded chunk — counter stays at the post-first-pump
639        // value.
640        let mut scene = Scene::new();
641        let id = scene.add_grid(GridTransform::identity());
642        let gen = StubGenerator::new();
643        let counter = Arc::clone(&gen.call_count);
644        let g = scene.grid_mut(id).unwrap();
645        g.set_generator(Some(Arc::new(gen)));
646        g.stream_radius = StreamRadius::new(180.0, 400.0);
647
648        scene.pump_streaming_sync(DVec3::ZERO);
649        let after_first = counter.load(Ordering::Relaxed);
650        scene.pump_streaming_sync(DVec3::ZERO);
651        let after_second = counter.load(Ordering::Relaxed);
652        assert_eq!(after_first, after_second, "second pump regenerated chunks");
653    }
654
655    #[test]
656    fn pump_streaming_sync_evicts_chunks_beyond_r_evict() {
657        // Stream chunks within r_active=200; then move the camera far
658        // away and verify r_evict trims the now-distant set.
659        let mut scene = Scene::new();
660        let id = scene.add_grid(GridTransform::identity());
661        let gen = StubGenerator::new();
662        let g = scene.grid_mut(id).unwrap();
663        g.set_generator(Some(Arc::new(gen)));
664        g.stream_radius = StreamRadius::new(200.0, 400.0);
665        scene.pump_streaming_sync(DVec3::ZERO);
666        let initial = scene.grid(id).unwrap().chunk_count();
667        assert!(initial > 0, "expected chunks streamed in around origin");
668
669        // Teleport camera ~10_000 voxels along +x; every chunk near
670        // the old origin is now > r_evict (400) away.
671        scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
672        let g = scene.grid(id).unwrap();
673        for idx in [
674            IVec3::new(0, 0, 0),
675            IVec3::new(1, 0, 0),
676            IVec3::new(-1, 0, 0),
677        ] {
678            assert!(
679                !g.chunks.contains_key(&idx),
680                "chunk {idx:?} survived eviction after far teleport"
681            );
682        }
683        // New chunks around the new camera position must exist.
684        // Camera at x=10_000 → chunk chx = 10_000 / 128 ≈ 78.
685        let cam_chx = 10_000_i32 / i32::try_from(CHUNK_SIZE_XY).unwrap();
686        assert!(g.chunks.contains_key(&IVec3::new(cam_chx, 0, 0)));
687    }
688
689    #[test]
690    fn pump_streaming_sync_hysteresis_band_retains_chunks() {
691        // r_active = 200, r_evict = 600. A chunk that's currently
692        // present and lives in the band (200 < d <= 600) is neither
693        // streamed in (it's already present) nor evicted (within
694        // r_evict). Move just past r_active and check the chunks that
695        // were inside the now-shrunk active set stay loaded.
696        let mut scene = Scene::new();
697        let id = scene.add_grid(GridTransform::identity());
698        let gen = StubGenerator::new();
699        let g = scene.grid_mut(id).unwrap();
700        g.set_generator(Some(Arc::new(gen)));
701        g.stream_radius = StreamRadius::new(200.0, 600.0);
702        scene.pump_streaming_sync(DVec3::ZERO);
703        let g = scene.grid(id).unwrap();
704        // A chunk at (1, 0, 0) is 128 voxels away — well inside
705        // r_active. After bumping the camera to (300, 0, 0), nearest
706        // face of (1, 0, 0) is x=256 → dist = max(0, 300-256) = 44 <
707        // r_active, still inside r_active so it would stream in again
708        // anyway. We need a chunk we KNOW will fall in the
709        // hysteresis band. (-2, 0, 0): AABB nearest face x=-128;
710        // initial dist at cam (0,0,0) = 128 (inside r_active). After
711        // pump #1, present. After cam → (300, 0, 0): dist = 300 - (-128)
712        // = 428 → in band (200, 600]. Must stay.
713        let band_idx = IVec3::new(-2, 0, 0);
714        // First, confirm (-2, 0, 0) was actually streamed in (its dist
715        // from origin is min(128, 256) = 128 along x → 128 < 200).
716        assert!(
717            g.chunks.contains_key(&band_idx),
718            "(-2, 0, 0) should be streamed at origin"
719        );
720
721        scene.pump_streaming_sync(DVec3::new(300.0, 0.0, 0.0));
722        let g = scene.grid(id).unwrap();
723        assert!(
724            g.chunks.contains_key(&band_idx),
725            "(-2, 0, 0) should remain in the hysteresis band"
726        );
727    }
728
729    #[test]
730    fn pump_streaming_sync_with_no_generator_does_not_panic() {
731        // r_active > 0 but no generator: pump must skip the stream-in
732        // pass cleanly and still run the evict pass. Use a manually-
733        // edited chunk that's far away to verify eviction still works.
734        let mut scene = Scene::new();
735        let id = scene.add_grid(GridTransform::identity());
736        let g = scene.grid_mut(id).unwrap();
737        g.stream_radius = StreamRadius::new(200.0, 400.0);
738        // Manual chunk at (50, 0, 0): grid-local x=50*128 = 6400, far
739        // outside r_evict from origin.
740        g.set_voxel(IVec3::new(50 * 128, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
741        assert_eq!(scene.grid(id).unwrap().chunk_count(), 1);
742
743        scene.pump_streaming_sync(DVec3::ZERO);
744        let g = scene.grid(id).unwrap();
745        // Stream-in pass was a no-op (no generator); evict pass
746        // dropped the far chunk.
747        assert_eq!(g.chunk_count(), 0);
748    }
749
750    #[test]
751    fn pump_streaming_sync_respects_grid_rotation() {
752        // Place a grid rotated 180° around Z. World camera at
753        // (+10, 0, 0) maps to grid-local (-10, 0, 0). The streamed
754        // chunk set must reflect that — chunk (-1, 0, 0) must be
755        // present (grid-local x=-10 falls inside (-128, 0]).
756        let transform = GridTransform {
757            origin: DVec3::ZERO,
758            rotation: DQuat::from_axis_angle(DVec3::Z, std::f64::consts::PI),
759        };
760        let mut scene = Scene::new();
761        let id = scene.add_grid(transform);
762        let gen = StubGenerator::new();
763        let g = scene.grid_mut(id).unwrap();
764        g.set_generator(Some(Arc::new(gen)));
765        g.stream_radius = StreamRadius::new(50.0, 200.0);
766
767        // World camera at (+10, 0, 0). After inverse 180°-Z, grid-local
768        // is (-10, 0, 0).
769        scene.pump_streaming_sync(DVec3::new(10.0, 0.0, 0.0));
770        let g = scene.grid(id).unwrap();
771        // Camera grid-local x = -10 → camera chunk chx = floor(-10/128) = -1.
772        // Chunk (-1, 0, 0) AABB nearest x lies in [-128, 0); camera in
773        // it → dist 0 → must be streamed.
774        assert!(
775            g.chunks.contains_key(&IVec3::new(-1, 0, 0)),
776            "rotation not applied — camera should map to chunk (-1, 0, 0)"
777        );
778        // Chunk (0, 0, 0) starts at x=0; camera at x=-10 → dist=10 <
779        // r_active → also streamed.
780        assert!(g.chunks.contains_key(&IVec3::new(0, 0, 0)));
781    }
782
783    // ---- S7.2: chunk_version interplay with streaming ----
784
785    #[test]
786    fn ensure_chunk_generated_does_not_bump_version() {
787        // A freshly-generated chunk has no user edits → version 0.
788        let mut g = Grid::new(GridTransform::identity());
789        let gen = StubGenerator::new();
790        g.set_generator(Some(Arc::new(gen)));
791        let idx = IVec3::new(2, 3, 0);
792        assert!(g.ensure_chunk_generated(idx));
793        assert_eq!(g.chunk_version(idx), 0);
794        // chunk_versions doesn't grow either.
795        assert!(g.chunk_versions.is_empty());
796    }
797
798    #[test]
799    fn ensure_chunk_generated_then_edit_starts_at_version_one() {
800        // Generator install + a single edit → version 1 (not 2).
801        let mut g = Grid::new(GridTransform::identity());
802        let gen = StubGenerator::new();
803        g.set_generator(Some(Arc::new(gen)));
804        let idx = IVec3::ZERO;
805        g.ensure_chunk_generated(idx);
806        g.set_voxel(IVec3::new(10, 10, 10), Some(VoxColor(0x80_aa_bb_cc)));
807        assert_eq!(g.chunk_version(idx), 1);
808    }
809
810    #[test]
811    fn pump_streaming_sync_eviction_drops_chunk_version_entry() {
812        // Edit a chunk (version bumps to 1), then evict it via the
813        // streaming pump. The chunk_versions map entry must also be
814        // dropped so the map stays bounded.
815        let mut scene = Scene::new();
816        let id = scene.add_grid(GridTransform::identity());
817        let g = scene.grid_mut(id).unwrap();
818        g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
819        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
820        g.stream_radius = StreamRadius::new(10.0, 50.0);
821
822        scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
823        let g = scene.grid(id).unwrap();
824        assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
825        assert_eq!(
826            g.chunk_version(IVec3::ZERO),
827            0,
828            "version entry should be cleared on eviction"
829        );
830        assert!(g.chunk_versions.is_empty(), "map should be empty");
831    }
832
833    // ---- S7.3: async pump_streaming ----
834
835    /// Test-only generator that pauses inside `generate` until the
836    /// test explicitly releases it. Two-channel design:
837    ///
838    /// - `arrival_tx`: each task signals "I'm running" with its
839    ///   chunk_idx before it blocks. Lets the test wait for the
840    ///   dispatcher to have actually scheduled work without
841    ///   sleeping.
842    /// - `release_rx`: each task blocks on `recv()` here until the
843    ///   test sends a `()` (or drops the matching `release_tx`,
844    ///   which unblocks all pending tasks via `Err` return — the
845    ///   safety net so a panicking test doesn't deadlock on
846    ///   `Scene::drop` waiting for blocked tasks to finish).
847    #[derive(Debug)]
848    #[cfg(not(target_arch = "wasm32"))]
849    struct BlockingGenerator {
850        arrival_tx: crossbeam_channel::Sender<IVec3>,
851        release_rx: crossbeam_channel::Receiver<()>,
852        call_count: Arc<AtomicUsize>,
853    }
854
855    #[cfg(not(target_arch = "wasm32"))]
856    impl ChunkGenerator for BlockingGenerator {
857        fn generate(&self, chunk_idx: IVec3) -> Vxl {
858            self.call_count.fetch_add(1, Ordering::Relaxed);
859            let _ = self.arrival_tx.send(chunk_idx);
860            // recv returns Err if the matching Sender was dropped
861            // (e.g. test panicked / ended); still produce a chunk
862            // so the rayon pool's drop doesn't hang.
863            let _ = self.release_rx.recv();
864            StubGenerator::new().generate(chunk_idx)
865        }
866    }
867
868    /// Convenience: pump_streaming in a spin-loop until `grid`'s
869    /// `pending_gen` is empty, releasing all gates as we go.
870    /// Panics on a 5-second timeout — generation tasks shouldn't
871    /// take that long even on the slowest CI.
872    #[cfg(not(target_arch = "wasm32"))]
873    fn pump_until_idle(
874        scene: &mut Scene,
875        cam: DVec3,
876        grid_id: crate::GridId,
877        release_tx: Option<&crossbeam_channel::Sender<()>>,
878    ) {
879        use std::time::{Duration, Instant};
880        let deadline = Instant::now() + Duration::from_secs(5);
881        loop {
882            scene.pump_streaming(cam);
883            let idle = scene.grid(grid_id).is_none_or(|g| g.pending_gen.is_empty());
884            if idle {
885                return;
886            }
887            if Instant::now() > deadline {
888                panic!("pump_until_idle: timeout with pending tasks");
889            }
890            // Release any blocked tasks so they can complete.
891            if let Some(tx) = release_tx {
892                let _ = tx.try_send(());
893            }
894            std::thread::sleep(Duration::from_millis(1));
895        }
896    }
897
898    // ---- S7.4: stream-in clears billboards cache ----
899
900    #[test]
901    fn ensure_chunk_generated_invalidates_billboard_cache() {
902        // Sync stream-in: a populated cache must be cleared when
903        // a generator installs a new chunk — the bounding sphere
904        // may have grown.
905        let mut g = Grid::new(GridTransform::identity());
906        let gen = StubGenerator::new();
907        g.set_generator(Some(Arc::new(gen)));
908        g.billboards = Some(crate::BillboardCache::new_empty(32));
909
910        let installed = g.ensure_chunk_generated(IVec3::new(2, 0, 0));
911        assert!(installed, "generator should have installed the chunk");
912        assert!(
913            g.billboards.is_none(),
914            "ensure_chunk_generated must clear billboards on install"
915        );
916    }
917
918    #[test]
919    fn ensure_chunk_generated_noop_preserves_billboard_cache() {
920        // No-install paths (no generator, already-present) must
921        // NOT clear the cache — there was no bounding-sphere
922        // change to invalidate.
923        let mut g = Grid::new(GridTransform::identity());
924        g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
925        g.billboards = Some(crate::BillboardCache::new_empty(32));
926        // No generator → no install → cache stays.
927        let installed = g.ensure_chunk_generated(IVec3::new(5, 5, 0));
928        assert!(!installed);
929        assert!(
930            g.billboards.is_some(),
931            "no-generator no-op must not clear billboards"
932        );
933        // Already-present chunk → no install → cache stays.
934        let installed = g.ensure_chunk_generated(IVec3::ZERO);
935        assert!(!installed);
936        assert!(
937            g.billboards.is_some(),
938            "already-present chunk must not clear billboards"
939        );
940    }
941
942    #[test]
943    #[cfg(not(target_arch = "wasm32"))]
944    fn pump_streaming_async_install_invalidates_billboard_cache() {
945        // Async path: pump_streaming installs chunks via the drain;
946        // each install must clear the cache so the next Far render
947        // rebuilds with the new chunk set.
948        let mut scene = Scene::new();
949        let id = scene.add_grid(GridTransform::identity());
950        let gen = StubGenerator::new();
951        let g = scene.grid_mut(id).unwrap();
952        g.set_generator(Some(Arc::new(gen)));
953        g.stream_radius = StreamRadius::new(10.0, 200.0);
954        // Stamp a sentinel cache before pumping.
955        g.billboards = Some(crate::BillboardCache::new_empty(32));
956        let cam = DVec3::new(64.0, 64.0, 128.0);
957
958        pump_until_idle(&mut scene, cam, id, None);
959
960        let g = scene.grid(id).unwrap();
961        assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
962        assert!(
963            g.billboards.is_none(),
964            "async install must clear billboards"
965        );
966    }
967
968    #[test]
969    #[cfg(not(target_arch = "wasm32"))]
970    fn pump_streaming_no_install_preserves_billboard_cache() {
971        // Pump with no missing chunks (all already present) must
972        // NOT clear billboards. Set up: stream chunks in sync,
973        // populate cache, pump again with same camera + same
974        // r_active → drain has nothing → cache survives.
975        let mut scene = Scene::new();
976        let id = scene.add_grid(GridTransform::identity());
977        let g = scene.grid_mut(id).unwrap();
978        let gen = StubGenerator::new();
979        g.set_generator(Some(Arc::new(gen)));
980        g.stream_radius = StreamRadius::new(10.0, 200.0);
981        let cam = DVec3::new(64.0, 64.0, 128.0);
982        // First pump: streams in chunk(s).
983        pump_until_idle(&mut scene, cam, id, None);
984        // Stamp cache.
985        scene.grid_mut(id).unwrap().billboards = Some(crate::BillboardCache::new_empty(32));
986        // Second pump at same camera: no new chunks installed.
987        scene.pump_streaming(cam);
988        let g = scene.grid(id).unwrap();
989        assert!(
990            g.billboards.is_some(),
991            "pump with no install should not clear billboards"
992        );
993    }
994
995    #[test]
996    #[cfg(not(target_arch = "wasm32"))]
997    fn pump_streaming_dispatches_and_installs_via_async_path() {
998        // Happy path: set up a fast (non-blocking) generator,
999        // configure r_active, call pump_streaming, spin until
1000        // idle, verify chunks installed.
1001        let mut scene = Scene::new();
1002        let id = scene.add_grid(GridTransform::identity());
1003        let gen = StubGenerator::new();
1004        let counter = Arc::clone(&gen.call_count);
1005        let g = scene.grid_mut(id).unwrap();
1006        g.set_generator(Some(Arc::new(gen)));
1007        // Camera inside chunk (0,0,0) far from edges so only one
1008        // chunk hits the r_active=10 ball.
1009        g.stream_radius = StreamRadius::new(10.0, 200.0);
1010        let cam = DVec3::new(64.0, 64.0, 128.0);
1011
1012        pump_until_idle(&mut scene, cam, id, None);
1013
1014        let g = scene.grid(id).unwrap();
1015        assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
1016        assert_eq!(counter.load(Ordering::Relaxed), 1, "generator called once");
1017        assert!(g.pending_gen.is_empty(), "no leftover pending");
1018    }
1019
1020    #[test]
1021    #[cfg(not(target_arch = "wasm32"))]
1022    fn pump_streaming_tracks_in_flight_chunks_in_pending_gen() {
1023        // Verify pending_gen reflects in-flight async tasks: after
1024        // dispatch, pending_gen contains the chunk; after release
1025        // + drain, it's empty.
1026        let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1027        let (release_tx, release_rx) = crossbeam_channel::unbounded();
1028        let counter = Arc::new(AtomicUsize::new(0));
1029        let gen = BlockingGenerator {
1030            arrival_tx,
1031            release_rx,
1032            call_count: Arc::clone(&counter),
1033        };
1034
1035        let mut scene = Scene::new();
1036        let id = scene.add_grid(GridTransform::identity());
1037        let g = scene.grid_mut(id).unwrap();
1038        g.set_generator(Some(Arc::new(gen)));
1039        g.stream_radius = StreamRadius::new(10.0, 200.0);
1040        let cam = DVec3::new(64.0, 64.0, 128.0);
1041
1042        scene.pump_streaming(cam);
1043
1044        // Wait for the task to actually start.
1045        let arrived = arrival_rx
1046            .recv_timeout(std::time::Duration::from_secs(2))
1047            .expect("task didn't start");
1048        assert_eq!(arrived, IVec3::ZERO);
1049
1050        // Right now the task is blocked inside `generate`.
1051        // pending_gen must reflect that.
1052        assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1053        assert!(scene.grid(id).unwrap().chunks.is_empty());
1054
1055        // Release and drain.
1056        release_tx.send(()).unwrap();
1057        pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1058
1059        let g = scene.grid(id).unwrap();
1060        assert!(g.chunks.contains_key(&IVec3::ZERO));
1061        assert!(!g.pending_gen.contains(&IVec3::ZERO));
1062        assert_eq!(counter.load(Ordering::Relaxed), 1);
1063    }
1064
1065    #[test]
1066    #[cfg(not(target_arch = "wasm32"))]
1067    fn pump_streaming_does_not_redispatch_in_flight_chunks() {
1068        // While chunk X is in pending_gen, repeated pump calls
1069        // must NOT enqueue another generate for X. Verified by
1070        // call_count staying at 1 across multiple pumps.
1071        let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1072        let (release_tx, release_rx) = crossbeam_channel::unbounded();
1073        let counter = Arc::new(AtomicUsize::new(0));
1074        let gen = BlockingGenerator {
1075            arrival_tx,
1076            release_rx,
1077            call_count: Arc::clone(&counter),
1078        };
1079
1080        let mut scene = Scene::new();
1081        let id = scene.add_grid(GridTransform::identity());
1082        let g = scene.grid_mut(id).unwrap();
1083        g.set_generator(Some(Arc::new(gen)));
1084        g.stream_radius = StreamRadius::new(10.0, 200.0);
1085        let cam = DVec3::new(64.0, 64.0, 128.0);
1086
1087        scene.pump_streaming(cam);
1088        let _ = arrival_rx
1089            .recv_timeout(std::time::Duration::from_secs(2))
1090            .expect("task didn't start");
1091
1092        // Pump several more times while task is blocked.
1093        for _ in 0..5 {
1094            scene.pump_streaming(cam);
1095        }
1096        assert_eq!(
1097            counter.load(Ordering::Relaxed),
1098            1,
1099            "in-flight chunk re-dispatched"
1100        );
1101
1102        release_tx.send(()).unwrap();
1103        pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1104        // Final post-drain assertion: still just one generate call.
1105        assert_eq!(counter.load(Ordering::Relaxed), 1);
1106    }
1107
1108    #[test]
1109    #[cfg(not(target_arch = "wasm32"))]
1110    fn pump_streaming_discards_stale_result_when_chunk_edited_during_gen() {
1111        // Race: dispatch a chunk; while task is blocked, edit the
1112        // chunk via set_voxel (creates a real chunk + bumps
1113        // version to 1). Release. The result arrives with
1114        // version_at_dispatch=0 vs current=1 → must discard. The
1115        // chunk keeps the user edit; doesn't get overwritten by
1116        // generator output.
1117        let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1118        let (release_tx, release_rx) = crossbeam_channel::unbounded();
1119        let counter = Arc::new(AtomicUsize::new(0));
1120        let gen = BlockingGenerator {
1121            arrival_tx,
1122            release_rx,
1123            call_count: Arc::clone(&counter),
1124        };
1125
1126        let mut scene = Scene::new();
1127        let id = scene.add_grid(GridTransform::identity());
1128        let g = scene.grid_mut(id).unwrap();
1129        g.set_generator(Some(Arc::new(gen)));
1130        g.stream_radius = StreamRadius::new(10.0, 200.0);
1131        let cam = DVec3::new(64.0, 64.0, 128.0);
1132
1133        scene.pump_streaming(cam);
1134        let _ = arrival_rx
1135            .recv_timeout(std::time::Duration::from_secs(2))
1136            .expect("task didn't start");
1137
1138        // Edit while the task is blocked.
1139        let g = scene.grid_mut(id).unwrap();
1140        // A user voxel at (10, 11, 12) inside chunk (0,0,0).
1141        g.set_voxel(IVec3::new(10, 11, 12), Some(VoxColor(0x80_de_ad_be)));
1142        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
1143        let chunk = g.chunk(IVec3::ZERO).unwrap();
1144        assert!(voxel_is_solid(chunk, 10, 11, 12));
1145        // Stub's signature voxel for chunk_idx.x=0 lives at
1146        // (0, 0, 0). After the user edit, before release, that
1147        // voxel is NOT solid (manual edit only set (10,11,12)).
1148        assert!(!voxel_is_solid(chunk, 0, 0, 0));
1149
1150        release_tx.send(()).unwrap();
1151        pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1152
1153        // Chunk has the user voxel and NOT the generator signature.
1154        let g = scene.grid(id).unwrap();
1155        let chunk = g.chunk(IVec3::ZERO).unwrap();
1156        assert!(voxel_is_solid(chunk, 10, 11, 12), "user edit survived");
1157        assert!(
1158            !voxel_is_solid(chunk, 0, 0, 0),
1159            "stale generator output must not have overwritten the chunk"
1160        );
1161        // Generator ran exactly once before we discarded its result.
1162        assert_eq!(counter.load(Ordering::Relaxed), 1);
1163    }
1164
1165    #[test]
1166    #[cfg(not(target_arch = "wasm32"))]
1167    fn pump_streaming_eviction_drops_pending_gen_entry() {
1168        // Dispatch a chunk; while task is blocked, move the camera
1169        // far enough that the chunk is past r_evict. After the
1170        // next pump, the chunk's pending_gen entry must be gone
1171        // (the eviction half of pump removes it). When the task
1172        // finally completes, the drain discards the result via
1173        // "was_pending = false".
1174        let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1175        let (release_tx, release_rx) = crossbeam_channel::unbounded();
1176        let counter = Arc::new(AtomicUsize::new(0));
1177        let gen = BlockingGenerator {
1178            arrival_tx,
1179            release_rx,
1180            call_count: Arc::clone(&counter),
1181        };
1182
1183        let mut scene = Scene::new();
1184        let id = scene.add_grid(GridTransform::identity());
1185        let g = scene.grid_mut(id).unwrap();
1186        g.set_generator(Some(Arc::new(gen)));
1187        g.stream_radius = StreamRadius::new(10.0, 50.0);
1188        let near_cam = DVec3::new(64.0, 64.0, 128.0);
1189        scene.pump_streaming(near_cam);
1190        let _ = arrival_rx
1191            .recv_timeout(std::time::Duration::from_secs(2))
1192            .expect("task didn't start");
1193        assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1194
1195        // Teleport the camera 10_000 voxels along +x. Chunk
1196        // (0,0,0)'s nearest face at x=128 is now ~9872 away —
1197        // well past r_evict.
1198        let far_cam = DVec3::new(10_000.0, 64.0, 128.0);
1199        scene.pump_streaming(far_cam);
1200        assert!(
1201            !scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO),
1202            "eviction should have cleared the pending entry"
1203        );
1204
1205        // Now release the blocked task. Its result arrives with
1206        // was_pending = false → silently dropped.
1207        release_tx.send(()).unwrap();
1208        // Drain at the far camera; chunk (0,0,0) is not in
1209        // r_active there, so no re-dispatch.
1210        pump_until_idle(&mut scene, far_cam, id, Some(&release_tx));
1211        let g = scene.grid(id).unwrap();
1212        assert!(
1213            !g.chunks.contains_key(&IVec3::ZERO),
1214            "evicted chunk must not be re-installed by the stale result"
1215        );
1216    }
1217
1218    #[test]
1219    #[cfg(not(target_arch = "wasm32"))]
1220    fn pump_streaming_with_disabled_radius_is_noop() {
1221        // Like the sync pump's disabled-noop test, but going
1222        // through the async path. No dispatch, no drain, no
1223        // panic.
1224        let mut scene = Scene::new();
1225        let id = scene.add_grid(GridTransform::identity());
1226        let gen = StubGenerator::new();
1227        let counter = Arc::clone(&gen.call_count);
1228        scene
1229            .grid_mut(id)
1230            .unwrap()
1231            .set_generator(Some(Arc::new(gen)));
1232        // stream_radius defaults to DISABLED.
1233        scene.pump_streaming(DVec3::ZERO);
1234        let g = scene.grid(id).unwrap();
1235        assert!(g.chunks.is_empty());
1236        assert!(g.pending_gen.is_empty());
1237        assert_eq!(counter.load(Ordering::Relaxed), 0);
1238    }
1239
1240    #[test]
1241    #[cfg(not(target_arch = "wasm32"))]
1242    fn set_streaming_threads_zero_panics() {
1243        let mut scene = Scene::new();
1244        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1245            scene.set_streaming_threads(0);
1246        }));
1247        assert!(result.is_err(), "zero threads must panic");
1248    }
1249
1250    #[test]
1251    #[cfg(not(target_arch = "wasm32"))]
1252    fn set_streaming_threads_lazily_applied_before_first_pump() {
1253        // Set thread count before any pump → pool is built with
1254        // the new count on next pump. Verified by a successful
1255        // round-trip with thread_count = 1.
1256        let mut scene = Scene::new();
1257        scene.set_streaming_threads(1);
1258        let id = scene.add_grid(GridTransform::identity());
1259        let gen = StubGenerator::new();
1260        let g = scene.grid_mut(id).unwrap();
1261        g.set_generator(Some(Arc::new(gen)));
1262        g.stream_radius = StreamRadius::new(10.0, 200.0);
1263        let cam = DVec3::new(64.0, 64.0, 128.0);
1264        pump_until_idle(&mut scene, cam, id, None);
1265        assert!(scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO));
1266    }
1267
1268    #[test]
1269    fn pump_streaming_sync_eviction_clears_billboard_cache() {
1270        // S7.4 will hand off invalidation more carefully; for S7.1
1271        // we just pin that eviction nukes the cache so a future Far
1272        // render rebuilds it. (Cache stays untouched when nothing
1273        // gets evicted.)
1274        use crate::BillboardCache;
1275        let mut scene = Scene::new();
1276        let id = scene.add_grid(GridTransform::identity());
1277        let g = scene.grid_mut(id).unwrap();
1278        // Seed a single chunk to evict and a placeholder cache.
1279        g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
1280        g.billboards = Some(BillboardCache::new_empty(64));
1281        g.stream_radius = StreamRadius::new(10.0, 50.0);
1282
1283        // Camera far enough that the chunk's nearest face > r_evict.
1284        scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
1285        let g = scene.grid(id).unwrap();
1286        assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
1287        assert!(g.billboards.is_none(), "billboard cache should be cleared");
1288    }
1289
1290    // ---- QE.5a: ChunkStore persistence across evict + re-stream ----
1291
1292    /// In-memory [`ChunkStore`] for the persistence tests.
1293    #[derive(Debug, Default)]
1294    struct MemStore {
1295        slots: std::sync::Mutex<std::collections::HashMap<IVec3, (Vxl, u64)>>,
1296    }
1297
1298    impl ChunkStore for MemStore {
1299        fn store(&self, chunk_idx: IVec3, vxl: &Vxl, version: u64) {
1300            self.slots
1301                .lock()
1302                .unwrap()
1303                .insert(chunk_idx, (vxl.clone(), version));
1304        }
1305        fn load(&self, chunk_idx: IVec3) -> Option<(Vxl, u64)> {
1306            self.slots
1307                .lock()
1308                .unwrap()
1309                .get(&chunk_idx)
1310                .map(|(vxl, version)| (vxl.clone(), *version))
1311        }
1312    }
1313
1314    #[test]
1315    fn chunk_store_round_trips_edits_across_evict_and_restream() {
1316        let mut scene = Scene::new();
1317        let id = scene.add_grid(GridTransform::identity());
1318        let store = Arc::new(MemStore::default());
1319        {
1320            let g = scene.grid_mut(id).unwrap();
1321            g.set_generator(Some(Arc::new(StubGenerator::new())));
1322            g.set_chunk_store(Some(store.clone()));
1323            g.stream_radius = StreamRadius::new(50.0, 200.0);
1324        }
1325
1326        // Stream in around the origin, then edit chunk (0, 0, 0).
1327        scene.pump_streaming_sync(DVec3::ZERO);
1328        let edited_voxel = IVec3::new(1, 1, 40);
1329        let edited_version = {
1330            let g = scene.grid_mut(id).unwrap();
1331            assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk streamed in");
1332            g.set_voxel(edited_voxel, Some(VoxColor(0x8011_2233)));
1333            assert!(g.voxel_solid(edited_voxel));
1334            let v = g.chunk_version(IVec3::ZERO);
1335            assert!(v > 0, "edit bumps the version");
1336            v
1337        };
1338
1339        // Walk far away: the edited chunk must land in the store; a
1340        // pristine (version 0) neighbour must NOT be stored.
1341        scene.pump_streaming_sync(DVec3::new(10_000.0, 10_000.0, 0.0));
1342        assert!(
1343            !scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO),
1344            "edited chunk evicted"
1345        );
1346        let stored = store.load(IVec3::ZERO).expect("edited chunk persisted");
1347        assert_eq!(stored.1, edited_version, "persisted edit version");
1348        assert!(
1349            store.load(IVec3::new(1, 0, 0)).is_none(),
1350            "pristine generator chunks are regenerable and skip the store"
1351        );
1352
1353        // Walk back: the chunk restores from the store — edits intact,
1354        // version restored (not regenerated at version 0).
1355        scene.pump_streaming_sync(DVec3::ZERO);
1356        let g = scene.grid(id).unwrap();
1357        assert!(
1358            g.voxel_solid(edited_voxel),
1359            "player edit survived evict + re-stream"
1360        );
1361        assert_eq!(
1362            g.chunk_version(IVec3::ZERO),
1363            edited_version,
1364            "persisted edit version restored"
1365        );
1366    }
1367
1368    #[test]
1369    fn chunk_store_alone_restores_without_generator() {
1370        // A grid with ONLY a store (no generator) still restores
1371        // persisted chunks — edits can outlive their generator.
1372        let store = Arc::new(MemStore::default());
1373        {
1374            // Seed the store from a throwaway grid.
1375            let mut g = Grid::new(GridTransform::identity());
1376            g.set_voxel(IVec3::new(2, 2, 50), Some(VoxColor(0x8044_5566)));
1377            let vxl = g.chunks.get(&IVec3::ZERO).expect("materialised");
1378            store.store(IVec3::ZERO, vxl, 7);
1379        }
1380
1381        let mut scene = Scene::new();
1382        let id = scene.add_grid(GridTransform::identity());
1383        {
1384            let g = scene.grid_mut(id).unwrap();
1385            g.set_chunk_store(Some(store));
1386            g.stream_radius = StreamRadius::new(50.0, 200.0);
1387        }
1388        scene.pump_streaming_sync(DVec3::ZERO);
1389        let g = scene.grid(id).unwrap();
1390        assert!(g.voxel_solid(IVec3::new(2, 2, 50)));
1391        assert_eq!(g.chunk_version(IVec3::ZERO), 7);
1392    }
1393}