use std::fmt;
use glam::{DVec3, IVec3};
use roxlap_formats::vxl::Vxl;
use crate::{CHUNK_SIZE_XY, CHUNK_SIZE_Z};
pub trait ChunkGenerator: fmt::Debug + Send + Sync {
fn generate(&self, chunk_idx: IVec3) -> Vxl;
fn should_generate(&self, _chunk_idx: IVec3) -> bool {
true
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StreamRadius {
pub r_active: f64,
pub r_evict: f64,
}
impl StreamRadius {
pub const DISABLED: Self = Self {
r_active: 0.0,
r_evict: f64::INFINITY,
};
#[must_use]
pub fn new(r_active: f64, r_evict: f64) -> Self {
assert!(
r_evict >= r_active,
"StreamRadius: r_evict ({r_evict}) must be >= r_active ({r_active})"
);
assert!(
r_active.is_finite() && r_active >= 0.0,
"StreamRadius: r_active must be finite and >= 0, got {r_active}"
);
assert!(
r_evict >= 0.0,
"StreamRadius: r_evict must be >= 0, got {r_evict}"
);
Self { r_active, r_evict }
}
#[must_use]
pub fn is_disabled(self) -> bool {
self.r_active == 0.0 && self.r_evict == f64::INFINITY
}
}
impl Default for StreamRadius {
fn default() -> Self {
Self::DISABLED
}
}
#[must_use]
pub(crate) fn chunk_aabb_dist_sq(p_local: DVec3, chunk_idx: IVec3) -> f64 {
let sxy = f64::from(CHUNK_SIZE_XY);
let sz = f64::from(CHUNK_SIZE_Z);
let lo = DVec3::new(
f64::from(chunk_idx.x) * sxy,
f64::from(chunk_idx.y) * sxy,
f64::from(chunk_idx.z) * sz,
);
let hi = DVec3::new(lo.x + sxy, lo.y + sxy, lo.z + sz);
let dx = (lo.x - p_local.x).max(0.0).max(p_local.x - hi.x);
let dy = (lo.y - p_local.y).max(0.0).max(p_local.y - hi.y);
let dz = (lo.z - p_local.z).max(0.0).max(p_local.z - hi.z);
dx * dx + dy * dy + dz * dz
}
#[must_use]
pub(crate) fn world_to_grid_local_pos(world_pos: DVec3, transform: &crate::GridTransform) -> DVec3 {
transform.rotation.inverse() * (world_pos - transform.origin)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use native::{ChunkResult, StreamingState};
#[cfg(not(target_arch = "wasm32"))]
mod native {
use super::*;
use crate::GridId;
pub(crate) struct ChunkResult {
pub grid_id: GridId,
pub chunk_idx: IVec3,
pub version_at_dispatch: u64,
pub vxl: Vxl,
}
pub(crate) struct StreamingState {
pub thread_count: usize,
pub pool: Option<rayon::ThreadPool>,
pub tx: crossbeam_channel::Sender<ChunkResult>,
pub rx: crossbeam_channel::Receiver<ChunkResult>,
}
impl Default for StreamingState {
fn default() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
Self {
thread_count: 2,
pool: None,
tx,
rx,
}
}
}
impl std::fmt::Debug for StreamingState {
#[allow(clippy::missing_fields_in_debug)]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StreamingState")
.field("thread_count", &self.thread_count)
.field("pool_built", &self.pool.is_some())
.finish()
}
}
impl StreamingState {
pub fn ensure_pool(&mut self) {
if self.pool.is_none() {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(self.thread_count)
.thread_name(|i| format!("roxlap-stream-{i}"))
.build()
.expect("rayon ThreadPoolBuilder");
self.pool = Some(pool);
}
}
pub fn set_thread_count(&mut self, n: usize) {
assert!(n > 0, "streaming thread count must be >= 1");
if self.thread_count == n {
return;
}
self.thread_count = n;
self.pool = None;
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::chunks::tests::voxel_is_solid;
use crate::{Grid, GridTransform, Scene, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
use glam::DQuat;
use roxlap_formats::edit::{set_spans, Vspan};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[derive(Debug)]
pub(crate) struct StubGenerator {
pub call_count: Arc<AtomicUsize>,
}
impl StubGenerator {
pub(crate) fn new() -> Self {
Self {
call_count: Arc::new(AtomicUsize::new(0)),
}
}
}
impl ChunkGenerator for StubGenerator {
fn generate(&self, chunk_idx: IVec3) -> Vxl {
self.call_count.fetch_add(1, Ordering::Relaxed);
let mut g = Grid::new(GridTransform::identity());
let mark_z = (chunk_idx.x.rem_euclid(200) as u32) % CHUNK_SIZE_Z;
g.ensure_chunk(IVec3::ZERO);
let vxl = g.chunks.remove(&IVec3::ZERO).expect("just inserted");
let mut vxl = vxl;
set_spans(
&mut vxl,
&[Vspan {
x: 0,
y: 0,
z0: u8::try_from(mark_z).unwrap_or(0),
z1: u8::try_from(mark_z).unwrap_or(0),
}],
Some(0x80_aa_bb_cc),
);
vxl
}
}
#[test]
fn stub_generator_emits_distinguishable_chunks() {
let gen = StubGenerator::new();
let a = gen.generate(IVec3::new(0, 0, 0));
let b = gen.generate(IVec3::new(7, 0, 0));
assert_eq!(a.vsid, CHUNK_SIZE_XY);
assert_eq!(b.vsid, CHUNK_SIZE_XY);
assert!(voxel_is_solid(&a, 0, 0, 0), "chunk_idx.x=0 marks z=0");
assert!(voxel_is_solid(&b, 0, 0, 7), "chunk_idx.x=7 marks z=7");
assert_eq!(gen.call_count.load(Ordering::Relaxed), 2);
}
#[test]
fn ensure_chunk_generated_populates_via_generator() {
let mut g = Grid::new(GridTransform::identity());
let gen = StubGenerator::new();
let counter = Arc::clone(&gen.call_count);
g.set_generator(Some(Arc::new(gen)));
assert_eq!(g.chunk_count(), 0);
let idx = IVec3::new(3, 0, 0);
let produced = g.ensure_chunk_generated(idx);
assert!(
produced,
"ensure_chunk_generated returns true when it generates"
);
assert_eq!(g.chunk_count(), 1);
let chunk = g.chunk(idx).expect("chunk now present");
assert!(
voxel_is_solid(chunk, 0, 0, 3),
"stub generator's mark voxel for chunk_idx.x=3 missing"
);
assert_eq!(counter.load(Ordering::Relaxed), 1);
}
#[test]
fn ensure_chunk_generated_is_idempotent() {
let mut g = Grid::new(GridTransform::identity());
let gen = StubGenerator::new();
let counter = Arc::clone(&gen.call_count);
g.set_generator(Some(Arc::new(gen)));
let idx = IVec3::new(5, -2, 0);
assert!(g.ensure_chunk_generated(idx));
assert!(!g.ensure_chunk_generated(idx), "second call no-ops");
assert!(!g.ensure_chunk_generated(idx), "third call still no-ops");
assert_eq!(g.chunk_count(), 1);
assert_eq!(counter.load(Ordering::Relaxed), 1);
}
#[test]
fn ensure_chunk_generated_without_generator_is_noop() {
let mut g = Grid::new(GridTransform::identity());
let idx = IVec3::new(0, 0, 0);
assert!(g.generator.is_none());
let produced = g.ensure_chunk_generated(idx);
assert!(!produced, "no generator → no chunk generated");
assert_eq!(g.chunk_count(), 0);
assert!(g.chunk(idx).is_none());
}
#[test]
fn ensure_chunk_generated_on_already_present_chunk_skips_generator() {
let mut g = Grid::new(GridTransform::identity());
let idx = IVec3::new(0, 0, 0);
g.set_voxel(IVec3::new(10, 10, 10), Some(0x80_11_22_33));
assert_eq!(g.chunk_count(), 1);
let gen = StubGenerator::new();
let counter = Arc::clone(&gen.call_count);
g.set_generator(Some(Arc::new(gen)));
let produced = g.ensure_chunk_generated(idx);
assert!(!produced, "existing chunk not regenerated");
assert_eq!(counter.load(Ordering::Relaxed), 0);
let chunk = g.chunk(idx).expect("manual chunk present");
assert!(voxel_is_solid(chunk, 10, 10, 10), "manual voxel survived");
assert!(
!voxel_is_solid(chunk, 0, 0, 0),
"generator's mark voxel must NOT appear"
);
}
#[test]
fn stream_radius_disabled_is_truly_zero_infty() {
let r = StreamRadius::DISABLED;
assert_eq!(r.r_active, 0.0);
assert!(r.r_evict.is_infinite() && r.r_evict.is_sign_positive());
assert!(r.is_disabled());
assert!(StreamRadius::default().is_disabled());
}
#[test]
fn stream_radius_new_rejects_evict_below_active() {
let result = std::panic::catch_unwind(|| StreamRadius::new(200.0, 100.0));
assert!(result.is_err(), "r_evict < r_active must panic");
}
#[test]
fn chunk_aabb_dist_sq_inside_chunk_is_zero() {
let d = chunk_aabb_dist_sq(DVec3::new(10.0, 20.0, 30.0), IVec3::new(0, 0, 0));
assert_eq!(d, 0.0);
}
#[test]
fn chunk_aabb_dist_sq_axis_aligned() {
let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(1, 0, 0));
let expected = 128.0_f64.powi(2);
assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(0, 0, 1));
let expected = 256.0_f64.powi(2);
assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
}
#[test]
fn pump_streaming_sync_with_disabled_radius_is_noop() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let gen = StubGenerator::new();
let counter = Arc::clone(&gen.call_count);
scene
.grid_mut(id)
.unwrap()
.set_generator(Some(Arc::new(gen)));
scene
.grid_mut(id)
.unwrap()
.set_voxel(IVec3::new(10_000, 0, 0), Some(0x80_11_22_33));
let baseline_chunks = scene.grid(id).unwrap().chunk_count();
scene.pump_streaming_sync(DVec3::ZERO);
assert_eq!(scene.grid(id).unwrap().chunk_count(), baseline_chunks);
assert_eq!(counter.load(Ordering::Relaxed), 0);
}
#[test]
fn pump_streaming_sync_streams_in_chunks_within_r_active() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let gen = StubGenerator::new();
let counter = Arc::clone(&gen.call_count);
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(200.0, 400.0);
scene.pump_streaming_sync(DVec3::ZERO);
let g = scene.grid(id).unwrap();
let must_have = [
IVec3::new(0, 0, 0),
IVec3::new(1, 0, 0),
IVec3::new(-1, 0, 0),
IVec3::new(0, 1, 0),
IVec3::new(0, -1, 0),
IVec3::new(0, 0, -1),
];
for idx in must_have {
assert!(
g.chunks.contains_key(&idx),
"chunk {idx:?} missing from streamed set"
);
}
assert!(g.chunks.contains_key(&IVec3::new(1, 1, 0)));
assert!(!g.chunks.contains_key(&IVec3::new(2, 0, 0)));
assert!(!g.chunks.contains_key(&IVec3::new(0, 0, 1)));
let streamed = g.chunk_count();
assert_eq!(counter.load(Ordering::Relaxed), streamed);
}
#[test]
fn pump_streaming_sync_idempotent_under_stationary_camera() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let gen = StubGenerator::new();
let counter = Arc::clone(&gen.call_count);
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(180.0, 400.0);
scene.pump_streaming_sync(DVec3::ZERO);
let after_first = counter.load(Ordering::Relaxed);
scene.pump_streaming_sync(DVec3::ZERO);
let after_second = counter.load(Ordering::Relaxed);
assert_eq!(after_first, after_second, "second pump regenerated chunks");
}
#[test]
fn pump_streaming_sync_evicts_chunks_beyond_r_evict() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let gen = StubGenerator::new();
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(200.0, 400.0);
scene.pump_streaming_sync(DVec3::ZERO);
let initial = scene.grid(id).unwrap().chunk_count();
assert!(initial > 0, "expected chunks streamed in around origin");
scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
let g = scene.grid(id).unwrap();
for idx in [
IVec3::new(0, 0, 0),
IVec3::new(1, 0, 0),
IVec3::new(-1, 0, 0),
] {
assert!(
!g.chunks.contains_key(&idx),
"chunk {idx:?} survived eviction after far teleport"
);
}
let cam_chx = 10_000_i32 / i32::try_from(CHUNK_SIZE_XY).unwrap();
assert!(g.chunks.contains_key(&IVec3::new(cam_chx, 0, 0)));
}
#[test]
fn pump_streaming_sync_hysteresis_band_retains_chunks() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let gen = StubGenerator::new();
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(200.0, 600.0);
scene.pump_streaming_sync(DVec3::ZERO);
let g = scene.grid(id).unwrap();
let band_idx = IVec3::new(-2, 0, 0);
assert!(
g.chunks.contains_key(&band_idx),
"(-2, 0, 0) should be streamed at origin"
);
scene.pump_streaming_sync(DVec3::new(300.0, 0.0, 0.0));
let g = scene.grid(id).unwrap();
assert!(
g.chunks.contains_key(&band_idx),
"(-2, 0, 0) should remain in the hysteresis band"
);
}
#[test]
fn pump_streaming_sync_with_no_generator_does_not_panic() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.stream_radius = StreamRadius::new(200.0, 400.0);
g.set_voxel(IVec3::new(50 * 128, 0, 0), Some(0x80_aa_bb_cc));
assert_eq!(scene.grid(id).unwrap().chunk_count(), 1);
scene.pump_streaming_sync(DVec3::ZERO);
let g = scene.grid(id).unwrap();
assert_eq!(g.chunk_count(), 0);
}
#[test]
fn pump_streaming_sync_respects_grid_rotation() {
let transform = GridTransform {
origin: DVec3::ZERO,
rotation: DQuat::from_axis_angle(DVec3::Z, std::f64::consts::PI),
};
let mut scene = Scene::new();
let id = scene.add_grid(transform);
let gen = StubGenerator::new();
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(50.0, 200.0);
scene.pump_streaming_sync(DVec3::new(10.0, 0.0, 0.0));
let g = scene.grid(id).unwrap();
assert!(
g.chunks.contains_key(&IVec3::new(-1, 0, 0)),
"rotation not applied — camera should map to chunk (-1, 0, 0)"
);
assert!(g.chunks.contains_key(&IVec3::new(0, 0, 0)));
}
#[test]
fn ensure_chunk_generated_does_not_bump_version() {
let mut g = Grid::new(GridTransform::identity());
let gen = StubGenerator::new();
g.set_generator(Some(Arc::new(gen)));
let idx = IVec3::new(2, 3, 0);
assert!(g.ensure_chunk_generated(idx));
assert_eq!(g.chunk_version(idx), 0);
assert!(g.chunk_versions.is_empty());
}
#[test]
fn ensure_chunk_generated_then_edit_starts_at_version_one() {
let mut g = Grid::new(GridTransform::identity());
let gen = StubGenerator::new();
g.set_generator(Some(Arc::new(gen)));
let idx = IVec3::ZERO;
g.ensure_chunk_generated(idx);
g.set_voxel(IVec3::new(10, 10, 10), Some(0x80_aa_bb_cc));
assert_eq!(g.chunk_version(idx), 1);
}
#[test]
fn pump_streaming_sync_eviction_drops_chunk_version_entry() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
assert_eq!(g.chunk_version(IVec3::ZERO), 1);
g.stream_radius = StreamRadius::new(10.0, 50.0);
scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
let g = scene.grid(id).unwrap();
assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
assert_eq!(
g.chunk_version(IVec3::ZERO),
0,
"version entry should be cleared on eviction"
);
assert!(g.chunk_versions.is_empty(), "map should be empty");
}
#[derive(Debug)]
#[cfg(not(target_arch = "wasm32"))]
struct BlockingGenerator {
arrival_tx: crossbeam_channel::Sender<IVec3>,
release_rx: crossbeam_channel::Receiver<()>,
call_count: Arc<AtomicUsize>,
}
#[cfg(not(target_arch = "wasm32"))]
impl ChunkGenerator for BlockingGenerator {
fn generate(&self, chunk_idx: IVec3) -> Vxl {
self.call_count.fetch_add(1, Ordering::Relaxed);
let _ = self.arrival_tx.send(chunk_idx);
let _ = self.release_rx.recv();
StubGenerator::new().generate(chunk_idx)
}
}
#[cfg(not(target_arch = "wasm32"))]
fn pump_until_idle(
scene: &mut Scene,
cam: DVec3,
grid_id: crate::GridId,
release_tx: Option<&crossbeam_channel::Sender<()>>,
) {
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(5);
loop {
scene.pump_streaming(cam);
let idle = scene
.grid(grid_id)
.map_or(true, |g| g.pending_gen.is_empty());
if idle {
return;
}
if Instant::now() > deadline {
panic!("pump_until_idle: timeout with pending tasks");
}
if let Some(tx) = release_tx {
let _ = tx.try_send(());
}
std::thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn ensure_chunk_generated_invalidates_billboard_cache() {
let mut g = Grid::new(GridTransform::identity());
let gen = StubGenerator::new();
g.set_generator(Some(Arc::new(gen)));
g.billboards = Some(crate::BillboardCache::new_empty(32));
let installed = g.ensure_chunk_generated(IVec3::new(2, 0, 0));
assert!(installed, "generator should have installed the chunk");
assert!(
g.billboards.is_none(),
"ensure_chunk_generated must clear billboards on install"
);
}
#[test]
fn ensure_chunk_generated_noop_preserves_billboard_cache() {
let mut g = Grid::new(GridTransform::identity());
g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
g.billboards = Some(crate::BillboardCache::new_empty(32));
let installed = g.ensure_chunk_generated(IVec3::new(5, 5, 0));
assert!(!installed);
assert!(
g.billboards.is_some(),
"no-generator no-op must not clear billboards"
);
let installed = g.ensure_chunk_generated(IVec3::ZERO);
assert!(!installed);
assert!(
g.billboards.is_some(),
"already-present chunk must not clear billboards"
);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_async_install_invalidates_billboard_cache() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let gen = StubGenerator::new();
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(10.0, 200.0);
g.billboards = Some(crate::BillboardCache::new_empty(32));
let cam = DVec3::new(64.0, 64.0, 128.0);
pump_until_idle(&mut scene, cam, id, None);
let g = scene.grid(id).unwrap();
assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
assert!(
g.billboards.is_none(),
"async install must clear billboards"
);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_no_install_preserves_billboard_cache() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
let gen = StubGenerator::new();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(10.0, 200.0);
let cam = DVec3::new(64.0, 64.0, 128.0);
pump_until_idle(&mut scene, cam, id, None);
scene.grid_mut(id).unwrap().billboards = Some(crate::BillboardCache::new_empty(32));
scene.pump_streaming(cam);
let g = scene.grid(id).unwrap();
assert!(
g.billboards.is_some(),
"pump with no install should not clear billboards"
);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_dispatches_and_installs_via_async_path() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let gen = StubGenerator::new();
let counter = Arc::clone(&gen.call_count);
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(10.0, 200.0);
let cam = DVec3::new(64.0, 64.0, 128.0);
pump_until_idle(&mut scene, cam, id, None);
let g = scene.grid(id).unwrap();
assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
assert_eq!(counter.load(Ordering::Relaxed), 1, "generator called once");
assert!(g.pending_gen.is_empty(), "no leftover pending");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_tracks_in_flight_chunks_in_pending_gen() {
let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
let (release_tx, release_rx) = crossbeam_channel::unbounded();
let counter = Arc::new(AtomicUsize::new(0));
let gen = BlockingGenerator {
arrival_tx,
release_rx,
call_count: Arc::clone(&counter),
};
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(10.0, 200.0);
let cam = DVec3::new(64.0, 64.0, 128.0);
scene.pump_streaming(cam);
let arrived = arrival_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("task didn't start");
assert_eq!(arrived, IVec3::ZERO);
assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
assert!(scene.grid(id).unwrap().chunks.is_empty());
release_tx.send(()).unwrap();
pump_until_idle(&mut scene, cam, id, Some(&release_tx));
let g = scene.grid(id).unwrap();
assert!(g.chunks.contains_key(&IVec3::ZERO));
assert!(!g.pending_gen.contains(&IVec3::ZERO));
assert_eq!(counter.load(Ordering::Relaxed), 1);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_does_not_redispatch_in_flight_chunks() {
let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
let (release_tx, release_rx) = crossbeam_channel::unbounded();
let counter = Arc::new(AtomicUsize::new(0));
let gen = BlockingGenerator {
arrival_tx,
release_rx,
call_count: Arc::clone(&counter),
};
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(10.0, 200.0);
let cam = DVec3::new(64.0, 64.0, 128.0);
scene.pump_streaming(cam);
let _ = arrival_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("task didn't start");
for _ in 0..5 {
scene.pump_streaming(cam);
}
assert_eq!(
counter.load(Ordering::Relaxed),
1,
"in-flight chunk re-dispatched"
);
release_tx.send(()).unwrap();
pump_until_idle(&mut scene, cam, id, Some(&release_tx));
assert_eq!(counter.load(Ordering::Relaxed), 1);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_discards_stale_result_when_chunk_edited_during_gen() {
let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
let (release_tx, release_rx) = crossbeam_channel::unbounded();
let counter = Arc::new(AtomicUsize::new(0));
let gen = BlockingGenerator {
arrival_tx,
release_rx,
call_count: Arc::clone(&counter),
};
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(10.0, 200.0);
let cam = DVec3::new(64.0, 64.0, 128.0);
scene.pump_streaming(cam);
let _ = arrival_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("task didn't start");
let g = scene.grid_mut(id).unwrap();
g.set_voxel(IVec3::new(10, 11, 12), Some(0x80_de_ad_be));
assert_eq!(g.chunk_version(IVec3::ZERO), 1);
let chunk = g.chunk(IVec3::ZERO).unwrap();
assert!(voxel_is_solid(chunk, 10, 11, 12));
assert!(!voxel_is_solid(chunk, 0, 0, 0));
release_tx.send(()).unwrap();
pump_until_idle(&mut scene, cam, id, Some(&release_tx));
let g = scene.grid(id).unwrap();
let chunk = g.chunk(IVec3::ZERO).unwrap();
assert!(voxel_is_solid(chunk, 10, 11, 12), "user edit survived");
assert!(
!voxel_is_solid(chunk, 0, 0, 0),
"stale generator output must not have overwritten the chunk"
);
assert_eq!(counter.load(Ordering::Relaxed), 1);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_eviction_drops_pending_gen_entry() {
let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
let (release_tx, release_rx) = crossbeam_channel::unbounded();
let counter = Arc::new(AtomicUsize::new(0));
let gen = BlockingGenerator {
arrival_tx,
release_rx,
call_count: Arc::clone(&counter),
};
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(10.0, 50.0);
let near_cam = DVec3::new(64.0, 64.0, 128.0);
scene.pump_streaming(near_cam);
let _ = arrival_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("task didn't start");
assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
let far_cam = DVec3::new(10_000.0, 64.0, 128.0);
scene.pump_streaming(far_cam);
assert!(
!scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO),
"eviction should have cleared the pending entry"
);
release_tx.send(()).unwrap();
pump_until_idle(&mut scene, far_cam, id, Some(&release_tx));
let g = scene.grid(id).unwrap();
assert!(
!g.chunks.contains_key(&IVec3::ZERO),
"evicted chunk must not be re-installed by the stale result"
);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_with_disabled_radius_is_noop() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let gen = StubGenerator::new();
let counter = Arc::clone(&gen.call_count);
scene
.grid_mut(id)
.unwrap()
.set_generator(Some(Arc::new(gen)));
scene.pump_streaming(DVec3::ZERO);
let g = scene.grid(id).unwrap();
assert!(g.chunks.is_empty());
assert!(g.pending_gen.is_empty());
assert_eq!(counter.load(Ordering::Relaxed), 0);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn set_streaming_threads_zero_panics() {
let mut scene = Scene::new();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
scene.set_streaming_threads(0);
}));
assert!(result.is_err(), "zero threads must panic");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn set_streaming_threads_lazily_applied_before_first_pump() {
let mut scene = Scene::new();
scene.set_streaming_threads(1);
let id = scene.add_grid(GridTransform::identity());
let gen = StubGenerator::new();
let g = scene.grid_mut(id).unwrap();
g.set_generator(Some(Arc::new(gen)));
g.stream_radius = StreamRadius::new(10.0, 200.0);
let cam = DVec3::new(64.0, 64.0, 128.0);
pump_until_idle(&mut scene, cam, id, None);
assert!(scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO));
}
#[test]
fn pump_streaming_sync_eviction_clears_billboard_cache() {
use crate::BillboardCache;
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
g.billboards = Some(BillboardCache::new_empty(64));
g.stream_radius = StreamRadius::new(10.0, 50.0);
scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
let g = scene.grid(id).unwrap();
assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
assert!(g.billboards.is_none(), "billboard cache should be cleared");
}
}