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    /// Mutably borrow a materialised chunk. Returns `None` for
148    /// implicit-air chunks; use [`Grid::ensure_chunk`] when you
149    /// need a `&mut Vxl` for an edit that may write voxels.
150    pub fn chunk_mut(&mut self, chunk_idx: IVec3) -> Option<&mut Vxl> {
151        self.chunks.get_mut(&chunk_idx)
152    }
153
154    /// Borrow `chunk_idx`'s [`Vxl`], creating an empty all-air
155    /// chunk first if it doesn't exist yet. The returned `&mut`
156    /// is valid for editing via [`roxlap_formats::edit`] — the new
157    /// chunk has [`Vxl::reserve_edit_capacity`] already applied.
158    pub fn ensure_chunk(&mut self, chunk_idx: IVec3) -> &mut Vxl {
159        self.chunks.entry(chunk_idx).or_insert_with(empty_chunk_vxl)
160    }
161
162    /// Number of materialised chunks. Implicit-air chunks don't
163    /// count.
164    #[must_use]
165    pub fn chunk_count(&self) -> usize {
166        self.chunks.len()
167    }
168
169    /// S4B.2.c.3: build a per-chunk [`roxlap_core::GridView`] table
170    /// over this grid's XY chunk footprint at `chz = 0`.
171    ///
172    /// Returns `None` if no chz=0 chunk is populated (the entire
173    /// grid would render as implicit air anyway).
174    ///
175    /// Iterates `chunks` once to find the chx/chy bounding box,
176    /// then a second time to fill the row-major
177    /// `Vec<Option<GridView<'_>>>`. Empty XY slots (implicit-air
178    /// chunks inside the box) get `None`.
179    ///
180    /// Pair with [`roxlap_core::ChunkGrid`] + [`roxlap_core::
181    /// GridView::from_chunk_grid`] to drive the Approach B render
182    /// path:
183    ///
184    /// ```ignore
185    /// let backing = grid.chunk_xy_backing().unwrap();
186    /// let cg = roxlap_core::ChunkGrid {
187    ///     chunks: &backing.chunks,
188    ///     origin_chunk_xy: backing.origin_chunk_xy,
189    ///     chunks_x: backing.chunks_x,
190    ///     chunks_y: backing.chunks_y,
191    /// };
192    /// let view = roxlap_core::GridView::from_chunk_grid(
193    ///     &cg, crate::CHUNK_SIZE_XY,
194    /// );
195    /// ```
196    ///
197    /// Only chz=0 chunks contribute (multi-z handoff lands in
198    /// S4B.3); higher-chz chunks in [`Self::chunks`] are ignored
199    /// here.
200    #[must_use]
201    pub fn chunk_xy_backing(&self) -> Option<ChunkXyBacking<'_>> {
202        let mut min_x = i32::MAX;
203        let mut min_y = i32::MAX;
204        let mut max_x = i32::MIN;
205        let mut max_y = i32::MIN;
206        let mut any = false;
207        for chunk_idx in self.chunks.keys() {
208            if chunk_idx.z != 0 {
209                continue;
210            }
211            min_x = min_x.min(chunk_idx.x);
212            min_y = min_y.min(chunk_idx.y);
213            max_x = max_x.max(chunk_idx.x);
214            max_y = max_y.max(chunk_idx.y);
215            any = true;
216        }
217        if !any {
218            return None;
219        }
220        #[allow(clippy::cast_sign_loss)]
221        let chunks_x = (max_x - min_x + 1) as u32;
222        #[allow(clippy::cast_sign_loss)]
223        let chunks_y = (max_y - min_y + 1) as u32;
224        let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
225            vec![None; (chunks_x * chunks_y) as usize];
226        for (chunk_idx, vxl) in &self.chunks {
227            if chunk_idx.z != 0 {
228                continue;
229            }
230            let dx = chunk_idx.x - min_x;
231            let dy = chunk_idx.y - min_y;
232            #[allow(clippy::cast_sign_loss)]
233            let i = (dy as u32 * chunks_x + dx as u32) as usize;
234            table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
235        }
236        Some(ChunkXyBacking {
237            chunks: table,
238            origin_chunk_xy: [min_x, min_y],
239            origin_chunk_z: 0,
240            chunks_x,
241            chunks_y,
242            chunks_z: 1,
243        })
244    }
245
246    /// S4B.6.a: 3D-aware version of [`Self::chunk_xy_backing`].
247    /// Enumerates ALL chunks across the chx/chy/chz bounding box
248    /// (not just `chz=0`) so a stacked grid can be rendered once
249    /// S4B.6.c switches the rasterizer to a chunk-z-aware column
250    /// walker.
251    ///
252    /// Iterates `chunks` once for the XYZ bbox, then a second time
253    /// to fill the row-major-per-z `Vec<Option<GridView<'_>>>`.
254    /// Index layout: `[(dz * chunks_y + dy) * chunks_x + dx]` —
255    /// matches [`roxlap_core::ChunkGrid`]'s indexing exactly.
256    ///
257    /// Returns `None` for empty grids.
258    #[must_use]
259    pub fn chunk_xyz_backing(&self) -> Option<ChunkXyBacking<'_>> {
260        let mut min_x = i32::MAX;
261        let mut min_y = i32::MAX;
262        let mut min_z = i32::MAX;
263        let mut max_x = i32::MIN;
264        let mut max_y = i32::MIN;
265        let mut max_z = i32::MIN;
266        let mut any = false;
267        for chunk_idx in self.chunks.keys() {
268            min_x = min_x.min(chunk_idx.x);
269            min_y = min_y.min(chunk_idx.y);
270            min_z = min_z.min(chunk_idx.z);
271            max_x = max_x.max(chunk_idx.x);
272            max_y = max_y.max(chunk_idx.y);
273            max_z = max_z.max(chunk_idx.z);
274            any = true;
275        }
276        if !any {
277            return None;
278        }
279        #[allow(clippy::cast_sign_loss)]
280        let chunks_x = (max_x - min_x + 1) as u32;
281        #[allow(clippy::cast_sign_loss)]
282        let chunks_y = (max_y - min_y + 1) as u32;
283        #[allow(clippy::cast_sign_loss)]
284        let chunks_z = (max_z - min_z + 1) as u32;
285        let mut table: Vec<Option<roxlap_core::GridView<'_>>> =
286            vec![None; (chunks_x * chunks_y * chunks_z) as usize];
287        for (chunk_idx, vxl) in &self.chunks {
288            let dx = chunk_idx.x - min_x;
289            let dy = chunk_idx.y - min_y;
290            let dz = chunk_idx.z - min_z;
291            #[allow(clippy::cast_sign_loss)]
292            let (dx, dy, dz) = (dx as u32, dy as u32, dz as u32);
293            let i = ((dz * chunks_y + dy) * chunks_x + dx) as usize;
294            table[i] = Some(roxlap_core::GridView::from_single_vxl(vxl));
295        }
296        Some(ChunkXyBacking {
297            chunks: table,
298            origin_chunk_xy: [min_x, min_y],
299            origin_chunk_z: min_z,
300            chunks_x,
301            chunks_y,
302            chunks_z,
303        })
304    }
305}
306
307/// S4B.2.c.3: chx/chy chunk table built from a [`Grid`].
308///
309/// Owns the `Vec<Option<GridView>>` so [`roxlap_core::ChunkGrid`]
310/// (which borrows the table) can live alongside the GridView
311/// constructed from it. Used by the Approach B render path —
312/// see [`Grid::chunk_xy_backing`].
313///
314/// S4B.6.a: gained `chunks_z` + `origin_chunk_z` for tall-world
315/// support. Pre-S4B.6.a `chunk_xy_backing` always populates these
316/// as `chunks_z=1 origin_chunk_z=0` (= chz=0 only); S4B.6.c will
317/// switch the render path to `chunk_xyz_backing` once the
318/// rasterizer is stack-aware.
319pub struct ChunkXyBacking<'a> {
320    /// Per-chunk views over the chx/chy/chz extent.
321    /// Length `chunks_x * chunks_y * chunks_z`; index layout
322    /// `[(dz * chunks_y + dy) * chunks_x + dx]`. `None` for
323    /// implicit-air chunks inside the bbox.
324    pub chunks: Vec<Option<roxlap_core::GridView<'a>>>,
325    /// XY index of the chunk at `chunks[0]` — the minimum chx/chy
326    /// among populated chunks at `chz = origin_chunk_z`.
327    pub origin_chunk_xy: [i32; 2],
328    /// Z index of the chunk at `chunks[0]`.
329    pub origin_chunk_z: i32,
330    /// Number of chunks along the X axis. Row stride.
331    pub chunks_x: u32,
332    /// Number of chunks along the Y axis.
333    pub chunks_y: u32,
334    /// Number of chunks along the Z axis. `1` from
335    /// [`Grid::chunk_xy_backing`]; `>1` only via the S4B.6.a
336    /// [`Grid::chunk_xyz_backing`].
337    pub chunks_z: u32,
338}
339
340#[cfg(test)]
341pub(crate) mod tests {
342    use super::*;
343    use crate::{GridTransform, CHUNK_SIZE_Z};
344
345    /// Decode `column`'s slab bytes and return `true` iff `z` is
346    /// covered by any solid run. Mirrors voxlap's column-walk
347    /// semantics — the b2 buffer is `[top0, bot0, top1, bot1, ...,
348    /// MAXZDIM_sentinel]`, with each `[top, bot)` pair denoting a
349    /// solid range.
350    pub(crate) fn voxel_is_solid(vxl: &Vxl, x: u32, y: u32, z: u32) -> bool {
351        super::vxl_voxel_solid(vxl, x, y, z)
352    }
353
354    #[test]
355    fn voxel_solid_reflects_set_voxel() {
356        // Grid::voxel_solid (the public picking query) reads back an
357        // edit: the set voxel is solid, its neighbour is air, and an
358        // unmaterialised chunk reads as air.
359        let mut g = Grid::new(GridTransform::identity());
360        g.set_voxel(IVec3::new(5, 6, 7), Some(0x80_aa_bb_cc));
361        assert!(g.voxel_solid(IVec3::new(5, 6, 7)), "set voxel is solid");
362        assert!(!g.voxel_solid(IVec3::new(5, 6, 8)), "neighbour is air");
363        assert!(
364            !g.voxel_solid(IVec3::new(900, 900, 7)),
365            "absent chunk reads as air",
366        );
367    }
368
369    #[test]
370    fn voxel_solid_handles_negative_coords() {
371        // Negative grid-local voxels decompose via div_euclid (addr
372        // semantics); the query must follow the same split.
373        let mut g = Grid::new(GridTransform::identity());
374        g.set_voxel(IVec3::new(-1, -1, 10), Some(0x80_11_22_33));
375        assert!(g.voxel_solid(IVec3::new(-1, -1, 10)));
376        assert!(!g.voxel_solid(IVec3::new(-1, -1, 11)));
377    }
378
379    #[test]
380    fn empty_chunk_has_correct_vsid() {
381        let vxl = empty_chunk_vxl();
382        assert_eq!(vxl.vsid, CHUNK_SIZE_XY);
383    }
384
385    #[test]
386    fn empty_chunk_is_all_air() {
387        let vxl = empty_chunk_vxl();
388        // Sample a few representative voxels — full coverage is in
389        // `empty_chunk_no_voxel_solid_anywhere` below.
390        for &(x, y, z) in &[
391            (0u32, 0u32, 0u32),
392            (0, 0, 100),
393            (0, 0, 200),
394            (CHUNK_SIZE_XY - 1, CHUNK_SIZE_XY - 1, 0),
395            (64, 64, 128),
396        ] {
397            assert!(
398                !voxel_is_solid(&vxl, x, y, z),
399                "voxel ({x}, {y}, {z}) should be air"
400            );
401        }
402    }
403
404    #[test]
405    fn empty_chunk_air_above_bedrock_on_grid_sample() {
406        // Stride 16 across the chunk catches structural breakage
407        // (a corner column wrong, a z-band wrong, etc.) without the
408        // 4M-query cost of a brute-force scan in debug mode.
409        // Voxlap's slab format keeps z=255 solid as the "below the
410        // world" sentinel; the renderer's `treat_z_max_as_air` flag
411        // handles displaying it as transparent. See
412        // `project_below_bedrock_all_sky.md` for the S1.X fix.
413        let vxl = empty_chunk_vxl();
414        let bedrock_z = CHUNK_SIZE_Z - 1;
415        for y in (0..CHUNK_SIZE_XY).step_by(16) {
416            for x in (0..CHUNK_SIZE_XY).step_by(16) {
417                for z in (0..bedrock_z).step_by(16) {
418                    assert!(
419                        !voxel_is_solid(&vxl, x, y, z),
420                        "voxel ({x}, {y}, {z}) leaked solid in empty chunk"
421                    );
422                }
423                // bedrock z is solid (placeholder).
424                assert!(voxel_is_solid(&vxl, x, y, bedrock_z));
425            }
426        }
427    }
428
429    #[test]
430    fn empty_chunk_keeps_bedrock_placeholder() {
431        // Voxlap's invariant: every column carries an implicit
432        // solid voxel at z = MAXZDIM-1 = 255 even after a full
433        // carve. The renderer reads this as the bedrock placeholder.
434        let vxl = empty_chunk_vxl();
435        assert!(voxel_is_solid(&vxl, 0, 0, CHUNK_SIZE_Z - 1));
436        assert!(voxel_is_solid(&vxl, 64, 64, CHUNK_SIZE_Z - 1));
437    }
438
439    #[test]
440    fn ensure_chunk_creates_when_missing() {
441        let mut g = Grid::new(GridTransform::identity());
442        assert_eq!(g.chunk_count(), 0);
443        assert!(g.chunk(IVec3::ZERO).is_none());
444        let _ = g.ensure_chunk(IVec3::ZERO);
445        assert_eq!(g.chunk_count(), 1);
446        assert!(g.chunk(IVec3::ZERO).is_some());
447    }
448
449    #[test]
450    fn ensure_chunk_returns_existing() {
451        // Calling ensure_chunk a second time on the same index
452        // doesn't replace the chunk. Verify by writing through the
453        // first call and reading through the second.
454        let mut g = Grid::new(GridTransform::identity());
455        let chunk = IVec3::new(2, -1, 0);
456        g.ensure_chunk(chunk);
457        // Voxel local (5, 6, 7) inside chunk (2, -1, 0) is
458        // grid-local global (2*128 + 5, -1*128 + 6, 0*256 + 7) =
459        // (261, -122, 7).
460        g.set_voxel(IVec3::new(261, -122, 7), Some(0x80_aa_bb_cc));
461        let vxl = g.ensure_chunk(chunk);
462        assert!(voxel_is_solid(vxl, 5, 6, 7));
463        assert_eq!(g.chunk_count(), 1);
464    }
465
466    #[test]
467    fn chunk_mut_returns_none_for_missing() {
468        let mut g = Grid::new(GridTransform::identity());
469        assert!(g.chunk_mut(IVec3::ZERO).is_none());
470    }
471
472    /// S4B.6.a: legacy `chunk_xy_backing` ignores chz!=0 chunks and
473    /// always returns `chunks_z=1 origin_chunk_z=0`. Sanity-check
474    /// the new field defaults so pre-S4B.6 render path stays
475    /// byte-identical.
476    #[test]
477    fn chunk_xy_backing_returns_chunks_z_one() {
478        let mut g = Grid::new(GridTransform::identity());
479        g.ensure_chunk(IVec3::new(0, 0, 0));
480        g.ensure_chunk(IVec3::new(1, 0, 0));
481        // Add a chunk at chz=1 — chunk_xy_backing should ignore it.
482        g.ensure_chunk(IVec3::new(0, 0, 1));
483        let backing = g.chunk_xy_backing().expect("two chz=0 chunks present");
484        assert_eq!(backing.chunks_z, 1);
485        assert_eq!(backing.origin_chunk_z, 0);
486        assert_eq!(backing.chunks_x, 2);
487        assert_eq!(backing.chunks_y, 1);
488        assert_eq!(backing.chunks.len(), 2);
489    }
490
491    /// S4B.6.a: new `chunk_xyz_backing` enumerates ALL chunks
492    /// including chz!=0. Indexing layout must match
493    /// `roxlap_core::ChunkGrid`: row-major per z slab.
494    #[test]
495    fn chunk_xyz_backing_with_stacked_chunks_enumerates_all_z() {
496        let mut g = Grid::new(GridTransform::identity());
497        g.ensure_chunk(IVec3::new(0, 0, 0));
498        g.ensure_chunk(IVec3::new(1, 0, 0));
499        g.ensure_chunk(IVec3::new(0, 0, 1));
500        // Leave (1, 0, 1) implicit-air.
501        let backing = g.chunk_xyz_backing().expect("at least one chunk");
502        assert_eq!(backing.chunks_x, 2);
503        assert_eq!(backing.chunks_y, 1);
504        assert_eq!(backing.chunks_z, 2);
505        assert_eq!(backing.origin_chunk_xy, [0, 0]);
506        assert_eq!(backing.origin_chunk_z, 0);
507        assert_eq!(backing.chunks.len(), 2 * 1 * 2);
508        // Index layout: [(dz * chunks_y + dy) * chunks_x + dx]
509        // (0, 0, 0) → dx=0, dy=0, dz=0 → 0
510        // (1, 0, 0) → dx=1, dy=0, dz=0 → 1
511        // (0, 0, 1) → dx=0, dy=0, dz=1 → 2
512        // (1, 0, 1) → dx=1, dy=0, dz=1 → 3 (implicit-air → None)
513        assert!(backing.chunks[0].is_some(), "(0, 0, 0) present");
514        assert!(backing.chunks[1].is_some(), "(1, 0, 0) present");
515        assert!(backing.chunks[2].is_some(), "(0, 0, 1) present");
516        assert!(backing.chunks[3].is_none(), "(1, 0, 1) implicit-air");
517    }
518
519    /// S4B.6.a: chunk_xyz_backing with negative chz origin —
520    /// origin_chunk_z = min_z must reflect the actual minimum chz.
521    #[test]
522    fn chunk_xyz_backing_with_negative_origin_chunk_z() {
523        let mut g = Grid::new(GridTransform::identity());
524        g.ensure_chunk(IVec3::new(0, 0, -2));
525        g.ensure_chunk(IVec3::new(0, 0, 0));
526        let backing = g.chunk_xyz_backing().expect("at least one chunk");
527        assert_eq!(backing.chunks_z, 3); // chz range [-2, 0]
528        assert_eq!(backing.origin_chunk_z, -2);
529        assert!(backing.chunks[0].is_some(), "chz=-2 → dz=0");
530        assert!(backing.chunks[1].is_none(), "chz=-1 → dz=1 implicit-air");
531        assert!(backing.chunks[2].is_some(), "chz=0 → dz=2");
532    }
533}