1use std::fmt;
25
26use glam::{DVec3, IVec3};
27use roxlap_formats::vxl::Vxl;
28
29use crate::{CHUNK_SIZE_XY, CHUNK_SIZE_Z};
30
31pub trait ChunkGenerator: fmt::Debug + Send + Sync {
51 fn generate(&self, chunk_idx: IVec3) -> Vxl;
55
56 fn should_generate(&self, _chunk_idx: IVec3) -> bool {
73 true
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct StreamRadius {
102 pub r_active: f64,
105 pub r_evict: f64,
108}
109
110impl StreamRadius {
111 pub const DISABLED: Self = Self {
115 r_active: 0.0,
116 r_evict: f64::INFINITY,
117 };
118
119 #[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 #[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#[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#[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#[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 pub(crate) struct ChunkResult {
219 pub grid_id: GridId,
220 pub chunk_idx: IVec3,
221 pub version_at_dispatch: u64,
226 pub vxl: Vxl,
227 }
228
229 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 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 #[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 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 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 #[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 let mut g = Grid::new(GridTransform::identity());
354 let mark_z = (chunk_idx.x.rem_euclid(200) as u32) % CHUNK_SIZE_Z;
355 g.ensure_chunk(IVec3::ZERO);
358 let vxl = g.chunks.remove(&IVec3::ZERO).expect("just inserted");
359 let mut vxl = vxl;
360 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 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 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 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 let mut g = Grid::new(GridTransform::identity());
454 let idx = IVec3::new(0, 0, 0);
455 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 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 #[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 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 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 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 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 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 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 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 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 assert!(g.chunks.contains_key(&IVec3::new(1, 1, 0)));
576 assert!(!g.chunks.contains_key(&IVec3::new(2, 0, 0)));
578 assert!(!g.chunks.contains_key(&IVec3::new(0, 0, 1)));
580
581 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 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 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 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 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 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 let band_idx = IVec3::new(-2, 0, 0);
665 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 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 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 assert_eq!(g.chunk_count(), 0);
699 }
700
701 #[test]
702 fn pump_streaming_sync_respects_grid_rotation() {
703 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 scene.pump_streaming_sync(DVec3::new(10.0, 0.0, 0.0));
721 let g = scene.grid(id).unwrap();
722 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 assert!(g.chunks.contains_key(&IVec3::new(0, 0, 0)));
732 }
733
734 #[test]
737 fn ensure_chunk_generated_does_not_bump_version() {
738 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 assert!(g.chunk_versions.is_empty());
747 }
748
749 #[test]
750 fn ensure_chunk_generated_then_edit_starts_at_version_one() {
751 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 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 #[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 let _ = self.release_rx.recv();
815 StubGenerator::new().generate(chunk_idx)
816 }
817 }
818
819 #[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 if let Some(tx) = release_tx {
845 let _ = tx.try_send(());
846 }
847 std::thread::sleep(Duration::from_millis(1));
848 }
849 }
850
851 #[test]
854 fn ensure_chunk_generated_invalidates_billboard_cache() {
855 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 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 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 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 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 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 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 pump_until_idle(&mut scene, cam, id, None);
937 scene.grid_mut(id).unwrap().billboards = Some(crate::BillboardCache::new_empty(32));
939 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 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 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 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 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 assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1006 assert!(scene.grid(id).unwrap().chunks.is_empty());
1007
1008 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 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 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 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 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 let g = scene.grid_mut(id).unwrap();
1093 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 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 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 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 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 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 release_tx.send(()).unwrap();
1161 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 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 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 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 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 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 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}