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