Skip to main content

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
151impl Grid {
152    /// True if the grid-local integer voxel `voxel` is solid (inside a
153    /// solid run of its chunk). An implicit-air or absent chunk reads
154    /// as `false`. `voxel` is a grid-local voxel coordinate
155    /// (pre-transform) — get one from a world point via
156    /// [`crate::world_to_grid_local`] + [`crate::voxel_global`]. Useful
157    /// for picking, collision, and world queries.
158    #[must_use]
159    pub fn voxel_solid(&self, voxel: IVec3) -> bool {
160        let (chunk_idx, in_chunk) = crate::voxel_split(voxel);
161        match self.chunk(chunk_idx) {
162            Some(vxl) => vxl_voxel_solid(vxl, in_chunk.x, in_chunk.y, in_chunk.z),
163            None => false,
164        }
165    }
166
167    /// PF.6 — a chunk-cached [`SolidSampler`] over this grid, for DDA
168    /// marches that issue many [`Self::voxel_solid`]-style queries with
169    /// strong chunk locality (shadow rays, `Scene::raycast`).
170    pub(crate) fn solid_sampler(&self) -> SolidSampler<'_> {
171        SolidSampler {
172            grid: self,
173            cached_idx: IVec3::ZERO,
174            cached: None,
175            primed: false,
176        }
177    }
178
179    /// Packed BGRA colour of the textured voxel at grid-local `voxel`,
180    /// or `None` for air / untextured cells. Thin wrapper over
181    /// [`roxlap_formats::vxl::Vxl::voxel_color`] after the chunk split —
182    /// the colour-inspection companion to [`Self::voxel_solid`]. Use it
183    /// to read back what a pick / raycast hit looks like.
184    #[must_use]
185    pub fn voxel_color(&self, voxel: IVec3) -> Option<u32> {
186        let (chunk_idx, in_chunk) = crate::voxel_split(voxel);
187        self.chunk(chunk_idx)?
188            .voxel_color(in_chunk.x, in_chunk.y, in_chunk.z)
189    }
190
191    /// Borrow the chunk at `chunk_idx` if it has been materialised.
192    /// `None` means the chunk is implicitly all-air.
193    #[must_use]
194    pub fn chunk(&self, chunk_idx: IVec3) -> Option<&Vxl> {
195        self.chunks.get(&chunk_idx)
196    }
197
198    /// Bake per-voxel lighting (voxlap `updatevxl`/`estnorm` shading)
199    /// into every materialised chunk's brightness bytes, in place.
200    /// `lightmode` is voxlap's mode (1 = directional estnorm shading,
201    /// the look the cave + terrain demos use). Both the CPU rasteriser
202    /// and the GPU marcher read these pre-baked brightness bytes, so
203    /// call this once after building a grid and again over edited
204    /// chunks after a carve (then bump their versions so the GPU
205    /// re-uploads — edits already do, via [`Grid::set_voxel`] &c.).
206    ///
207    /// Each chunk is baked neighbour-aware in all three axes: estnorm's
208    /// (and AO's) ±2-voxel padding that crosses a chunk face reads the
209    /// actual neighbour chunk (when populated) — XY neighbours and, for
210    /// stacked grids, the chunks above/below on `chz±1` — so brightness
211    /// / occlusion is continuous across every seam. Point lights aren't
212    /// applied (directional-only) — matching the demos' bake. No-op for
213    /// an empty grid.
214    pub fn bake_lightmode(&mut self, lightmode: u32) {
215        self.bake_lightmode_with_ao(lightmode, roxlap_core::AoParams::default());
216    }
217
218    /// [`Self::bake_lightmode`] with explicit [`AoParams`] — the AO bake
219    /// (lightmode 3) tunes `strength`/`radius` through these.
220    ///
221    /// [`AoParams`]: roxlap_core::AoParams
222    pub fn bake_lightmode_with_ao(&mut self, lightmode: u32, ao: roxlap_core::AoParams) {
223        if lightmode == 0 {
224            return;
225        }
226        #[allow(clippy::cast_possible_wrap)]
227        let cs_xy = CHUNK_SIZE_XY as i32;
228        #[allow(clippy::cast_possible_wrap)]
229        let cs_z = CHUNK_SIZE_Z as i32;
230        let chunk_idxs: Vec<IVec3> = self.chunks.keys().copied().collect();
231        // PF.11 — wave-parallel cache phase: each chunk's estnorm cache
232        // reads the grid immutably and is independent of the others, so a
233        // wave of `current_num_threads` caches builds in parallel; the
234        // apply phase (itself row-parallel inside
235        // `apply_lighting_with_cache`) then runs per chunk. Waves bound
236        // the resident cache memory. Byte-identical to the serial bake —
237        // caches are pure functions of the (unmodified-during-the-wave)
238        // grid, and each apply writes only its own chunk.
239        use rayon::prelude::*;
240        let wave = rayon::current_num_threads().max(1);
241        for batch in chunk_idxs.chunks(wave) {
242            let caches: Vec<(IVec3, roxlap_core::EstNormCache)> = batch
243                .par_iter()
244                .map(|&chunk_idx| {
245                    (
246                        chunk_idx,
247                        self.build_chunk_estnorm_cache(chunk_idx, 0, 0, cs_xy, cs_xy),
248                    )
249                })
250                .collect();
251            for (chunk_idx, cache) in caches {
252                let target = self.chunks.get_mut(&chunk_idx).expect("populated chunk");
253                roxlap_core::apply_lighting_with_cache(
254                    &mut target.data,
255                    &target.column_offset,
256                    CHUNK_SIZE_XY,
257                    0,
258                    0,
259                    0,
260                    cs_xy,
261                    cs_xy,
262                    cs_z,
263                    &cache,
264                    lightmode,
265                    &[],
266                    ao,
267                );
268            }
269        }
270    }
271
272    /// PF.11 — re-bake lighting over just the grid-local voxel bbox
273    /// `[lo, hi]` (inclusive), neighbour-aware across chunk seams in all
274    /// three axes: the write region is padded by `±ESTNORMRAD` internally
275    /// (an edit changes the estnorm of nearby voxels too — pass only the
276    /// geometric edit extent, mirroring `update_lighting`'s convention),
277    /// and any chunk the padded region touches gets its strip re-baked.
278    /// Touched chunks get their versions bumped so the GPU re-uploads.
279    ///
280    /// This is the runtime-edit primitive the full-grid
281    /// [`Self::bake_lightmode`] is far too heavy for: a bullet-hole
282    /// rebake touches a few hundred columns instead of a whole chunk
283    /// (the cave demo measured ~0.04 ms vs 4–7 ms). Mip regeneration is
284    /// NOT performed — near-field renders read mip 0; callers streaming
285    /// distant edited chunks should remip as they already do for edits.
286    #[allow(clippy::cast_possible_wrap)]
287    pub fn bake_lightmode_bbox(&mut self, lo: IVec3, hi: IVec3, lightmode: u32) {
288        if lightmode == 0 {
289            return;
290        }
291        let cs_xy = CHUNK_SIZE_XY as i32;
292        let cs_z = CHUNK_SIZE_Z as i32;
293        let pad = roxlap_core::ESTNORMRAD;
294        // Padded half-open apply region in grid-local voxel coords.
295        let a_lo = IVec3::new(lo.x - pad, lo.y - pad, lo.z - pad);
296        let a_hi = IVec3::new(hi.x + pad + 1, hi.y + pad + 1, hi.z + pad + 1);
297        if a_lo.x >= a_hi.x || a_lo.y >= a_hi.y || a_lo.z >= a_hi.z {
298            return;
299        }
300        // Chunk range the padded region touches (inclusive).
301        let c_lo = IVec3::new(
302            a_lo.x.div_euclid(cs_xy),
303            a_lo.y.div_euclid(cs_xy),
304            a_lo.z.div_euclid(cs_z),
305        );
306        let c_hi = IVec3::new(
307            (a_hi.x - 1).div_euclid(cs_xy),
308            (a_hi.y - 1).div_euclid(cs_xy),
309            (a_hi.z - 1).div_euclid(cs_z),
310        );
311        for chz in c_lo.z..=c_hi.z {
312            for chy in c_lo.y..=c_hi.y {
313                for chx in c_lo.x..=c_hi.x {
314                    let chunk_idx = IVec3::new(chx, chy, chz);
315                    if !self.chunks.contains_key(&chunk_idx) {
316                        continue;
317                    }
318                    // Clip the padded region to this chunk, chunk-local.
319                    let base = IVec3::new(chx * cs_xy, chy * cs_xy, chz * cs_z);
320                    let lx0 = (a_lo.x - base.x).max(0);
321                    let ly0 = (a_lo.y - base.y).max(0);
322                    let lz0 = (a_lo.z - base.z).max(0);
323                    let lx1 = (a_hi.x - base.x).min(cs_xy);
324                    let ly1 = (a_hi.y - base.y).min(cs_xy);
325                    let lz1 = (a_hi.z - base.z).min(cs_z);
326                    if lx0 >= lx1 || ly0 >= ly1 || lz0 >= lz1 {
327                        continue;
328                    }
329                    let cache = self.build_chunk_estnorm_cache(chunk_idx, lx0, ly0, lx1, ly1);
330                    let target = self.chunks.get_mut(&chunk_idx).expect("populated chunk");
331                    roxlap_core::apply_lighting_with_cache(
332                        &mut target.data,
333                        &target.column_offset,
334                        CHUNK_SIZE_XY,
335                        lx0,
336                        ly0,
337                        lz0,
338                        lx1,
339                        ly1,
340                        lz1,
341                        &cache,
342                        lightmode,
343                        &[],
344                        roxlap_core::AoParams::default(),
345                    );
346                    // PF.12 — brightness bytes changed within the clipped
347                    // region only.
348                    self.bump_chunk_version_bbox(
349                        chunk_idx,
350                        IVec3::new(lx0, ly0, lz0),
351                        IVec3::new(lx1 - 1, ly1 - 1, lz1 - 1),
352                    );
353                }
354            }
355        }
356    }
357
358    /// PF.11 — build the neighbour-aware estnorm cache for a chunk-local
359    /// region of `chunk_idx` from an immutable grid borrow: the reader
360    /// resolves a chunk-local `(px, py)` (which may extend `±ESTNORMRAD`
361    /// outside the region / the target chunk) into the neighbour chunk
362    /// owning that column, and `chz_delta` walks the stacked neighbour in
363    /// z (continuous AO + estnorm across every seam). Padding over an
364    /// unpopulated neighbour returns `None` (= treated as air).
365    #[allow(clippy::cast_possible_wrap)]
366    fn build_chunk_estnorm_cache(
367        &self,
368        chunk_idx: IVec3,
369        x0: i32,
370        y0: i32,
371        x1: i32,
372        y1: i32,
373    ) -> roxlap_core::EstNormCache {
374        let cs_xy = CHUNK_SIZE_XY as i32;
375        let reader = |px: i32, py: i32, chz_delta: i32| -> Option<&[u8]> {
376            let nb_chx = chunk_idx.x + px.div_euclid(cs_xy);
377            let nb_chy = chunk_idx.y + py.div_euclid(cs_xy);
378            let in_x = px.rem_euclid(cs_xy);
379            let in_y = py.rem_euclid(cs_xy);
380            let chunk = self.chunk(IVec3::new(nb_chx, nb_chy, chunk_idx.z + chz_delta))?;
381            let col_idx = (in_y as u32) * CHUNK_SIZE_XY + (in_x as u32);
382            let off = chunk.column_offset[col_idx as usize] as usize;
383            Some(&chunk.data[off..])
384        };
385        roxlap_core::EstNormCache::build_with_reader_z(reader, x0, y0, x1, y1)
386    }
387
388    /// Mutably borrow a materialised chunk. Returns `None` for
389    /// implicit-air chunks; use [`Grid::ensure_chunk`] when you
390    /// need a `&mut Vxl` for an edit that may write voxels.
391    pub fn chunk_mut(&mut self, chunk_idx: IVec3) -> Option<&mut Vxl> {
392        self.chunks.get_mut(&chunk_idx)
393    }
394
395    /// Borrow `chunk_idx`'s [`Vxl`], creating an empty all-air
396    /// chunk first if it doesn't exist yet. The returned `&mut`
397    /// is valid for editing via [`roxlap_formats::edit`] — the new
398    /// chunk has [`Vxl::reserve_edit_capacity`] already applied.
399    pub fn ensure_chunk(&mut self, chunk_idx: IVec3) -> &mut Vxl {
400        // PF.13 (H9) — materialising a chunk mutates the chunk set.
401        if !self.chunks.contains_key(&chunk_idx) {
402            self.note_chunk_set_changed();
403        }
404        self.chunks.entry(chunk_idx).or_insert_with(empty_chunk_vxl)
405    }
406
407    /// Number of materialised chunks. Implicit-air chunks don't
408    /// count.
409    #[must_use]
410    pub fn chunk_count(&self) -> usize {
411        self.chunks.len()
412    }
413
414    /// S4B.2.c.3: build a per-chunk [`roxlap_core::GridView`] table
415    /// over this grid's XY chunk footprint at `chz = 0`.
416    ///
417    /// Returns `None` if no chz=0 chunk is populated (the entire
418    /// grid would render as implicit air anyway).
419    ///
420    /// Iterates `chunks` once to find the chx/chy bounding box,
421    /// then a second time to fill the row-major
422    /// `Vec<Option<GridView<'_>>>`. Empty XY slots (implicit-air
423    /// chunks inside the box) get `None`.
424    ///
425    /// Pair with [`roxlap_core::ChunkGrid`] + [`roxlap_core::
426    /// GridView::from_chunk_grid`] to drive the Approach B render
427    /// path:
428    ///
429    /// ```ignore
430    /// let backing = grid.chunk_xy_backing().unwrap();
431    /// let cg = roxlap_core::ChunkGrid {
432    ///     chunks: &backing.chunks,
433    ///     origin_chunk_xy: backing.origin_chunk_xy,
434    ///     chunks_x: backing.chunks_x,
435    ///     chunks_y: backing.chunks_y,
436    /// };
437    /// let view = roxlap_core::GridView::from_chunk_grid(
438    ///     &cg, crate::CHUNK_SIZE_XY,
439    /// );
440    /// ```
441    ///
442    /// Only chz=0 chunks contribute (multi-z handoff lands in
443    /// S4B.3); higher-chz chunks in [`Self::chunks`] are ignored
444    /// here.
445    #[must_use]
446    pub fn chunk_xy_backing(&self) -> Option<ChunkXyBacking<'_>> {
447        let mut min_x = i32::MAX;
448        let mut min_y = i32::MAX;
449        let mut max_x = i32::MIN;
450        let mut max_y = i32::MIN;
451        let mut any = false;
452        for chunk_idx in self.chunks.keys() {
453            if chunk_idx.z != 0 {
454                continue;
455            }
456            min_x = min_x.min(chunk_idx.x);
457            min_y = min_y.min(chunk_idx.y);
458            max_x = max_x.max(chunk_idx.x);
459            max_y = max_y.max(chunk_idx.y);
460            any = true;
461        }
462        if !any {
463            return None;
464        }
465        #[allow(clippy::cast_sign_loss)]
466        let chunks_x = (max_x - min_x + 1) as u32;
467        #[allow(clippy::cast_sign_loss)]
468        let chunks_y = (max_y - min_y + 1) as u32;
469        let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
470            vec![None; (chunks_x * chunks_y) as usize];
471        for (chunk_idx, vxl) in &self.chunks {
472            if chunk_idx.z != 0 {
473                continue;
474            }
475            let dx = chunk_idx.x - min_x;
476            let dy = chunk_idx.y - min_y;
477            #[allow(clippy::cast_sign_loss)]
478            let i = (dy as u32 * chunks_x + dx as u32) as usize;
479            table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
480        }
481        Some(ChunkXyBacking {
482            chunks: table,
483            origin_chunk_xy: [min_x, min_y],
484            origin_chunk_z: 0,
485            chunks_x,
486            chunks_y,
487            chunks_z: 1,
488        })
489    }
490
491    /// S4B.6.a: 3D-aware version of [`Self::chunk_xy_backing`].
492    /// Enumerates ALL chunks across the chx/chy/chz bounding box
493    /// (not just `chz=0`) so a stacked grid can be rendered once
494    /// S4B.6.c switches the rasterizer to a chunk-z-aware column
495    /// walker.
496    ///
497    /// Iterates `chunks` once for the XYZ bbox, then a second time
498    /// to fill the row-major-per-z `Vec<Option<GridView<'_>>>`.
499    /// Index layout: `[(dz * chunks_y + dy) * chunks_x + dx]` —
500    /// matches [`roxlap_core::ChunkGrid`]'s indexing exactly.
501    ///
502    /// Returns `None` for empty grids.
503    #[must_use]
504    pub fn chunk_xyz_backing(&self) -> Option<ChunkXyBacking<'_>> {
505        let mut min_x = i32::MAX;
506        let mut min_y = i32::MAX;
507        let mut min_z = i32::MAX;
508        let mut max_x = i32::MIN;
509        let mut max_y = i32::MIN;
510        let mut max_z = i32::MIN;
511        let mut any = false;
512        for chunk_idx in self.chunks.keys() {
513            min_x = min_x.min(chunk_idx.x);
514            min_y = min_y.min(chunk_idx.y);
515            min_z = min_z.min(chunk_idx.z);
516            max_x = max_x.max(chunk_idx.x);
517            max_y = max_y.max(chunk_idx.y);
518            max_z = max_z.max(chunk_idx.z);
519            any = true;
520        }
521        if !any {
522            return None;
523        }
524        #[allow(clippy::cast_sign_loss)]
525        let chunks_x = (max_x - min_x + 1) as u32;
526        #[allow(clippy::cast_sign_loss)]
527        let chunks_y = (max_y - min_y + 1) as u32;
528        #[allow(clippy::cast_sign_loss)]
529        let chunks_z = (max_z - min_z + 1) as u32;
530        let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
531            vec![None; (chunks_x * chunks_y * chunks_z) as usize];
532        for (chunk_idx, vxl) in &self.chunks {
533            let dx = chunk_idx.x - min_x;
534            let dy = chunk_idx.y - min_y;
535            let dz = chunk_idx.z - min_z;
536            #[allow(clippy::cast_sign_loss)]
537            let (dx, dy, dz) = (dx as u32, dy as u32, dz as u32);
538            let i = ((dz * chunks_y + dy) * chunks_x + dx) as usize;
539            table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
540        }
541        Some(ChunkXyBacking {
542            chunks: table,
543            origin_chunk_xy: [min_x, min_y],
544            origin_chunk_z: min_z,
545            chunks_x,
546            chunks_y,
547            chunks_z,
548        })
549    }
550}
551
552/// S4B.2.c.3: chx/chy chunk table built from a [`Grid`].
553///
554/// Owns the `Vec<Option<GridView>>` so [`roxlap_core::ChunkGrid`]
555/// (which borrows the table) can live alongside the GridView
556/// constructed from it. Used by the Approach B render path —
557/// see [`Grid::chunk_xy_backing`].
558///
559/// S4B.6.a: gained `chunks_z` + `origin_chunk_z` for tall-world
560/// support. Pre-S4B.6.a `chunk_xy_backing` always populates these
561/// as `chunks_z=1 origin_chunk_z=0` (= chz=0 only); S4B.6.c will
562/// switch the render path to `chunk_xyz_backing` once the
563/// rasterizer is stack-aware.
564pub struct ChunkXyBacking<'a> {
565    /// Per-chunk views over the chx/chy/chz extent.
566    /// Length `chunks_x * chunks_y * chunks_z`; index layout
567    /// `[(dz * chunks_y + dy) * chunks_x + dx]`. `None` for
568    /// implicit-air chunks inside the bbox.
569    pub chunks: Vec<Option<roxlap_core::GridView<'a>>>,
570    /// XY index of the chunk at `chunks[0]` — the minimum chx/chy
571    /// among populated chunks at `chz = origin_chunk_z`.
572    pub origin_chunk_xy: [i32; 2],
573    /// Z index of the chunk at `chunks[0]`.
574    pub origin_chunk_z: i32,
575    /// Number of chunks along the X axis. Row stride.
576    pub chunks_x: u32,
577    /// Number of chunks along the Y axis.
578    pub chunks_y: u32,
579    /// Number of chunks along the Z axis. `1` from
580    /// [`Grid::chunk_xy_backing`]; `>1` only via the S4B.6.a
581    /// [`Grid::chunk_xyz_backing`].
582    pub chunks_z: u32,
583}
584
585#[cfg(test)]
586pub(crate) mod tests {
587    use super::*;
588    use crate::{GridTransform, CHUNK_SIZE_Z};
589
590    /// Decode `column`'s slab bytes and return `true` iff `z` is
591    /// covered by any solid run. Mirrors voxlap's column-walk
592    /// semantics — the b2 buffer is `[top0, bot0, top1, bot1, ...,
593    /// MAXZDIM_sentinel]`, with each `[top, bot)` pair denoting a
594    /// solid range.
595    pub(crate) fn voxel_is_solid(vxl: &Vxl, x: u32, y: u32, z: u32) -> bool {
596        super::vxl_voxel_solid(vxl, x, y, z)
597    }
598
599    #[test]
600    fn voxel_solid_reflects_set_voxel() {
601        // Grid::voxel_solid (the public picking query) reads back an
602        // edit: the set voxel is solid, its neighbour is air, and an
603        // unmaterialised chunk reads as air.
604        let mut g = Grid::new(GridTransform::identity());
605        g.set_voxel(IVec3::new(5, 6, 7), Some(0x80_aa_bb_cc));
606        assert!(g.voxel_solid(IVec3::new(5, 6, 7)), "set voxel is solid");
607        assert!(!g.voxel_solid(IVec3::new(5, 6, 8)), "neighbour is air");
608        assert!(
609            !g.voxel_solid(IVec3::new(900, 900, 7)),
610            "absent chunk reads as air",
611        );
612    }
613
614    #[test]
615    fn voxel_solid_handles_negative_coords() {
616        // Negative grid-local voxels decompose via div_euclid (addr
617        // semantics); the query must follow the same split.
618        let mut g = Grid::new(GridTransform::identity());
619        g.set_voxel(IVec3::new(-1, -1, 10), Some(0x80_11_22_33));
620        assert!(g.voxel_solid(IVec3::new(-1, -1, 10)));
621        assert!(!g.voxel_solid(IVec3::new(-1, -1, 11)));
622    }
623
624    #[test]
625    fn empty_chunk_has_correct_vsid() {
626        let vxl = empty_chunk_vxl();
627        assert_eq!(vxl.vsid, CHUNK_SIZE_XY);
628    }
629
630    #[test]
631    fn empty_chunk_is_all_air() {
632        let vxl = empty_chunk_vxl();
633        // Sample a few representative voxels — full coverage is in
634        // `empty_chunk_no_voxel_solid_anywhere` below.
635        for &(x, y, z) in &[
636            (0u32, 0u32, 0u32),
637            (0, 0, 100),
638            (0, 0, 200),
639            (CHUNK_SIZE_XY - 1, CHUNK_SIZE_XY - 1, 0),
640            (64, 64, 128),
641        ] {
642            assert!(
643                !voxel_is_solid(&vxl, x, y, z),
644                "voxel ({x}, {y}, {z}) should be air"
645            );
646        }
647    }
648
649    #[test]
650    fn empty_chunk_air_above_bedrock_on_grid_sample() {
651        // Stride 16 across the chunk catches structural breakage
652        // (a corner column wrong, a z-band wrong, etc.) without the
653        // 4M-query cost of a brute-force scan in debug mode.
654        // Voxlap's slab format keeps z=255 solid as the "below the
655        // world" sentinel; the renderer's `treat_z_max_as_air` flag
656        // handles displaying it as transparent. See
657        // `project_below_bedrock_all_sky.md` for the S1.X fix.
658        let vxl = empty_chunk_vxl();
659        let bedrock_z = CHUNK_SIZE_Z - 1;
660        for y in (0..CHUNK_SIZE_XY).step_by(16) {
661            for x in (0..CHUNK_SIZE_XY).step_by(16) {
662                for z in (0..bedrock_z).step_by(16) {
663                    assert!(
664                        !voxel_is_solid(&vxl, x, y, z),
665                        "voxel ({x}, {y}, {z}) leaked solid in empty chunk"
666                    );
667                }
668                // bedrock z is solid (placeholder).
669                assert!(voxel_is_solid(&vxl, x, y, bedrock_z));
670            }
671        }
672    }
673
674    #[test]
675    fn empty_chunk_keeps_bedrock_placeholder() {
676        // Voxlap's invariant: every column carries an implicit
677        // solid voxel at z = MAXZDIM-1 = 255 even after a full
678        // carve. The renderer reads this as the bedrock placeholder.
679        let vxl = empty_chunk_vxl();
680        assert!(voxel_is_solid(&vxl, 0, 0, CHUNK_SIZE_Z - 1));
681        assert!(voxel_is_solid(&vxl, 64, 64, CHUNK_SIZE_Z - 1));
682    }
683
684    #[test]
685    fn ensure_chunk_creates_when_missing() {
686        let mut g = Grid::new(GridTransform::identity());
687        assert_eq!(g.chunk_count(), 0);
688        assert!(g.chunk(IVec3::ZERO).is_none());
689        let _ = g.ensure_chunk(IVec3::ZERO);
690        assert_eq!(g.chunk_count(), 1);
691        assert!(g.chunk(IVec3::ZERO).is_some());
692    }
693
694    #[test]
695    fn ensure_chunk_returns_existing() {
696        // Calling ensure_chunk a second time on the same index
697        // doesn't replace the chunk. Verify by writing through the
698        // first call and reading through the second.
699        let mut g = Grid::new(GridTransform::identity());
700        let chunk = IVec3::new(2, -1, 0);
701        g.ensure_chunk(chunk);
702        // Voxel local (5, 6, 7) inside chunk (2, -1, 0) is
703        // grid-local global (2*128 + 5, -1*128 + 6, 0*256 + 7) =
704        // (261, -122, 7).
705        g.set_voxel(IVec3::new(261, -122, 7), Some(0x80_aa_bb_cc));
706        let vxl = g.ensure_chunk(chunk);
707        assert!(voxel_is_solid(vxl, 5, 6, 7));
708        assert_eq!(g.chunk_count(), 1);
709    }
710
711    #[test]
712    fn chunk_mut_returns_none_for_missing() {
713        let mut g = Grid::new(GridTransform::identity());
714        assert!(g.chunk_mut(IVec3::ZERO).is_none());
715    }
716
717    /// S4B.6.a: legacy `chunk_xy_backing` ignores chz!=0 chunks and
718    /// always returns `chunks_z=1 origin_chunk_z=0`. Sanity-check
719    /// the new field defaults so pre-S4B.6 render path stays
720    /// byte-identical.
721    #[test]
722    fn chunk_xy_backing_returns_chunks_z_one() {
723        let mut g = Grid::new(GridTransform::identity());
724        g.ensure_chunk(IVec3::new(0, 0, 0));
725        g.ensure_chunk(IVec3::new(1, 0, 0));
726        // Add a chunk at chz=1 — chunk_xy_backing should ignore it.
727        g.ensure_chunk(IVec3::new(0, 0, 1));
728        let backing = g.chunk_xy_backing().expect("two chz=0 chunks present");
729        assert_eq!(backing.chunks_z, 1);
730        assert_eq!(backing.origin_chunk_z, 0);
731        assert_eq!(backing.chunks_x, 2);
732        assert_eq!(backing.chunks_y, 1);
733        assert_eq!(backing.chunks.len(), 2);
734    }
735
736    /// S4B.6.a: new `chunk_xyz_backing` enumerates ALL chunks
737    /// including chz!=0. Indexing layout must match
738    /// `roxlap_core::ChunkGrid`: row-major per z slab.
739    #[test]
740    fn chunk_xyz_backing_with_stacked_chunks_enumerates_all_z() {
741        let mut g = Grid::new(GridTransform::identity());
742        g.ensure_chunk(IVec3::new(0, 0, 0));
743        g.ensure_chunk(IVec3::new(1, 0, 0));
744        g.ensure_chunk(IVec3::new(0, 0, 1));
745        // Leave (1, 0, 1) implicit-air.
746        let backing = g.chunk_xyz_backing().expect("at least one chunk");
747        assert_eq!(backing.chunks_x, 2);
748        assert_eq!(backing.chunks_y, 1);
749        assert_eq!(backing.chunks_z, 2);
750        assert_eq!(backing.origin_chunk_xy, [0, 0]);
751        assert_eq!(backing.origin_chunk_z, 0);
752        assert_eq!(backing.chunks.len(), 4); // dims [2, 1, 2]
753                                             // Index layout: [(dz * chunks_y + dy) * chunks_x + dx]
754                                             // (0, 0, 0) → dx=0, dy=0, dz=0 → 0
755                                             // (1, 0, 0) → dx=1, dy=0, dz=0 → 1
756                                             // (0, 0, 1) → dx=0, dy=0, dz=1 → 2
757                                             // (1, 0, 1) → dx=1, dy=0, dz=1 → 3 (implicit-air → None)
758        assert!(backing.chunks[0].is_some(), "(0, 0, 0) present");
759        assert!(backing.chunks[1].is_some(), "(1, 0, 0) present");
760        assert!(backing.chunks[2].is_some(), "(0, 0, 1) present");
761        assert!(backing.chunks[3].is_none(), "(1, 0, 1) implicit-air");
762    }
763
764    /// S4B.6.a: chunk_xyz_backing with negative chz origin —
765    /// origin_chunk_z = min_z must reflect the actual minimum chz.
766    #[test]
767    fn chunk_xyz_backing_with_negative_origin_chunk_z() {
768        let mut g = Grid::new(GridTransform::identity());
769        g.ensure_chunk(IVec3::new(0, 0, -2));
770        g.ensure_chunk(IVec3::new(0, 0, 0));
771        let backing = g.chunk_xyz_backing().expect("at least one chunk");
772        assert_eq!(backing.chunks_z, 3); // chz range [-2, 0]
773        assert_eq!(backing.origin_chunk_z, -2);
774        assert!(backing.chunks[0].is_some(), "chz=-2 → dz=0");
775        assert!(backing.chunks[1].is_none(), "chz=-1 → dz=1 implicit-air");
776        assert!(backing.chunks[2].is_some(), "chz=0 → dz=2");
777    }
778
779    /// PF.11 — `bake_lightmode_bbox` around an edit must reproduce, byte
780    /// for byte, what a full-grid re-bake would write: the padded write
781    /// region covers every voxel whose estnorm the edit could change
782    /// (±ESTNORMRAD), including strips in neighbour chunks across seams.
783    #[test]
784    fn bbox_rebake_matches_full_rebake() {
785        // Terrain spanning a 2×2 chunk seam, with relief so estnorm is
786        // non-trivial around the edit.
787        let build = || {
788            let mut g = Grid::new(GridTransform::identity());
789            g.set_rect(
790                IVec3::new(0, 0, 160),
791                IVec3::new(255, 255, 255),
792                Some(0x80_66_77_88),
793            );
794            for i in 0..10 {
795                let (x, y) = (23 * i % 240 + 8, 37 * i % 240 + 8);
796                g.set_sphere(IVec3::new(x, y, 165), 6, None);
797            }
798            g.bake_lightmode(1);
799            g
800        };
801        let mut full = build();
802        let mut bbox = build();
803
804        // Identical edit in both, straddling the chunk (0,0)/(1,0) seam
805        // so the neighbour-strip rebake is exercised.
806        let (lo, hi) = (IVec3::new(120, 60, 158), IVec3::new(136, 76, 200));
807        full.set_rect(lo, hi, None);
808        bbox.set_rect(lo, hi, None);
809
810        // Ground truth: full re-bake. Candidate: bbox-only re-bake.
811        full.bake_lightmode(1);
812        bbox.bake_lightmode_bbox(lo, hi, 1);
813
814        for (idx, a) in &full.chunks {
815            let b = bbox.chunks.get(idx).expect("same chunk set");
816            assert_eq!(
817                a.data, b.data,
818                "chunk {idx:?} bytes diverge between full and bbox rebake",
819            );
820        }
821    }
822
823    /// PF.12 — `remip_bbox` must be byte-identical to a full
824    /// `generate_mips` when the bbox covers the edits: data bytes,
825    /// column offsets, and the mip table all match — across repeated
826    /// incremental rounds, corner-of-chunk edits, and voxalloc-scattered
827    /// columns.
828    #[test]
829    fn remip_bbox_matches_generate_mips() {
830        use roxlap_formats::edit::{set_sphere_with_colfunc, SpanOp};
831
832        // Realistic chunk: terrain + caves, then a full initial ladder.
833        let mut g = Grid::new(GridTransform::identity());
834        g.set_rect(
835            IVec3::new(0, 0, 150),
836            IVec3::new(127, 127, 255),
837            Some(0x80_66_77_88),
838        );
839        for i in 0..8 {
840            let (x, y) = (29 * i % 100 + 12, 41 * i % 100 + 12);
841            g.set_sphere(IVec3::new(x, y, 158), 5, None);
842        }
843        let base = g.chunks.get_mut(&IVec3::ZERO).expect("chunk");
844        base.generate_mips(6);
845
846        let mut full = base.clone();
847        let mut inc = base.clone();
848
849        // Round 1: a carve straddling brick/column-group boundaries plus
850        // a corner edit (exercises clamping at the chunk edge).
851        let edits: [(IVec3, u32); 3] = [
852            (IVec3::new(63, 64, 170), 7),
853            (IVec3::new(0, 0, 155), 4),
854            (IVec3::new(126, 100, 165), 5),
855        ];
856        for round in 0..2 {
857            let mut lo = IVec3::splat(i32::MAX);
858            let mut hi = IVec3::splat(i32::MIN);
859            for (k, &(c, r)) in edits.iter().enumerate() {
860                if k % 2 != round % 2 {
861                    continue; // vary the edit set per round
862                }
863                #[allow(clippy::cast_possible_wrap)]
864                let ri = r as i32;
865                for v in [&mut full, &mut inc] {
866                    set_sphere_with_colfunc(v, c.into(), r, SpanOp::Carve, |_, _, _| {
867                        0x80_31_41_59_u32 as i32
868                    });
869                }
870                lo = lo.min(c - IVec3::splat(ri));
871                hi = hi.max(c + IVec3::splat(ri));
872            }
873            full.generate_mips(6);
874            inc.remip_bbox(lo.x, lo.y, hi.x, hi.y, 6);
875
876            assert_eq!(
877                full.mip_base_offsets, inc.mip_base_offsets,
878                "round {round}: mip tables diverge",
879            );
880            assert_eq!(
881                full.column_offset, inc.column_offset,
882                "round {round}: column offsets diverge",
883            );
884            assert_eq!(full.data, inc.data, "round {round}: data bytes diverge");
885        }
886    }
887
888    /// PF.6 — the in-place slab walk in `vxl_voxel_solid` must agree
889    /// with the `expandrle` run list (the pre-PF.6 reference decoder)
890    /// for every z, on columns with multiple slabs (caves), single
891    /// slabs, and untouched bedrock.
892    #[test]
893    fn vxl_voxel_solid_matches_expandrle_reference() {
894        use roxlap_formats::edit::expandrle;
895
896        let mut g = Grid::new(GridTransform::identity());
897        // Carve two separate air pockets into one column (multi-slab)
898        // plus a sphere for varied neighbours.
899        g.set_rect(IVec3::new(3, 4, 40), IVec3::new(4, 5, 60), None);
900        g.set_rect(IVec3::new(3, 4, 100), IVec3::new(4, 5, 140), None);
901        g.set_voxel(IVec3::new(3, 4, 120), Some(0x80_11_22_33)); // island inside the pocket
902        g.set_sphere(IVec3::new(8, 8, 80), 6, None);
903        let vxl = g.chunk(IVec3::ZERO).expect("chunk materialised");
904
905        let maxzdim = CHUNK_SIZE_Z as i32;
906        for (x, y) in [(3u32, 4u32), (8, 8), (0, 0), (8, 2), (12, 8)] {
907            // Reference: full expandrle decode → run-list scan.
908            let column = vxl.column_data((y * vxl.vsid + x) as usize);
909            let mut b2 = vec![maxzdim; 2 * (CHUNK_SIZE_Z as usize) + 4];
910            expandrle(column, &mut b2);
911            for z in 0..CHUNK_SIZE_Z {
912                let zi = z as i32;
913                let mut reference = false;
914                let mut i = 0;
915                while b2[i] < maxzdim {
916                    if zi >= b2[i] && zi < b2[i + 1] {
917                        reference = true;
918                        break;
919                    }
920                    i += 2;
921                }
922                assert_eq!(
923                    vxl_voxel_solid(vxl, x, y, z),
924                    reference,
925                    "column ({x}, {y}) z={z} disagrees with expandrle",
926                );
927            }
928        }
929    }
930}