pub mod addr;
pub mod billboard;
pub mod cavegen;
pub mod character;
pub mod chunks;
pub mod collide;
pub mod edit;
pub mod lod;
pub mod occluder;
pub mod render;
pub mod snapshot;
pub mod streaming;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use glam::{DQuat, DVec3, IVec3, UVec3};
use roxlap_formats::vxl::Vxl;
use serde::{Deserialize, Serialize};
pub use addr::{grid_local_to_world, voxel_global, voxel_split, world_to_grid_local, GridLocalPos};
pub use billboard::{canonical_viewpoints, BillboardCache, BillboardSnapshot};
pub use character::{CharacterBody, CharacterDef, MoveMode, WalkInput};
pub use chunks::{BakeLight, BakeMode};
pub use collide::{box_overlaps_solid, grid_box_overlaps_solid, point_overlaps_solid, Solidity};
pub use edit::SpanOp;
pub use lod::{select_lod, Lod, LodThresholds};
pub use roxlap_core::AoParams;
pub use roxlap_formats::color::{OverlayColor, Rgb, VoxColor};
pub use streaming::{ChunkGenerator, ChunkStore, StreamRadius};
pub const CHUNK_SIZE_XY: u32 = 128;
pub const CHUNK_SIZE_Z: u32 = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct GridId(u32);
impl GridId {
#[must_use]
pub const fn raw(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RayHit {
pub grid: GridId,
pub voxel: IVec3,
pub world: DVec3,
pub t: f64,
pub color: Option<VoxColor>,
}
fn ray_box(o: DVec3, d: DVec3, blo: DVec3, bhi: DVec3) -> Option<(f64, f64)> {
let mut tmin = f64::NEG_INFINITY;
let mut tmax = f64::INFINITY;
for a in 0..3 {
if d[a].abs() < 1e-12 {
if o[a] < blo[a] || o[a] > bhi[a] {
return None;
}
} else {
let inv = 1.0 / d[a];
let (t0, t1) = ((blo[a] - o[a]) * inv, (bhi[a] - o[a]) * inv);
tmin = tmin.max(t0.min(t1));
tmax = tmax.min(t0.max(t1));
if tmin > tmax {
return None;
}
}
}
Some((tmin, tmax))
}
fn grid_voxel_aabb_f64(grid: &Grid) -> Option<(DVec3, DVec3)> {
let mut min = IVec3::splat(i32::MAX);
let mut max = IVec3::splat(i32::MIN);
let mut any = false;
for idx in grid.chunks.keys() {
any = true;
min = min.min(*idx);
max = max.max(*idx);
}
if !any {
return None;
}
let (cs_xy, cs_z) = (
i64::from(CHUNK_SIZE_XY as i32),
i64::from(CHUNK_SIZE_Z as i32),
);
#[allow(clippy::cast_precision_loss)]
let v = |i: IVec3, add: i64| {
DVec3::new(
((i64::from(i.x) + add) * cs_xy) as f64,
((i64::from(i.y) + add) * cs_xy) as f64,
((i64::from(i.z) + add) * cs_z) as f64,
)
};
Some((v(min, 0), v(max, 1)))
}
#[allow(clippy::cast_possible_truncation)]
fn voxel_dda(grid: &Grid, lo: DVec3, ld: DVec3, max_t: f64) -> Option<(IVec3, f64)> {
let (blo, bhi) = grid_voxel_aabb_f64(grid)?;
let (t0, t1) = ray_box(lo, ld, blo, bhi)?;
let t_enter = t0.max(0.0);
let t_exit = t1.min(max_t);
if t_enter > t_exit {
return None;
}
let step = IVec3::new(
i32::from(ld.x > 0.0) - i32::from(ld.x < 0.0),
i32::from(ld.y > 0.0) - i32::from(ld.y < 0.0),
i32::from(ld.z > 0.0) - i32::from(ld.z < 0.0),
);
let inv_abs = |d: f64| {
if d == 0.0 {
f64::INFINITY
} else {
(1.0 / d).abs()
}
};
let t_delta = DVec3::new(inv_abs(ld.x), inv_abs(ld.y), inv_abs(ld.z));
let seed_t_max = |p: IVec3| -> DVec3 {
let axis = |pa: i32, oa: f64, da: f64| -> f64 {
if da > 0.0 {
(f64::from(pa) + 1.0 - oa) / da
} else if da < 0.0 {
(f64::from(pa) - oa) / da
} else {
f64::INFINITY
}
};
DVec3::new(
axis(p.x, lo.x, ld.x),
axis(p.y, lo.y, ld.y),
axis(p.z, lo.z, ld.z),
)
};
let start = lo + ld * t_enter;
let mut p = IVec3::new(
start.x.floor() as i32,
start.y.floor() as i32,
start.z.floor() as i32,
);
let mut t_max = seed_t_max(p);
let mut t_curr = t_enter;
let mut sampler = grid.solid_sampler();
#[allow(clippy::cast_sign_loss)]
let max_steps = (max_t * 3.0) as u64 + 8;
let (cs_xy, cs_z) = (
f64::from(CHUNK_SIZE_XY as i32),
f64::from(CHUNK_SIZE_Z as i32),
);
for _ in 0..max_steps {
let (chunk_idx, in_chunk) = voxel_split(p);
if let Some(vxl) = sampler.chunk_at(chunk_idx) {
if chunks::vxl_voxel_solid(vxl, in_chunk.x, in_chunk.y, in_chunk.z) {
return Some((p, t_curr));
}
let t = if t_max.x <= t_max.y && t_max.x <= t_max.z {
p.x += step.x;
let t = t_max.x;
t_max.x += t_delta.x;
t
} else if t_max.y <= t_max.z {
p.y += step.y;
let t = t_max.y;
t_max.y += t_delta.y;
t
} else {
p.z += step.z;
let t = t_max.z;
t_max.z += t_delta.z;
t
};
if t > t_exit {
return None;
}
t_curr = t;
} else {
let clo = DVec3::new(
f64::from(chunk_idx.x) * cs_xy,
f64::from(chunk_idx.y) * cs_xy,
f64::from(chunk_idx.z) * cs_z,
);
let chi = clo + DVec3::new(cs_xy, cs_xy, cs_z);
let exit = match ray_box(lo, ld, clo, chi) {
Some((_, t1)) => t1.max(t_curr) + 1e-4,
None => return None, };
if exit > t_exit {
return None;
}
let q = lo + ld * exit;
p = IVec3::new(q.x.floor() as i32, q.y.floor() as i32, q.z.floor() as i32);
t_max = seed_t_max(p);
t_curr = exit;
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct GridTransform {
pub origin: DVec3,
pub rotation: DQuat,
#[serde(skip, default = "one_f64")]
pub voxel_world_size: f64,
}
fn one_f64() -> f64 {
1.0
}
impl GridTransform {
#[must_use]
pub fn identity() -> Self {
Self {
origin: DVec3::ZERO,
rotation: DQuat::IDENTITY,
voxel_world_size: 1.0,
}
}
#[must_use]
pub fn at(origin: DVec3) -> Self {
Self {
origin,
rotation: DQuat::IDENTITY,
voxel_world_size: 1.0,
}
}
#[must_use]
pub fn at_scale(origin: DVec3, voxel_world_size: f64) -> Self {
debug_assert!(
voxel_world_size.is_finite() && voxel_world_size > 0.0,
"voxel_world_size must be finite and > 0, got {voxel_world_size}"
);
Self {
origin,
rotation: DQuat::IDENTITY,
voxel_world_size,
}
}
}
impl Default for GridTransform {
fn default() -> Self {
Self::identity()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GridAddr {
pub grid: GridId,
pub chunk: IVec3,
pub voxel: UVec3,
}
#[derive(Debug)]
pub struct Grid {
pub transform: GridTransform,
pub chunks: HashMap<IVec3, Vxl>,
pub render_sky: bool,
pub mip_levels_override: Option<u32>,
pub lod_thresholds: LodThresholds,
pub billboards: Option<BillboardCache>,
pub generator: Option<Arc<dyn ChunkGenerator>>,
pub name: Option<String>,
pub store: Option<Arc<dyn ChunkStore>>,
pub stream_radius: StreamRadius,
pub bake_lights: Vec<BakeLight>,
chunk_versions: HashMap<IVec3, u64>,
pub pending_gen: HashSet<IVec3>,
pub dda_brick_cache: roxlap_core::BrickCache,
chunk_dirty: HashMap<IVec3, DirtyExtent>,
mutations: u64,
last_bricks: Option<(u64, u32, u32)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirtyExtent {
Full,
Bbox(IVec3, IVec3),
}
impl Grid {
#[must_use]
pub fn new(transform: GridTransform) -> Self {
Self {
transform,
chunks: HashMap::new(),
render_sky: true,
mip_levels_override: None,
lod_thresholds: LodThresholds::always_near(),
billboards: None,
generator: None,
name: None,
store: None,
stream_radius: StreamRadius::DISABLED,
bake_lights: Vec::new(),
chunk_versions: HashMap::new(),
pending_gen: HashSet::new(),
dda_brick_cache: roxlap_core::BrickCache::new(),
chunk_dirty: HashMap::new(),
mutations: 0,
last_bricks: None,
}
}
pub fn ensure_dda_bricks(&mut self, requested_mip: u32) -> u32 {
if let Some((counter, req, eff)) = self.last_bricks {
if counter == self.mutations && req == requested_mip {
return eff;
}
}
let mutations = self.mutations;
let Self {
chunks,
chunk_versions,
dda_brick_cache,
..
} = self;
let mut mip = requested_mip;
if requested_mip > 0 {
for vxl in chunks.values() {
mip = mip.min(vxl.mip_count().saturating_sub(1));
}
}
for (idx, vxl) in chunks.iter() {
let version = chunk_versions.get(idx).copied().unwrap_or(0);
let view = roxlap_core::GridView::from_single_vxl(vxl);
dda_brick_cache.ensure([idx.x, idx.y, idx.z], mip, version, &view);
}
dda_brick_cache.retain_chunks(|c| chunks.contains_key(&IVec3::new(c[0], c[1], c[2])));
self.last_bricks = Some((mutations, requested_mip, mip));
mip
}
#[must_use]
pub fn chunk_version(&self, chunk_idx: IVec3) -> u64 {
self.chunk_versions.get(&chunk_idx).copied().unwrap_or(0)
}
pub fn bump_chunk_version(&mut self, chunk_idx: IVec3) {
let entry = self.chunk_versions.entry(chunk_idx).or_insert(0);
*entry = entry.saturating_add(1);
self.chunk_dirty.insert(chunk_idx, DirtyExtent::Full);
self.mutations = self.mutations.wrapping_add(1);
}
#[must_use]
pub fn mutation_counter(&self) -> u64 {
self.mutations
}
pub fn bump_chunk_version_bbox(&mut self, chunk_idx: IVec3, lo: IVec3, hi: IVec3) {
let entry = self.chunk_versions.entry(chunk_idx).or_insert(0);
*entry = entry.saturating_add(1);
let merged = match self.chunk_dirty.get(&chunk_idx) {
Some(DirtyExtent::Full) => DirtyExtent::Full,
Some(DirtyExtent::Bbox(l, h)) => DirtyExtent::Bbox(l.min(lo), h.max(hi)),
None => DirtyExtent::Bbox(lo, hi),
};
self.chunk_dirty.insert(chunk_idx, merged);
self.mutations = self.mutations.wrapping_add(1);
}
pub fn take_chunk_dirty(&mut self, chunk_idx: IVec3) -> Option<DirtyExtent> {
self.chunk_dirty.remove(&chunk_idx)
}
#[must_use]
pub fn chunk_versions(&self) -> &HashMap<IVec3, u64> {
&self.chunk_versions
}
pub(crate) fn note_chunk_set_changed(&mut self) {
self.mutations = self.mutations.wrapping_add(1);
}
pub(crate) fn forget_chunk_tracking(&mut self, chunk_idx: IVec3) {
self.chunk_versions.remove(&chunk_idx);
self.chunk_dirty.remove(&chunk_idx);
}
pub(crate) fn restore_chunk_version(&mut self, chunk_idx: IVec3, version: u64) {
self.chunk_versions.insert(chunk_idx, version);
}
pub fn set_generator(&mut self, generator: Option<Arc<dyn ChunkGenerator>>) {
self.generator = generator;
}
pub fn set_chunk_store(&mut self, store: Option<Arc<dyn ChunkStore>>) {
self.store = store;
}
pub fn ensure_chunk_generated(&mut self, chunk_idx: IVec3) -> bool {
if self.chunks.contains_key(&chunk_idx) {
return false;
}
if let Some((vxl, version)) = self.store.as_ref().and_then(|store| store.load(chunk_idx)) {
self.chunks.insert(chunk_idx, vxl);
self.restore_chunk_version(chunk_idx, version);
self.note_chunk_set_changed();
self.billboards = None; return true;
}
let Some(generator) = self.generator.as_ref() else {
return false;
};
if !generator.should_generate(chunk_idx) {
return false;
}
let chunk = generator.generate(chunk_idx);
self.chunks.insert(chunk_idx, chunk);
self.note_chunk_set_changed();
self.billboards = None;
true
}
#[must_use]
pub fn bounding_radius(&self) -> f64 {
if self.chunks.is_empty() {
return 0.0;
}
let mut min = IVec3::splat(i32::MAX);
let mut max = IVec3::splat(i32::MIN);
for &idx in self.chunks.keys() {
min = min.min(idx);
max = max.max(idx);
}
let sx = f64::from(CHUNK_SIZE_XY);
let sz = f64::from(CHUNK_SIZE_Z);
let lo = DVec3::new(
f64::from(min.x) * sx,
f64::from(min.y) * sx,
f64::from(min.z) * sz,
);
let hi = DVec3::new(
f64::from(max.x + 1) * sx,
f64::from(max.y + 1) * sx,
f64::from(max.z + 1) * sz,
);
let half_extent = (hi - lo) * 0.5;
half_extent.length() * self.transform.voxel_world_size
}
#[must_use]
pub fn select_lod(&self, camera_world_pos: DVec3) -> Lod {
select_lod(camera_world_pos, &self.transform, self.lod_thresholds)
}
}
#[derive(Debug, Default)]
pub struct Scene {
grids: HashMap<GridId, Grid>,
next_grid_id: u32,
#[cfg(not(target_arch = "wasm32"))]
streaming: streaming::StreamingState,
}
impl Scene {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn grid_count(&self) -> usize {
self.grids.len()
}
pub fn add_grid(&mut self, transform: GridTransform) -> GridId {
debug_assert!(
transform.voxel_world_size.is_finite() && transform.voxel_world_size > 0.0,
"grid voxel_world_size must be finite and > 0, got {}",
transform.voxel_world_size
);
let id = GridId(self.next_grid_id);
self.next_grid_id += 1;
self.grids.insert(id, Grid::new(transform));
id
}
pub fn remove_grid(&mut self, id: GridId) -> Option<Grid> {
self.grids.remove(&id)
}
#[must_use]
pub fn grid(&self, id: GridId) -> Option<&Grid> {
self.grids.get(&id)
}
pub fn grid_mut(&mut self, id: GridId) -> Option<&mut Grid> {
self.grids.get_mut(&id)
}
pub fn grids(&self) -> impl Iterator<Item = (GridId, &Grid)> {
self.grids.iter().map(|(id, g)| (*id, g))
}
pub fn grids_mut(&mut self) -> impl Iterator<Item = (GridId, &mut Grid)> {
self.grids.iter_mut().map(|(id, g)| (*id, g))
}
#[must_use]
pub fn resolve_voxel(&self, world: DVec3, ray_dir: DVec3) -> Option<(GridId, IVec3)> {
let len = ray_dir.length();
if len < 1e-9 {
return None;
}
let inside = world + ray_dir * (0.5 / len); for (id, grid) in self.grids() {
let glp = addr::world_to_grid_local(inside, &grid.transform);
let v = addr::voxel_global(glp.chunk, glp.voxel);
if grid.voxel_solid(v) {
return Some((id, v));
}
}
None
}
#[must_use]
pub fn raycast(&self, origin: DVec3, dir: DVec3, max_dist: f64) -> Option<RayHit> {
let len = dir.length();
if len < 1e-12 || max_dist <= 0.0 {
return None;
}
let dn = dir / len; let mut best: Option<RayHit> = None;
for (id, grid) in self.grids() {
let inv = grid.transform.rotation.inverse();
let vws = grid.transform.voxel_world_size;
let lo = (inv * (origin - grid.transform.origin)) / vws;
let ld = inv * dn;
if let Some((voxel, t_local)) = voxel_dda(grid, lo, ld, max_dist / vws) {
let t = t_local * vws;
if best.as_ref().is_none_or(|b| t < b.t) {
best = Some(RayHit {
grid: id,
voxel,
world: origin + dn * t,
t,
color: grid.voxel_color(voxel),
});
}
}
}
best
}
#[cfg(not(target_arch = "wasm32"))]
pub fn set_streaming_threads(&mut self, n: usize) {
self.streaming.set_thread_count(n);
}
#[cfg(target_arch = "wasm32")]
pub fn set_streaming_threads(&mut self, _n: usize) {
}
pub fn pump_streaming(&mut self, camera_world_pos: DVec3) {
#[cfg(target_arch = "wasm32")]
{
self.pump_streaming_sync(camera_world_pos);
}
#[cfg(not(target_arch = "wasm32"))]
{
self.pump_streaming_native(camera_world_pos);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn pump_streaming_native(&mut self, camera_world_pos: DVec3) {
while let Ok(result) = self.streaming.rx.try_recv() {
let Some(grid) = self.grids.get_mut(&result.grid_id) else {
continue;
};
let was_pending = grid.pending_gen.remove(&result.chunk_idx);
if !was_pending {
continue;
}
if grid.chunks.contains_key(&result.chunk_idx) {
continue;
}
if grid.chunk_version(result.chunk_idx) != result.version_at_dispatch {
continue;
}
let Some(vxl) = result.vxl else {
continue;
};
grid.chunks.insert(result.chunk_idx, vxl);
if let Some(version) = result.restored_version {
grid.restore_chunk_version(result.chunk_idx, version);
}
grid.note_chunk_set_changed();
grid.billboards = None;
}
self.streaming.ensure_pool();
let pool: &rayon::ThreadPool = self.streaming.pool.as_ref().expect("ensure_pool just ran");
let tx_template = &self.streaming.tx;
for (grid_id, grid) in &mut self.grids {
evict_grid_chunks(grid, camera_world_pos);
dispatch_grid_async(*grid_id, grid, camera_world_pos, pool, tx_template);
}
}
pub fn pump_streaming_sync(&mut self, camera_world_pos: DVec3) {
for grid in self.grids.values_mut() {
pump_grid_streaming_sync(grid, camera_world_pos);
}
}
}
fn pump_grid_streaming_sync(grid: &mut Grid, camera_world_pos: DVec3) {
let radius = grid.stream_radius;
if radius.is_disabled() {
return;
}
let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
if radius.r_active > 0.0 && (grid.generator.is_some() || grid.store.is_some()) {
for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
grid.ensure_chunk_generated(idx);
});
}
evict_grid_chunks_with_cam(grid, cam_local);
}
#[cfg(not(target_arch = "wasm32"))]
fn evict_grid_chunks(grid: &mut Grid, camera_world_pos: DVec3) {
let radius = grid.stream_radius;
if radius.is_disabled() {
return;
}
let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
evict_grid_chunks_with_cam(grid, cam_local);
}
fn evict_grid_chunks_with_cam(grid: &mut Grid, cam_local: DVec3) {
let radius = grid.stream_radius;
if !radius.r_evict.is_finite() {
return;
}
let r_sq = radius.r_evict * radius.r_evict;
let to_evict: Vec<IVec3> = grid
.chunks
.keys()
.filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
.copied()
.collect();
let to_evict_pending: Vec<IVec3> = grid
.pending_gen
.iter()
.filter(|&&idx| streaming::chunk_aabb_dist_sq(cam_local, idx) > r_sq)
.copied()
.collect();
if to_evict.is_empty() && to_evict_pending.is_empty() {
return;
}
for idx in &to_evict {
if let Some(store) = grid.store.as_ref() {
let version = grid.chunk_version(*idx);
if version != 0 {
if let Some(vxl) = grid.chunks.get(idx) {
store.store(*idx, vxl, version);
}
}
}
grid.chunks.remove(idx);
grid.note_chunk_set_changed();
grid.forget_chunk_tracking(*idx);
grid.pending_gen.remove(idx);
}
for idx in &to_evict_pending {
grid.pending_gen.remove(idx);
}
if !to_evict.is_empty() {
grid.billboards = None;
}
}
fn for_each_chunk_in_radius<F>(cam_local: DVec3, r_active: f64, mut f: F)
where
F: FnMut(IVec3),
{
let r_sq = r_active * r_active;
let sxy = f64::from(CHUNK_SIZE_XY);
let sz = f64::from(CHUNK_SIZE_Z);
#[allow(clippy::cast_possible_truncation)]
let r_chunks_xy = (r_active / sxy).ceil() as i32 + 1;
#[allow(clippy::cast_possible_truncation)]
let r_chunks_z = (r_active / sz).ceil() as i32 + 1;
#[allow(clippy::cast_possible_truncation)]
let cx_chunk = (cam_local.x / sxy).floor() as i32;
#[allow(clippy::cast_possible_truncation)]
let cy_chunk = (cam_local.y / sxy).floor() as i32;
#[allow(clippy::cast_possible_truncation)]
let cz_chunk = (cam_local.z / sz).floor() as i32;
for chz in (cz_chunk - r_chunks_z)..=(cz_chunk + r_chunks_z) {
for chy in (cy_chunk - r_chunks_xy)..=(cy_chunk + r_chunks_xy) {
for chx in (cx_chunk - r_chunks_xy)..=(cx_chunk + r_chunks_xy) {
let idx = IVec3::new(chx, chy, chz);
if streaming::chunk_aabb_dist_sq(cam_local, idx) <= r_sq {
f(idx);
}
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn dispatch_grid_async(
grid_id: GridId,
grid: &mut Grid,
camera_world_pos: DVec3,
pool: &rayon::ThreadPool,
tx: &crossbeam_channel::Sender<streaming::ChunkResult>,
) {
let radius = grid.stream_radius;
if radius.is_disabled() || radius.r_active <= 0.0 {
return;
}
let generator = grid.generator.as_ref().map(Arc::clone);
let store = grid.store.as_ref().map(Arc::clone);
if generator.is_none() && store.is_none() {
return;
}
let cam_local = streaming::world_to_grid_local_pos(camera_world_pos, &grid.transform);
for_each_chunk_in_radius(cam_local, radius.r_active, |idx| {
if grid.chunks.contains_key(&idx) {
return; }
if grid.pending_gen.contains(&idx) {
return; }
let declined = !generator.as_ref().is_some_and(|g| g.should_generate(idx));
if declined && store.is_none() {
return;
}
grid.pending_gen.insert(idx);
let version_at_dispatch = grid.chunk_version(idx);
let tx_clone = tx.clone();
let gen_clone = generator.clone();
let store_clone = store.clone();
pool.spawn(move || {
let (vxl, restored_version) = match store_clone.as_ref().and_then(|s| s.load(idx)) {
Some((vxl, version)) => (Some(vxl), Some(version)),
None if declined => (None, None),
None => (gen_clone.map(|g| g.generate(idx)), None),
};
let _ = tx_clone.send(streaming::ChunkResult {
grid_id,
chunk_idx: idx,
version_at_dispatch,
vxl,
restored_version,
});
});
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_scene_has_no_grids() {
let scene = Scene::new();
assert_eq!(scene.grid_count(), 0);
assert!(scene.grids().next().is_none());
}
#[test]
fn raycast_hits_axis_aligned_voxel() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
scene
.grid_mut(id)
.unwrap()
.set_voxel(IVec3::new(5, 5, 10), Some(VoxColor(0x80_aa_bb_cc)));
let hit = scene
.raycast(DVec3::new(5.5, 5.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.expect("ray hits the voxel");
assert_eq!(hit.grid, id);
assert_eq!(hit.voxel, IVec3::new(5, 5, 10));
assert!((hit.t - 10.0).abs() < 1e-6, "t≈10, got {}", hit.t);
assert!(hit.color.is_some(), "textured voxel has a colour");
assert!(
scene
.raycast(DVec3::new(0.5, 0.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.is_none(),
"empty column → no hit",
);
}
#[test]
fn raycast_respects_grid_transform() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
scene
.grid_mut(id)
.unwrap()
.set_voxel(IVec3::new(5, 5, 10), Some(VoxColor(0x80_11_22_33)));
let hit = scene
.raycast(DVec3::new(105.5, 5.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.expect("ray hits the translated voxel");
assert_eq!(hit.voxel, IVec3::new(5, 5, 10), "grid-local voxel");
assert!((hit.world.x - 105.5).abs() < 1e-6, "world x preserved");
assert!((hit.t - 10.0).abs() < 1e-6, "t≈10, got {}", hit.t);
}
#[test]
fn raycast_picks_nearest_grid() {
let mut scene = Scene::new();
let near = scene.add_grid(GridTransform::identity());
let far = scene.add_grid(GridTransform::identity());
scene
.grid_mut(near)
.unwrap()
.set_voxel(IVec3::new(1, 1, 20), Some(VoxColor(0x80_00_ff_00)));
scene
.grid_mut(far)
.unwrap()
.set_voxel(IVec3::new(1, 1, 40), Some(VoxColor(0x80_ff_00_00)));
let hit = scene
.raycast(DVec3::new(1.5, 1.5, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.expect("hits the nearer voxel");
assert_eq!(hit.grid, near);
assert_eq!(hit.voxel, IVec3::new(1, 1, 20));
}
#[test]
fn raycast_into_scaled_grid_returns_world_t() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 2.0));
scene
.grid_mut(id)
.unwrap()
.set_voxel(IVec3::new(5, 5, 10), Some(VoxColor(0x80_aa_bb_cc)));
let hit = scene
.raycast(DVec3::new(11.0, 11.0, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.expect("ray hits the scaled voxel");
assert_eq!(hit.voxel, IVec3::new(5, 5, 10), "grid-local voxel");
assert!((hit.t - 20.0).abs() < 1e-4, "world t≈20, got {}", hit.t);
assert!((hit.world.z - 20.0).abs() < 1e-4, "world hit z≈20");
}
#[test]
fn raycast_nearest_is_by_world_distance_across_scales() {
let mut scene = Scene::new();
let a = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 2.0));
let b = scene.add_grid(GridTransform::at_scale(DVec3::ZERO, 0.5));
scene
.grid_mut(a)
.unwrap()
.set_voxel(IVec3::new(0, 0, 6), Some(VoxColor(0x80_ff_00_00)));
scene
.grid_mut(b)
.unwrap()
.set_voxel(IVec3::new(2, 2, 20), Some(VoxColor(0x80_00_ff_00)));
let hit = scene
.raycast(DVec3::new(1.0, 1.0, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.expect("hits the world-nearer voxel");
assert_eq!(
hit.grid, b,
"grid B is nearer in WORLD units (10 < 12) though FARTHER in \
voxel-local units (20 > 6) — the t-conversion must decide by world"
);
assert!((hit.t - 10.0).abs() < 1e-4, "world t≈10, got {}", hit.t);
}
#[test]
fn add_grid_returns_fresh_ids() {
let mut scene = Scene::new();
let a = scene.add_grid(GridTransform::identity());
let b = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
assert_ne!(a, b);
assert_eq!(a.raw(), 0);
assert_eq!(b.raw(), 1);
assert_eq!(scene.grid_count(), 2);
}
#[test]
fn grid_lookup_round_trips() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
let g = scene.grid(id).expect("grid registered");
assert_eq!(g.transform.origin, DVec3::new(10.0, 20.0, 30.0));
assert_eq!(g.transform.rotation, DQuat::IDENTITY);
assert!(g.chunks.is_empty());
}
#[test]
fn remove_grid_drops_it_from_scene() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let removed = scene.remove_grid(id);
assert!(removed.is_some());
assert_eq!(scene.grid_count(), 0);
assert!(scene.grid(id).is_none());
let id2 = scene.add_grid(GridTransform::identity());
assert_ne!(id, id2);
assert_eq!(id2.raw(), 1);
}
#[test]
fn remove_unknown_grid_is_none() {
let mut scene = Scene::new();
let bogus = GridId(999);
assert!(scene.remove_grid(bogus).is_none());
}
#[test]
fn grid_mut_can_modify_transform() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
scene.grid_mut(id).unwrap().transform.origin = DVec3::new(1.0, 2.0, 3.0);
assert_eq!(
scene.grid(id).unwrap().transform.origin,
DVec3::new(1.0, 2.0, 3.0)
);
}
#[test]
fn chunk_size_constants_match_plan() {
assert_eq!(CHUNK_SIZE_XY, 128);
assert_eq!(CHUNK_SIZE_Z, 256);
}
#[test]
fn new_grid_defaults_to_always_near_lod() {
let g = Grid::new(GridTransform::identity());
assert_eq!(g.lod_thresholds.r_near, f64::INFINITY);
assert_eq!(g.lod_thresholds.r_mid, f64::INFINITY);
assert_eq!(g.select_lod(DVec3::new(1e9, 0.0, 0.0)), Lod::Near);
}
#[test]
fn bounding_radius_empty_grid_is_zero() {
let g = Grid::new(GridTransform::identity());
assert_eq!(g.bounding_radius(), 0.0);
}
#[test]
fn bounding_radius_single_chunk_at_origin() {
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(VoxColor(0x80_88_88_88)));
let r = g.bounding_radius();
let expected = ((64.0_f64).powi(2) * 2.0 + (128.0_f64).powi(2)).sqrt();
assert!(
(r - expected).abs() < 1e-9,
"bounding_radius={r} expected={expected}"
);
}
#[test]
fn sc3_bounding_radius_is_world_scaled() {
let mut scene = Scene::new();
let id = scene.add_grid(crate::GridTransform::at_scale(DVec3::ZERO, 3.0));
let g = scene.grid_mut(id).unwrap();
g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_88_88_88)));
let voxel_half = ((64.0_f64).powi(2) * 2.0 + (128.0_f64).powi(2)).sqrt();
let r = g.bounding_radius();
assert!(
(r - voxel_half * 3.0).abs() < 1e-9,
"world radius must be voxel half-extent × vws: got {r}"
);
g.lod_thresholds = crate::LodThresholds::from_radius(r);
assert_eq!(
g.select_lod(DVec3::new(r * 0.5, 0.0, 0.0)),
crate::Lod::Near
);
assert_eq!(
g.select_lod(DVec3::new(r * 2.0, 0.0, 0.0)),
crate::Lod::Mid,
"tier must flip at the WORLD (scaled) distance"
);
}
#[test]
fn bounding_radius_grows_with_chunk_extent() {
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(VoxColor(0x80_88_88_88)));
g.set_voxel(IVec3::new(384, 0, 0), Some(VoxColor(0x80_88_88_88)));
assert_eq!(g.chunks.len(), 2);
let r = g.bounding_radius();
let expected = (256.0_f64.powi(2) + 64.0_f64.powi(2) + 128.0_f64.powi(2)).sqrt();
assert!(
(r - expected).abs() < 1e-9,
"bounding_radius={r} expected={expected}"
);
}
#[test]
fn grid_select_lod_respects_lod_thresholds_field() {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at(DVec3::new(100.0, 0.0, 0.0)));
let g = scene.grid_mut(id).unwrap();
g.lod_thresholds = LodThresholds {
r_near: 50.0,
r_mid: 200.0,
..LodThresholds::always_near()
};
assert_eq!(g.select_lod(DVec3::new(125.0, 0.0, 0.0)), Lod::Near);
assert_eq!(g.select_lod(DVec3::new(200.0, 0.0, 0.0)), Lod::Mid);
assert_eq!(g.select_lod(DVec3::new(600.0, 0.0, 0.0)), Lod::Far);
}
}