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