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::{expandrle, 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)`. Walks the column's
89/// expanded `[top, bot)` run list (voxlap b2 convention). `(x, y)` are
90/// `< CHUNK_SIZE_XY`, `z < CHUNK_SIZE_Z`.
91#[allow(clippy::cast_possible_wrap)]
92pub(crate) fn vxl_voxel_solid(vxl: &Vxl, x: u32, y: u32, z: u32) -> bool {
93    let idx = (y * vxl.vsid + x) as usize;
94    let column = vxl.column_data(idx);
95    // Pre-fill with the MAXZDIM sentinel so unwritten slots terminate
96    // the walk (matches voxlap's b2 init convention).
97    let maxzdim = CHUNK_SIZE_Z as i32;
98    let mut b2 = vec![maxzdim; 2 * (CHUNK_SIZE_Z as usize) + 4];
99    expandrle(column, &mut b2);
100    let z = z as i32;
101    let mut i = 0;
102    while b2[i] < maxzdim {
103        let (top, bot) = (b2[i], b2[i + 1]);
104        if z >= top && z < bot {
105            return true;
106        }
107        i += 2;
108    }
109    false
110}
111
112impl Grid {
113    /// True if the grid-local integer voxel `voxel` is solid (inside a
114    /// solid run of its chunk). An implicit-air or absent chunk reads
115    /// as `false`. `voxel` is a grid-local voxel coordinate
116    /// (pre-transform) — get one from a world point via
117    /// [`crate::world_to_grid_local`] + [`crate::voxel_global`]. Useful
118    /// for picking, collision, and world queries.
119    #[must_use]
120    pub fn voxel_solid(&self, voxel: IVec3) -> bool {
121        let (chunk_idx, in_chunk) = crate::voxel_split(voxel);
122        match self.chunk(chunk_idx) {
123            Some(vxl) => vxl_voxel_solid(vxl, in_chunk.x, in_chunk.y, in_chunk.z),
124            None => false,
125        }
126    }
127
128    /// Packed BGRA colour of the textured voxel at grid-local `voxel`,
129    /// or `None` for air / untextured cells. Thin wrapper over
130    /// [`roxlap_formats::vxl::Vxl::voxel_color`] after the chunk split —
131    /// the colour-inspection companion to [`Self::voxel_solid`]. Use it
132    /// to read back what a pick / raycast hit looks like.
133    #[must_use]
134    pub fn voxel_color(&self, voxel: IVec3) -> Option<u32> {
135        let (chunk_idx, in_chunk) = crate::voxel_split(voxel);
136        self.chunk(chunk_idx)?
137            .voxel_color(in_chunk.x, in_chunk.y, in_chunk.z)
138    }
139
140    /// Borrow the chunk at `chunk_idx` if it has been materialised.
141    /// `None` means the chunk is implicitly all-air.
142    #[must_use]
143    pub fn chunk(&self, chunk_idx: IVec3) -> Option<&Vxl> {
144        self.chunks.get(&chunk_idx)
145    }
146
147    /// Bake per-voxel lighting (voxlap `updatevxl`/`estnorm` shading)
148    /// into every materialised chunk's brightness bytes, in place.
149    /// `lightmode` is voxlap's mode (1 = directional estnorm shading,
150    /// the look the cave + terrain demos use). Both the CPU rasteriser
151    /// and the GPU marcher read these pre-baked brightness bytes, so
152    /// call this once after building a grid and again over edited
153    /// chunks after a carve (then bump their versions so the GPU
154    /// re-uploads — edits already do, via [`Grid::set_voxel`] &c.).
155    ///
156    /// Each chunk is baked neighbour-aware in all three axes: estnorm's
157    /// (and AO's) ±2-voxel padding that crosses a chunk face reads the
158    /// actual neighbour chunk (when populated) — XY neighbours and, for
159    /// stacked grids, the chunks above/below on `chz±1` — so brightness
160    /// / occlusion is continuous across every seam. Point lights aren't
161    /// applied (directional-only) — matching the demos' bake. No-op for
162    /// an empty grid.
163    pub fn bake_lightmode(&mut self, lightmode: u32) {
164        #[allow(clippy::cast_possible_wrap)]
165        let cs_xy = CHUNK_SIZE_XY as i32;
166        #[allow(clippy::cast_possible_wrap)]
167        let cs_z = CHUNK_SIZE_Z as i32;
168        let chunk_idxs: Vec<IVec3> = self.chunks.keys().copied().collect();
169        for chunk_idx in chunk_idxs {
170            // Build the estnorm cache from an immutable grid borrow: the
171            // reader resolves a chunk-local `(px, py)` (which may extend
172            // ±ESTNORMRAD outside the target chunk) into the neighbour
173            // chunk that owns that voxel column, same `chz`. Padding over
174            // an unpopulated neighbour returns `None` (= treated as air).
175            let cache = {
176                let grid_ref: &Self = &*self;
177                // `chz_delta` walks the stacked neighbour in z so the
178                // ESTNORMRAD padding crossing a chunk's top/bottom face
179                // reads the chunk above/below (continuous AO + estnorm
180                // across a z-seam); `0` is the target layer.
181                let reader = |px: i32, py: i32, chz_delta: i32| -> Option<&[u8]> {
182                    let nb_chx = chunk_idx.x + px.div_euclid(cs_xy);
183                    let nb_chy = chunk_idx.y + py.div_euclid(cs_xy);
184                    let in_x = px.rem_euclid(cs_xy);
185                    let in_y = py.rem_euclid(cs_xy);
186                    let chunk =
187                        grid_ref.chunk(IVec3::new(nb_chx, nb_chy, chunk_idx.z + chz_delta))?;
188                    let col_idx = (in_y as u32) * CHUNK_SIZE_XY + (in_x as u32);
189                    let off = chunk.column_offset[col_idx as usize] as usize;
190                    Some(&chunk.data[off..])
191                };
192                roxlap_core::EstNormCache::build_with_reader_z(reader, 0, 0, cs_xy, cs_xy)
193            };
194            let target = self.chunks.get_mut(&chunk_idx).expect("populated chunk");
195            roxlap_core::apply_lighting_with_cache(
196                &mut target.data,
197                &target.column_offset,
198                CHUNK_SIZE_XY,
199                0,
200                0,
201                0,
202                cs_xy,
203                cs_xy,
204                cs_z,
205                &cache,
206                lightmode,
207                &[],
208                roxlap_core::AoParams::default(),
209            );
210        }
211    }
212
213    /// Mutably borrow a materialised chunk. Returns `None` for
214    /// implicit-air chunks; use [`Grid::ensure_chunk`] when you
215    /// need a `&mut Vxl` for an edit that may write voxels.
216    pub fn chunk_mut(&mut self, chunk_idx: IVec3) -> Option<&mut Vxl> {
217        self.chunks.get_mut(&chunk_idx)
218    }
219
220    /// Borrow `chunk_idx`'s [`Vxl`], creating an empty all-air
221    /// chunk first if it doesn't exist yet. The returned `&mut`
222    /// is valid for editing via [`roxlap_formats::edit`] — the new
223    /// chunk has [`Vxl::reserve_edit_capacity`] already applied.
224    pub fn ensure_chunk(&mut self, chunk_idx: IVec3) -> &mut Vxl {
225        self.chunks.entry(chunk_idx).or_insert_with(empty_chunk_vxl)
226    }
227
228    /// Number of materialised chunks. Implicit-air chunks don't
229    /// count.
230    #[must_use]
231    pub fn chunk_count(&self) -> usize {
232        self.chunks.len()
233    }
234
235    /// S4B.2.c.3: build a per-chunk [`roxlap_core::GridView`] table
236    /// over this grid's XY chunk footprint at `chz = 0`.
237    ///
238    /// Returns `None` if no chz=0 chunk is populated (the entire
239    /// grid would render as implicit air anyway).
240    ///
241    /// Iterates `chunks` once to find the chx/chy bounding box,
242    /// then a second time to fill the row-major
243    /// `Vec<Option<GridView<'_>>>`. Empty XY slots (implicit-air
244    /// chunks inside the box) get `None`.
245    ///
246    /// Pair with [`roxlap_core::ChunkGrid`] + [`roxlap_core::
247    /// GridView::from_chunk_grid`] to drive the Approach B render
248    /// path:
249    ///
250    /// ```ignore
251    /// let backing = grid.chunk_xy_backing().unwrap();
252    /// let cg = roxlap_core::ChunkGrid {
253    ///     chunks: &backing.chunks,
254    ///     origin_chunk_xy: backing.origin_chunk_xy,
255    ///     chunks_x: backing.chunks_x,
256    ///     chunks_y: backing.chunks_y,
257    /// };
258    /// let view = roxlap_core::GridView::from_chunk_grid(
259    ///     &cg, crate::CHUNK_SIZE_XY,
260    /// );
261    /// ```
262    ///
263    /// Only chz=0 chunks contribute (multi-z handoff lands in
264    /// S4B.3); higher-chz chunks in [`Self::chunks`] are ignored
265    /// here.
266    #[must_use]
267    pub fn chunk_xy_backing(&self) -> Option<ChunkXyBacking<'_>> {
268        let mut min_x = i32::MAX;
269        let mut min_y = i32::MAX;
270        let mut max_x = i32::MIN;
271        let mut max_y = i32::MIN;
272        let mut any = false;
273        for chunk_idx in self.chunks.keys() {
274            if chunk_idx.z != 0 {
275                continue;
276            }
277            min_x = min_x.min(chunk_idx.x);
278            min_y = min_y.min(chunk_idx.y);
279            max_x = max_x.max(chunk_idx.x);
280            max_y = max_y.max(chunk_idx.y);
281            any = true;
282        }
283        if !any {
284            return None;
285        }
286        #[allow(clippy::cast_sign_loss)]
287        let chunks_x = (max_x - min_x + 1) as u32;
288        #[allow(clippy::cast_sign_loss)]
289        let chunks_y = (max_y - min_y + 1) as u32;
290        let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
291            vec![None; (chunks_x * chunks_y) as usize];
292        for (chunk_idx, vxl) in &self.chunks {
293            if chunk_idx.z != 0 {
294                continue;
295            }
296            let dx = chunk_idx.x - min_x;
297            let dy = chunk_idx.y - min_y;
298            #[allow(clippy::cast_sign_loss)]
299            let i = (dy as u32 * chunks_x + dx as u32) as usize;
300            table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
301        }
302        Some(ChunkXyBacking {
303            chunks: table,
304            origin_chunk_xy: [min_x, min_y],
305            origin_chunk_z: 0,
306            chunks_x,
307            chunks_y,
308            chunks_z: 1,
309        })
310    }
311
312    /// S4B.6.a: 3D-aware version of [`Self::chunk_xy_backing`].
313    /// Enumerates ALL chunks across the chx/chy/chz bounding box
314    /// (not just `chz=0`) so a stacked grid can be rendered once
315    /// S4B.6.c switches the rasterizer to a chunk-z-aware column
316    /// walker.
317    ///
318    /// Iterates `chunks` once for the XYZ bbox, then a second time
319    /// to fill the row-major-per-z `Vec<Option<GridView<'_>>>`.
320    /// Index layout: `[(dz * chunks_y + dy) * chunks_x + dx]` —
321    /// matches [`roxlap_core::ChunkGrid`]'s indexing exactly.
322    ///
323    /// Returns `None` for empty grids.
324    #[must_use]
325    pub fn chunk_xyz_backing(&self) -> Option<ChunkXyBacking<'_>> {
326        let mut min_x = i32::MAX;
327        let mut min_y = i32::MAX;
328        let mut min_z = i32::MAX;
329        let mut max_x = i32::MIN;
330        let mut max_y = i32::MIN;
331        let mut max_z = i32::MIN;
332        let mut any = false;
333        for chunk_idx in self.chunks.keys() {
334            min_x = min_x.min(chunk_idx.x);
335            min_y = min_y.min(chunk_idx.y);
336            min_z = min_z.min(chunk_idx.z);
337            max_x = max_x.max(chunk_idx.x);
338            max_y = max_y.max(chunk_idx.y);
339            max_z = max_z.max(chunk_idx.z);
340            any = true;
341        }
342        if !any {
343            return None;
344        }
345        #[allow(clippy::cast_sign_loss)]
346        let chunks_x = (max_x - min_x + 1) as u32;
347        #[allow(clippy::cast_sign_loss)]
348        let chunks_y = (max_y - min_y + 1) as u32;
349        #[allow(clippy::cast_sign_loss)]
350        let chunks_z = (max_z - min_z + 1) as u32;
351        let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
352            vec![None; (chunks_x * chunks_y * chunks_z) as usize];
353        for (chunk_idx, vxl) in &self.chunks {
354            let dx = chunk_idx.x - min_x;
355            let dy = chunk_idx.y - min_y;
356            let dz = chunk_idx.z - min_z;
357            #[allow(clippy::cast_sign_loss)]
358            let (dx, dy, dz) = (dx as u32, dy as u32, dz as u32);
359            let i = ((dz * chunks_y + dy) * chunks_x + dx) as usize;
360            table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
361        }
362        Some(ChunkXyBacking {
363            chunks: table,
364            origin_chunk_xy: [min_x, min_y],
365            origin_chunk_z: min_z,
366            chunks_x,
367            chunks_y,
368            chunks_z,
369        })
370    }
371}
372
373/// S4B.2.c.3: chx/chy chunk table built from a [`Grid`].
374///
375/// Owns the `Vec<Option<GridView>>` so [`roxlap_core::ChunkGrid`]
376/// (which borrows the table) can live alongside the GridView
377/// constructed from it. Used by the Approach B render path —
378/// see [`Grid::chunk_xy_backing`].
379///
380/// S4B.6.a: gained `chunks_z` + `origin_chunk_z` for tall-world
381/// support. Pre-S4B.6.a `chunk_xy_backing` always populates these
382/// as `chunks_z=1 origin_chunk_z=0` (= chz=0 only); S4B.6.c will
383/// switch the render path to `chunk_xyz_backing` once the
384/// rasterizer is stack-aware.
385pub struct ChunkXyBacking<'a> {
386    /// Per-chunk views over the chx/chy/chz extent.
387    /// Length `chunks_x * chunks_y * chunks_z`; index layout
388    /// `[(dz * chunks_y + dy) * chunks_x + dx]`. `None` for
389    /// implicit-air chunks inside the bbox.
390    pub chunks: Vec<Option<roxlap_core::GridView<'a>>>,
391    /// XY index of the chunk at `chunks[0]` — the minimum chx/chy
392    /// among populated chunks at `chz = origin_chunk_z`.
393    pub origin_chunk_xy: [i32; 2],
394    /// Z index of the chunk at `chunks[0]`.
395    pub origin_chunk_z: i32,
396    /// Number of chunks along the X axis. Row stride.
397    pub chunks_x: u32,
398    /// Number of chunks along the Y axis.
399    pub chunks_y: u32,
400    /// Number of chunks along the Z axis. `1` from
401    /// [`Grid::chunk_xy_backing`]; `>1` only via the S4B.6.a
402    /// [`Grid::chunk_xyz_backing`].
403    pub chunks_z: u32,
404}
405
406#[cfg(test)]
407pub(crate) mod tests {
408    use super::*;
409    use crate::{GridTransform, CHUNK_SIZE_Z};
410
411    /// Decode `column`'s slab bytes and return `true` iff `z` is
412    /// covered by any solid run. Mirrors voxlap's column-walk
413    /// semantics — the b2 buffer is `[top0, bot0, top1, bot1, ...,
414    /// MAXZDIM_sentinel]`, with each `[top, bot)` pair denoting a
415    /// solid range.
416    pub(crate) fn voxel_is_solid(vxl: &Vxl, x: u32, y: u32, z: u32) -> bool {
417        super::vxl_voxel_solid(vxl, x, y, z)
418    }
419
420    #[test]
421    fn voxel_solid_reflects_set_voxel() {
422        // Grid::voxel_solid (the public picking query) reads back an
423        // edit: the set voxel is solid, its neighbour is air, and an
424        // unmaterialised chunk reads as air.
425        let mut g = Grid::new(GridTransform::identity());
426        g.set_voxel(IVec3::new(5, 6, 7), Some(0x80_aa_bb_cc));
427        assert!(g.voxel_solid(IVec3::new(5, 6, 7)), "set voxel is solid");
428        assert!(!g.voxel_solid(IVec3::new(5, 6, 8)), "neighbour is air");
429        assert!(
430            !g.voxel_solid(IVec3::new(900, 900, 7)),
431            "absent chunk reads as air",
432        );
433    }
434
435    #[test]
436    fn voxel_solid_handles_negative_coords() {
437        // Negative grid-local voxels decompose via div_euclid (addr
438        // semantics); the query must follow the same split.
439        let mut g = Grid::new(GridTransform::identity());
440        g.set_voxel(IVec3::new(-1, -1, 10), Some(0x80_11_22_33));
441        assert!(g.voxel_solid(IVec3::new(-1, -1, 10)));
442        assert!(!g.voxel_solid(IVec3::new(-1, -1, 11)));
443    }
444
445    #[test]
446    fn empty_chunk_has_correct_vsid() {
447        let vxl = empty_chunk_vxl();
448        assert_eq!(vxl.vsid, CHUNK_SIZE_XY);
449    }
450
451    #[test]
452    fn empty_chunk_is_all_air() {
453        let vxl = empty_chunk_vxl();
454        // Sample a few representative voxels — full coverage is in
455        // `empty_chunk_no_voxel_solid_anywhere` below.
456        for &(x, y, z) in &[
457            (0u32, 0u32, 0u32),
458            (0, 0, 100),
459            (0, 0, 200),
460            (CHUNK_SIZE_XY - 1, CHUNK_SIZE_XY - 1, 0),
461            (64, 64, 128),
462        ] {
463            assert!(
464                !voxel_is_solid(&vxl, x, y, z),
465                "voxel ({x}, {y}, {z}) should be air"
466            );
467        }
468    }
469
470    #[test]
471    fn empty_chunk_air_above_bedrock_on_grid_sample() {
472        // Stride 16 across the chunk catches structural breakage
473        // (a corner column wrong, a z-band wrong, etc.) without the
474        // 4M-query cost of a brute-force scan in debug mode.
475        // Voxlap's slab format keeps z=255 solid as the "below the
476        // world" sentinel; the renderer's `treat_z_max_as_air` flag
477        // handles displaying it as transparent. See
478        // `project_below_bedrock_all_sky.md` for the S1.X fix.
479        let vxl = empty_chunk_vxl();
480        let bedrock_z = CHUNK_SIZE_Z - 1;
481        for y in (0..CHUNK_SIZE_XY).step_by(16) {
482            for x in (0..CHUNK_SIZE_XY).step_by(16) {
483                for z in (0..bedrock_z).step_by(16) {
484                    assert!(
485                        !voxel_is_solid(&vxl, x, y, z),
486                        "voxel ({x}, {y}, {z}) leaked solid in empty chunk"
487                    );
488                }
489                // bedrock z is solid (placeholder).
490                assert!(voxel_is_solid(&vxl, x, y, bedrock_z));
491            }
492        }
493    }
494
495    #[test]
496    fn empty_chunk_keeps_bedrock_placeholder() {
497        // Voxlap's invariant: every column carries an implicit
498        // solid voxel at z = MAXZDIM-1 = 255 even after a full
499        // carve. The renderer reads this as the bedrock placeholder.
500        let vxl = empty_chunk_vxl();
501        assert!(voxel_is_solid(&vxl, 0, 0, CHUNK_SIZE_Z - 1));
502        assert!(voxel_is_solid(&vxl, 64, 64, CHUNK_SIZE_Z - 1));
503    }
504
505    #[test]
506    fn ensure_chunk_creates_when_missing() {
507        let mut g = Grid::new(GridTransform::identity());
508        assert_eq!(g.chunk_count(), 0);
509        assert!(g.chunk(IVec3::ZERO).is_none());
510        let _ = g.ensure_chunk(IVec3::ZERO);
511        assert_eq!(g.chunk_count(), 1);
512        assert!(g.chunk(IVec3::ZERO).is_some());
513    }
514
515    #[test]
516    fn ensure_chunk_returns_existing() {
517        // Calling ensure_chunk a second time on the same index
518        // doesn't replace the chunk. Verify by writing through the
519        // first call and reading through the second.
520        let mut g = Grid::new(GridTransform::identity());
521        let chunk = IVec3::new(2, -1, 0);
522        g.ensure_chunk(chunk);
523        // Voxel local (5, 6, 7) inside chunk (2, -1, 0) is
524        // grid-local global (2*128 + 5, -1*128 + 6, 0*256 + 7) =
525        // (261, -122, 7).
526        g.set_voxel(IVec3::new(261, -122, 7), Some(0x80_aa_bb_cc));
527        let vxl = g.ensure_chunk(chunk);
528        assert!(voxel_is_solid(vxl, 5, 6, 7));
529        assert_eq!(g.chunk_count(), 1);
530    }
531
532    #[test]
533    fn chunk_mut_returns_none_for_missing() {
534        let mut g = Grid::new(GridTransform::identity());
535        assert!(g.chunk_mut(IVec3::ZERO).is_none());
536    }
537
538    /// S4B.6.a: legacy `chunk_xy_backing` ignores chz!=0 chunks and
539    /// always returns `chunks_z=1 origin_chunk_z=0`. Sanity-check
540    /// the new field defaults so pre-S4B.6 render path stays
541    /// byte-identical.
542    #[test]
543    fn chunk_xy_backing_returns_chunks_z_one() {
544        let mut g = Grid::new(GridTransform::identity());
545        g.ensure_chunk(IVec3::new(0, 0, 0));
546        g.ensure_chunk(IVec3::new(1, 0, 0));
547        // Add a chunk at chz=1 — chunk_xy_backing should ignore it.
548        g.ensure_chunk(IVec3::new(0, 0, 1));
549        let backing = g.chunk_xy_backing().expect("two chz=0 chunks present");
550        assert_eq!(backing.chunks_z, 1);
551        assert_eq!(backing.origin_chunk_z, 0);
552        assert_eq!(backing.chunks_x, 2);
553        assert_eq!(backing.chunks_y, 1);
554        assert_eq!(backing.chunks.len(), 2);
555    }
556
557    /// S4B.6.a: new `chunk_xyz_backing` enumerates ALL chunks
558    /// including chz!=0. Indexing layout must match
559    /// `roxlap_core::ChunkGrid`: row-major per z slab.
560    #[test]
561    fn chunk_xyz_backing_with_stacked_chunks_enumerates_all_z() {
562        let mut g = Grid::new(GridTransform::identity());
563        g.ensure_chunk(IVec3::new(0, 0, 0));
564        g.ensure_chunk(IVec3::new(1, 0, 0));
565        g.ensure_chunk(IVec3::new(0, 0, 1));
566        // Leave (1, 0, 1) implicit-air.
567        let backing = g.chunk_xyz_backing().expect("at least one chunk");
568        assert_eq!(backing.chunks_x, 2);
569        assert_eq!(backing.chunks_y, 1);
570        assert_eq!(backing.chunks_z, 2);
571        assert_eq!(backing.origin_chunk_xy, [0, 0]);
572        assert_eq!(backing.origin_chunk_z, 0);
573        assert_eq!(backing.chunks.len(), 4); // dims [2, 1, 2]
574                                             // Index layout: [(dz * chunks_y + dy) * chunks_x + dx]
575                                             // (0, 0, 0) → dx=0, dy=0, dz=0 → 0
576                                             // (1, 0, 0) → dx=1, dy=0, dz=0 → 1
577                                             // (0, 0, 1) → dx=0, dy=0, dz=1 → 2
578                                             // (1, 0, 1) → dx=1, dy=0, dz=1 → 3 (implicit-air → None)
579        assert!(backing.chunks[0].is_some(), "(0, 0, 0) present");
580        assert!(backing.chunks[1].is_some(), "(1, 0, 0) present");
581        assert!(backing.chunks[2].is_some(), "(0, 0, 1) present");
582        assert!(backing.chunks[3].is_none(), "(1, 0, 1) implicit-air");
583    }
584
585    /// S4B.6.a: chunk_xyz_backing with negative chz origin —
586    /// origin_chunk_z = min_z must reflect the actual minimum chz.
587    #[test]
588    fn chunk_xyz_backing_with_negative_origin_chunk_z() {
589        let mut g = Grid::new(GridTransform::identity());
590        g.ensure_chunk(IVec3::new(0, 0, -2));
591        g.ensure_chunk(IVec3::new(0, 0, 0));
592        let backing = g.chunk_xyz_backing().expect("at least one chunk");
593        assert_eq!(backing.chunks_z, 3); // chz range [-2, 0]
594        assert_eq!(backing.origin_chunk_z, -2);
595        assert!(backing.chunks[0].is_some(), "chz=-2 → dz=0");
596        assert!(backing.chunks[1].is_none(), "chz=-1 → dz=1 implicit-air");
597        assert!(backing.chunks[2].is_some(), "chz=0 → dz=2");
598    }
599}