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
77pub trait ChunkStore: fmt::Debug + Send + Sync {
109 fn store(&self, chunk_idx: IVec3, vxl: &Vxl, version: u64);
112
113 fn load(&self, chunk_idx: IVec3) -> Option<(Vxl, u64)>;
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
143pub struct StreamRadius {
144 pub r_active: f64,
147 pub r_evict: f64,
150}
151
152impl StreamRadius {
153 pub const DISABLED: Self = Self {
157 r_active: 0.0,
158 r_evict: f64::INFINITY,
159 };
160
161 #[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 #[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#[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#[must_use]
234pub(crate) fn world_to_grid_local_pos(world_pos: DVec3, transform: &crate::GridTransform) -> DVec3 {
235 transform.rotation.inverse() * (world_pos - transform.origin)
236}
237
238#[cfg(not(target_arch = "wasm32"))]
250pub(crate) use native::{ChunkResult, StreamingState};
251
252#[cfg(not(target_arch = "wasm32"))]
253mod native {
254 use super::*;
255 use crate::GridId;
256
257 pub(crate) struct ChunkResult {
261 pub grid_id: GridId,
262 pub chunk_idx: IVec3,
263 pub version_at_dispatch: u64,
268 pub vxl: Option<Vxl>,
272 pub restored_version: Option<u64>,
275 }
276
277 pub(crate) struct StreamingState {
286 pub thread_count: usize,
287 pub pool: Option<rayon::ThreadPool>,
288 pub tx: crossbeam_channel::Sender<ChunkResult>,
289 pub rx: crossbeam_channel::Receiver<ChunkResult>,
290 }
291
292 impl Default for StreamingState {
293 fn default() -> Self {
294 let (tx, rx) = crossbeam_channel::unbounded();
300 Self {
301 thread_count: 2,
302 pool: None,
303 tx,
304 rx,
305 }
306 }
307 }
308
309 impl std::fmt::Debug for StreamingState {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 f.debug_struct("StreamingState")
315 .field("thread_count", &self.thread_count)
316 .field("pool_built", &self.pool.is_some())
317 .finish_non_exhaustive()
318 }
319 }
320
321 impl StreamingState {
322 pub fn ensure_pool(&mut self) {
330 if self.pool.is_none() {
331 let pool = rayon::ThreadPoolBuilder::new()
332 .num_threads(self.thread_count)
333 .thread_name(|i| format!("roxlap-stream-{i}"))
334 .build()
335 .expect("rayon ThreadPoolBuilder");
336 self.pool = Some(pool);
337 }
338 }
339
340 pub fn set_thread_count(&mut self, n: usize) {
353 assert!(n > 0, "streaming thread count must be >= 1");
354 if self.thread_count == n {
355 return;
356 }
357 self.thread_count = n;
358 self.pool = None;
359 }
360 }
361}
362
363#[cfg(test)]
364pub(crate) mod tests {
365 use super::*;
366 use crate::chunks::tests::voxel_is_solid;
367 use crate::{Grid, GridTransform, Scene, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
368 use glam::DQuat;
369 use roxlap_formats::color::VoxColor;
370 use roxlap_formats::edit::{set_spans, Vspan};
371 use std::sync::atomic::{AtomicUsize, Ordering};
372 use std::sync::Arc;
373
374 #[derive(Debug)]
382 pub(crate) struct StubGenerator {
383 pub call_count: Arc<AtomicUsize>,
384 }
385
386 impl StubGenerator {
387 pub(crate) fn new() -> Self {
388 Self {
389 call_count: Arc::new(AtomicUsize::new(0)),
390 }
391 }
392 }
393
394 impl ChunkGenerator for StubGenerator {
395 fn generate(&self, chunk_idx: IVec3) -> Vxl {
396 self.call_count.fetch_add(1, Ordering::Relaxed);
397 let mut g = Grid::new(GridTransform::identity());
403 let mark_z = (chunk_idx.x.rem_euclid(200) as u32) % CHUNK_SIZE_Z;
404 g.ensure_chunk(IVec3::ZERO);
407 let vxl = g.chunks.remove(&IVec3::ZERO).expect("just inserted");
408 let mut vxl = vxl;
409 set_spans(
412 &mut vxl,
413 &[Vspan {
414 x: 0,
415 y: 0,
416 z0: u8::try_from(mark_z).unwrap_or(0),
417 z1: u8::try_from(mark_z).unwrap_or(0),
418 }],
419 Some(VoxColor(0x80_aa_bb_cc)),
420 );
421 vxl
422 }
423 }
424
425 #[test]
426 fn stub_generator_emits_distinguishable_chunks() {
427 let gen = StubGenerator::new();
431 let a = gen.generate(IVec3::new(0, 0, 0));
432 let b = gen.generate(IVec3::new(7, 0, 0));
433 assert_eq!(a.vsid, CHUNK_SIZE_XY);
434 assert_eq!(b.vsid, CHUNK_SIZE_XY);
435 assert!(voxel_is_solid(&a, 0, 0, 0), "chunk_idx.x=0 marks z=0");
436 assert!(voxel_is_solid(&b, 0, 0, 7), "chunk_idx.x=7 marks z=7");
437 assert_eq!(gen.call_count.load(Ordering::Relaxed), 2);
438 }
439
440 #[test]
441 fn ensure_chunk_generated_populates_via_generator() {
442 let mut g = Grid::new(GridTransform::identity());
443 let gen = StubGenerator::new();
444 let counter = Arc::clone(&gen.call_count);
445 g.set_generator(Some(Arc::new(gen)));
446
447 assert_eq!(g.chunk_count(), 0);
448 let idx = IVec3::new(3, 0, 0);
449 let produced = g.ensure_chunk_generated(idx);
450 assert!(
451 produced,
452 "ensure_chunk_generated returns true when it generates"
453 );
454 assert_eq!(g.chunk_count(), 1);
455 let chunk = g.chunk(idx).expect("chunk now present");
456 assert!(
457 voxel_is_solid(chunk, 0, 0, 3),
458 "stub generator's mark voxel for chunk_idx.x=3 missing"
459 );
460 assert_eq!(counter.load(Ordering::Relaxed), 1);
461 }
462
463 #[test]
464 fn ensure_chunk_generated_is_idempotent() {
465 let mut g = Grid::new(GridTransform::identity());
469 let gen = StubGenerator::new();
470 let counter = Arc::clone(&gen.call_count);
471 g.set_generator(Some(Arc::new(gen)));
472
473 let idx = IVec3::new(5, -2, 0);
474 assert!(g.ensure_chunk_generated(idx));
475 assert!(!g.ensure_chunk_generated(idx), "second call no-ops");
476 assert!(!g.ensure_chunk_generated(idx), "third call still no-ops");
477 assert_eq!(g.chunk_count(), 1);
478 assert_eq!(counter.load(Ordering::Relaxed), 1);
479 }
480
481 #[test]
482 fn ensure_chunk_generated_without_generator_is_noop() {
483 let mut g = Grid::new(GridTransform::identity());
488 let idx = IVec3::new(0, 0, 0);
489 assert!(g.generator.is_none());
490 let produced = g.ensure_chunk_generated(idx);
491 assert!(!produced, "no generator → no chunk generated");
492 assert_eq!(g.chunk_count(), 0);
493 assert!(g.chunk(idx).is_none());
494 }
495
496 #[test]
497 fn ensure_chunk_generated_on_already_present_chunk_skips_generator() {
498 let mut g = Grid::new(GridTransform::identity());
503 let idx = IVec3::new(0, 0, 0);
504 g.set_voxel(IVec3::new(10, 10, 10), Some(VoxColor(0x80_11_22_33)));
506 assert_eq!(g.chunk_count(), 1);
507
508 let gen = StubGenerator::new();
509 let counter = Arc::clone(&gen.call_count);
510 g.set_generator(Some(Arc::new(gen)));
511
512 let produced = g.ensure_chunk_generated(idx);
513 assert!(!produced, "existing chunk not regenerated");
514 assert_eq!(counter.load(Ordering::Relaxed), 0);
515 let chunk = g.chunk(idx).expect("manual chunk present");
517 assert!(voxel_is_solid(chunk, 10, 10, 10), "manual voxel survived");
518 assert!(
519 !voxel_is_solid(chunk, 0, 0, 0),
520 "generator's mark voxel must NOT appear"
521 );
522 }
523
524 #[test]
527 fn stream_radius_disabled_is_truly_zero_infty() {
528 let r = StreamRadius::DISABLED;
529 assert_eq!(r.r_active, 0.0);
530 assert!(r.r_evict.is_infinite() && r.r_evict.is_sign_positive());
531 assert!(r.is_disabled());
532 assert!(StreamRadius::default().is_disabled());
533 }
534
535 #[test]
536 fn stream_radius_new_rejects_evict_below_active() {
537 let result = std::panic::catch_unwind(|| StreamRadius::new(200.0, 100.0));
540 assert!(result.is_err(), "r_evict < r_active must panic");
541 }
542
543 #[test]
544 fn chunk_aabb_dist_sq_inside_chunk_is_zero() {
545 let d = chunk_aabb_dist_sq(DVec3::new(10.0, 20.0, 30.0), IVec3::new(0, 0, 0));
548 assert_eq!(d, 0.0);
549 }
550
551 #[test]
552 fn chunk_aabb_dist_sq_axis_aligned() {
553 let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(1, 0, 0));
556 let expected = 128.0_f64.powi(2);
557 assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
558 let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(0, 0, 1));
560 let expected = 256.0_f64.powi(2);
561 assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
562 }
563
564 #[test]
565 fn pump_streaming_sync_with_disabled_radius_is_noop() {
566 let mut scene = Scene::new();
569 let id = scene.add_grid(GridTransform::identity());
570 let gen = StubGenerator::new();
571 let counter = Arc::clone(&gen.call_count);
572 scene
573 .grid_mut(id)
574 .unwrap()
575 .set_generator(Some(Arc::new(gen)));
576 scene
579 .grid_mut(id)
580 .unwrap()
581 .set_voxel(IVec3::new(10_000, 0, 0), Some(VoxColor(0x80_11_22_33)));
582 let baseline_chunks = scene.grid(id).unwrap().chunk_count();
583 scene.pump_streaming_sync(DVec3::ZERO);
584 assert_eq!(scene.grid(id).unwrap().chunk_count(), baseline_chunks);
585 assert_eq!(counter.load(Ordering::Relaxed), 0);
586 }
587
588 #[test]
589 fn pump_streaming_sync_streams_in_chunks_within_r_active() {
590 let mut scene = Scene::new();
595 let id = scene.add_grid(GridTransform::identity());
596 let gen = StubGenerator::new();
597 let counter = Arc::clone(&gen.call_count);
598 let g = scene.grid_mut(id).unwrap();
599 g.set_generator(Some(Arc::new(gen)));
600 g.stream_radius = StreamRadius::new(200.0, 400.0);
601 scene.pump_streaming_sync(DVec3::ZERO);
602
603 let g = scene.grid(id).unwrap();
608 let must_have = [
609 IVec3::new(0, 0, 0),
610 IVec3::new(1, 0, 0),
611 IVec3::new(-1, 0, 0),
612 IVec3::new(0, 1, 0),
613 IVec3::new(0, -1, 0),
614 IVec3::new(0, 0, -1),
615 ];
616 for idx in must_have {
617 assert!(
618 g.chunks.contains_key(&idx),
619 "chunk {idx:?} missing from streamed set"
620 );
621 }
622 assert!(g.chunks.contains_key(&IVec3::new(1, 1, 0)));
625 assert!(!g.chunks.contains_key(&IVec3::new(2, 0, 0)));
627 assert!(!g.chunks.contains_key(&IVec3::new(0, 0, 1)));
629
630 let streamed = g.chunk_count();
632 assert_eq!(counter.load(Ordering::Relaxed), streamed);
633 }
634
635 #[test]
636 fn pump_streaming_sync_idempotent_under_stationary_camera() {
637 let mut scene = Scene::new();
641 let id = scene.add_grid(GridTransform::identity());
642 let gen = StubGenerator::new();
643 let counter = Arc::clone(&gen.call_count);
644 let g = scene.grid_mut(id).unwrap();
645 g.set_generator(Some(Arc::new(gen)));
646 g.stream_radius = StreamRadius::new(180.0, 400.0);
647
648 scene.pump_streaming_sync(DVec3::ZERO);
649 let after_first = counter.load(Ordering::Relaxed);
650 scene.pump_streaming_sync(DVec3::ZERO);
651 let after_second = counter.load(Ordering::Relaxed);
652 assert_eq!(after_first, after_second, "second pump regenerated chunks");
653 }
654
655 #[test]
656 fn pump_streaming_sync_evicts_chunks_beyond_r_evict() {
657 let mut scene = Scene::new();
660 let id = scene.add_grid(GridTransform::identity());
661 let gen = StubGenerator::new();
662 let g = scene.grid_mut(id).unwrap();
663 g.set_generator(Some(Arc::new(gen)));
664 g.stream_radius = StreamRadius::new(200.0, 400.0);
665 scene.pump_streaming_sync(DVec3::ZERO);
666 let initial = scene.grid(id).unwrap().chunk_count();
667 assert!(initial > 0, "expected chunks streamed in around origin");
668
669 scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
672 let g = scene.grid(id).unwrap();
673 for idx in [
674 IVec3::new(0, 0, 0),
675 IVec3::new(1, 0, 0),
676 IVec3::new(-1, 0, 0),
677 ] {
678 assert!(
679 !g.chunks.contains_key(&idx),
680 "chunk {idx:?} survived eviction after far teleport"
681 );
682 }
683 let cam_chx = 10_000_i32 / i32::try_from(CHUNK_SIZE_XY).unwrap();
686 assert!(g.chunks.contains_key(&IVec3::new(cam_chx, 0, 0)));
687 }
688
689 #[test]
690 fn pump_streaming_sync_hysteresis_band_retains_chunks() {
691 let mut scene = Scene::new();
697 let id = scene.add_grid(GridTransform::identity());
698 let gen = StubGenerator::new();
699 let g = scene.grid_mut(id).unwrap();
700 g.set_generator(Some(Arc::new(gen)));
701 g.stream_radius = StreamRadius::new(200.0, 600.0);
702 scene.pump_streaming_sync(DVec3::ZERO);
703 let g = scene.grid(id).unwrap();
704 let band_idx = IVec3::new(-2, 0, 0);
714 assert!(
717 g.chunks.contains_key(&band_idx),
718 "(-2, 0, 0) should be streamed at origin"
719 );
720
721 scene.pump_streaming_sync(DVec3::new(300.0, 0.0, 0.0));
722 let g = scene.grid(id).unwrap();
723 assert!(
724 g.chunks.contains_key(&band_idx),
725 "(-2, 0, 0) should remain in the hysteresis band"
726 );
727 }
728
729 #[test]
730 fn pump_streaming_sync_with_no_generator_does_not_panic() {
731 let mut scene = Scene::new();
735 let id = scene.add_grid(GridTransform::identity());
736 let g = scene.grid_mut(id).unwrap();
737 g.stream_radius = StreamRadius::new(200.0, 400.0);
738 g.set_voxel(IVec3::new(50 * 128, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
741 assert_eq!(scene.grid(id).unwrap().chunk_count(), 1);
742
743 scene.pump_streaming_sync(DVec3::ZERO);
744 let g = scene.grid(id).unwrap();
745 assert_eq!(g.chunk_count(), 0);
748 }
749
750 #[test]
751 fn pump_streaming_sync_respects_grid_rotation() {
752 let transform = GridTransform {
757 origin: DVec3::ZERO,
758 rotation: DQuat::from_axis_angle(DVec3::Z, std::f64::consts::PI),
759 };
760 let mut scene = Scene::new();
761 let id = scene.add_grid(transform);
762 let gen = StubGenerator::new();
763 let g = scene.grid_mut(id).unwrap();
764 g.set_generator(Some(Arc::new(gen)));
765 g.stream_radius = StreamRadius::new(50.0, 200.0);
766
767 scene.pump_streaming_sync(DVec3::new(10.0, 0.0, 0.0));
770 let g = scene.grid(id).unwrap();
771 assert!(
775 g.chunks.contains_key(&IVec3::new(-1, 0, 0)),
776 "rotation not applied — camera should map to chunk (-1, 0, 0)"
777 );
778 assert!(g.chunks.contains_key(&IVec3::new(0, 0, 0)));
781 }
782
783 #[test]
786 fn ensure_chunk_generated_does_not_bump_version() {
787 let mut g = Grid::new(GridTransform::identity());
789 let gen = StubGenerator::new();
790 g.set_generator(Some(Arc::new(gen)));
791 let idx = IVec3::new(2, 3, 0);
792 assert!(g.ensure_chunk_generated(idx));
793 assert_eq!(g.chunk_version(idx), 0);
794 assert!(g.chunk_versions.is_empty());
796 }
797
798 #[test]
799 fn ensure_chunk_generated_then_edit_starts_at_version_one() {
800 let mut g = Grid::new(GridTransform::identity());
802 let gen = StubGenerator::new();
803 g.set_generator(Some(Arc::new(gen)));
804 let idx = IVec3::ZERO;
805 g.ensure_chunk_generated(idx);
806 g.set_voxel(IVec3::new(10, 10, 10), Some(VoxColor(0x80_aa_bb_cc)));
807 assert_eq!(g.chunk_version(idx), 1);
808 }
809
810 #[test]
811 fn pump_streaming_sync_eviction_drops_chunk_version_entry() {
812 let mut scene = Scene::new();
816 let id = scene.add_grid(GridTransform::identity());
817 let g = scene.grid_mut(id).unwrap();
818 g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
819 assert_eq!(g.chunk_version(IVec3::ZERO), 1);
820 g.stream_radius = StreamRadius::new(10.0, 50.0);
821
822 scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
823 let g = scene.grid(id).unwrap();
824 assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
825 assert_eq!(
826 g.chunk_version(IVec3::ZERO),
827 0,
828 "version entry should be cleared on eviction"
829 );
830 assert!(g.chunk_versions.is_empty(), "map should be empty");
831 }
832
833 #[derive(Debug)]
848 #[cfg(not(target_arch = "wasm32"))]
849 struct BlockingGenerator {
850 arrival_tx: crossbeam_channel::Sender<IVec3>,
851 release_rx: crossbeam_channel::Receiver<()>,
852 call_count: Arc<AtomicUsize>,
853 }
854
855 #[cfg(not(target_arch = "wasm32"))]
856 impl ChunkGenerator for BlockingGenerator {
857 fn generate(&self, chunk_idx: IVec3) -> Vxl {
858 self.call_count.fetch_add(1, Ordering::Relaxed);
859 let _ = self.arrival_tx.send(chunk_idx);
860 let _ = self.release_rx.recv();
864 StubGenerator::new().generate(chunk_idx)
865 }
866 }
867
868 #[cfg(not(target_arch = "wasm32"))]
873 fn pump_until_idle(
874 scene: &mut Scene,
875 cam: DVec3,
876 grid_id: crate::GridId,
877 release_tx: Option<&crossbeam_channel::Sender<()>>,
878 ) {
879 use std::time::{Duration, Instant};
880 let deadline = Instant::now() + Duration::from_secs(5);
881 loop {
882 scene.pump_streaming(cam);
883 let idle = scene.grid(grid_id).is_none_or(|g| g.pending_gen.is_empty());
884 if idle {
885 return;
886 }
887 if Instant::now() > deadline {
888 panic!("pump_until_idle: timeout with pending tasks");
889 }
890 if let Some(tx) = release_tx {
892 let _ = tx.try_send(());
893 }
894 std::thread::sleep(Duration::from_millis(1));
895 }
896 }
897
898 #[test]
901 fn ensure_chunk_generated_invalidates_billboard_cache() {
902 let mut g = Grid::new(GridTransform::identity());
906 let gen = StubGenerator::new();
907 g.set_generator(Some(Arc::new(gen)));
908 g.billboards = Some(crate::BillboardCache::new_empty(32));
909
910 let installed = g.ensure_chunk_generated(IVec3::new(2, 0, 0));
911 assert!(installed, "generator should have installed the chunk");
912 assert!(
913 g.billboards.is_none(),
914 "ensure_chunk_generated must clear billboards on install"
915 );
916 }
917
918 #[test]
919 fn ensure_chunk_generated_noop_preserves_billboard_cache() {
920 let mut g = Grid::new(GridTransform::identity());
924 g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
925 g.billboards = Some(crate::BillboardCache::new_empty(32));
926 let installed = g.ensure_chunk_generated(IVec3::new(5, 5, 0));
928 assert!(!installed);
929 assert!(
930 g.billboards.is_some(),
931 "no-generator no-op must not clear billboards"
932 );
933 let installed = g.ensure_chunk_generated(IVec3::ZERO);
935 assert!(!installed);
936 assert!(
937 g.billboards.is_some(),
938 "already-present chunk must not clear billboards"
939 );
940 }
941
942 #[test]
943 #[cfg(not(target_arch = "wasm32"))]
944 fn pump_streaming_async_install_invalidates_billboard_cache() {
945 let mut scene = Scene::new();
949 let id = scene.add_grid(GridTransform::identity());
950 let gen = StubGenerator::new();
951 let g = scene.grid_mut(id).unwrap();
952 g.set_generator(Some(Arc::new(gen)));
953 g.stream_radius = StreamRadius::new(10.0, 200.0);
954 g.billboards = Some(crate::BillboardCache::new_empty(32));
956 let cam = DVec3::new(64.0, 64.0, 128.0);
957
958 pump_until_idle(&mut scene, cam, id, None);
959
960 let g = scene.grid(id).unwrap();
961 assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
962 assert!(
963 g.billboards.is_none(),
964 "async install must clear billboards"
965 );
966 }
967
968 #[test]
969 #[cfg(not(target_arch = "wasm32"))]
970 fn pump_streaming_no_install_preserves_billboard_cache() {
971 let mut scene = Scene::new();
976 let id = scene.add_grid(GridTransform::identity());
977 let g = scene.grid_mut(id).unwrap();
978 let gen = StubGenerator::new();
979 g.set_generator(Some(Arc::new(gen)));
980 g.stream_radius = StreamRadius::new(10.0, 200.0);
981 let cam = DVec3::new(64.0, 64.0, 128.0);
982 pump_until_idle(&mut scene, cam, id, None);
984 scene.grid_mut(id).unwrap().billboards = Some(crate::BillboardCache::new_empty(32));
986 scene.pump_streaming(cam);
988 let g = scene.grid(id).unwrap();
989 assert!(
990 g.billboards.is_some(),
991 "pump with no install should not clear billboards"
992 );
993 }
994
995 #[test]
996 #[cfg(not(target_arch = "wasm32"))]
997 fn pump_streaming_dispatches_and_installs_via_async_path() {
998 let mut scene = Scene::new();
1002 let id = scene.add_grid(GridTransform::identity());
1003 let gen = StubGenerator::new();
1004 let counter = Arc::clone(&gen.call_count);
1005 let g = scene.grid_mut(id).unwrap();
1006 g.set_generator(Some(Arc::new(gen)));
1007 g.stream_radius = StreamRadius::new(10.0, 200.0);
1010 let cam = DVec3::new(64.0, 64.0, 128.0);
1011
1012 pump_until_idle(&mut scene, cam, id, None);
1013
1014 let g = scene.grid(id).unwrap();
1015 assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
1016 assert_eq!(counter.load(Ordering::Relaxed), 1, "generator called once");
1017 assert!(g.pending_gen.is_empty(), "no leftover pending");
1018 }
1019
1020 #[test]
1021 #[cfg(not(target_arch = "wasm32"))]
1022 fn pump_streaming_tracks_in_flight_chunks_in_pending_gen() {
1023 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1027 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1028 let counter = Arc::new(AtomicUsize::new(0));
1029 let gen = BlockingGenerator {
1030 arrival_tx,
1031 release_rx,
1032 call_count: Arc::clone(&counter),
1033 };
1034
1035 let mut scene = Scene::new();
1036 let id = scene.add_grid(GridTransform::identity());
1037 let g = scene.grid_mut(id).unwrap();
1038 g.set_generator(Some(Arc::new(gen)));
1039 g.stream_radius = StreamRadius::new(10.0, 200.0);
1040 let cam = DVec3::new(64.0, 64.0, 128.0);
1041
1042 scene.pump_streaming(cam);
1043
1044 let arrived = arrival_rx
1046 .recv_timeout(std::time::Duration::from_secs(2))
1047 .expect("task didn't start");
1048 assert_eq!(arrived, IVec3::ZERO);
1049
1050 assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1053 assert!(scene.grid(id).unwrap().chunks.is_empty());
1054
1055 release_tx.send(()).unwrap();
1057 pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1058
1059 let g = scene.grid(id).unwrap();
1060 assert!(g.chunks.contains_key(&IVec3::ZERO));
1061 assert!(!g.pending_gen.contains(&IVec3::ZERO));
1062 assert_eq!(counter.load(Ordering::Relaxed), 1);
1063 }
1064
1065 #[test]
1066 #[cfg(not(target_arch = "wasm32"))]
1067 fn pump_streaming_does_not_redispatch_in_flight_chunks() {
1068 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1072 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1073 let counter = Arc::new(AtomicUsize::new(0));
1074 let gen = BlockingGenerator {
1075 arrival_tx,
1076 release_rx,
1077 call_count: Arc::clone(&counter),
1078 };
1079
1080 let mut scene = Scene::new();
1081 let id = scene.add_grid(GridTransform::identity());
1082 let g = scene.grid_mut(id).unwrap();
1083 g.set_generator(Some(Arc::new(gen)));
1084 g.stream_radius = StreamRadius::new(10.0, 200.0);
1085 let cam = DVec3::new(64.0, 64.0, 128.0);
1086
1087 scene.pump_streaming(cam);
1088 let _ = arrival_rx
1089 .recv_timeout(std::time::Duration::from_secs(2))
1090 .expect("task didn't start");
1091
1092 for _ in 0..5 {
1094 scene.pump_streaming(cam);
1095 }
1096 assert_eq!(
1097 counter.load(Ordering::Relaxed),
1098 1,
1099 "in-flight chunk re-dispatched"
1100 );
1101
1102 release_tx.send(()).unwrap();
1103 pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1104 assert_eq!(counter.load(Ordering::Relaxed), 1);
1106 }
1107
1108 #[test]
1109 #[cfg(not(target_arch = "wasm32"))]
1110 fn pump_streaming_discards_stale_result_when_chunk_edited_during_gen() {
1111 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1118 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1119 let counter = Arc::new(AtomicUsize::new(0));
1120 let gen = BlockingGenerator {
1121 arrival_tx,
1122 release_rx,
1123 call_count: Arc::clone(&counter),
1124 };
1125
1126 let mut scene = Scene::new();
1127 let id = scene.add_grid(GridTransform::identity());
1128 let g = scene.grid_mut(id).unwrap();
1129 g.set_generator(Some(Arc::new(gen)));
1130 g.stream_radius = StreamRadius::new(10.0, 200.0);
1131 let cam = DVec3::new(64.0, 64.0, 128.0);
1132
1133 scene.pump_streaming(cam);
1134 let _ = arrival_rx
1135 .recv_timeout(std::time::Duration::from_secs(2))
1136 .expect("task didn't start");
1137
1138 let g = scene.grid_mut(id).unwrap();
1140 g.set_voxel(IVec3::new(10, 11, 12), Some(VoxColor(0x80_de_ad_be)));
1142 assert_eq!(g.chunk_version(IVec3::ZERO), 1);
1143 let chunk = g.chunk(IVec3::ZERO).unwrap();
1144 assert!(voxel_is_solid(chunk, 10, 11, 12));
1145 assert!(!voxel_is_solid(chunk, 0, 0, 0));
1149
1150 release_tx.send(()).unwrap();
1151 pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1152
1153 let g = scene.grid(id).unwrap();
1155 let chunk = g.chunk(IVec3::ZERO).unwrap();
1156 assert!(voxel_is_solid(chunk, 10, 11, 12), "user edit survived");
1157 assert!(
1158 !voxel_is_solid(chunk, 0, 0, 0),
1159 "stale generator output must not have overwritten the chunk"
1160 );
1161 assert_eq!(counter.load(Ordering::Relaxed), 1);
1163 }
1164
1165 #[test]
1166 #[cfg(not(target_arch = "wasm32"))]
1167 fn pump_streaming_eviction_drops_pending_gen_entry() {
1168 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1175 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1176 let counter = Arc::new(AtomicUsize::new(0));
1177 let gen = BlockingGenerator {
1178 arrival_tx,
1179 release_rx,
1180 call_count: Arc::clone(&counter),
1181 };
1182
1183 let mut scene = Scene::new();
1184 let id = scene.add_grid(GridTransform::identity());
1185 let g = scene.grid_mut(id).unwrap();
1186 g.set_generator(Some(Arc::new(gen)));
1187 g.stream_radius = StreamRadius::new(10.0, 50.0);
1188 let near_cam = DVec3::new(64.0, 64.0, 128.0);
1189 scene.pump_streaming(near_cam);
1190 let _ = arrival_rx
1191 .recv_timeout(std::time::Duration::from_secs(2))
1192 .expect("task didn't start");
1193 assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1194
1195 let far_cam = DVec3::new(10_000.0, 64.0, 128.0);
1199 scene.pump_streaming(far_cam);
1200 assert!(
1201 !scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO),
1202 "eviction should have cleared the pending entry"
1203 );
1204
1205 release_tx.send(()).unwrap();
1208 pump_until_idle(&mut scene, far_cam, id, Some(&release_tx));
1211 let g = scene.grid(id).unwrap();
1212 assert!(
1213 !g.chunks.contains_key(&IVec3::ZERO),
1214 "evicted chunk must not be re-installed by the stale result"
1215 );
1216 }
1217
1218 #[test]
1219 #[cfg(not(target_arch = "wasm32"))]
1220 fn pump_streaming_with_disabled_radius_is_noop() {
1221 let mut scene = Scene::new();
1225 let id = scene.add_grid(GridTransform::identity());
1226 let gen = StubGenerator::new();
1227 let counter = Arc::clone(&gen.call_count);
1228 scene
1229 .grid_mut(id)
1230 .unwrap()
1231 .set_generator(Some(Arc::new(gen)));
1232 scene.pump_streaming(DVec3::ZERO);
1234 let g = scene.grid(id).unwrap();
1235 assert!(g.chunks.is_empty());
1236 assert!(g.pending_gen.is_empty());
1237 assert_eq!(counter.load(Ordering::Relaxed), 0);
1238 }
1239
1240 #[test]
1241 #[cfg(not(target_arch = "wasm32"))]
1242 fn set_streaming_threads_zero_panics() {
1243 let mut scene = Scene::new();
1244 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1245 scene.set_streaming_threads(0);
1246 }));
1247 assert!(result.is_err(), "zero threads must panic");
1248 }
1249
1250 #[test]
1251 #[cfg(not(target_arch = "wasm32"))]
1252 fn set_streaming_threads_lazily_applied_before_first_pump() {
1253 let mut scene = Scene::new();
1257 scene.set_streaming_threads(1);
1258 let id = scene.add_grid(GridTransform::identity());
1259 let gen = StubGenerator::new();
1260 let g = scene.grid_mut(id).unwrap();
1261 g.set_generator(Some(Arc::new(gen)));
1262 g.stream_radius = StreamRadius::new(10.0, 200.0);
1263 let cam = DVec3::new(64.0, 64.0, 128.0);
1264 pump_until_idle(&mut scene, cam, id, None);
1265 assert!(scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO));
1266 }
1267
1268 #[test]
1269 fn pump_streaming_sync_eviction_clears_billboard_cache() {
1270 use crate::BillboardCache;
1275 let mut scene = Scene::new();
1276 let id = scene.add_grid(GridTransform::identity());
1277 let g = scene.grid_mut(id).unwrap();
1278 g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
1280 g.billboards = Some(BillboardCache::new_empty(64));
1281 g.stream_radius = StreamRadius::new(10.0, 50.0);
1282
1283 scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
1285 let g = scene.grid(id).unwrap();
1286 assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
1287 assert!(g.billboards.is_none(), "billboard cache should be cleared");
1288 }
1289
1290 #[derive(Debug, Default)]
1294 struct MemStore {
1295 slots: std::sync::Mutex<std::collections::HashMap<IVec3, (Vxl, u64)>>,
1296 }
1297
1298 impl ChunkStore for MemStore {
1299 fn store(&self, chunk_idx: IVec3, vxl: &Vxl, version: u64) {
1300 self.slots
1301 .lock()
1302 .unwrap()
1303 .insert(chunk_idx, (vxl.clone(), version));
1304 }
1305 fn load(&self, chunk_idx: IVec3) -> Option<(Vxl, u64)> {
1306 self.slots
1307 .lock()
1308 .unwrap()
1309 .get(&chunk_idx)
1310 .map(|(vxl, version)| (vxl.clone(), *version))
1311 }
1312 }
1313
1314 #[test]
1315 fn chunk_store_round_trips_edits_across_evict_and_restream() {
1316 let mut scene = Scene::new();
1317 let id = scene.add_grid(GridTransform::identity());
1318 let store = Arc::new(MemStore::default());
1319 {
1320 let g = scene.grid_mut(id).unwrap();
1321 g.set_generator(Some(Arc::new(StubGenerator::new())));
1322 g.set_chunk_store(Some(store.clone()));
1323 g.stream_radius = StreamRadius::new(50.0, 200.0);
1324 }
1325
1326 scene.pump_streaming_sync(DVec3::ZERO);
1328 let edited_voxel = IVec3::new(1, 1, 40);
1329 let edited_version = {
1330 let g = scene.grid_mut(id).unwrap();
1331 assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk streamed in");
1332 g.set_voxel(edited_voxel, Some(VoxColor(0x8011_2233)));
1333 assert!(g.voxel_solid(edited_voxel));
1334 let v = g.chunk_version(IVec3::ZERO);
1335 assert!(v > 0, "edit bumps the version");
1336 v
1337 };
1338
1339 scene.pump_streaming_sync(DVec3::new(10_000.0, 10_000.0, 0.0));
1342 assert!(
1343 !scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO),
1344 "edited chunk evicted"
1345 );
1346 let stored = store.load(IVec3::ZERO).expect("edited chunk persisted");
1347 assert_eq!(stored.1, edited_version, "persisted edit version");
1348 assert!(
1349 store.load(IVec3::new(1, 0, 0)).is_none(),
1350 "pristine generator chunks are regenerable and skip the store"
1351 );
1352
1353 scene.pump_streaming_sync(DVec3::ZERO);
1356 let g = scene.grid(id).unwrap();
1357 assert!(
1358 g.voxel_solid(edited_voxel),
1359 "player edit survived evict + re-stream"
1360 );
1361 assert_eq!(
1362 g.chunk_version(IVec3::ZERO),
1363 edited_version,
1364 "persisted edit version restored"
1365 );
1366 }
1367
1368 #[test]
1369 fn chunk_store_alone_restores_without_generator() {
1370 let store = Arc::new(MemStore::default());
1373 {
1374 let mut g = Grid::new(GridTransform::identity());
1376 g.set_voxel(IVec3::new(2, 2, 50), Some(VoxColor(0x8044_5566)));
1377 let vxl = g.chunks.get(&IVec3::ZERO).expect("materialised");
1378 store.store(IVec3::ZERO, vxl, 7);
1379 }
1380
1381 let mut scene = Scene::new();
1382 let id = scene.add_grid(GridTransform::identity());
1383 {
1384 let g = scene.grid_mut(id).unwrap();
1385 g.set_chunk_store(Some(store));
1386 g.stream_radius = StreamRadius::new(50.0, 200.0);
1387 }
1388 scene.pump_streaming_sync(DVec3::ZERO);
1389 let g = scene.grid(id).unwrap();
1390 assert!(g.voxel_solid(IVec3::new(2, 2, 50)));
1391 assert_eq!(g.chunk_version(IVec3::ZERO), 7);
1392 }
1393}