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