roxlap_scene/chunks.rs
1//! Sparse chunk storage helpers.
2//!
3//! A grid's [`Grid::chunks`] map holds populated chunks keyed by
4//! their `(chx, chy, chz)` index. A missing entry is an implicit
5//! all-air chunk; this module provides the constructor for fresh
6//! all-air chunks plus the `chunk` / `chunk_mut` / `ensure_chunk`
7//! lookup API.
8//!
9//! [`Grid::chunks`]: crate::Grid::chunks
10
11use glam::IVec3;
12use roxlap_formats::edit::{set_spans, Vspan};
13use roxlap_formats::vxl::Vxl;
14
15use crate::{Grid, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
16
17/// Bytes of edit-pool headroom reserved per chunk on creation.
18/// 256 bytes/column × 128² columns ≈ 4 MiB; a generous budget for
19/// runtime edits within a single chunk before [`voxalloc`] starts
20/// returning out-of-space. Tunable later if memory becomes an
21/// issue.
22///
23/// [`voxalloc`]: roxlap_formats::vxl::Vxl::voxalloc
24const CHUNK_EDIT_HEADROOM_PER_COLUMN: usize = 256;
25
26/// Construct a fresh all-air [`Vxl`] sized for one chunk
27/// (`vsid = CHUNK_SIZE_XY`).
28///
29/// Strategy mirrors `roxlap_cavegen::pack_dense_grid_to_vxl`: seed
30/// each column with one solid voxel at z=0 + implicit-solid below
31/// (the voxlap "loadnul" shape), then carve the entire z range to
32/// air via [`set_spans`]. Finishes with [`Vxl::reserve_edit_capacity`]
33/// so subsequent runtime edits don't need a separate upgrade pass.
34///
35/// This is the canonical empty-chunk constructor — every code
36/// path that materialises a sparse chunk goes through it (see
37/// [`Grid::ensure_chunk`]).
38pub(crate) fn empty_chunk_vxl() -> Vxl {
39 let vsid = CHUNK_SIZE_XY;
40 let n_cols = (vsid as usize) * (vsid as usize);
41
42 // 1. Seed: every column = 4-byte slab header + 1 colour. Colour
43 // is irrelevant — the whole column gets carved below.
44 let mut data: Vec<u8> = Vec::with_capacity(n_cols * 8);
45 let mut column_offset: Vec<u32> = Vec::with_capacity(n_cols + 1);
46 for _ in 0..n_cols {
47 column_offset.push(u32::try_from(data.len()).expect("offset fits in u32"));
48 data.extend_from_slice(&[0, 0, 0, 0]); // header
49 data.extend_from_slice(&[0, 0, 0, 0]); // 1 placeholder colour
50 }
51 column_offset.push(u32::try_from(data.len()).expect("offset fits in u32"));
52
53 let mut vxl = Vxl {
54 vsid,
55 // Per-grid placement lives on `GridTransform`; the per-chunk
56 // Vxl's intrinsic camera fields are unused at this layer.
57 ipo: [0.0; 3],
58 ist: [1.0, 0.0, 0.0],
59 ihe: [0.0, 0.0, 1.0],
60 ifo: [0.0, 1.0, 0.0],
61 data: data.into_boxed_slice(),
62 column_offset: column_offset.into_boxed_slice(),
63 mip_base_offsets: Box::new([0, n_cols + 1]),
64 vbit: Box::new([]),
65 vbiti: 0,
66 };
67 vxl.reserve_edit_capacity(n_cols * CHUNK_EDIT_HEADROOM_PER_COLUMN);
68
69 // 2. Carve [0, 255] in every column to make it all-air.
70 // `Vspan.z1` is inclusive per voxlap's vspans convention.
71 let mut spans: Vec<Vspan> = Vec::with_capacity(n_cols);
72 for y in 0..vsid {
73 for x in 0..vsid {
74 spans.push(Vspan {
75 x,
76 y,
77 z0: 0,
78 z1: u8::MAX,
79 });
80 }
81 }
82 set_spans(&mut vxl, &spans, None);
83
84 vxl
85}
86
87/// True if voxel `(x, y, z)` is solid within one chunk's [`Vxl`] —
88/// i.e. covered by a solid run in column `(x, y)`. `(x, y)` are
89/// `< CHUNK_SIZE_XY`, `z < CHUNK_SIZE_Z`.
90///
91/// PF.6 — walks the slab chain in place with an early-out at `z`,
92/// mirroring `expandrle`'s run derivation ONE RUN AT A TIME instead of
93/// heap-allocating a 516-int buffer and decoding the whole column per
94/// query (this sits on every CPU shadow-ray step and every
95/// `Scene::raycast` step). Run k spans `[top_k, bot_k)` where `top_0 =
96/// slab[1]`, each following non-degenerate slab header closes the
97/// previous run at `slab[v+3]` and opens the next at `slab[v+1]`, and
98/// the final run extends to the column bottom — exactly the list
99/// `expandrle` would emit.
100#[allow(clippy::cast_possible_wrap)]
101pub(crate) fn vxl_voxel_solid(vxl: &Vxl, x: u32, y: u32, z: u32) -> bool {
102 let idx = (y * vxl.vsid + x) as usize;
103 let slab = vxl.column_data(idx);
104 let z = z as i32;
105 let mut top = i32::from(slab[1]);
106 let mut v = 0usize;
107 while slab[v] != 0 {
108 v += usize::from(slab[v]) * 4;
109 if slab[v + 3] >= slab[v + 1] {
110 // Degenerate slab (no air gap above): merges into the
111 // current run — same skip `expandrle` takes.
112 continue;
113 }
114 let bot = i32::from(slab[v + 3]);
115 if z < bot {
116 // z is above this run's bottom: solid iff inside the run
117 // (below `top` would be the preceding air gap).
118 return z >= top;
119 }
120 top = i32::from(slab[v + 1]);
121 }
122 // Last run extends to the column bottom (bedrock).
123 z >= top
124}
125
126/// PF.6 — chunk-cached solid sampler for DDA marches. Consecutive steps
127/// almost always stay inside one chunk; caching the last `chunks`
128/// HashMap probe turns the per-step cost into one compare + the in-place
129/// slab walk. `chunk_at` exposes presence so callers can skip absent
130/// (all-air) chunks wholesale.
131pub(crate) struct SolidSampler<'a> {
132 grid: &'a Grid,
133 cached_idx: IVec3,
134 cached: Option<&'a Vxl>,
135 primed: bool,
136}
137
138impl<'a> SolidSampler<'a> {
139 /// The chunk holding grid-local voxel-space chunk index `chunk_idx`,
140 /// through the one-entry cache.
141 pub(crate) fn chunk_at(&mut self, chunk_idx: IVec3) -> Option<&'a Vxl> {
142 if !self.primed || chunk_idx != self.cached_idx {
143 self.cached = self.grid.chunk(chunk_idx);
144 self.cached_idx = chunk_idx;
145 self.primed = true;
146 }
147 self.cached
148 }
149}
150
151/// What [`Grid::bake`] / [`Grid::bake_bbox`] write into the per-voxel
152/// brightness byte (QE-B6 — replaces the voxlap magic-`u32` lightmode).
153#[derive(Debug, Clone, Copy, PartialEq)]
154pub enum BakeMode {
155 /// Directional estnorm shading (voxlap lightmode 1) — the classic
156 /// standalone look for hosts that don't run a dynamic light rig.
157 Directional,
158 /// Ambient occlusion (lightmode 3): crevices and inner corners
159 /// darken. The right bake *under* a runtime `LightRig`, which
160 /// reads the byte as its ambient/AO fill. Carries its tuning
161 /// parameters — unlike the deprecated `bake_lightmode_bbox`,
162 /// [`Grid::bake_bbox`] honours them.
163 AmbientOcclusion(roxlap_core::AoParams),
164 /// EV.3 — voxlap's point-light bake (lightmode 2): a **dim**
165 /// directional base (about a quarter of
166 /// [`Directional`](Self::Directional)'s) plus a cube-law
167 /// Lambertian pool around every light in
168 /// [`Grid::bake_lights`] — glowing crystals, torches, lava.
169 /// [`Grid::bake_bbox`] (the carve-relight primitive) picks the
170 /// grid's lights up automatically, so incremental edits keep
171 /// their glow pools. The dark base is the point: light pools
172 /// read against gloom.
173 PointLights,
174}
175
176impl BakeMode {
177 /// The voxlap wire value the bake internals consume.
178 fn lightmode(self) -> u32 {
179 match self {
180 Self::Directional => 1,
181 Self::AmbientOcclusion(_) => 3,
182 Self::PointLights => 2,
183 }
184 }
185
186 /// The AO parameters (defaults for the non-AO modes, where the
187 /// bake ignores them).
188 fn ao(self) -> roxlap_core::AoParams {
189 match self {
190 Self::Directional | Self::PointLights => roxlap_core::AoParams::default(),
191 Self::AmbientOcclusion(ao) => ao,
192 }
193 }
194}
195
196/// EV.3 — one baked point light (voxlap's `lightsrc[]`), consumed by
197/// [`BakeMode::PointLights`] from [`Grid::bake_lights`]. Baked, not
198/// dynamic: its Lambertian pool is written into the per-voxel
199/// brightness bytes by [`Grid::bake`] / [`Grid::bake_bbox`] and costs
200/// nothing at render time (both backends just read the byte). For a
201/// flickering/moving light use the runtime `LightRig` instead.
202#[derive(Debug, Clone, Copy, PartialEq)]
203pub struct BakeLight {
204 /// Grid-local voxel-space position (the same frame as
205 /// [`Grid::set_voxel`]).
206 pub pos: glam::Vec3,
207 /// Hard cutoff radius in voxels — the cube-law contribution fades
208 /// to exactly zero here.
209 pub radius: f32,
210 /// Voxlap brightness scale: the byte gain at distance `d` is
211 /// roughly `strength · cosθ / d²` (cube-law falloff × Lambert), on
212 /// the 0–255 brightness-byte scale whose neutral is 128. A wall 5
213 /// voxels away therefore gains about `strength / 25` — `2000` is a
214 /// solid reading-torch, `8000` floods a small cavern.
215 pub strength: f32,
216}
217
218impl BakeLight {
219 /// This light rebased into `chunk_idx`'s chunk-local frame as the
220 /// bake-internal [`roxlap_core::LightSrc`], or `None` when its
221 /// influence sphere misses the chunk's voxel box entirely.
222 #[allow(clippy::cast_possible_wrap, clippy::cast_precision_loss)]
223 fn chunk_local(&self, chunk_idx: IVec3) -> Option<roxlap_core::LightSrc> {
224 let base = glam::Vec3::new(
225 (chunk_idx.x * CHUNK_SIZE_XY as i32) as f32,
226 (chunk_idx.y * CHUNK_SIZE_XY as i32) as f32,
227 (chunk_idx.z * CHUNK_SIZE_Z as i32) as f32,
228 );
229 let local = self.pos - base;
230 // Sphere-vs-chunk-AABB cull: nearest box point to the light.
231 let ext = glam::Vec3::new(
232 CHUNK_SIZE_XY as f32,
233 CHUNK_SIZE_XY as f32,
234 CHUNK_SIZE_Z as f32,
235 );
236 let nearest = local.clamp(glam::Vec3::ZERO, ext);
237 if (nearest - local).length_squared() > self.radius * self.radius {
238 return None;
239 }
240 Some(roxlap_core::LightSrc {
241 pos: [local.x, local.y, local.z],
242 r2: self.radius * self.radius,
243 sc: self.strength,
244 })
245 }
246}
247
248impl Grid {
249 /// True if the grid-local integer voxel `voxel` is solid (inside a
250 /// solid run of its chunk). An implicit-air or absent chunk reads
251 /// as `false`. `voxel` is a grid-local voxel coordinate
252 /// (pre-transform) — get one from a world point via
253 /// [`crate::world_to_grid_local`] + [`crate::voxel_global`]. Useful
254 /// for picking, collision, and world queries.
255 #[must_use]
256 pub fn voxel_solid(&self, voxel: IVec3) -> bool {
257 let (chunk_idx, in_chunk) = crate::voxel_split(voxel);
258 match self.chunk(chunk_idx) {
259 Some(vxl) => vxl_voxel_solid(vxl, in_chunk.x, in_chunk.y, in_chunk.z),
260 None => false,
261 }
262 }
263
264 /// AU.0 — the chunk-local half of [`Self::voxel_solid`], for
265 /// external DDA marches with strong chunk locality: split the
266 /// voxel with [`crate::voxel_split`], borrow the chunk once via
267 /// [`Self::chunk`], and test cells against the borrow — one
268 /// HashMap probe per chunk instead of per voxel. `in_chunk` is
269 /// the chunk-local coordinate `voxel_split` returned.
270 #[must_use]
271 pub fn chunk_voxel_solid(vxl: &Vxl, in_chunk: glam::UVec3) -> bool {
272 vxl_voxel_solid(vxl, in_chunk.x, in_chunk.y, in_chunk.z)
273 }
274
275 /// PF.6 — a chunk-cached [`SolidSampler`] over this grid, for DDA
276 /// marches that issue many [`Self::voxel_solid`]-style queries with
277 /// strong chunk locality (shadow rays, `Scene::raycast`).
278 pub(crate) fn solid_sampler(&self) -> SolidSampler<'_> {
279 SolidSampler {
280 grid: self,
281 cached_idx: IVec3::ZERO,
282 cached: None,
283 primed: false,
284 }
285 }
286
287 /// Packed BGRA colour of the textured voxel at grid-local `voxel`,
288 /// or `None` for air / untextured cells. Thin wrapper over
289 /// [`roxlap_formats::vxl::Vxl::voxel_color`] after the chunk split —
290 /// the colour-inspection companion to [`Self::voxel_solid`]. Use it
291 /// to read back what a pick / raycast hit looks like.
292 #[must_use]
293 pub fn voxel_color(&self, voxel: IVec3) -> Option<roxlap_formats::color::VoxColor> {
294 let (chunk_idx, in_chunk) = crate::voxel_split(voxel);
295 self.chunk(chunk_idx)?
296 .voxel_color(in_chunk.x, in_chunk.y, in_chunk.z)
297 }
298
299 /// Borrow the chunk at `chunk_idx` if it has been materialised.
300 /// `None` means the chunk is implicitly all-air.
301 #[must_use]
302 pub fn chunk(&self, chunk_idx: IVec3) -> Option<&Vxl> {
303 self.chunks.get(&chunk_idx)
304 }
305
306 /// Bake per-voxel lighting (voxlap `updatevxl`/`estnorm` shading)
307 /// into every materialised chunk's brightness bytes, in place.
308 /// Both the CPU rasteriser and the GPU marcher read these
309 /// pre-baked brightness bytes, so call this once after building a
310 /// grid and again over edited chunks after a carve (then bump
311 /// their versions so the GPU re-uploads — edits already do, via
312 /// [`Grid::set_voxel`] &c.). For small runtime edits prefer
313 /// [`Self::bake_bbox`] — it re-bakes only the touched columns.
314 ///
315 /// Each chunk is baked neighbour-aware in all three axes: estnorm's
316 /// (and AO's) ±2-voxel padding that crosses a chunk face reads the
317 /// actual neighbour chunk (when populated) — XY neighbours and, for
318 /// stacked grids, the chunks above/below on `chz±1` — so brightness
319 /// / occlusion is continuous across every seam. Under
320 /// [`BakeMode::PointLights`] the grid's [`Grid::bake_lights`] are
321 /// applied (EV.3); the other modes ignore them. No-op for an empty
322 /// grid.
323 pub fn bake(&mut self, mode: BakeMode) {
324 self.bake_u32(mode.lightmode(), mode.ao());
325 }
326
327 /// QE-B6 — magic `u32` lightmode; use [`Self::bake`] with a typed
328 /// [`BakeMode`] instead (`1` → `BakeMode::Directional`, `3` →
329 /// `BakeMode::AmbientOcclusion(AoParams::default())`).
330 #[deprecated(since = "0.23.0", note = "use `bake(BakeMode::..)`")]
331 pub fn bake_lightmode(&mut self, lightmode: u32) {
332 self.bake_u32(lightmode, roxlap_core::AoParams::default());
333 }
334
335 /// QE-B6 — use [`Self::bake`] with
336 /// `BakeMode::AmbientOcclusion(ao)` instead.
337 #[deprecated(since = "0.23.0", note = "use `bake(BakeMode::AmbientOcclusion(ao))`")]
338 pub fn bake_lightmode_with_ao(&mut self, lightmode: u32, ao: roxlap_core::AoParams) {
339 self.bake_u32(lightmode, ao);
340 }
341
342 fn bake_u32(&mut self, lightmode: u32, ao: roxlap_core::AoParams) {
343 if lightmode == 0 {
344 return;
345 }
346 #[allow(clippy::cast_possible_wrap)]
347 let cs_xy = CHUNK_SIZE_XY as i32;
348 #[allow(clippy::cast_possible_wrap)]
349 let cs_z = CHUNK_SIZE_Z as i32;
350 let chunk_idxs: Vec<IVec3> = self.chunks.keys().copied().collect();
351 // PF.11 — wave-parallel cache phase: each chunk's estnorm cache
352 // reads the grid immutably and is independent of the others, so a
353 // wave of `current_num_threads` caches builds in parallel; the
354 // apply phase (itself row-parallel inside
355 // `apply_lighting_with_cache`) then runs per chunk. Waves bound
356 // the resident cache memory. Byte-identical to the serial bake —
357 // caches are pure functions of the (unmodified-during-the-wave)
358 // grid, and each apply writes only its own chunk.
359 use rayon::prelude::*;
360 let wave = rayon::current_num_threads().max(1);
361 for batch in chunk_idxs.chunks(wave) {
362 let caches: Vec<(IVec3, roxlap_core::EstNormCache)> = batch
363 .par_iter()
364 .map(|&chunk_idx| {
365 (
366 chunk_idx,
367 self.build_chunk_estnorm_cache(chunk_idx, 0, 0, cs_xy, cs_xy),
368 )
369 })
370 .collect();
371 for (chunk_idx, cache) in caches {
372 // EV.3 — lightmode 2 pulls the grid's baked point
373 // lights, rebased chunk-local + sphere-culled; the
374 // other modes ignore lights, so skip the translation.
375 let lights = self.chunk_bake_lights(chunk_idx, lightmode);
376 let target = self.chunks.get_mut(&chunk_idx).expect("populated chunk");
377 roxlap_core::apply_lighting_with_cache(
378 &mut target.data,
379 &target.column_offset,
380 CHUNK_SIZE_XY,
381 0,
382 0,
383 0,
384 cs_xy,
385 cs_xy,
386 cs_z,
387 &cache,
388 lightmode,
389 &lights,
390 ao,
391 );
392 // PF.12 — a full bake rewrites every brightness byte;
393 // bump the version (whole-chunk extent) so version-keyed
394 // consumers (the GPU dirty-chunk poller, the FW opacity /
395 // light cache) re-read. `bake_bbox` already does this;
396 // the full bake omitting it left a re-bake invisible to
397 // those caches (FW light-gate rooms stayed dark).
398 self.bump_chunk_version(chunk_idx);
399 }
400 }
401 }
402
403 /// PF.11 — re-bake lighting over just the grid-local voxel bbox
404 /// `[lo, hi]` (inclusive), neighbour-aware across chunk seams in all
405 /// three axes: the write region is padded by `±ESTNORMRAD` internally
406 /// (an edit changes the estnorm of nearby voxels too — pass only the
407 /// geometric edit extent, mirroring `update_lighting`'s convention),
408 /// and any chunk the padded region touches gets its strip re-baked.
409 /// Touched chunks get their versions bumped so the GPU re-uploads.
410 ///
411 /// This is the runtime-edit primitive the full-grid
412 /// [`Self::bake_lightmode`] is far too heavy for: a bullet-hole
413 /// rebake touches a few hundred columns instead of a whole chunk
414 /// (the cave demo measured ~0.04 ms vs 4–7 ms). Mip regeneration is
415 /// NOT performed — near-field renders read mip 0; callers streaming
416 /// distant edited chunks should remip as they already do for edits.
417 #[allow(clippy::cast_possible_wrap)]
418 pub fn bake_bbox(&mut self, lo: IVec3, hi: IVec3, mode: BakeMode) {
419 self.bake_bbox_u32(lo, hi, mode.lightmode(), mode.ao());
420 }
421
422 /// QE-B6 — magic `u32` lightmode, and this variant silently baked
423 /// AO with default params. Use [`Self::bake_bbox`] with a typed
424 /// [`BakeMode`] (which honours `AmbientOcclusion`'s params).
425 #[deprecated(since = "0.23.0", note = "use `bake_bbox(lo, hi, BakeMode::..)`")]
426 pub fn bake_lightmode_bbox(&mut self, lo: IVec3, hi: IVec3, lightmode: u32) {
427 self.bake_bbox_u32(lo, hi, lightmode, roxlap_core::AoParams::default());
428 }
429
430 #[allow(clippy::cast_possible_wrap)]
431 fn bake_bbox_u32(&mut self, lo: IVec3, hi: IVec3, lightmode: u32, ao: roxlap_core::AoParams) {
432 if lightmode == 0 {
433 return;
434 }
435 let cs_xy = CHUNK_SIZE_XY as i32;
436 let cs_z = CHUNK_SIZE_Z as i32;
437 let pad = roxlap_core::ESTNORMRAD;
438 // Padded half-open apply region in grid-local voxel coords.
439 let a_lo = IVec3::new(lo.x - pad, lo.y - pad, lo.z - pad);
440 let a_hi = IVec3::new(hi.x + pad + 1, hi.y + pad + 1, hi.z + pad + 1);
441 if a_lo.x >= a_hi.x || a_lo.y >= a_hi.y || a_lo.z >= a_hi.z {
442 return;
443 }
444 // Chunk range the padded region touches (inclusive).
445 let c_lo = IVec3::new(
446 a_lo.x.div_euclid(cs_xy),
447 a_lo.y.div_euclid(cs_xy),
448 a_lo.z.div_euclid(cs_z),
449 );
450 let c_hi = IVec3::new(
451 (a_hi.x - 1).div_euclid(cs_xy),
452 (a_hi.y - 1).div_euclid(cs_xy),
453 (a_hi.z - 1).div_euclid(cs_z),
454 );
455 for chz in c_lo.z..=c_hi.z {
456 for chy in c_lo.y..=c_hi.y {
457 for chx in c_lo.x..=c_hi.x {
458 let chunk_idx = IVec3::new(chx, chy, chz);
459 if !self.chunks.contains_key(&chunk_idx) {
460 continue;
461 }
462 // Clip the padded region to this chunk, chunk-local.
463 let base = IVec3::new(chx * cs_xy, chy * cs_xy, chz * cs_z);
464 let lx0 = (a_lo.x - base.x).max(0);
465 let ly0 = (a_lo.y - base.y).max(0);
466 let lz0 = (a_lo.z - base.z).max(0);
467 let lx1 = (a_hi.x - base.x).min(cs_xy);
468 let ly1 = (a_hi.y - base.y).min(cs_xy);
469 let lz1 = (a_hi.z - base.z).min(cs_z);
470 if lx0 >= lx1 || ly0 >= ly1 || lz0 >= lz1 {
471 continue;
472 }
473 let cache = self.build_chunk_estnorm_cache(chunk_idx, lx0, ly0, lx1, ly1);
474 // EV.3 — carve relights keep their glow pools: the
475 // grid's baked lights flow into the bbox re-bake too.
476 let lights = self.chunk_bake_lights(chunk_idx, lightmode);
477 let target = self.chunks.get_mut(&chunk_idx).expect("populated chunk");
478 roxlap_core::apply_lighting_with_cache(
479 &mut target.data,
480 &target.column_offset,
481 CHUNK_SIZE_XY,
482 lx0,
483 ly0,
484 lz0,
485 lx1,
486 ly1,
487 lz1,
488 &cache,
489 lightmode,
490 &lights,
491 ao,
492 );
493 // PF.12 — brightness bytes changed within the clipped
494 // region only.
495 self.bump_chunk_version_bbox(
496 chunk_idx,
497 IVec3::new(lx0, ly0, lz0),
498 IVec3::new(lx1 - 1, ly1 - 1, lz1 - 1),
499 );
500 }
501 }
502 }
503 }
504
505 /// EV.3 — the grid's [`BakeLight`]s that reach `chunk_idx`, rebased
506 /// chunk-local for the bake internals. Empty (no allocation beyond
507 /// the `Vec` header) for the non-point-light modes and for chunks
508 /// outside every light's influence sphere.
509 fn chunk_bake_lights(&self, chunk_idx: IVec3, lightmode: u32) -> Vec<roxlap_core::LightSrc> {
510 if lightmode != 2 || self.bake_lights.is_empty() {
511 return Vec::new();
512 }
513 self.bake_lights
514 .iter()
515 .filter_map(|l| l.chunk_local(chunk_idx))
516 .collect()
517 }
518
519 /// PF.11 — build the neighbour-aware estnorm cache for a chunk-local
520 /// region of `chunk_idx` from an immutable grid borrow: the reader
521 /// resolves a chunk-local `(px, py)` (which may extend `±ESTNORMRAD`
522 /// outside the region / the target chunk) into the neighbour chunk
523 /// owning that column, and `chz_delta` walks the stacked neighbour in
524 /// z (continuous AO + estnorm across every seam). Padding over an
525 /// unpopulated neighbour returns `None` (= treated as air).
526 #[allow(clippy::cast_possible_wrap)]
527 fn build_chunk_estnorm_cache(
528 &self,
529 chunk_idx: IVec3,
530 x0: i32,
531 y0: i32,
532 x1: i32,
533 y1: i32,
534 ) -> roxlap_core::EstNormCache {
535 let cs_xy = CHUNK_SIZE_XY as i32;
536 let reader = |px: i32, py: i32, chz_delta: i32| -> Option<&[u8]> {
537 let nb_chx = chunk_idx.x + px.div_euclid(cs_xy);
538 let nb_chy = chunk_idx.y + py.div_euclid(cs_xy);
539 let in_x = px.rem_euclid(cs_xy);
540 let in_y = py.rem_euclid(cs_xy);
541 let chunk = self.chunk(IVec3::new(nb_chx, nb_chy, chunk_idx.z + chz_delta))?;
542 let col_idx = (in_y as u32) * CHUNK_SIZE_XY + (in_x as u32);
543 let off = chunk.column_offset[col_idx as usize] as usize;
544 Some(&chunk.data[off..])
545 };
546 roxlap_core::EstNormCache::build_with_reader_z(reader, x0, y0, x1, y1)
547 }
548
549 /// Mutably borrow a materialised chunk. Returns `None` for
550 /// implicit-air chunks; use [`Grid::ensure_chunk`] when you
551 /// need a `&mut Vxl` for an edit that may write voxels.
552 pub fn chunk_mut(&mut self, chunk_idx: IVec3) -> Option<&mut Vxl> {
553 self.chunks.get_mut(&chunk_idx)
554 }
555
556 /// Borrow `chunk_idx`'s [`Vxl`], creating an empty all-air
557 /// chunk first if it doesn't exist yet. The returned `&mut`
558 /// is valid for editing via [`roxlap_formats::edit`] — the new
559 /// chunk has [`Vxl::reserve_edit_capacity`] already applied.
560 pub fn ensure_chunk(&mut self, chunk_idx: IVec3) -> &mut Vxl {
561 // PF.13 (H9) — materialising a chunk mutates the chunk set.
562 if !self.chunks.contains_key(&chunk_idx) {
563 self.note_chunk_set_changed();
564 }
565 self.chunks.entry(chunk_idx).or_insert_with(empty_chunk_vxl)
566 }
567
568 /// Number of materialised chunks. Implicit-air chunks don't
569 /// count.
570 #[must_use]
571 pub fn chunk_count(&self) -> usize {
572 self.chunks.len()
573 }
574
575 /// S4B.2.c.3: build a per-chunk [`roxlap_core::GridView`] table
576 /// over this grid's XY chunk footprint at `chz = 0`.
577 ///
578 /// Returns `None` if no chz=0 chunk is populated (the entire
579 /// grid would render as implicit air anyway).
580 ///
581 /// Iterates `chunks` once to find the chx/chy bounding box,
582 /// then a second time to fill the row-major
583 /// `Vec<Option<GridView<'_>>>`. Empty XY slots (implicit-air
584 /// chunks inside the box) get `None`.
585 ///
586 /// Pair with [`roxlap_core::ChunkGrid`] + [`roxlap_core::
587 /// GridView::from_chunk_grid`] to drive the Approach B render
588 /// path:
589 ///
590 /// ```ignore
591 /// let backing = grid.chunk_xy_backing().unwrap();
592 /// let cg = roxlap_core::ChunkGrid {
593 /// chunks: &backing.chunks,
594 /// origin_chunk_xy: backing.origin_chunk_xy,
595 /// chunks_x: backing.chunks_x,
596 /// chunks_y: backing.chunks_y,
597 /// };
598 /// let view = roxlap_core::GridView::from_chunk_grid(
599 /// &cg, crate::CHUNK_SIZE_XY,
600 /// );
601 /// ```
602 ///
603 /// Only chz=0 chunks contribute (multi-z handoff lands in
604 /// S4B.3); higher-chz chunks in [`Self::chunks`] are ignored
605 /// here.
606 #[must_use]
607 pub fn chunk_xy_backing(&self) -> Option<ChunkXyBacking<'_>> {
608 let mut min_x = i32::MAX;
609 let mut min_y = i32::MAX;
610 let mut max_x = i32::MIN;
611 let mut max_y = i32::MIN;
612 let mut any = false;
613 for chunk_idx in self.chunks.keys() {
614 if chunk_idx.z != 0 {
615 continue;
616 }
617 min_x = min_x.min(chunk_idx.x);
618 min_y = min_y.min(chunk_idx.y);
619 max_x = max_x.max(chunk_idx.x);
620 max_y = max_y.max(chunk_idx.y);
621 any = true;
622 }
623 if !any {
624 return None;
625 }
626 #[allow(clippy::cast_sign_loss)]
627 let chunks_x = (max_x - min_x + 1) as u32;
628 #[allow(clippy::cast_sign_loss)]
629 let chunks_y = (max_y - min_y + 1) as u32;
630 let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
631 vec![None; (chunks_x * chunks_y) as usize];
632 for (chunk_idx, vxl) in &self.chunks {
633 if chunk_idx.z != 0 {
634 continue;
635 }
636 let dx = chunk_idx.x - min_x;
637 let dy = chunk_idx.y - min_y;
638 #[allow(clippy::cast_sign_loss)]
639 let i = (dy as u32 * chunks_x + dx as u32) as usize;
640 table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
641 }
642 Some(ChunkXyBacking {
643 chunks: table,
644 origin_chunk_xy: [min_x, min_y],
645 origin_chunk_z: 0,
646 chunks_x,
647 chunks_y,
648 chunks_z: 1,
649 })
650 }
651
652 /// S4B.6.a: 3D-aware version of [`Self::chunk_xy_backing`].
653 /// Enumerates ALL chunks across the chx/chy/chz bounding box
654 /// (not just `chz=0`) so a stacked grid can be rendered once
655 /// S4B.6.c switches the rasterizer to a chunk-z-aware column
656 /// walker.
657 ///
658 /// Iterates `chunks` once for the XYZ bbox, then a second time
659 /// to fill the row-major-per-z `Vec<Option<GridView<'_>>>`.
660 /// Index layout: `[(dz * chunks_y + dy) * chunks_x + dx]` —
661 /// matches [`roxlap_core::ChunkGrid`]'s indexing exactly.
662 ///
663 /// Returns `None` for empty grids.
664 #[must_use]
665 pub fn chunk_xyz_backing(&self) -> Option<ChunkXyBacking<'_>> {
666 let mut min_x = i32::MAX;
667 let mut min_y = i32::MAX;
668 let mut min_z = i32::MAX;
669 let mut max_x = i32::MIN;
670 let mut max_y = i32::MIN;
671 let mut max_z = i32::MIN;
672 let mut any = false;
673 for chunk_idx in self.chunks.keys() {
674 min_x = min_x.min(chunk_idx.x);
675 min_y = min_y.min(chunk_idx.y);
676 min_z = min_z.min(chunk_idx.z);
677 max_x = max_x.max(chunk_idx.x);
678 max_y = max_y.max(chunk_idx.y);
679 max_z = max_z.max(chunk_idx.z);
680 any = true;
681 }
682 if !any {
683 return None;
684 }
685 #[allow(clippy::cast_sign_loss)]
686 let chunks_x = (max_x - min_x + 1) as u32;
687 #[allow(clippy::cast_sign_loss)]
688 let chunks_y = (max_y - min_y + 1) as u32;
689 #[allow(clippy::cast_sign_loss)]
690 let chunks_z = (max_z - min_z + 1) as u32;
691 let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
692 vec![None; (chunks_x * chunks_y * chunks_z) as usize];
693 for (chunk_idx, vxl) in &self.chunks {
694 let dx = chunk_idx.x - min_x;
695 let dy = chunk_idx.y - min_y;
696 let dz = chunk_idx.z - min_z;
697 #[allow(clippy::cast_sign_loss)]
698 let (dx, dy, dz) = (dx as u32, dy as u32, dz as u32);
699 let i = ((dz * chunks_y + dy) * chunks_x + dx) as usize;
700 table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
701 }
702 Some(ChunkXyBacking {
703 chunks: table,
704 origin_chunk_xy: [min_x, min_y],
705 origin_chunk_z: min_z,
706 chunks_x,
707 chunks_y,
708 chunks_z,
709 })
710 }
711}
712
713/// S4B.2.c.3: chx/chy chunk table built from a [`Grid`].
714///
715/// Owns the `Vec<Option<GridView>>` so [`roxlap_core::ChunkGrid`]
716/// (which borrows the table) can live alongside the GridView
717/// constructed from it. Used by the Approach B render path —
718/// see [`Grid::chunk_xy_backing`].
719///
720/// S4B.6.a: gained `chunks_z` + `origin_chunk_z` for tall-world
721/// support. Pre-S4B.6.a `chunk_xy_backing` always populates these
722/// as `chunks_z=1 origin_chunk_z=0` (= chz=0 only); S4B.6.c will
723/// switch the render path to `chunk_xyz_backing` once the
724/// rasterizer is stack-aware.
725pub struct ChunkXyBacking<'a> {
726 /// Per-chunk views over the chx/chy/chz extent.
727 /// Length `chunks_x * chunks_y * chunks_z`; index layout
728 /// `[(dz * chunks_y + dy) * chunks_x + dx]`. `None` for
729 /// implicit-air chunks inside the bbox.
730 pub chunks: Vec<Option<roxlap_core::GridView<'a>>>,
731 /// XY index of the chunk at `chunks[0]` — the minimum chx/chy
732 /// among populated chunks at `chz = origin_chunk_z`.
733 pub origin_chunk_xy: [i32; 2],
734 /// Z index of the chunk at `chunks[0]`.
735 pub origin_chunk_z: i32,
736 /// Number of chunks along the X axis. Row stride.
737 pub chunks_x: u32,
738 /// Number of chunks along the Y axis.
739 pub chunks_y: u32,
740 /// Number of chunks along the Z axis. `1` from
741 /// [`Grid::chunk_xy_backing`]; `>1` only via the S4B.6.a
742 /// [`Grid::chunk_xyz_backing`].
743 pub chunks_z: u32,
744}
745
746#[cfg(test)]
747pub(crate) mod tests {
748 use super::*;
749 use crate::{GridTransform, CHUNK_SIZE_Z};
750 use roxlap_formats::color::VoxColor;
751
752 /// Decode `column`'s slab bytes and return `true` iff `z` is
753 /// covered by any solid run. Mirrors voxlap's column-walk
754 /// semantics — the b2 buffer is `[top0, bot0, top1, bot1, ...,
755 /// MAXZDIM_sentinel]`, with each `[top, bot)` pair denoting a
756 /// solid range.
757 pub(crate) fn voxel_is_solid(vxl: &Vxl, x: u32, y: u32, z: u32) -> bool {
758 super::vxl_voxel_solid(vxl, x, y, z)
759 }
760
761 #[test]
762 fn voxel_solid_reflects_set_voxel() {
763 // Grid::voxel_solid (the public picking query) reads back an
764 // edit: the set voxel is solid, its neighbour is air, and an
765 // unmaterialised chunk reads as air.
766 let mut g = Grid::new(GridTransform::identity());
767 g.set_voxel(IVec3::new(5, 6, 7), Some(VoxColor(0x80_aa_bb_cc)));
768 assert!(g.voxel_solid(IVec3::new(5, 6, 7)), "set voxel is solid");
769 assert!(!g.voxel_solid(IVec3::new(5, 6, 8)), "neighbour is air");
770 assert!(
771 !g.voxel_solid(IVec3::new(900, 900, 7)),
772 "absent chunk reads as air",
773 );
774 }
775
776 #[test]
777 fn voxel_solid_handles_negative_coords() {
778 // Negative grid-local voxels decompose via div_euclid (addr
779 // semantics); the query must follow the same split.
780 let mut g = Grid::new(GridTransform::identity());
781 g.set_voxel(IVec3::new(-1, -1, 10), Some(VoxColor(0x80_11_22_33)));
782 assert!(g.voxel_solid(IVec3::new(-1, -1, 10)));
783 assert!(!g.voxel_solid(IVec3::new(-1, -1, 11)));
784 }
785
786 #[test]
787 fn empty_chunk_has_correct_vsid() {
788 let vxl = empty_chunk_vxl();
789 assert_eq!(vxl.vsid, CHUNK_SIZE_XY);
790 }
791
792 #[test]
793 fn empty_chunk_is_all_air() {
794 let vxl = empty_chunk_vxl();
795 // Sample a few representative voxels — full coverage is in
796 // `empty_chunk_no_voxel_solid_anywhere` below.
797 for &(x, y, z) in &[
798 (0u32, 0u32, 0u32),
799 (0, 0, 100),
800 (0, 0, 200),
801 (CHUNK_SIZE_XY - 1, CHUNK_SIZE_XY - 1, 0),
802 (64, 64, 128),
803 ] {
804 assert!(
805 !voxel_is_solid(&vxl, x, y, z),
806 "voxel ({x}, {y}, {z}) should be air"
807 );
808 }
809 }
810
811 #[test]
812 fn empty_chunk_air_above_bedrock_on_grid_sample() {
813 // Stride 16 across the chunk catches structural breakage
814 // (a corner column wrong, a z-band wrong, etc.) without the
815 // 4M-query cost of a brute-force scan in debug mode.
816 // Voxlap's slab format keeps z=255 solid as the "below the
817 // world" sentinel; the renderer's `treat_z_max_as_air` flag
818 // handles displaying it as transparent. See
819 // `project_below_bedrock_all_sky.md` for the S1.X fix.
820 let vxl = empty_chunk_vxl();
821 let bedrock_z = CHUNK_SIZE_Z - 1;
822 for y in (0..CHUNK_SIZE_XY).step_by(16) {
823 for x in (0..CHUNK_SIZE_XY).step_by(16) {
824 for z in (0..bedrock_z).step_by(16) {
825 assert!(
826 !voxel_is_solid(&vxl, x, y, z),
827 "voxel ({x}, {y}, {z}) leaked solid in empty chunk"
828 );
829 }
830 // bedrock z is solid (placeholder).
831 assert!(voxel_is_solid(&vxl, x, y, bedrock_z));
832 }
833 }
834 }
835
836 #[test]
837 fn empty_chunk_keeps_bedrock_placeholder() {
838 // Voxlap's invariant: every column carries an implicit
839 // solid voxel at z = MAXZDIM-1 = 255 even after a full
840 // carve. The renderer reads this as the bedrock placeholder.
841 let vxl = empty_chunk_vxl();
842 assert!(voxel_is_solid(&vxl, 0, 0, CHUNK_SIZE_Z - 1));
843 assert!(voxel_is_solid(&vxl, 64, 64, CHUNK_SIZE_Z - 1));
844 }
845
846 #[test]
847 fn ensure_chunk_creates_when_missing() {
848 let mut g = Grid::new(GridTransform::identity());
849 assert_eq!(g.chunk_count(), 0);
850 assert!(g.chunk(IVec3::ZERO).is_none());
851 let _ = g.ensure_chunk(IVec3::ZERO);
852 assert_eq!(g.chunk_count(), 1);
853 assert!(g.chunk(IVec3::ZERO).is_some());
854 }
855
856 #[test]
857 fn ensure_chunk_returns_existing() {
858 // Calling ensure_chunk a second time on the same index
859 // doesn't replace the chunk. Verify by writing through the
860 // first call and reading through the second.
861 let mut g = Grid::new(GridTransform::identity());
862 let chunk = IVec3::new(2, -1, 0);
863 g.ensure_chunk(chunk);
864 // Voxel local (5, 6, 7) inside chunk (2, -1, 0) is
865 // grid-local global (2*128 + 5, -1*128 + 6, 0*256 + 7) =
866 // (261, -122, 7).
867 g.set_voxel(IVec3::new(261, -122, 7), Some(VoxColor(0x80_aa_bb_cc)));
868 let vxl = g.ensure_chunk(chunk);
869 assert!(voxel_is_solid(vxl, 5, 6, 7));
870 assert_eq!(g.chunk_count(), 1);
871 }
872
873 #[test]
874 fn chunk_mut_returns_none_for_missing() {
875 let mut g = Grid::new(GridTransform::identity());
876 assert!(g.chunk_mut(IVec3::ZERO).is_none());
877 }
878
879 /// S4B.6.a: legacy `chunk_xy_backing` ignores chz!=0 chunks and
880 /// always returns `chunks_z=1 origin_chunk_z=0`. Sanity-check
881 /// the new field defaults so pre-S4B.6 render path stays
882 /// byte-identical.
883 #[test]
884 fn chunk_xy_backing_returns_chunks_z_one() {
885 let mut g = Grid::new(GridTransform::identity());
886 g.ensure_chunk(IVec3::new(0, 0, 0));
887 g.ensure_chunk(IVec3::new(1, 0, 0));
888 // Add a chunk at chz=1 — chunk_xy_backing should ignore it.
889 g.ensure_chunk(IVec3::new(0, 0, 1));
890 let backing = g.chunk_xy_backing().expect("two chz=0 chunks present");
891 assert_eq!(backing.chunks_z, 1);
892 assert_eq!(backing.origin_chunk_z, 0);
893 assert_eq!(backing.chunks_x, 2);
894 assert_eq!(backing.chunks_y, 1);
895 assert_eq!(backing.chunks.len(), 2);
896 }
897
898 /// S4B.6.a: new `chunk_xyz_backing` enumerates ALL chunks
899 /// including chz!=0. Indexing layout must match
900 /// `roxlap_core::ChunkGrid`: row-major per z slab.
901 #[test]
902 fn chunk_xyz_backing_with_stacked_chunks_enumerates_all_z() {
903 let mut g = Grid::new(GridTransform::identity());
904 g.ensure_chunk(IVec3::new(0, 0, 0));
905 g.ensure_chunk(IVec3::new(1, 0, 0));
906 g.ensure_chunk(IVec3::new(0, 0, 1));
907 // Leave (1, 0, 1) implicit-air.
908 let backing = g.chunk_xyz_backing().expect("at least one chunk");
909 assert_eq!(backing.chunks_x, 2);
910 assert_eq!(backing.chunks_y, 1);
911 assert_eq!(backing.chunks_z, 2);
912 assert_eq!(backing.origin_chunk_xy, [0, 0]);
913 assert_eq!(backing.origin_chunk_z, 0);
914 assert_eq!(backing.chunks.len(), 4); // dims [2, 1, 2]
915 // Index layout: [(dz * chunks_y + dy) * chunks_x + dx]
916 // (0, 0, 0) → dx=0, dy=0, dz=0 → 0
917 // (1, 0, 0) → dx=1, dy=0, dz=0 → 1
918 // (0, 0, 1) → dx=0, dy=0, dz=1 → 2
919 // (1, 0, 1) → dx=1, dy=0, dz=1 → 3 (implicit-air → None)
920 assert!(backing.chunks[0].is_some(), "(0, 0, 0) present");
921 assert!(backing.chunks[1].is_some(), "(1, 0, 0) present");
922 assert!(backing.chunks[2].is_some(), "(0, 0, 1) present");
923 assert!(backing.chunks[3].is_none(), "(1, 0, 1) implicit-air");
924 }
925
926 /// S4B.6.a: chunk_xyz_backing with negative chz origin —
927 /// origin_chunk_z = min_z must reflect the actual minimum chz.
928 #[test]
929 fn chunk_xyz_backing_with_negative_origin_chunk_z() {
930 let mut g = Grid::new(GridTransform::identity());
931 g.ensure_chunk(IVec3::new(0, 0, -2));
932 g.ensure_chunk(IVec3::new(0, 0, 0));
933 let backing = g.chunk_xyz_backing().expect("at least one chunk");
934 assert_eq!(backing.chunks_z, 3); // chz range [-2, 0]
935 assert_eq!(backing.origin_chunk_z, -2);
936 assert!(backing.chunks[0].is_some(), "chz=-2 → dz=0");
937 assert!(backing.chunks[1].is_none(), "chz=-1 → dz=1 implicit-air");
938 assert!(backing.chunks[2].is_some(), "chz=0 → dz=2");
939 }
940
941 /// PF.11 — `bake_lightmode_bbox` around an edit must reproduce, byte
942 /// for byte, what a full-grid re-bake would write: the padded write
943 /// region covers every voxel whose estnorm the edit could change
944 /// (±ESTNORMRAD), including strips in neighbour chunks across seams.
945 #[test]
946 fn bbox_rebake_matches_full_rebake() {
947 // Terrain spanning a 2×2 chunk seam, with relief so estnorm is
948 // non-trivial around the edit.
949 let build = || {
950 let mut g = Grid::new(GridTransform::identity());
951 g.set_rect(
952 IVec3::new(0, 0, 160),
953 IVec3::new(255, 255, 255),
954 Some(VoxColor(0x80_66_77_88)),
955 );
956 for i in 0..10 {
957 let (x, y) = (23 * i % 240 + 8, 37 * i % 240 + 8);
958 g.set_sphere(IVec3::new(x, y, 165), 6, None);
959 }
960 g.bake(BakeMode::Directional);
961 g
962 };
963 let mut full = build();
964 let mut bbox = build();
965
966 // Identical edit in both, straddling the chunk (0,0)/(1,0) seam
967 // so the neighbour-strip rebake is exercised.
968 let (lo, hi) = (IVec3::new(120, 60, 158), IVec3::new(136, 76, 200));
969 full.set_rect(lo, hi, None);
970 bbox.set_rect(lo, hi, None);
971
972 // Ground truth: full re-bake. Candidate: bbox-only re-bake.
973 full.bake(BakeMode::Directional);
974 bbox.bake_bbox(lo, hi, BakeMode::Directional);
975
976 for (idx, a) in &full.chunks {
977 let b = bbox.chunks.get(idx).expect("same chunk set");
978 assert_eq!(
979 a.data, b.data,
980 "chunk {idx:?} bytes diverge between full and bbox rebake",
981 );
982 }
983 }
984
985 /// EV.3 — `BakeMode::PointLights` writes a Lambertian pool around a
986 /// [`BakeLight`]: floor voxels under the light brighten, voxels
987 /// beyond its radius stay at the dim lightmode-2 base, and that
988 /// base is darker than the `Directional` bake.
989 #[test]
990 fn point_light_bake_brightens_near_light() {
991 let floor = |g: &mut Grid| {
992 g.set_rect(
993 IVec3::new(0, 0, 200),
994 IVec3::new(127, 127, 255),
995 Some(VoxColor(0x80_66_77_88)),
996 );
997 };
998 let byte_at = |g: &Grid, x: u32, y: u32| {
999 (g.chunk(IVec3::ZERO)
1000 .expect("chunk")
1001 .voxel_color(x, y, 200)
1002 .expect("floor voxel")
1003 .0
1004 >> 24)
1005 & 0xff
1006 };
1007
1008 let mut g = Grid::new(GridTransform::identity());
1009 floor(&mut g);
1010 // Crystal light hovering 8 voxels above the floor centre.
1011 g.bake_lights.push(BakeLight {
1012 pos: glam::Vec3::new(64.0, 64.0, 192.0),
1013 radius: 40.0,
1014 strength: 4000.0,
1015 });
1016 g.bake(BakeMode::PointLights);
1017 let near = byte_at(&g, 64, 64);
1018 let far = byte_at(&g, 4, 4); // ~85 voxels away > radius 40 ⇒ base only
1019 assert!(
1020 near > far + 20,
1021 "light pool must brighten the floor under it (near={near} far={far})"
1022 );
1023
1024 // The lightmode-2 base is deliberately dim vs the Directional bake.
1025 let mut dir = Grid::new(GridTransform::identity());
1026 floor(&mut dir);
1027 dir.bake(BakeMode::Directional);
1028 let dir_far = byte_at(&dir, 4, 4);
1029 assert!(
1030 far < dir_far,
1031 "PointLights base ({far}) must be dimmer than Directional ({dir_far})"
1032 );
1033 }
1034
1035 /// EV.3 — a bbox re-bake under `PointLights` must reproduce the
1036 /// full re-bake byte-for-byte, glow pools included (the carve
1037 /// relight path keeps crystal lighting intact).
1038 #[test]
1039 fn point_light_bbox_rebake_matches_full_rebake() {
1040 let build = || {
1041 let mut g = Grid::new(GridTransform::identity());
1042 g.set_rect(
1043 IVec3::new(0, 0, 160),
1044 IVec3::new(255, 255, 255),
1045 Some(VoxColor(0x80_66_77_88)),
1046 );
1047 g.bake_lights.push(BakeLight {
1048 pos: glam::Vec3::new(126.0, 70.0, 152.0),
1049 radius: 48.0,
1050 strength: 4000.0,
1051 });
1052 g.bake(BakeMode::PointLights);
1053 g
1054 };
1055 let mut full = build();
1056 let mut bbox = build();
1057
1058 // Carve inside the light's pool, straddling the chunk seam.
1059 let (lo, hi) = (IVec3::new(120, 60, 158), IVec3::new(136, 76, 200));
1060 full.set_rect(lo, hi, None);
1061 bbox.set_rect(lo, hi, None);
1062
1063 full.bake(BakeMode::PointLights);
1064 bbox.bake_bbox(lo, hi, BakeMode::PointLights);
1065
1066 for (idx, a) in &full.chunks {
1067 let b = bbox.chunks.get(idx).expect("same chunk set");
1068 assert_eq!(
1069 a.data, b.data,
1070 "chunk {idx:?} bytes diverge between full and bbox point-light rebake",
1071 );
1072 }
1073 }
1074
1075 /// PF.12 — `remip_bbox` must be byte-identical to a full
1076 /// `generate_mips` when the bbox covers the edits: data bytes,
1077 /// column offsets, and the mip table all match — across repeated
1078 /// incremental rounds, corner-of-chunk edits, and voxalloc-scattered
1079 /// columns.
1080 #[test]
1081 fn remip_bbox_matches_generate_mips() {
1082 use roxlap_formats::edit::{set_sphere_with_colfunc, SpanOp};
1083
1084 // Realistic chunk: terrain + caves, then a full initial ladder.
1085 let mut g = Grid::new(GridTransform::identity());
1086 g.set_rect(
1087 IVec3::new(0, 0, 150),
1088 IVec3::new(127, 127, 255),
1089 Some(VoxColor(0x80_66_77_88)),
1090 );
1091 for i in 0..8 {
1092 let (x, y) = (29 * i % 100 + 12, 41 * i % 100 + 12);
1093 g.set_sphere(IVec3::new(x, y, 158), 5, None);
1094 }
1095 let base = g.chunks.get_mut(&IVec3::ZERO).expect("chunk");
1096 base.generate_mips(6);
1097
1098 let mut full = base.clone();
1099 let mut inc = base.clone();
1100
1101 // Round 1: a carve straddling brick/column-group boundaries plus
1102 // a corner edit (exercises clamping at the chunk edge).
1103 let edits: [(IVec3, u32); 3] = [
1104 (IVec3::new(63, 64, 170), 7),
1105 (IVec3::new(0, 0, 155), 4),
1106 (IVec3::new(126, 100, 165), 5),
1107 ];
1108 for round in 0..2 {
1109 let mut lo = IVec3::splat(i32::MAX);
1110 let mut hi = IVec3::splat(i32::MIN);
1111 for (k, &(c, r)) in edits.iter().enumerate() {
1112 if k % 2 != round % 2 {
1113 continue; // vary the edit set per round
1114 }
1115 #[allow(clippy::cast_possible_wrap)]
1116 let ri = r as i32;
1117 for v in [&mut full, &mut inc] {
1118 set_sphere_with_colfunc(v, c.into(), r, SpanOp::Carve, |_, _, _| {
1119 VoxColor(0x80_31_41_59)
1120 });
1121 }
1122 lo = lo.min(c - IVec3::splat(ri));
1123 hi = hi.max(c + IVec3::splat(ri));
1124 }
1125 full.generate_mips(6);
1126 inc.remip_bbox(lo.x, lo.y, hi.x, hi.y, 6);
1127
1128 assert_eq!(
1129 full.mip_base_offsets, inc.mip_base_offsets,
1130 "round {round}: mip tables diverge",
1131 );
1132 assert_eq!(
1133 full.column_offset, inc.column_offset,
1134 "round {round}: column offsets diverge",
1135 );
1136 assert_eq!(full.data, inc.data, "round {round}: data bytes diverge");
1137 }
1138 }
1139
1140 /// PF.6 — the in-place slab walk in `vxl_voxel_solid` must agree
1141 /// with the `expandrle` run list (the pre-PF.6 reference decoder)
1142 /// for every z, on columns with multiple slabs (caves), single
1143 /// slabs, and untouched bedrock.
1144 #[test]
1145 fn vxl_voxel_solid_matches_expandrle_reference() {
1146 use roxlap_formats::edit::expandrle;
1147
1148 let mut g = Grid::new(GridTransform::identity());
1149 // Carve two separate air pockets into one column (multi-slab)
1150 // plus a sphere for varied neighbours.
1151 g.set_rect(IVec3::new(3, 4, 40), IVec3::new(4, 5, 60), None);
1152 g.set_rect(IVec3::new(3, 4, 100), IVec3::new(4, 5, 140), None);
1153 g.set_voxel(IVec3::new(3, 4, 120), Some(VoxColor(0x80_11_22_33))); // island inside the pocket
1154 g.set_sphere(IVec3::new(8, 8, 80), 6, None);
1155 let vxl = g.chunk(IVec3::ZERO).expect("chunk materialised");
1156
1157 let maxzdim = CHUNK_SIZE_Z as i32;
1158 for (x, y) in [(3u32, 4u32), (8, 8), (0, 0), (8, 2), (12, 8)] {
1159 // Reference: full expandrle decode → run-list scan.
1160 let column = vxl.column_data((y * vxl.vsid + x) as usize);
1161 let mut b2 = vec![maxzdim; 2 * (CHUNK_SIZE_Z as usize) + 4];
1162 expandrle(column, &mut b2);
1163 for z in 0..CHUNK_SIZE_Z {
1164 let zi = z as i32;
1165 let mut reference = false;
1166 let mut i = 0;
1167 while b2[i] < maxzdim {
1168 if zi >= b2[i] && zi < b2[i + 1] {
1169 reference = true;
1170 break;
1171 }
1172 i += 2;
1173 }
1174 assert_eq!(
1175 vxl_voxel_solid(vxl, x, y, z),
1176 reference,
1177 "column ({x}, {y}) z={z} disagrees with expandrle",
1178 );
1179 }
1180 }
1181 }
1182}