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::edit::{set_spans, Vspan};
370 use std::sync::atomic::{AtomicUsize, Ordering};
371 use std::sync::Arc;
372
373 #[derive(Debug)]
381 pub(crate) struct StubGenerator {
382 pub call_count: Arc<AtomicUsize>,
383 }
384
385 impl StubGenerator {
386 pub(crate) fn new() -> Self {
387 Self {
388 call_count: Arc::new(AtomicUsize::new(0)),
389 }
390 }
391 }
392
393 impl ChunkGenerator for StubGenerator {
394 fn generate(&self, chunk_idx: IVec3) -> Vxl {
395 self.call_count.fetch_add(1, Ordering::Relaxed);
396 let mut g = Grid::new(GridTransform::identity());
402 let mark_z = (chunk_idx.x.rem_euclid(200) as u32) % CHUNK_SIZE_Z;
403 g.ensure_chunk(IVec3::ZERO);
406 let vxl = g.chunks.remove(&IVec3::ZERO).expect("just inserted");
407 let mut vxl = vxl;
408 set_spans(
411 &mut vxl,
412 &[Vspan {
413 x: 0,
414 y: 0,
415 z0: u8::try_from(mark_z).unwrap_or(0),
416 z1: u8::try_from(mark_z).unwrap_or(0),
417 }],
418 Some(0x80_aa_bb_cc),
419 );
420 vxl
421 }
422 }
423
424 #[test]
425 fn stub_generator_emits_distinguishable_chunks() {
426 let gen = StubGenerator::new();
430 let a = gen.generate(IVec3::new(0, 0, 0));
431 let b = gen.generate(IVec3::new(7, 0, 0));
432 assert_eq!(a.vsid, CHUNK_SIZE_XY);
433 assert_eq!(b.vsid, CHUNK_SIZE_XY);
434 assert!(voxel_is_solid(&a, 0, 0, 0), "chunk_idx.x=0 marks z=0");
435 assert!(voxel_is_solid(&b, 0, 0, 7), "chunk_idx.x=7 marks z=7");
436 assert_eq!(gen.call_count.load(Ordering::Relaxed), 2);
437 }
438
439 #[test]
440 fn ensure_chunk_generated_populates_via_generator() {
441 let mut g = Grid::new(GridTransform::identity());
442 let gen = StubGenerator::new();
443 let counter = Arc::clone(&gen.call_count);
444 g.set_generator(Some(Arc::new(gen)));
445
446 assert_eq!(g.chunk_count(), 0);
447 let idx = IVec3::new(3, 0, 0);
448 let produced = g.ensure_chunk_generated(idx);
449 assert!(
450 produced,
451 "ensure_chunk_generated returns true when it generates"
452 );
453 assert_eq!(g.chunk_count(), 1);
454 let chunk = g.chunk(idx).expect("chunk now present");
455 assert!(
456 voxel_is_solid(chunk, 0, 0, 3),
457 "stub generator's mark voxel for chunk_idx.x=3 missing"
458 );
459 assert_eq!(counter.load(Ordering::Relaxed), 1);
460 }
461
462 #[test]
463 fn ensure_chunk_generated_is_idempotent() {
464 let mut g = Grid::new(GridTransform::identity());
468 let gen = StubGenerator::new();
469 let counter = Arc::clone(&gen.call_count);
470 g.set_generator(Some(Arc::new(gen)));
471
472 let idx = IVec3::new(5, -2, 0);
473 assert!(g.ensure_chunk_generated(idx));
474 assert!(!g.ensure_chunk_generated(idx), "second call no-ops");
475 assert!(!g.ensure_chunk_generated(idx), "third call still no-ops");
476 assert_eq!(g.chunk_count(), 1);
477 assert_eq!(counter.load(Ordering::Relaxed), 1);
478 }
479
480 #[test]
481 fn ensure_chunk_generated_without_generator_is_noop() {
482 let mut g = Grid::new(GridTransform::identity());
487 let idx = IVec3::new(0, 0, 0);
488 assert!(g.generator.is_none());
489 let produced = g.ensure_chunk_generated(idx);
490 assert!(!produced, "no generator → no chunk generated");
491 assert_eq!(g.chunk_count(), 0);
492 assert!(g.chunk(idx).is_none());
493 }
494
495 #[test]
496 fn ensure_chunk_generated_on_already_present_chunk_skips_generator() {
497 let mut g = Grid::new(GridTransform::identity());
502 let idx = IVec3::new(0, 0, 0);
503 g.set_voxel(IVec3::new(10, 10, 10), Some(0x80_11_22_33));
505 assert_eq!(g.chunk_count(), 1);
506
507 let gen = StubGenerator::new();
508 let counter = Arc::clone(&gen.call_count);
509 g.set_generator(Some(Arc::new(gen)));
510
511 let produced = g.ensure_chunk_generated(idx);
512 assert!(!produced, "existing chunk not regenerated");
513 assert_eq!(counter.load(Ordering::Relaxed), 0);
514 let chunk = g.chunk(idx).expect("manual chunk present");
516 assert!(voxel_is_solid(chunk, 10, 10, 10), "manual voxel survived");
517 assert!(
518 !voxel_is_solid(chunk, 0, 0, 0),
519 "generator's mark voxel must NOT appear"
520 );
521 }
522
523 #[test]
526 fn stream_radius_disabled_is_truly_zero_infty() {
527 let r = StreamRadius::DISABLED;
528 assert_eq!(r.r_active, 0.0);
529 assert!(r.r_evict.is_infinite() && r.r_evict.is_sign_positive());
530 assert!(r.is_disabled());
531 assert!(StreamRadius::default().is_disabled());
532 }
533
534 #[test]
535 fn stream_radius_new_rejects_evict_below_active() {
536 let result = std::panic::catch_unwind(|| StreamRadius::new(200.0, 100.0));
539 assert!(result.is_err(), "r_evict < r_active must panic");
540 }
541
542 #[test]
543 fn chunk_aabb_dist_sq_inside_chunk_is_zero() {
544 let d = chunk_aabb_dist_sq(DVec3::new(10.0, 20.0, 30.0), IVec3::new(0, 0, 0));
547 assert_eq!(d, 0.0);
548 }
549
550 #[test]
551 fn chunk_aabb_dist_sq_axis_aligned() {
552 let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(1, 0, 0));
555 let expected = 128.0_f64.powi(2);
556 assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
557 let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(0, 0, 1));
559 let expected = 256.0_f64.powi(2);
560 assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
561 }
562
563 #[test]
564 fn pump_streaming_sync_with_disabled_radius_is_noop() {
565 let mut scene = Scene::new();
568 let id = scene.add_grid(GridTransform::identity());
569 let gen = StubGenerator::new();
570 let counter = Arc::clone(&gen.call_count);
571 scene
572 .grid_mut(id)
573 .unwrap()
574 .set_generator(Some(Arc::new(gen)));
575 scene
578 .grid_mut(id)
579 .unwrap()
580 .set_voxel(IVec3::new(10_000, 0, 0), Some(0x80_11_22_33));
581 let baseline_chunks = scene.grid(id).unwrap().chunk_count();
582 scene.pump_streaming_sync(DVec3::ZERO);
583 assert_eq!(scene.grid(id).unwrap().chunk_count(), baseline_chunks);
584 assert_eq!(counter.load(Ordering::Relaxed), 0);
585 }
586
587 #[test]
588 fn pump_streaming_sync_streams_in_chunks_within_r_active() {
589 let mut scene = Scene::new();
594 let id = scene.add_grid(GridTransform::identity());
595 let gen = StubGenerator::new();
596 let counter = Arc::clone(&gen.call_count);
597 let g = scene.grid_mut(id).unwrap();
598 g.set_generator(Some(Arc::new(gen)));
599 g.stream_radius = StreamRadius::new(200.0, 400.0);
600 scene.pump_streaming_sync(DVec3::ZERO);
601
602 let g = scene.grid(id).unwrap();
607 let must_have = [
608 IVec3::new(0, 0, 0),
609 IVec3::new(1, 0, 0),
610 IVec3::new(-1, 0, 0),
611 IVec3::new(0, 1, 0),
612 IVec3::new(0, -1, 0),
613 IVec3::new(0, 0, -1),
614 ];
615 for idx in must_have {
616 assert!(
617 g.chunks.contains_key(&idx),
618 "chunk {idx:?} missing from streamed set"
619 );
620 }
621 assert!(g.chunks.contains_key(&IVec3::new(1, 1, 0)));
624 assert!(!g.chunks.contains_key(&IVec3::new(2, 0, 0)));
626 assert!(!g.chunks.contains_key(&IVec3::new(0, 0, 1)));
628
629 let streamed = g.chunk_count();
631 assert_eq!(counter.load(Ordering::Relaxed), streamed);
632 }
633
634 #[test]
635 fn pump_streaming_sync_idempotent_under_stationary_camera() {
636 let mut scene = Scene::new();
640 let id = scene.add_grid(GridTransform::identity());
641 let gen = StubGenerator::new();
642 let counter = Arc::clone(&gen.call_count);
643 let g = scene.grid_mut(id).unwrap();
644 g.set_generator(Some(Arc::new(gen)));
645 g.stream_radius = StreamRadius::new(180.0, 400.0);
646
647 scene.pump_streaming_sync(DVec3::ZERO);
648 let after_first = counter.load(Ordering::Relaxed);
649 scene.pump_streaming_sync(DVec3::ZERO);
650 let after_second = counter.load(Ordering::Relaxed);
651 assert_eq!(after_first, after_second, "second pump regenerated chunks");
652 }
653
654 #[test]
655 fn pump_streaming_sync_evicts_chunks_beyond_r_evict() {
656 let mut scene = Scene::new();
659 let id = scene.add_grid(GridTransform::identity());
660 let gen = StubGenerator::new();
661 let g = scene.grid_mut(id).unwrap();
662 g.set_generator(Some(Arc::new(gen)));
663 g.stream_radius = StreamRadius::new(200.0, 400.0);
664 scene.pump_streaming_sync(DVec3::ZERO);
665 let initial = scene.grid(id).unwrap().chunk_count();
666 assert!(initial > 0, "expected chunks streamed in around origin");
667
668 scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
671 let g = scene.grid(id).unwrap();
672 for idx in [
673 IVec3::new(0, 0, 0),
674 IVec3::new(1, 0, 0),
675 IVec3::new(-1, 0, 0),
676 ] {
677 assert!(
678 !g.chunks.contains_key(&idx),
679 "chunk {idx:?} survived eviction after far teleport"
680 );
681 }
682 let cam_chx = 10_000_i32 / i32::try_from(CHUNK_SIZE_XY).unwrap();
685 assert!(g.chunks.contains_key(&IVec3::new(cam_chx, 0, 0)));
686 }
687
688 #[test]
689 fn pump_streaming_sync_hysteresis_band_retains_chunks() {
690 let mut scene = Scene::new();
696 let id = scene.add_grid(GridTransform::identity());
697 let gen = StubGenerator::new();
698 let g = scene.grid_mut(id).unwrap();
699 g.set_generator(Some(Arc::new(gen)));
700 g.stream_radius = StreamRadius::new(200.0, 600.0);
701 scene.pump_streaming_sync(DVec3::ZERO);
702 let g = scene.grid(id).unwrap();
703 let band_idx = IVec3::new(-2, 0, 0);
713 assert!(
716 g.chunks.contains_key(&band_idx),
717 "(-2, 0, 0) should be streamed at origin"
718 );
719
720 scene.pump_streaming_sync(DVec3::new(300.0, 0.0, 0.0));
721 let g = scene.grid(id).unwrap();
722 assert!(
723 g.chunks.contains_key(&band_idx),
724 "(-2, 0, 0) should remain in the hysteresis band"
725 );
726 }
727
728 #[test]
729 fn pump_streaming_sync_with_no_generator_does_not_panic() {
730 let mut scene = Scene::new();
734 let id = scene.add_grid(GridTransform::identity());
735 let g = scene.grid_mut(id).unwrap();
736 g.stream_radius = StreamRadius::new(200.0, 400.0);
737 g.set_voxel(IVec3::new(50 * 128, 0, 0), Some(0x80_aa_bb_cc));
740 assert_eq!(scene.grid(id).unwrap().chunk_count(), 1);
741
742 scene.pump_streaming_sync(DVec3::ZERO);
743 let g = scene.grid(id).unwrap();
744 assert_eq!(g.chunk_count(), 0);
747 }
748
749 #[test]
750 fn pump_streaming_sync_respects_grid_rotation() {
751 let transform = GridTransform {
756 origin: DVec3::ZERO,
757 rotation: DQuat::from_axis_angle(DVec3::Z, std::f64::consts::PI),
758 };
759 let mut scene = Scene::new();
760 let id = scene.add_grid(transform);
761 let gen = StubGenerator::new();
762 let g = scene.grid_mut(id).unwrap();
763 g.set_generator(Some(Arc::new(gen)));
764 g.stream_radius = StreamRadius::new(50.0, 200.0);
765
766 scene.pump_streaming_sync(DVec3::new(10.0, 0.0, 0.0));
769 let g = scene.grid(id).unwrap();
770 assert!(
774 g.chunks.contains_key(&IVec3::new(-1, 0, 0)),
775 "rotation not applied — camera should map to chunk (-1, 0, 0)"
776 );
777 assert!(g.chunks.contains_key(&IVec3::new(0, 0, 0)));
780 }
781
782 #[test]
785 fn ensure_chunk_generated_does_not_bump_version() {
786 let mut g = Grid::new(GridTransform::identity());
788 let gen = StubGenerator::new();
789 g.set_generator(Some(Arc::new(gen)));
790 let idx = IVec3::new(2, 3, 0);
791 assert!(g.ensure_chunk_generated(idx));
792 assert_eq!(g.chunk_version(idx), 0);
793 assert!(g.chunk_versions.is_empty());
795 }
796
797 #[test]
798 fn ensure_chunk_generated_then_edit_starts_at_version_one() {
799 let mut g = Grid::new(GridTransform::identity());
801 let gen = StubGenerator::new();
802 g.set_generator(Some(Arc::new(gen)));
803 let idx = IVec3::ZERO;
804 g.ensure_chunk_generated(idx);
805 g.set_voxel(IVec3::new(10, 10, 10), Some(0x80_aa_bb_cc));
806 assert_eq!(g.chunk_version(idx), 1);
807 }
808
809 #[test]
810 fn pump_streaming_sync_eviction_drops_chunk_version_entry() {
811 let mut scene = Scene::new();
815 let id = scene.add_grid(GridTransform::identity());
816 let g = scene.grid_mut(id).unwrap();
817 g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
818 assert_eq!(g.chunk_version(IVec3::ZERO), 1);
819 g.stream_radius = StreamRadius::new(10.0, 50.0);
820
821 scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
822 let g = scene.grid(id).unwrap();
823 assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
824 assert_eq!(
825 g.chunk_version(IVec3::ZERO),
826 0,
827 "version entry should be cleared on eviction"
828 );
829 assert!(g.chunk_versions.is_empty(), "map should be empty");
830 }
831
832 #[derive(Debug)]
847 #[cfg(not(target_arch = "wasm32"))]
848 struct BlockingGenerator {
849 arrival_tx: crossbeam_channel::Sender<IVec3>,
850 release_rx: crossbeam_channel::Receiver<()>,
851 call_count: Arc<AtomicUsize>,
852 }
853
854 #[cfg(not(target_arch = "wasm32"))]
855 impl ChunkGenerator for BlockingGenerator {
856 fn generate(&self, chunk_idx: IVec3) -> Vxl {
857 self.call_count.fetch_add(1, Ordering::Relaxed);
858 let _ = self.arrival_tx.send(chunk_idx);
859 let _ = self.release_rx.recv();
863 StubGenerator::new().generate(chunk_idx)
864 }
865 }
866
867 #[cfg(not(target_arch = "wasm32"))]
872 fn pump_until_idle(
873 scene: &mut Scene,
874 cam: DVec3,
875 grid_id: crate::GridId,
876 release_tx: Option<&crossbeam_channel::Sender<()>>,
877 ) {
878 use std::time::{Duration, Instant};
879 let deadline = Instant::now() + Duration::from_secs(5);
880 loop {
881 scene.pump_streaming(cam);
882 let idle = scene
883 .grid(grid_id)
884 .map_or(true, |g| g.pending_gen.is_empty());
885 if idle {
886 return;
887 }
888 if Instant::now() > deadline {
889 panic!("pump_until_idle: timeout with pending tasks");
890 }
891 if let Some(tx) = release_tx {
893 let _ = tx.try_send(());
894 }
895 std::thread::sleep(Duration::from_millis(1));
896 }
897 }
898
899 #[test]
902 fn ensure_chunk_generated_invalidates_billboard_cache() {
903 let mut g = Grid::new(GridTransform::identity());
907 let gen = StubGenerator::new();
908 g.set_generator(Some(Arc::new(gen)));
909 g.billboards = Some(crate::BillboardCache::new_empty(32));
910
911 let installed = g.ensure_chunk_generated(IVec3::new(2, 0, 0));
912 assert!(installed, "generator should have installed the chunk");
913 assert!(
914 g.billboards.is_none(),
915 "ensure_chunk_generated must clear billboards on install"
916 );
917 }
918
919 #[test]
920 fn ensure_chunk_generated_noop_preserves_billboard_cache() {
921 let mut g = Grid::new(GridTransform::identity());
925 g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
926 g.billboards = Some(crate::BillboardCache::new_empty(32));
927 let installed = g.ensure_chunk_generated(IVec3::new(5, 5, 0));
929 assert!(!installed);
930 assert!(
931 g.billboards.is_some(),
932 "no-generator no-op must not clear billboards"
933 );
934 let installed = g.ensure_chunk_generated(IVec3::ZERO);
936 assert!(!installed);
937 assert!(
938 g.billboards.is_some(),
939 "already-present chunk must not clear billboards"
940 );
941 }
942
943 #[test]
944 #[cfg(not(target_arch = "wasm32"))]
945 fn pump_streaming_async_install_invalidates_billboard_cache() {
946 let mut scene = Scene::new();
950 let id = scene.add_grid(GridTransform::identity());
951 let gen = StubGenerator::new();
952 let g = scene.grid_mut(id).unwrap();
953 g.set_generator(Some(Arc::new(gen)));
954 g.stream_radius = StreamRadius::new(10.0, 200.0);
955 g.billboards = Some(crate::BillboardCache::new_empty(32));
957 let cam = DVec3::new(64.0, 64.0, 128.0);
958
959 pump_until_idle(&mut scene, cam, id, None);
960
961 let g = scene.grid(id).unwrap();
962 assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
963 assert!(
964 g.billboards.is_none(),
965 "async install must clear billboards"
966 );
967 }
968
969 #[test]
970 #[cfg(not(target_arch = "wasm32"))]
971 fn pump_streaming_no_install_preserves_billboard_cache() {
972 let mut scene = Scene::new();
977 let id = scene.add_grid(GridTransform::identity());
978 let g = scene.grid_mut(id).unwrap();
979 let gen = StubGenerator::new();
980 g.set_generator(Some(Arc::new(gen)));
981 g.stream_radius = StreamRadius::new(10.0, 200.0);
982 let cam = DVec3::new(64.0, 64.0, 128.0);
983 pump_until_idle(&mut scene, cam, id, None);
985 scene.grid_mut(id).unwrap().billboards = Some(crate::BillboardCache::new_empty(32));
987 scene.pump_streaming(cam);
989 let g = scene.grid(id).unwrap();
990 assert!(
991 g.billboards.is_some(),
992 "pump with no install should not clear billboards"
993 );
994 }
995
996 #[test]
997 #[cfg(not(target_arch = "wasm32"))]
998 fn pump_streaming_dispatches_and_installs_via_async_path() {
999 let mut scene = Scene::new();
1003 let id = scene.add_grid(GridTransform::identity());
1004 let gen = StubGenerator::new();
1005 let counter = Arc::clone(&gen.call_count);
1006 let g = scene.grid_mut(id).unwrap();
1007 g.set_generator(Some(Arc::new(gen)));
1008 g.stream_radius = StreamRadius::new(10.0, 200.0);
1011 let cam = DVec3::new(64.0, 64.0, 128.0);
1012
1013 pump_until_idle(&mut scene, cam, id, None);
1014
1015 let g = scene.grid(id).unwrap();
1016 assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
1017 assert_eq!(counter.load(Ordering::Relaxed), 1, "generator called once");
1018 assert!(g.pending_gen.is_empty(), "no leftover pending");
1019 }
1020
1021 #[test]
1022 #[cfg(not(target_arch = "wasm32"))]
1023 fn pump_streaming_tracks_in_flight_chunks_in_pending_gen() {
1024 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1028 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1029 let counter = Arc::new(AtomicUsize::new(0));
1030 let gen = BlockingGenerator {
1031 arrival_tx,
1032 release_rx,
1033 call_count: Arc::clone(&counter),
1034 };
1035
1036 let mut scene = Scene::new();
1037 let id = scene.add_grid(GridTransform::identity());
1038 let g = scene.grid_mut(id).unwrap();
1039 g.set_generator(Some(Arc::new(gen)));
1040 g.stream_radius = StreamRadius::new(10.0, 200.0);
1041 let cam = DVec3::new(64.0, 64.0, 128.0);
1042
1043 scene.pump_streaming(cam);
1044
1045 let arrived = arrival_rx
1047 .recv_timeout(std::time::Duration::from_secs(2))
1048 .expect("task didn't start");
1049 assert_eq!(arrived, IVec3::ZERO);
1050
1051 assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1054 assert!(scene.grid(id).unwrap().chunks.is_empty());
1055
1056 release_tx.send(()).unwrap();
1058 pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1059
1060 let g = scene.grid(id).unwrap();
1061 assert!(g.chunks.contains_key(&IVec3::ZERO));
1062 assert!(!g.pending_gen.contains(&IVec3::ZERO));
1063 assert_eq!(counter.load(Ordering::Relaxed), 1);
1064 }
1065
1066 #[test]
1067 #[cfg(not(target_arch = "wasm32"))]
1068 fn pump_streaming_does_not_redispatch_in_flight_chunks() {
1069 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1073 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1074 let counter = Arc::new(AtomicUsize::new(0));
1075 let gen = BlockingGenerator {
1076 arrival_tx,
1077 release_rx,
1078 call_count: Arc::clone(&counter),
1079 };
1080
1081 let mut scene = Scene::new();
1082 let id = scene.add_grid(GridTransform::identity());
1083 let g = scene.grid_mut(id).unwrap();
1084 g.set_generator(Some(Arc::new(gen)));
1085 g.stream_radius = StreamRadius::new(10.0, 200.0);
1086 let cam = DVec3::new(64.0, 64.0, 128.0);
1087
1088 scene.pump_streaming(cam);
1089 let _ = arrival_rx
1090 .recv_timeout(std::time::Duration::from_secs(2))
1091 .expect("task didn't start");
1092
1093 for _ in 0..5 {
1095 scene.pump_streaming(cam);
1096 }
1097 assert_eq!(
1098 counter.load(Ordering::Relaxed),
1099 1,
1100 "in-flight chunk re-dispatched"
1101 );
1102
1103 release_tx.send(()).unwrap();
1104 pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1105 assert_eq!(counter.load(Ordering::Relaxed), 1);
1107 }
1108
1109 #[test]
1110 #[cfg(not(target_arch = "wasm32"))]
1111 fn pump_streaming_discards_stale_result_when_chunk_edited_during_gen() {
1112 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1119 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1120 let counter = Arc::new(AtomicUsize::new(0));
1121 let gen = BlockingGenerator {
1122 arrival_tx,
1123 release_rx,
1124 call_count: Arc::clone(&counter),
1125 };
1126
1127 let mut scene = Scene::new();
1128 let id = scene.add_grid(GridTransform::identity());
1129 let g = scene.grid_mut(id).unwrap();
1130 g.set_generator(Some(Arc::new(gen)));
1131 g.stream_radius = StreamRadius::new(10.0, 200.0);
1132 let cam = DVec3::new(64.0, 64.0, 128.0);
1133
1134 scene.pump_streaming(cam);
1135 let _ = arrival_rx
1136 .recv_timeout(std::time::Duration::from_secs(2))
1137 .expect("task didn't start");
1138
1139 let g = scene.grid_mut(id).unwrap();
1141 g.set_voxel(IVec3::new(10, 11, 12), Some(0x80_de_ad_be));
1143 assert_eq!(g.chunk_version(IVec3::ZERO), 1);
1144 let chunk = g.chunk(IVec3::ZERO).unwrap();
1145 assert!(voxel_is_solid(chunk, 10, 11, 12));
1146 assert!(!voxel_is_solid(chunk, 0, 0, 0));
1150
1151 release_tx.send(()).unwrap();
1152 pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1153
1154 let g = scene.grid(id).unwrap();
1156 let chunk = g.chunk(IVec3::ZERO).unwrap();
1157 assert!(voxel_is_solid(chunk, 10, 11, 12), "user edit survived");
1158 assert!(
1159 !voxel_is_solid(chunk, 0, 0, 0),
1160 "stale generator output must not have overwritten the chunk"
1161 );
1162 assert_eq!(counter.load(Ordering::Relaxed), 1);
1164 }
1165
1166 #[test]
1167 #[cfg(not(target_arch = "wasm32"))]
1168 fn pump_streaming_eviction_drops_pending_gen_entry() {
1169 let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1176 let (release_tx, release_rx) = crossbeam_channel::unbounded();
1177 let counter = Arc::new(AtomicUsize::new(0));
1178 let gen = BlockingGenerator {
1179 arrival_tx,
1180 release_rx,
1181 call_count: Arc::clone(&counter),
1182 };
1183
1184 let mut scene = Scene::new();
1185 let id = scene.add_grid(GridTransform::identity());
1186 let g = scene.grid_mut(id).unwrap();
1187 g.set_generator(Some(Arc::new(gen)));
1188 g.stream_radius = StreamRadius::new(10.0, 50.0);
1189 let near_cam = DVec3::new(64.0, 64.0, 128.0);
1190 scene.pump_streaming(near_cam);
1191 let _ = arrival_rx
1192 .recv_timeout(std::time::Duration::from_secs(2))
1193 .expect("task didn't start");
1194 assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1195
1196 let far_cam = DVec3::new(10_000.0, 64.0, 128.0);
1200 scene.pump_streaming(far_cam);
1201 assert!(
1202 !scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO),
1203 "eviction should have cleared the pending entry"
1204 );
1205
1206 release_tx.send(()).unwrap();
1209 pump_until_idle(&mut scene, far_cam, id, Some(&release_tx));
1212 let g = scene.grid(id).unwrap();
1213 assert!(
1214 !g.chunks.contains_key(&IVec3::ZERO),
1215 "evicted chunk must not be re-installed by the stale result"
1216 );
1217 }
1218
1219 #[test]
1220 #[cfg(not(target_arch = "wasm32"))]
1221 fn pump_streaming_with_disabled_radius_is_noop() {
1222 let mut scene = Scene::new();
1226 let id = scene.add_grid(GridTransform::identity());
1227 let gen = StubGenerator::new();
1228 let counter = Arc::clone(&gen.call_count);
1229 scene
1230 .grid_mut(id)
1231 .unwrap()
1232 .set_generator(Some(Arc::new(gen)));
1233 scene.pump_streaming(DVec3::ZERO);
1235 let g = scene.grid(id).unwrap();
1236 assert!(g.chunks.is_empty());
1237 assert!(g.pending_gen.is_empty());
1238 assert_eq!(counter.load(Ordering::Relaxed), 0);
1239 }
1240
1241 #[test]
1242 #[cfg(not(target_arch = "wasm32"))]
1243 fn set_streaming_threads_zero_panics() {
1244 let mut scene = Scene::new();
1245 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1246 scene.set_streaming_threads(0);
1247 }));
1248 assert!(result.is_err(), "zero threads must panic");
1249 }
1250
1251 #[test]
1252 #[cfg(not(target_arch = "wasm32"))]
1253 fn set_streaming_threads_lazily_applied_before_first_pump() {
1254 let mut scene = Scene::new();
1258 scene.set_streaming_threads(1);
1259 let id = scene.add_grid(GridTransform::identity());
1260 let gen = StubGenerator::new();
1261 let g = scene.grid_mut(id).unwrap();
1262 g.set_generator(Some(Arc::new(gen)));
1263 g.stream_radius = StreamRadius::new(10.0, 200.0);
1264 let cam = DVec3::new(64.0, 64.0, 128.0);
1265 pump_until_idle(&mut scene, cam, id, None);
1266 assert!(scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO));
1267 }
1268
1269 #[test]
1270 fn pump_streaming_sync_eviction_clears_billboard_cache() {
1271 use crate::BillboardCache;
1276 let mut scene = Scene::new();
1277 let id = scene.add_grid(GridTransform::identity());
1278 let g = scene.grid_mut(id).unwrap();
1279 g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
1281 g.billboards = Some(BillboardCache::new_empty(64));
1282 g.stream_radius = StreamRadius::new(10.0, 50.0);
1283
1284 scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
1286 let g = scene.grid(id).unwrap();
1287 assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
1288 assert!(g.billboards.is_none(), "billboard cache should be cleared");
1289 }
1290
1291 #[derive(Debug, Default)]
1295 struct MemStore {
1296 slots: std::sync::Mutex<std::collections::HashMap<IVec3, (Vxl, u64)>>,
1297 }
1298
1299 impl ChunkStore for MemStore {
1300 fn store(&self, chunk_idx: IVec3, vxl: &Vxl, version: u64) {
1301 self.slots
1302 .lock()
1303 .unwrap()
1304 .insert(chunk_idx, (vxl.clone(), version));
1305 }
1306 fn load(&self, chunk_idx: IVec3) -> Option<(Vxl, u64)> {
1307 self.slots
1308 .lock()
1309 .unwrap()
1310 .get(&chunk_idx)
1311 .map(|(vxl, version)| (vxl.clone(), *version))
1312 }
1313 }
1314
1315 #[test]
1316 fn chunk_store_round_trips_edits_across_evict_and_restream() {
1317 let mut scene = Scene::new();
1318 let id = scene.add_grid(GridTransform::identity());
1319 let store = Arc::new(MemStore::default());
1320 {
1321 let g = scene.grid_mut(id).unwrap();
1322 g.set_generator(Some(Arc::new(StubGenerator::new())));
1323 g.set_chunk_store(Some(store.clone()));
1324 g.stream_radius = StreamRadius::new(50.0, 200.0);
1325 }
1326
1327 scene.pump_streaming_sync(DVec3::ZERO);
1329 let edited_voxel = IVec3::new(1, 1, 40);
1330 let edited_version = {
1331 let g = scene.grid_mut(id).unwrap();
1332 assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk streamed in");
1333 g.set_voxel(edited_voxel, Some(0x8011_2233));
1334 assert!(g.voxel_solid(edited_voxel));
1335 let v = g.chunk_version(IVec3::ZERO);
1336 assert!(v > 0, "edit bumps the version");
1337 v
1338 };
1339
1340 scene.pump_streaming_sync(DVec3::new(10_000.0, 10_000.0, 0.0));
1343 assert!(
1344 !scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO),
1345 "edited chunk evicted"
1346 );
1347 let stored = store.load(IVec3::ZERO).expect("edited chunk persisted");
1348 assert_eq!(stored.1, edited_version, "persisted edit version");
1349 assert!(
1350 store.load(IVec3::new(1, 0, 0)).is_none(),
1351 "pristine generator chunks are regenerable and skip the store"
1352 );
1353
1354 scene.pump_streaming_sync(DVec3::ZERO);
1357 let g = scene.grid(id).unwrap();
1358 assert!(
1359 g.voxel_solid(edited_voxel),
1360 "player edit survived evict + re-stream"
1361 );
1362 assert_eq!(
1363 g.chunk_version(IVec3::ZERO),
1364 edited_version,
1365 "persisted edit version restored"
1366 );
1367 }
1368
1369 #[test]
1370 fn chunk_store_alone_restores_without_generator() {
1371 let store = Arc::new(MemStore::default());
1374 {
1375 let mut g = Grid::new(GridTransform::identity());
1377 g.set_voxel(IVec3::new(2, 2, 50), Some(0x8044_5566));
1378 let vxl = g.chunks.get(&IVec3::ZERO).expect("materialised");
1379 store.store(IVec3::ZERO, vxl, 7);
1380 }
1381
1382 let mut scene = Scene::new();
1383 let id = scene.add_grid(GridTransform::identity());
1384 {
1385 let g = scene.grid_mut(id).unwrap();
1386 g.set_chunk_store(Some(store));
1387 g.stream_radius = StreamRadius::new(50.0, 200.0);
1388 }
1389 scene.pump_streaming_sync(DVec3::ZERO);
1390 let g = scene.grid(id).unwrap();
1391 assert!(g.voxel_solid(IVec3::new(2, 2, 50)));
1392 assert_eq!(g.chunk_version(IVec3::ZERO), 7);
1393 }
1394}