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]
239pub(crate) fn world_to_grid_local_pos(world_pos: DVec3, transform: &crate::GridTransform) -> DVec3 {
240 transform.rotation.inverse() * (world_pos - transform.origin) / transform.voxel_world_size
241}
242
243#[cfg(not(target_arch = "wasm32"))]
255pub(crate) use native::{ChunkResult, StreamingState};
256
257#[cfg(not(target_arch = "wasm32"))]
258mod native {
259 use super::*;
260 use crate::GridId;
261
262 pub(crate) struct ChunkResult {
266 pub grid_id: GridId,
267 pub chunk_idx: IVec3,
268 pub version_at_dispatch: u64,
273 pub vxl: Option<Vxl>,
277 pub restored_version: Option<u64>,
280 }
281
282 pub(crate) struct StreamingState {
291 pub thread_count: usize,
292 pub pool: Option<rayon::ThreadPool>,
293 pub tx: crossbeam_channel::Sender<ChunkResult>,
294 pub rx: crossbeam_channel::Receiver<ChunkResult>,
295 }
296
297 impl Default for StreamingState {
298 fn default() -> Self {
299 let (tx, rx) = crossbeam_channel::unbounded();
305 Self {
306 thread_count: 2,
307 pool: None,
308 tx,
309 rx,
310 }
311 }
312 }
313
314 impl std::fmt::Debug for StreamingState {
315 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319 f.debug_struct("StreamingState")
320 .field("thread_count", &self.thread_count)
321 .field("pool_built", &self.pool.is_some())
322 .finish_non_exhaustive()
323 }
324 }
325
326 impl StreamingState {
327 pub fn ensure_pool(&mut self) {
335 if self.pool.is_none() {
336 let pool = rayon::ThreadPoolBuilder::new()
337 .num_threads(self.thread_count)
338 .thread_name(|i| format!("roxlap-stream-{i}"))
339 .build()
340 .expect("rayon ThreadPoolBuilder");
341 self.pool = Some(pool);
342 }
343 }
344
345 pub fn set_thread_count(&mut self, n: usize) {
358 assert!(n > 0, "streaming thread count must be >= 1");
359 if self.thread_count == n {
360 return;
361 }
362 self.thread_count = n;
363 self.pool = None;
364 }
365 }
366}
367
368#[cfg(test)]
369pub(crate) mod tests {
370 use super::*;
371 use crate::chunks::tests::voxel_is_solid;
372 use crate::{Grid, GridTransform, Scene, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
373 use glam::DQuat;
374 use roxlap_formats::color::VoxColor;
375 use roxlap_formats::edit::{set_spans, Vspan};
376 use std::sync::atomic::{AtomicUsize, Ordering};
377 use std::sync::Arc;
378
379 #[derive(Debug)]
387 pub(crate) struct StubGenerator {
388 pub call_count: Arc<AtomicUsize>,
389 }
390
391 impl StubGenerator {
392 pub(crate) fn new() -> Self {
393 Self {
394 call_count: Arc::new(AtomicUsize::new(0)),
395 }
396 }
397 }
398
399 impl ChunkGenerator for StubGenerator {
400 fn generate(&self, chunk_idx: IVec3) -> Vxl {
401 self.call_count.fetch_add(1, Ordering::Relaxed);
402 let mut g = Grid::new(GridTransform::identity());
408 let mark_z = (chunk_idx.x.rem_euclid(200) as u32) % CHUNK_SIZE_Z;
409 g.ensure_chunk(IVec3::ZERO);
412 let vxl = g.chunks.remove(&IVec3::ZERO).expect("just inserted");
413 let mut vxl = vxl;
414 set_spans(
417 &mut vxl,
418 &[Vspan {
419 x: 0,
420 y: 0,
421 z0: u8::try_from(mark_z).unwrap_or(0),
422 z1: u8::try_from(mark_z).unwrap_or(0),
423 }],
424 Some(VoxColor(0x80_aa_bb_cc)),
425 );
426 vxl
427 }
428 }
429
430 #[test]
431 fn stub_generator_emits_distinguishable_chunks() {
432 let gen = StubGenerator::new();
436 let a = gen.generate(IVec3::new(0, 0, 0));
437 let b = gen.generate(IVec3::new(7, 0, 0));
438 assert_eq!(a.vsid, CHUNK_SIZE_XY);
439 assert_eq!(b.vsid, CHUNK_SIZE_XY);
440 assert!(voxel_is_solid(&a, 0, 0, 0), "chunk_idx.x=0 marks z=0");
441 assert!(voxel_is_solid(&b, 0, 0, 7), "chunk_idx.x=7 marks z=7");
442 assert_eq!(gen.call_count.load(Ordering::Relaxed), 2);
443 }
444
445 #[test]
446 fn ensure_chunk_generated_populates_via_generator() {
447 let mut g = Grid::new(GridTransform::identity());
448 let gen = StubGenerator::new();
449 let counter = Arc::clone(&gen.call_count);
450 g.set_generator(Some(Arc::new(gen)));
451
452 assert_eq!(g.chunk_count(), 0);
453 let idx = IVec3::new(3, 0, 0);
454 let produced = g.ensure_chunk_generated(idx);
455 assert!(
456 produced,
457 "ensure_chunk_generated returns true when it generates"
458 );
459 assert_eq!(g.chunk_count(), 1);
460 let chunk = g.chunk(idx).expect("chunk now present");
461 assert!(
462 voxel_is_solid(chunk, 0, 0, 3),
463 "stub generator's mark voxel for chunk_idx.x=3 missing"
464 );
465 assert_eq!(counter.load(Ordering::Relaxed), 1);
466 }
467
468 #[test]
469 fn ensure_chunk_generated_is_idempotent() {
470 let mut g = Grid::new(GridTransform::identity());
474 let gen = StubGenerator::new();
475 let counter = Arc::clone(&gen.call_count);
476 g.set_generator(Some(Arc::new(gen)));
477
478 let idx = IVec3::new(5, -2, 0);
479 assert!(g.ensure_chunk_generated(idx));
480 assert!(!g.ensure_chunk_generated(idx), "second call no-ops");
481 assert!(!g.ensure_chunk_generated(idx), "third call still no-ops");
482 assert_eq!(g.chunk_count(), 1);
483 assert_eq!(counter.load(Ordering::Relaxed), 1);
484 }
485
486 #[test]
487 fn ensure_chunk_generated_without_generator_is_noop() {
488 let mut g = Grid::new(GridTransform::identity());
493 let idx = IVec3::new(0, 0, 0);
494 assert!(g.generator.is_none());
495 let produced = g.ensure_chunk_generated(idx);
496 assert!(!produced, "no generator → no chunk generated");
497 assert_eq!(g.chunk_count(), 0);
498 assert!(g.chunk(idx).is_none());
499 }
500
501 #[test]
502 fn ensure_chunk_generated_on_already_present_chunk_skips_generator() {
503 let mut g = Grid::new(GridTransform::identity());
508 let idx = IVec3::new(0, 0, 0);
509 g.set_voxel(IVec3::new(10, 10, 10), Some(VoxColor(0x80_11_22_33)));
511 assert_eq!(g.chunk_count(), 1);
512
513 let gen = StubGenerator::new();
514 let counter = Arc::clone(&gen.call_count);
515 g.set_generator(Some(Arc::new(gen)));
516
517 let produced = g.ensure_chunk_generated(idx);
518 assert!(!produced, "existing chunk not regenerated");
519 assert_eq!(counter.load(Ordering::Relaxed), 0);
520 let chunk = g.chunk(idx).expect("manual chunk present");
522 assert!(voxel_is_solid(chunk, 10, 10, 10), "manual voxel survived");
523 assert!(
524 !voxel_is_solid(chunk, 0, 0, 0),
525 "generator's mark voxel must NOT appear"
526 );
527 }
528
529 #[test]
532 fn sc3_world_to_grid_local_pos_scales_by_vws() {
533 let t = GridTransform::at_scale(DVec3::new(0.0, 0.0, 0.0), 4.0);
538 let local = world_to_grid_local_pos(DVec3::new(40.0, 0.0, 0.0), &t);
539 assert!(
540 (local - DVec3::new(10.0, 0.0, 0.0)).length() < 1e-9,
541 "world (40,0,0) at vws=4 must be voxel (10,0,0): got {local:?}"
542 );
543 let unit = world_to_grid_local_pos(DVec3::new(40.0, 0.0, 0.0), &GridTransform::identity());
545 assert!((unit - DVec3::new(40.0, 0.0, 0.0)).length() < 1e-9);
546 }
547
548 #[test]
549 fn stream_radius_disabled_is_truly_zero_infty() {
550 let r = StreamRadius::DISABLED;
551 assert_eq!(r.r_active, 0.0);
552 assert!(r.r_evict.is_infinite() && r.r_evict.is_sign_positive());
553 assert!(r.is_disabled());
554 assert!(StreamRadius::default().is_disabled());
555 }
556
557 #[test]
558 fn stream_radius_new_rejects_evict_below_active() {
559 let result = std::panic::catch_unwind(|| StreamRadius::new(200.0, 100.0));
562 assert!(result.is_err(), "r_evict < r_active must panic");
563 }
564
565 #[test]
566 fn chunk_aabb_dist_sq_inside_chunk_is_zero() {
567 let d = chunk_aabb_dist_sq(DVec3::new(10.0, 20.0, 30.0), IVec3::new(0, 0, 0));
570 assert_eq!(d, 0.0);
571 }
572
573 #[test]
574 fn chunk_aabb_dist_sq_axis_aligned() {
575 let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(1, 0, 0));
578 let expected = 128.0_f64.powi(2);
579 assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
580 let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(0, 0, 1));
582 let expected = 256.0_f64.powi(2);
583 assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
584 }
585
586 #[test]
587 fn pump_streaming_sync_with_disabled_radius_is_noop() {
588 let mut scene = Scene::new();
591 let id = scene.add_grid(GridTransform::identity());
592 let gen = StubGenerator::new();
593 let counter = Arc::clone(&gen.call_count);
594 scene
595 .grid_mut(id)
596 .unwrap()
597 .set_generator(Some(Arc::new(gen)));
598 scene
601 .grid_mut(id)
602 .unwrap()
603 .set_voxel(IVec3::new(10_000, 0, 0), Some(VoxColor(0x80_11_22_33)));
604 let baseline_chunks = scene.grid(id).unwrap().chunk_count();
605 scene.pump_streaming_sync(DVec3::ZERO);
606 assert_eq!(scene.grid(id).unwrap().chunk_count(), baseline_chunks);
607 assert_eq!(counter.load(Ordering::Relaxed), 0);
608 }
609
610 #[test]
611 fn pump_streaming_sync_streams_in_chunks_within_r_active() {
612 let mut scene = Scene::new();
617 let id = scene.add_grid(GridTransform::identity());
618 let gen = StubGenerator::new();
619 let counter = Arc::clone(&gen.call_count);
620 let g = scene.grid_mut(id).unwrap();
621 g.set_generator(Some(Arc::new(gen)));
622 g.stream_radius = StreamRadius::new(200.0, 400.0);
623 scene.pump_streaming_sync(DVec3::ZERO);
624
625 let g = scene.grid(id).unwrap();
630 let must_have = [
631 IVec3::new(0, 0, 0),
632 IVec3::new(1, 0, 0),
633 IVec3::new(-1, 0, 0),
634 IVec3::new(0, 1, 0),
635 IVec3::new(0, -1, 0),
636 IVec3::new(0, 0, -1),
637 ];
638 for idx in must_have {
639 assert!(
640 g.chunks.contains_key(&idx),
641 "chunk {idx:?} missing from streamed set"
642 );
643 }
644 assert!(g.chunks.contains_key(&IVec3::new(1, 1, 0)));
647 assert!(!g.chunks.contains_key(&IVec3::new(2, 0, 0)));
649 assert!(!g.chunks.contains_key(&IVec3::new(0, 0, 1)));
651
652 let streamed = g.chunk_count();
654 assert_eq!(counter.load(Ordering::Relaxed), streamed);
655 }
656
657 #[test]
658 fn pump_streaming_sync_idempotent_under_stationary_camera() {
659 let mut scene = Scene::new();
663 let id = scene.add_grid(GridTransform::identity());
664 let gen = StubGenerator::new();
665 let counter = Arc::clone(&gen.call_count);
666 let g = scene.grid_mut(id).unwrap();
667 g.set_generator(Some(Arc::new(gen)));
668 g.stream_radius = StreamRadius::new(180.0, 400.0);
669
670 scene.pump_streaming_sync(DVec3::ZERO);
671 let after_first = counter.load(Ordering::Relaxed);
672 scene.pump_streaming_sync(DVec3::ZERO);
673 let after_second = counter.load(Ordering::Relaxed);
674 assert_eq!(after_first, after_second, "second pump regenerated chunks");
675 }
676
677 #[test]
678 fn pump_streaming_sync_evicts_chunks_beyond_r_evict() {
679 let mut scene = Scene::new();
682 let id = scene.add_grid(GridTransform::identity());
683 let gen = StubGenerator::new();
684 let g = scene.grid_mut(id).unwrap();
685 g.set_generator(Some(Arc::new(gen)));
686 g.stream_radius = StreamRadius::new(200.0, 400.0);
687 scene.pump_streaming_sync(DVec3::ZERO);
688 let initial = scene.grid(id).unwrap().chunk_count();
689 assert!(initial > 0, "expected chunks streamed in around origin");
690
691 scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
694 let g = scene.grid(id).unwrap();
695 for idx in [
696 IVec3::new(0, 0, 0),
697 IVec3::new(1, 0, 0),
698 IVec3::new(-1, 0, 0),
699 ] {
700 assert!(
701 !g.chunks.contains_key(&idx),
702 "chunk {idx:?} survived eviction after far teleport"
703 );
704 }
705 let cam_chx = 10_000_i32 / i32::try_from(CHUNK_SIZE_XY).unwrap();
708 assert!(g.chunks.contains_key(&IVec3::new(cam_chx, 0, 0)));
709 }
710
711 #[test]
712 fn pump_streaming_sync_hysteresis_band_retains_chunks() {
713 let mut scene = Scene::new();
719 let id = scene.add_grid(GridTransform::identity());
720 let gen = StubGenerator::new();
721 let g = scene.grid_mut(id).unwrap();
722 g.set_generator(Some(Arc::new(gen)));
723 g.stream_radius = StreamRadius::new(200.0, 600.0);
724 scene.pump_streaming_sync(DVec3::ZERO);
725 let g = scene.grid(id).unwrap();
726 let band_idx = IVec3::new(-2, 0, 0);
736 assert!(
739 g.chunks.contains_key(&band_idx),
740 "(-2, 0, 0) should be streamed at origin"
741 );
742
743 scene.pump_streaming_sync(DVec3::new(300.0, 0.0, 0.0));
744 let g = scene.grid(id).unwrap();
745 assert!(
746 g.chunks.contains_key(&band_idx),
747 "(-2, 0, 0) should remain in the hysteresis band"
748 );
749 }
750
751 #[test]
752 fn pump_streaming_sync_with_no_generator_does_not_panic() {
753 let mut scene = Scene::new();
757 let id = scene.add_grid(GridTransform::identity());
758 let g = scene.grid_mut(id).unwrap();
759 g.stream_radius = StreamRadius::new(200.0, 400.0);
760 g.set_voxel(IVec3::new(50 * 128, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
763 assert_eq!(scene.grid(id).unwrap().chunk_count(), 1);
764
765 scene.pump_streaming_sync(DVec3::ZERO);
766 let g = scene.grid(id).unwrap();
767 assert_eq!(g.chunk_count(), 0);
770 }
771
772 #[test]
773 fn pump_streaming_sync_respects_grid_rotation() {
774 let transform = GridTransform {
779 origin: DVec3::ZERO,
780 rotation: DQuat::from_axis_angle(DVec3::Z, std::f64::consts::PI),
781 voxel_world_size: 1.0,
782 };
783 let mut scene = Scene::new();
784 let id = scene.add_grid(transform);
785 let gen = StubGenerator::new();
786 let g = scene.grid_mut(id).unwrap();
787 g.set_generator(Some(Arc::new(gen)));
788 g.stream_radius = StreamRadius::new(50.0, 200.0);
789
790 scene.pump_streaming_sync(DVec3::new(10.0, 0.0, 0.0));
793 let g = scene.grid(id).unwrap();
794 assert!(
798 g.chunks.contains_key(&IVec3::new(-1, 0, 0)),
799 "rotation not applied — camera should map to chunk (-1, 0, 0)"
800 );
801 assert!(g.chunks.contains_key(&IVec3::new(0, 0, 0)));
804 }
805
806 #[test]
809 fn ensure_chunk_generated_does_not_bump_version() {
810 let mut g = Grid::new(GridTransform::identity());
812 let gen = StubGenerator::new();
813 g.set_generator(Some(Arc::new(gen)));
814 let idx = IVec3::new(2, 3, 0);
815 assert!(g.ensure_chunk_generated(idx));
816 assert_eq!(g.chunk_version(idx), 0);
817 assert!(g.chunk_versions.is_empty());
819 }
820
821 #[test]
822 fn ensure_chunk_generated_then_edit_starts_at_version_one() {
823 let mut g = Grid::new(GridTransform::identity());
825 let gen = StubGenerator::new();
826 g.set_generator(Some(Arc::new(gen)));
827 let idx = IVec3::ZERO;
828 g.ensure_chunk_generated(idx);
829 g.set_voxel(IVec3::new(10, 10, 10), Some(VoxColor(0x80_aa_bb_cc)));
830 assert_eq!(g.chunk_version(idx), 1);
831 }
832
833 #[test]
834 fn pump_streaming_sync_eviction_drops_chunk_version_entry() {
835 let mut scene = Scene::new();
839 let id = scene.add_grid(GridTransform::identity());
840 let g = scene.grid_mut(id).unwrap();
841 g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
842 assert_eq!(g.chunk_version(IVec3::ZERO), 1);
843 g.stream_radius = StreamRadius::new(10.0, 50.0);
844
845 scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
846 let g = scene.grid(id).unwrap();
847 assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
848 assert_eq!(
849 g.chunk_version(IVec3::ZERO),
850 0,
851 "version entry should be cleared on eviction"
852 );
853 assert!(g.chunk_versions.is_empty(), "map should be empty");
854 }
855
856 #[derive(Debug)]
871 #[cfg(not(target_arch = "wasm32"))]
872 struct BlockingGenerator {
873 arrival_tx: crossbeam_channel::Sender<IVec3>,
874 release_rx: crossbeam_channel::Receiver<()>,
875 call_count: Arc<AtomicUsize>,
876 }
877
878 #[cfg(not(target_arch = "wasm32"))]
879 impl ChunkGenerator for BlockingGenerator {
880 fn generate(&self, chunk_idx: IVec3) -> Vxl {
881 self.call_count.fetch_add(1, Ordering::Relaxed);
882 let _ = self.arrival_tx.send(chunk_idx);
883 let _ = self.release_rx.recv();
887 StubGenerator::new().generate(chunk_idx)
888 }
889 }
890
891 #[cfg(not(target_arch = "wasm32"))]
896 fn pump_until_idle(
897 scene: &mut Scene,
898 cam: DVec3,
899 grid_id: crate::GridId,
900 release_tx: Option<&crossbeam_channel::Sender<()>>,
901 ) {
902 use std::time::{Duration, Instant};
903 let deadline = Instant::now() + Duration::from_secs(5);
904 loop {
905 scene.pump_streaming(cam);
906 let idle = scene.grid(grid_id).is_none_or(|g| g.pending_gen.is_empty());
907 if idle {
908 return;
909 }
910 if Instant::now() > deadline {
911 panic!("pump_until_idle: timeout with pending tasks");
912 }
913 if let Some(tx) = release_tx {
915 let _ = tx.try_send(());
916 }
917 std::thread::sleep(Duration::from_millis(1));
918 }
919 }
920
921 #[test]
924 fn ensure_chunk_generated_invalidates_billboard_cache() {
925 let mut g = Grid::new(GridTransform::identity());
929 let gen = StubGenerator::new();
930 g.set_generator(Some(Arc::new(gen)));
931 g.billboards = Some(crate::BillboardCache::new_empty(32));
932
933 let installed = g.ensure_chunk_generated(IVec3::new(2, 0, 0));
934 assert!(installed, "generator should have installed the chunk");
935 assert!(
936 g.billboards.is_none(),
937 "ensure_chunk_generated must clear billboards on install"
938 );
939 }
940
941 #[test]
942 fn ensure_chunk_generated_noop_preserves_billboard_cache() {
943 let mut g = Grid::new(GridTransform::identity());
947 g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
948 g.billboards = Some(crate::BillboardCache::new_empty(32));
949 let installed = g.ensure_chunk_generated(IVec3::new(5, 5, 0));
951 assert!(!installed);
952 assert!(
953 g.billboards.is_some(),
954 "no-generator no-op must not clear billboards"
955 );
956 let installed = g.ensure_chunk_generated(IVec3::ZERO);
958 assert!(!installed);
959 assert!(
960 g.billboards.is_some(),
961 "already-present chunk must not clear billboards"
962 );
963 }
964
965 #[test]
966 #[cfg(not(target_arch = "wasm32"))]
967 fn pump_streaming_async_install_invalidates_billboard_cache() {
968 let mut scene = Scene::new();
972 let id = scene.add_grid(GridTransform::identity());
973 let gen = StubGenerator::new();
974 let g = scene.grid_mut(id).unwrap();
975 g.set_generator(Some(Arc::new(gen)));
976 g.stream_radius = StreamRadius::new(10.0, 200.0);
977 g.billboards = Some(crate::BillboardCache::new_empty(32));
979 let cam = DVec3::new(64.0, 64.0, 128.0);
980
981 pump_until_idle(&mut scene, cam, id, None);
982
983 let g = scene.grid(id).unwrap();
984 assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
985 assert!(
986 g.billboards.is_none(),
987 "async install must clear billboards"
988 );
989 }
990
991 #[test]
992 #[cfg(not(target_arch = "wasm32"))]
993 fn pump_streaming_no_install_preserves_billboard_cache() {
994 let mut scene = Scene::new();
999 let id = scene.add_grid(GridTransform::identity());
1000 let g = scene.grid_mut(id).unwrap();
1001 let gen = StubGenerator::new();
1002 g.set_generator(Some(Arc::new(gen)));
1003 g.stream_radius = StreamRadius::new(10.0, 200.0);
1004 let cam = DVec3::new(64.0, 64.0, 128.0);
1005 pump_until_idle(&mut scene, cam, id, None);
1007 scene.grid_mut(id).unwrap().billboards = Some(crate::BillboardCache::new_empty(32));
1009 scene.pump_streaming(cam);
1011 let g = scene.grid(id).unwrap();
1012 assert!(
1013 g.billboards.is_some(),
1014 "pump with no install should not clear billboards"
1015 );
1016 }
1017
1018 #[test]
1019 #[cfg(not(target_arch = "wasm32"))]
1020 fn pump_streaming_dispatches_and_installs_via_async_path() {
1021 let mut scene = Scene::new();
1025 let id = scene.add_grid(GridTransform::identity());
1026 let gen = StubGenerator::new();
1027 let counter = Arc::clone(&gen.call_count);
1028 let g = scene.grid_mut(id).unwrap();
1029 g.set_generator(Some(Arc::new(gen)));
1030 g.stream_radius = StreamRadius::new(10.0, 200.0);
1033 let cam = DVec3::new(64.0, 64.0, 128.0);
1034
1035 pump_until_idle(&mut scene, cam, id, None);
1036
1037 let g = scene.grid(id).unwrap();
1038 assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
1039 assert_eq!(counter.load(Ordering::Relaxed), 1, "generator called once");
1040 assert!(g.pending_gen.is_empty(), "no leftover pending");
1041 }
1042
1043 #[test]
1044 #[cfg(not(target_arch = "wasm32"))]
1045 fn pump_streaming_tracks_in_flight_chunks_in_pending_gen() {
1046 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1050 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1051 let counter = Arc::new(AtomicUsize::new(0));
1052 let gen = BlockingGenerator {
1053 arrival_tx,
1054 release_rx,
1055 call_count: Arc::clone(&counter),
1056 };
1057
1058 let mut scene = Scene::new();
1059 let id = scene.add_grid(GridTransform::identity());
1060 let g = scene.grid_mut(id).unwrap();
1061 g.set_generator(Some(Arc::new(gen)));
1062 g.stream_radius = StreamRadius::new(10.0, 200.0);
1063 let cam = DVec3::new(64.0, 64.0, 128.0);
1064
1065 scene.pump_streaming(cam);
1066
1067 let arrived = arrival_rx
1069 .recv_timeout(std::time::Duration::from_secs(2))
1070 .expect("task didn't start");
1071 assert_eq!(arrived, IVec3::ZERO);
1072
1073 assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1076 assert!(scene.grid(id).unwrap().chunks.is_empty());
1077
1078 release_tx.send(()).unwrap();
1080 pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1081
1082 let g = scene.grid(id).unwrap();
1083 assert!(g.chunks.contains_key(&IVec3::ZERO));
1084 assert!(!g.pending_gen.contains(&IVec3::ZERO));
1085 assert_eq!(counter.load(Ordering::Relaxed), 1);
1086 }
1087
1088 #[test]
1089 #[cfg(not(target_arch = "wasm32"))]
1090 fn pump_streaming_does_not_redispatch_in_flight_chunks() {
1091 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1095 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1096 let counter = Arc::new(AtomicUsize::new(0));
1097 let gen = BlockingGenerator {
1098 arrival_tx,
1099 release_rx,
1100 call_count: Arc::clone(&counter),
1101 };
1102
1103 let mut scene = Scene::new();
1104 let id = scene.add_grid(GridTransform::identity());
1105 let g = scene.grid_mut(id).unwrap();
1106 g.set_generator(Some(Arc::new(gen)));
1107 g.stream_radius = StreamRadius::new(10.0, 200.0);
1108 let cam = DVec3::new(64.0, 64.0, 128.0);
1109
1110 scene.pump_streaming(cam);
1111 let _ = arrival_rx
1112 .recv_timeout(std::time::Duration::from_secs(2))
1113 .expect("task didn't start");
1114
1115 for _ in 0..5 {
1117 scene.pump_streaming(cam);
1118 }
1119 assert_eq!(
1120 counter.load(Ordering::Relaxed),
1121 1,
1122 "in-flight chunk re-dispatched"
1123 );
1124
1125 release_tx.send(()).unwrap();
1126 pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1127 assert_eq!(counter.load(Ordering::Relaxed), 1);
1129 }
1130
1131 #[test]
1132 #[cfg(not(target_arch = "wasm32"))]
1133 fn pump_streaming_discards_stale_result_when_chunk_edited_during_gen() {
1134 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1141 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1142 let counter = Arc::new(AtomicUsize::new(0));
1143 let gen = BlockingGenerator {
1144 arrival_tx,
1145 release_rx,
1146 call_count: Arc::clone(&counter),
1147 };
1148
1149 let mut scene = Scene::new();
1150 let id = scene.add_grid(GridTransform::identity());
1151 let g = scene.grid_mut(id).unwrap();
1152 g.set_generator(Some(Arc::new(gen)));
1153 g.stream_radius = StreamRadius::new(10.0, 200.0);
1154 let cam = DVec3::new(64.0, 64.0, 128.0);
1155
1156 scene.pump_streaming(cam);
1157 let _ = arrival_rx
1158 .recv_timeout(std::time::Duration::from_secs(2))
1159 .expect("task didn't start");
1160
1161 let g = scene.grid_mut(id).unwrap();
1163 g.set_voxel(IVec3::new(10, 11, 12), Some(VoxColor(0x80_de_ad_be)));
1165 assert_eq!(g.chunk_version(IVec3::ZERO), 1);
1166 let chunk = g.chunk(IVec3::ZERO).unwrap();
1167 assert!(voxel_is_solid(chunk, 10, 11, 12));
1168 assert!(!voxel_is_solid(chunk, 0, 0, 0));
1172
1173 release_tx.send(()).unwrap();
1174 pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1175
1176 let g = scene.grid(id).unwrap();
1178 let chunk = g.chunk(IVec3::ZERO).unwrap();
1179 assert!(voxel_is_solid(chunk, 10, 11, 12), "user edit survived");
1180 assert!(
1181 !voxel_is_solid(chunk, 0, 0, 0),
1182 "stale generator output must not have overwritten the chunk"
1183 );
1184 assert_eq!(counter.load(Ordering::Relaxed), 1);
1186 }
1187
1188 #[test]
1189 #[cfg(not(target_arch = "wasm32"))]
1190 fn pump_streaming_eviction_drops_pending_gen_entry() {
1191 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1198 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1199 let counter = Arc::new(AtomicUsize::new(0));
1200 let gen = BlockingGenerator {
1201 arrival_tx,
1202 release_rx,
1203 call_count: Arc::clone(&counter),
1204 };
1205
1206 let mut scene = Scene::new();
1207 let id = scene.add_grid(GridTransform::identity());
1208 let g = scene.grid_mut(id).unwrap();
1209 g.set_generator(Some(Arc::new(gen)));
1210 g.stream_radius = StreamRadius::new(10.0, 50.0);
1211 let near_cam = DVec3::new(64.0, 64.0, 128.0);
1212 scene.pump_streaming(near_cam);
1213 let _ = arrival_rx
1214 .recv_timeout(std::time::Duration::from_secs(2))
1215 .expect("task didn't start");
1216 assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1217
1218 let far_cam = DVec3::new(10_000.0, 64.0, 128.0);
1222 scene.pump_streaming(far_cam);
1223 assert!(
1224 !scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO),
1225 "eviction should have cleared the pending entry"
1226 );
1227
1228 release_tx.send(()).unwrap();
1231 pump_until_idle(&mut scene, far_cam, id, Some(&release_tx));
1234 let g = scene.grid(id).unwrap();
1235 assert!(
1236 !g.chunks.contains_key(&IVec3::ZERO),
1237 "evicted chunk must not be re-installed by the stale result"
1238 );
1239 }
1240
1241 #[test]
1242 #[cfg(not(target_arch = "wasm32"))]
1243 fn pump_streaming_with_disabled_radius_is_noop() {
1244 let mut scene = Scene::new();
1248 let id = scene.add_grid(GridTransform::identity());
1249 let gen = StubGenerator::new();
1250 let counter = Arc::clone(&gen.call_count);
1251 scene
1252 .grid_mut(id)
1253 .unwrap()
1254 .set_generator(Some(Arc::new(gen)));
1255 scene.pump_streaming(DVec3::ZERO);
1257 let g = scene.grid(id).unwrap();
1258 assert!(g.chunks.is_empty());
1259 assert!(g.pending_gen.is_empty());
1260 assert_eq!(counter.load(Ordering::Relaxed), 0);
1261 }
1262
1263 #[test]
1264 #[cfg(not(target_arch = "wasm32"))]
1265 fn set_streaming_threads_zero_panics() {
1266 let mut scene = Scene::new();
1267 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1268 scene.set_streaming_threads(0);
1269 }));
1270 assert!(result.is_err(), "zero threads must panic");
1271 }
1272
1273 #[test]
1274 #[cfg(not(target_arch = "wasm32"))]
1275 fn set_streaming_threads_lazily_applied_before_first_pump() {
1276 let mut scene = Scene::new();
1280 scene.set_streaming_threads(1);
1281 let id = scene.add_grid(GridTransform::identity());
1282 let gen = StubGenerator::new();
1283 let g = scene.grid_mut(id).unwrap();
1284 g.set_generator(Some(Arc::new(gen)));
1285 g.stream_radius = StreamRadius::new(10.0, 200.0);
1286 let cam = DVec3::new(64.0, 64.0, 128.0);
1287 pump_until_idle(&mut scene, cam, id, None);
1288 assert!(scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO));
1289 }
1290
1291 #[test]
1292 fn pump_streaming_sync_eviction_clears_billboard_cache() {
1293 use crate::BillboardCache;
1298 let mut scene = Scene::new();
1299 let id = scene.add_grid(GridTransform::identity());
1300 let g = scene.grid_mut(id).unwrap();
1301 g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
1303 g.billboards = Some(BillboardCache::new_empty(64));
1304 g.stream_radius = StreamRadius::new(10.0, 50.0);
1305
1306 scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
1308 let g = scene.grid(id).unwrap();
1309 assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
1310 assert!(g.billboards.is_none(), "billboard cache should be cleared");
1311 }
1312
1313 #[derive(Debug, Default)]
1317 struct MemStore {
1318 slots: std::sync::Mutex<std::collections::HashMap<IVec3, (Vxl, u64)>>,
1319 }
1320
1321 impl ChunkStore for MemStore {
1322 fn store(&self, chunk_idx: IVec3, vxl: &Vxl, version: u64) {
1323 self.slots
1324 .lock()
1325 .unwrap()
1326 .insert(chunk_idx, (vxl.clone(), version));
1327 }
1328 fn load(&self, chunk_idx: IVec3) -> Option<(Vxl, u64)> {
1329 self.slots
1330 .lock()
1331 .unwrap()
1332 .get(&chunk_idx)
1333 .map(|(vxl, version)| (vxl.clone(), *version))
1334 }
1335 }
1336
1337 #[test]
1338 fn chunk_store_round_trips_edits_across_evict_and_restream() {
1339 let mut scene = Scene::new();
1340 let id = scene.add_grid(GridTransform::identity());
1341 let store = Arc::new(MemStore::default());
1342 {
1343 let g = scene.grid_mut(id).unwrap();
1344 g.set_generator(Some(Arc::new(StubGenerator::new())));
1345 g.set_chunk_store(Some(store.clone()));
1346 g.stream_radius = StreamRadius::new(50.0, 200.0);
1347 }
1348
1349 scene.pump_streaming_sync(DVec3::ZERO);
1351 let edited_voxel = IVec3::new(1, 1, 40);
1352 let edited_version = {
1353 let g = scene.grid_mut(id).unwrap();
1354 assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk streamed in");
1355 g.set_voxel(edited_voxel, Some(VoxColor(0x8011_2233)));
1356 assert!(g.voxel_solid(edited_voxel));
1357 let v = g.chunk_version(IVec3::ZERO);
1358 assert!(v > 0, "edit bumps the version");
1359 v
1360 };
1361
1362 scene.pump_streaming_sync(DVec3::new(10_000.0, 10_000.0, 0.0));
1365 assert!(
1366 !scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO),
1367 "edited chunk evicted"
1368 );
1369 let stored = store.load(IVec3::ZERO).expect("edited chunk persisted");
1370 assert_eq!(stored.1, edited_version, "persisted edit version");
1371 assert!(
1372 store.load(IVec3::new(1, 0, 0)).is_none(),
1373 "pristine generator chunks are regenerable and skip the store"
1374 );
1375
1376 scene.pump_streaming_sync(DVec3::ZERO);
1379 let g = scene.grid(id).unwrap();
1380 assert!(
1381 g.voxel_solid(edited_voxel),
1382 "player edit survived evict + re-stream"
1383 );
1384 assert_eq!(
1385 g.chunk_version(IVec3::ZERO),
1386 edited_version,
1387 "persisted edit version restored"
1388 );
1389 }
1390
1391 #[test]
1392 fn chunk_store_alone_restores_without_generator() {
1393 let store = Arc::new(MemStore::default());
1396 {
1397 let mut g = Grid::new(GridTransform::identity());
1399 g.set_voxel(IVec3::new(2, 2, 50), Some(VoxColor(0x8044_5566)));
1400 let vxl = g.chunks.get(&IVec3::ZERO).expect("materialised");
1401 store.store(IVec3::ZERO, vxl, 7);
1402 }
1403
1404 let mut scene = Scene::new();
1405 let id = scene.add_grid(GridTransform::identity());
1406 {
1407 let g = scene.grid_mut(id).unwrap();
1408 g.set_chunk_store(Some(store));
1409 g.stream_radius = StreamRadius::new(50.0, 200.0);
1410 }
1411 scene.pump_streaming_sync(DVec3::ZERO);
1412 let g = scene.grid(id).unwrap();
1413 assert!(g.voxel_solid(IVec3::new(2, 2, 50)));
1414 assert_eq!(g.chunk_version(IVec3::ZERO), 7);
1415 }
1416}