Skip to main content

roxlap_core/
grid_view.rs

1//! Per-frame voxel-world borrow shape.
2//!
3//! Wraps the `(vsid, slab_buf, column_offsets, mip_base_offsets)`
4//! tuple the renderer ([`crate::dda`]) reads. A single
5//! [`roxlap_formats::vxl::Vxl`] is one chunk; a [`ChunkGrid`] composes
6//! many chunks behind the same borrow shape.
7//!
8//! Substage S4B.0 introduced the shape as a pure rename — opticast
9//! drove a single flat world behind a typed borrow. Subsequent
10//! S4B.x sub-substages grow this into a multi-chunk view:
11//!
12//! * S4B.1 — carry the camera's chunk index alongside the borrow.
13//! * S4B.2.a — `chunk_size_xy` field + `chunk_at_xy` method.
14//!   Today's single-chunk callers set `chunk_size_xy = vsid` and
15//!   the lookup only succeeds for `[0, 0]`.
16//! * S4B.2.b — grouscan column-step calls `chunk_at_xy` and swaps
17//!   active per-chunk `(slab_buf, column_offsets)` when `(cx, cy)`
18//!   crosses a chunk boundary. Single-chunk goldens byte-identical.
19//! * S4B.2.c.1 (this file) — [`ChunkGrid`] backend so `chunk_at_xy`
20//!   can resolve real per-chunk borrows. Pure infrastructure; no
21//!   caller integration yet.
22//! * S4B.2.c.2 — scene-side multi-chunk constructor + 32×32
23//!   ground seam test.
24//! * S4B.3 — chunk-z extent + handoff for cross-chunk-Z rays.
25//!
26//! See `project_s4_b_plan.md` for the full sub-substage plan.
27
28use roxlap_formats::vxl::Vxl;
29
30/// Per-frame world borrow that opticast + the rasterizer share.
31///
32/// Today: a single chunk's `(vsid, slab_buf, column_offsets,
33/// mip_base_offsets)`. `Copy` so callers can pass it to opticast
34/// and stash it on the rasterizer without ceremony — every field
35/// is a borrow or a `u32`.
36///
37/// Fields are public on purpose. External callers usually go
38/// through the [`from_single_vxl`](Self::from_single_vxl) /
39/// [`from_parts`](Self::from_parts) constructors, but the engine's
40/// internals destructure directly. Keeping the fields exposed
41/// avoids a layer of accessor methods that the borrow checker
42/// would otherwise force at every read.
43/// Z extent of a single chunk's slab table, in voxels. Voxlap's
44/// slab byte format encodes z as `u8`, so each chunk covers exactly
45/// 256 voxels along Z. Tall worlds stack chunks vertically (see
46/// `memory/project_s4b_6_z_stacking_plan.md`).
47pub const CHUNK_SIZE_Z: u32 = 256;
48
49#[derive(Clone, Copy)]
50pub struct GridView<'a> {
51    /// Square dimension of the currently-active chunk view (matches
52    /// the source `Vxl`'s `vsid` for single-chunk callers). The
53    /// per-chunk `column_offsets` table holds `(vsid² + 1)` entries.
54    pub vsid: u32,
55    /// S4B.2.a: square dimension of each chunk in XY voxel units.
56    /// For today's single-chunk callers, `chunk_size_xy == vsid` so
57    /// `(cx, cy)` in `[0, vsid)` never crosses a chunk boundary.
58    /// For multi-chunk callers (S4B.2.c+), `chunk_size_xy` is the
59    /// per-chunk dimension (typically 128) and `vsid` is the same
60    /// per-chunk value — they may diverge in S4B.4 when `GridView`
61    /// stops carrying a "default" chunk's flat fields.
62    pub chunk_size_xy: u32,
63    /// S4B.6.a: Z extent of each chunk in voxel units. Locked to
64    /// `MAXZDIM = 256` for every chunk (voxlap's slab byte format
65    /// uses a `u8` z field). Tall worlds stack chunks vertically
66    /// rather than extending this constant — see
67    /// `memory/project_s4b_6_z_stacking_plan.md`. Pre-S4B.6.a
68    /// callers set this to `256` (the only valid value) and the
69    /// rasterizer ignores chunk-z boundaries; S4B.6.c will start
70    /// consuming the field for cross-chunk-z column walks.
71    pub chunk_size_z: u32,
72    /// Flat slab byte buffer for every column at every built mip.
73    pub slab_buf: &'a [u8],
74    /// Per-column byte offsets into [`Self::slab_buf`], concatenated
75    /// across every mip's sub-table. Mip-0 occupies indices
76    /// `mip_base_offsets[0]..mip_base_offsets[1]`.
77    pub column_offsets: &'a [u32],
78    /// Mip-level boundaries inside [`Self::column_offsets`].
79    /// Length `mip_count + 1`; trailing sentinel equals
80    /// `column_offsets.len()`. Single-mip callers pass
81    /// `&[0, vsid² + 1]`.
82    pub mip_base_offsets: &'a [usize],
83    /// S4B.2.c.1: chunk-grid backend. `None` for single-chunk views
84    /// (e.g. anything built via [`Self::from_single_vxl`] /
85    /// [`Self::from_parts`]) — [`Self::chunk_at_xy`] falls back to
86    /// the `Some(Self) for [0, 0]` behaviour. `Some(&...)` for
87    /// multi-chunk views built via [`Self::from_chunk_grid`] —
88    /// [`Self::chunk_at_xy`] consults the table.
89    pub chunk_grid: Option<&'a ChunkGrid<'a>>,
90}
91
92/// S4B.2.c.1: chunk-grid metadata for multi-chunk [`GridView`]
93/// lookups.
94///
95/// Stores a 2D table of optional per-chunk [`GridView`] borrows so
96/// [`GridView::chunk_at_xy`] can resolve an XY chunk index to the
97/// matching chunk's `(slab_buf, column_offsets, ...)` view. Empty
98/// table entries (`None`) signal "no chunk at that index" — the
99/// grouscan column-step treats them as fully-air (the chunk renders
100/// as sky / empty until the ray crosses into a populated chunk).
101///
102/// Sized to match the chunk grid's full XYZ footprint:
103/// `chunks.len() == chunks_x * chunks_y * chunks_z`. Chunk at
104/// relative position `(dx, dy, dz)` from
105/// `(origin_chunk_xy, origin_chunk_z)` lives at index
106/// `(dz * chunks_y + dy) * chunks_x + dx`. The per-chunk
107/// [`GridView`] entries should have `chunk_grid: None` (they
108/// describe individual chunks, not the parent grid).
109///
110/// S4B.6.a: `chunks_z` + `origin_chunk_z` added for tall worlds.
111/// Pre-S4B.6 callers built `ChunkGrid` with implicit `chunks_z=1
112/// origin_chunk_z=0`; the new layout is backwards-compatible —
113/// `dz=0` substitution gives the same index `dy * chunks_x + dx`,
114/// so a flat `chunks_x * chunks_y` slice indexes identically.
115#[derive(Clone, Copy)]
116pub struct ChunkGrid<'a> {
117    /// Per-chunk views. Length `chunks_x * chunks_y * chunks_z`.
118    /// Index layout: `[(dz * chunks_y + dy) * chunks_x + dx]`.
119    pub chunks: &'a [Option<GridView<'a>>],
120    /// XY index of the chunk at `chunks[0]`. Subsequent chunks lie
121    /// along `+x` (next in the row) then `+y` (next row).
122    pub origin_chunk_xy: [i32; 2],
123    /// Z index of the chunk at `chunks[0]`. Subsequent z slabs lie
124    /// at `+chunks_x * chunks_y` strides into [`Self::chunks`].
125    pub origin_chunk_z: i32,
126    /// Number of chunks along the X axis. Row stride in
127    /// [`Self::chunks`].
128    pub chunks_x: u32,
129    /// Number of chunks along the Y axis.
130    pub chunks_y: u32,
131    /// Number of chunks along the Z axis. `1` for non-stacked
132    /// worlds (matches every pre-S4B.6.a caller).
133    pub chunks_z: u32,
134}
135
136impl<'a> GridView<'a> {
137    /// Build from explicit fields. Test fixtures use this directly;
138    /// production callers usually go through
139    /// [`from_single_vxl`](Self::from_single_vxl).
140    ///
141    /// Sets `chunk_size_xy = vsid` (single-chunk semantics). Use
142    /// [`with_chunk_size_xy`](Self::with_chunk_size_xy) to mark the
143    /// view as part of a chunk grid.
144    #[must_use]
145    pub fn from_parts(
146        vsid: u32,
147        slab_buf: &'a [u8],
148        column_offsets: &'a [u32],
149        mip_base_offsets: &'a [usize],
150    ) -> Self {
151        Self {
152            vsid,
153            chunk_size_xy: vsid,
154            chunk_size_z: CHUNK_SIZE_Z,
155            slab_buf,
156            column_offsets,
157            mip_base_offsets,
158            chunk_grid: None,
159        }
160    }
161
162    /// Borrow a parsed `.vxl` map as a single-chunk grid view. The
163    /// scene-graph stage's eventual multi-chunk constructor will
164    /// live alongside this one (`from_grid` over
165    /// `roxlap_scene::Grid`).
166    #[must_use]
167    pub fn from_single_vxl(vxl: &'a Vxl) -> Self {
168        Self {
169            vsid: vxl.vsid,
170            chunk_size_xy: vxl.vsid,
171            chunk_size_z: CHUNK_SIZE_Z,
172            slab_buf: &vxl.data,
173            column_offsets: &vxl.column_offset,
174            mip_base_offsets: &vxl.mip_base_offsets,
175            chunk_grid: None,
176        }
177    }
178
179    /// S4B.2.c.1: build a multi-chunk view from a [`ChunkGrid`].
180    ///
181    /// The returned [`GridView`]'s flat `(vsid, slab_buf,
182    /// column_offsets, mip_base_offsets)` fields are seeded from
183    /// the first populated chunk in the grid (so opticast's prelude
184    /// has a sensible default before its camera-chunk lookup
185    /// refreshes them). `chunk_size_xy` carries the caller-supplied
186    /// per-chunk dimension; [`Self::chunk_grid`] points at
187    /// `chunk_grid` so [`Self::chunk_at_xy`] resolves every
188    /// in-range index to its actual chunk borrow.
189    ///
190    /// Empty-grid case: if every chunk is `None`, the flat fields
191    /// fall back to empty slices and `vsid = chunk_size_xy`. The
192    /// grouscan column-step swap will see `chunk_at_xy → None` for
193    /// every index and render the whole grid as sky.
194    #[must_use]
195    pub fn from_chunk_grid(chunk_grid: &'a ChunkGrid<'a>, chunk_size_xy: u32) -> Self {
196        let default_chunk = chunk_grid.chunks.iter().find_map(|c| c.as_ref()).copied();
197        match default_chunk {
198            Some(c) => Self {
199                vsid: c.vsid,
200                chunk_size_xy,
201                chunk_size_z: CHUNK_SIZE_Z,
202                slab_buf: c.slab_buf,
203                column_offsets: c.column_offsets,
204                mip_base_offsets: c.mip_base_offsets,
205                chunk_grid: Some(chunk_grid),
206            },
207            None => Self {
208                vsid: chunk_size_xy,
209                chunk_size_xy,
210                chunk_size_z: CHUNK_SIZE_Z,
211                slab_buf: &[],
212                column_offsets: &[],
213                mip_base_offsets: &[],
214                chunk_grid: Some(chunk_grid),
215            },
216        }
217    }
218
219    /// S4B.2.a builder: override [`Self::chunk_size_xy`]. Multi-chunk
220    /// callers (S4B.2.c+) use this to mark the view as one chunk of
221    /// a larger grid. Today no caller needs it; the existence makes
222    /// the seam testable in isolation.
223    #[must_use]
224    pub fn with_chunk_size_xy(mut self, chunk_size_xy: u32) -> Self {
225        self.chunk_size_xy = chunk_size_xy;
226        self
227    }
228
229    /// Number of built mip levels (mip-0 always counts). `0` only for a
230    /// degenerate empty view.
231    #[must_use]
232    pub fn mip_count(&self) -> u32 {
233        #[allow(clippy::cast_possible_truncation)]
234        {
235            self.mip_base_offsets.len().saturating_sub(1) as u32
236        }
237    }
238
239    /// Raw slab-chain bytes for column `(x, y)` at mip `mip`, or `None`
240    /// if out of range / unbacked / `mip` not built. At mip-N the
241    /// per-chunk grid is `(vsid >> N)²` columns.
242    fn column_slab_mip(&self, x: u32, y: u32, mip: u32) -> Option<&'a [u8]> {
243        if mip >= self.mip_count() {
244            return None;
245        }
246        let vsid_m = (self.vsid >> mip).max(1);
247        if x >= vsid_m || y >= vsid_m {
248            return None;
249        }
250        let base = self.mip_base_offsets[mip as usize];
251        let col_idx = base + (y * vsid_m + x) as usize;
252        let start = *self.column_offsets.get(col_idx)? as usize;
253        // Return the unbounded tail from the column's start (not a
254        // `slng`-bounded slice): the slab-chain decoders self-terminate
255        // at `nextptr == 0`, so computing the exact length up front is a
256        // redundant full-chain walk — the dominant cost when
257        // `surface_color` is called per cell. `.get()` inside the
258        // decoders still guards the buffer end against malformed data.
259        self.slab_buf.get(start..)
260    }
261
262    /// Top z of the solid run containing voxel `(x, y, z)` at mip 0, or
263    /// `None` if the voxel is air. See [`Self::voxel_run_top_mip`].
264    #[must_use]
265    pub fn voxel_run_top(&self, x: u32, y: u32, z: u32) -> Option<i32> {
266        self.voxel_run_top_mip(x, y, z, 0)
267    }
268
269    /// Top z of the solid run containing voxel `(x, y, z)` at mip `mip`,
270    /// or `None` if air. Coordinates are in mip-`mip` units
271    /// (`x, y ∈ [0, vsid >> mip)`, `z ∈ [0, CHUNK_SIZE_Z >> mip)`).
272    ///
273    /// Mirrors [`roxlap_formats::edit::expandrle`]'s run decomposition
274    /// (the same `[top, bot)` solid runs the scene's `voxel_solid`
275    /// uses), walking the slab chain in place. The returned top is
276    /// always a colour-list voxel, so callers can shade an interior /
277    /// side-face hit with `voxel_color_mip(x, y, top, mip)`.
278    #[must_use]
279    pub fn voxel_run_top_mip(&self, x: u32, y: u32, z: u32, mip: u32) -> Option<i32> {
280        let maxz = (CHUNK_SIZE_Z >> mip) as i32;
281        if i64::from(z) >= i64::from(maxz) {
282            return None;
283        }
284        let slab = self.column_slab_mip(x, y, mip)?;
285        #[allow(clippy::cast_possible_wrap)]
286        let zi = z as i32;
287        // First run opens at the first slab's z1 (`expandrle uind[0]`).
288        let mut top = i32::from(slab[1]);
289        let mut v = 0usize;
290        loop {
291            let nextptr = usize::from(slab[v]);
292            if nextptr == 0 {
293                // Last run extends to bedrock: [top, maxz).
294                return (zi >= top && zi < maxz).then_some(top);
295            }
296            v += nextptr * 4;
297            let ze = i32::from(slab[v + 3]);
298            let z1 = i32::from(slab[v + 1]);
299            if ze >= z1 {
300                continue; // degenerate slab — run continues
301            }
302            // Current run closes at `ze`; the next opens at `z1`.
303            if zi >= top && zi < ze {
304                return Some(top);
305            }
306            top = z1;
307        }
308    }
309
310    /// Call `f(top, bot)` for each solid run `[top, bot)` of column
311    /// `(x, y)` at mip 0. See [`Self::for_each_run_mip`].
312    pub fn for_each_run(&self, x: u32, y: u32, f: impl FnMut(i32, i32)) {
313        self.for_each_run_mip(x, y, 0, f);
314    }
315
316    /// Call `f(top, bot)` for each solid run `[top, bot)` of column
317    /// `(x, y)` at mip `mip`, top-to-bottom (the
318    /// [`roxlap_formats::edit::expandrle`] decomposition). No-op for an
319    /// out-of-range / unbacked column. The brickmap builder
320    /// ([`crate::dda`]) uses this to mark occupied bricks.
321    pub fn for_each_run_mip(&self, x: u32, y: u32, mip: u32, mut f: impl FnMut(i32, i32)) {
322        let Some(slab) = self.column_slab_mip(x, y, mip) else {
323            return;
324        };
325        let maxz = (CHUNK_SIZE_Z >> mip) as i32;
326        let mut top = i32::from(slab[1]);
327        let mut v = 0usize;
328        loop {
329            let nextptr = usize::from(slab[v]);
330            if nextptr == 0 {
331                f(top, maxz); // last run extends to bedrock
332                return;
333            }
334            v += nextptr * 4;
335            let ze = i32::from(slab[v + 3]);
336            let z1 = i32::from(slab[v + 1]);
337            if ze >= z1 {
338                continue; // degenerate slab — run continues
339            }
340            f(top, ze);
341            top = z1;
342        }
343    }
344
345    /// DDA hit colour for voxel `(x, y, z)` at mip 0. See
346    /// [`Self::surface_color_mip`].
347    #[must_use]
348    pub fn surface_color(&self, x: u32, y: u32, z: u32) -> Option<u32> {
349        self.surface_color_mip(x, y, z, 0)
350    }
351
352    /// DDA hit colour for voxel `(x, y, z)` at mip `mip`: the display
353    /// colour if the cell is **solid and renderable**, or `None` for
354    /// air or an uncoloured bedrock run (stepped through transparently).
355    ///
356    /// Exact colour-list cells return their own colour; interior /
357    /// side-face cells fall back to the colour of their run's top voxel
358    /// (the surface colour "bleeds" down a cliff face).
359    #[must_use]
360    pub fn surface_color_mip(&self, x: u32, y: u32, z: u32, mip: u32) -> Option<u32> {
361        let top = self.voxel_run_top_mip(x, y, z, mip)?;
362        self.voxel_color_mip(x, y, z, mip)
363            .or_else(|| self.voxel_color_mip(x, y, u32::try_from(top).ok()?, mip))
364    }
365
366    /// Surface colour of voxel `(x, y, z)` at mip 0. See
367    /// [`Self::voxel_color_mip`].
368    #[must_use]
369    pub fn voxel_color(&self, x: u32, y: u32, z: u32) -> Option<u32> {
370        self.voxel_color_mip(x, y, z, 0)
371    }
372
373    /// Surface colour of voxel `(x, y, z)` at mip `mip` (mip-`mip`
374    /// coordinates), or `None` for an empty / out-of-range cell.
375    ///
376    /// Decodes the column's slab chain directly from the [`GridView`]
377    /// borrow (the same `vbuf` floor + ceiling list walk
378    /// [`roxlap_formats::vxl::Vxl::voxel_color`] performs). The returned
379    /// `u32` is packed `0xAARRGGBB`; a zero-RGB cell (empty-chunk
380    /// placeholder) reads as `None`. Surface voxels only — use
381    /// [`Self::surface_color_mip`] for the full solid-cell hit test.
382    #[must_use]
383    pub fn voxel_color_mip(&self, x: u32, y: u32, z: u32, mip: u32) -> Option<u32> {
384        let maxz = (CHUNK_SIZE_Z >> mip) as i32;
385        if i64::from(z) >= i64::from(maxz) {
386            return None;
387        }
388        let slab = self.column_slab_mip(x, y, mip)?;
389        #[allow(clippy::cast_possible_wrap)]
390        let zi = z as i32;
391        let texel = |b: &[u8]| -> Option<u32> {
392            let rgb = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
393            // Zero RGB = empty-chunk placeholder → untextured.
394            (rgb & 0x00ff_ffff != 0).then_some(rgb)
395        };
396        let mut v = 0usize;
397        loop {
398            // Floor colour list of the current slab: z ∈ [z1, z1c].
399            let z_start = i32::from(slab[v + 1]);
400            let z1c = i32::from(slab[v + 2]);
401            if zi >= z_start && zi <= z1c {
402                let off = v + 4 + ((zi - z_start) as usize) * 4;
403                return texel(slab.get(off..off + 4)?);
404            }
405            let nextptr = slab[v];
406            if nextptr == 0 {
407                return None; // last slab, z not in its floor list
408            }
409            let (prev_z1, prev_z1c, prev_nextptr) = (z_start, z1c, i32::from(nextptr));
410            v += usize::from(nextptr) * 4;
411            // Ceiling colour list for the NEW slab — stored in the tail
412            // of the previous slab's bytes (voxlap `vbuf` convention).
413            let ze = i32::from(slab[v + 3]);
414            let ceil_z_start = ze + prev_z1c - prev_z1 - prev_nextptr + 2;
415            if zi >= ceil_z_start && zi < ze {
416                let ceil_n = (ze - ceil_z_start) as usize;
417                let ceil_start = v - ceil_n * 4;
418                let off = ceil_start + ((zi - ceil_z_start) as usize) * 4;
419                return texel(slab.get(off..off + 4)?);
420            }
421        }
422    }
423
424    /// S4B.2.d: voxel-space XY axis-aligned bounding box of the
425    /// grid. Returns `([xmin, ymin], [xmax, ymax])` in voxel units;
426    /// the grid contains voxels with coordinates in
427    /// `[xmin, xmax) × [ymin, ymax)`.
428    ///
429    /// - Single-chunk (`chunk_grid: None`): returns
430    ///   `([0, 0], [vsid, vsid])`. Byte-identical to the historical
431    ///   single-chunk world-edge math.
432    /// - Multi-chunk (`chunk_grid: Some(&cg)`): derived from
433    ///   `cg.origin_chunk_xy + cg.chunks_x/y * chunk_size_xy`.
434    ///
435    /// Used as the renderer's outer DDA bounds (the world-edge box the
436    /// per-pixel ray is clipped to).
437    #[must_use]
438    pub fn aabb_xy(&self) -> ([i32; 2], [i32; 2]) {
439        if let Some(cg) = self.chunk_grid {
440            #[allow(clippy::cast_possible_wrap)]
441            let cs = self.chunk_size_xy as i32;
442            #[allow(clippy::cast_possible_wrap)]
443            let chunks_x = cg.chunks_x as i32;
444            #[allow(clippy::cast_possible_wrap)]
445            let chunks_y = cg.chunks_y as i32;
446            let xmin = cg.origin_chunk_xy[0] * cs;
447            let ymin = cg.origin_chunk_xy[1] * cs;
448            let xmax = xmin + chunks_x * cs;
449            let ymax = ymin + chunks_y * cs;
450            ([xmin, ymin], [xmax, ymax])
451        } else {
452            #[allow(clippy::cast_possible_wrap)]
453            let v = self.vsid as i32;
454            ([0, 0], [v, v])
455        }
456    }
457
458    /// Full voxel-space bounding box of the grid: `([x0, y0, z0],
459    /// [x1, y1, z1])` half-open in grid-local voxel coordinates. XY
460    /// comes from [`Self::aabb_xy`]; Z spans the chunk grid's
461    /// `chunks_z` layers (`origin_chunk_z * CHUNK_SIZE_Z` upward), or a
462    /// single `[0, CHUNK_SIZE_Z)` chunk when un-stacked. The DDA
463    /// renderer ([`crate::dda`]) uses this as the outer traversal box.
464    #[must_use]
465    pub fn voxel_bounds(&self) -> ([i32; 3], [i32; 3]) {
466        let ([x0, y0], [x1, y1]) = self.aabb_xy();
467        let csz = CHUNK_SIZE_Z as i32;
468        let (z0, z1) = if let Some(cg) = self.chunk_grid {
469            #[allow(clippy::cast_possible_wrap)]
470            let chunks_z = cg.chunks_z as i32;
471            (
472                cg.origin_chunk_z * csz,
473                (cg.origin_chunk_z + chunks_z) * csz,
474            )
475        } else {
476            (0, csz)
477        };
478        ([x0, y0, z0], [x1, y1, z1])
479    }
480
481    /// S4B.2.a: chunk lookup for the cross-chunk-XY DDA.
482    ///
483    /// Returns the [`GridView`] for the chunk at XY index
484    /// `chunk_idx` if one exists, `None` otherwise. Routes by
485    /// [`Self::chunk_grid`]:
486    ///
487    /// * `chunk_grid: Some(&cg)` — multi-chunk view. Resolves
488    ///   `chunk_idx` against `cg.origin_chunk_xy` /
489    ///   `cg.chunks_x` / `cg.chunks_y` and returns `cg.chunks[..]`
490    ///   at the matching slot (which may itself be `None` for
491    ///   empty chunks).
492    /// * `chunk_grid: None` — single-chunk view. Returns `Some(Self)`
493    ///   for `[0, 0]`, `None` for any other index. Matches today's
494    ///   single-chunk callers (every `from_single_vxl` /
495    ///   `from_parts` consumer).
496    ///
497    /// The grouscan column-step treats `None` as an empty chunk
498    /// (renders as sky / empty until the ray re-enters a populated
499    /// chunk).
500    #[must_use]
501    pub fn chunk_at_xy(&self, chunk_idx: [i32; 2]) -> Option<GridView<'a>> {
502        // Defer to chunk_at_xyz at the grid's `origin_chunk_z` (=
503        // 0 for non-stacked worlds, the "current" z layer for
504        // S4B.6+ stacked worlds). Pre-S4B.6.a behaviour is preserved
505        // because `chunks_z=1, origin_chunk_z=0` is the default.
506        let z = self.chunk_grid.map_or(0, |cg| cg.origin_chunk_z);
507        self.chunk_at_xyz([chunk_idx[0], chunk_idx[1], z])
508    }
509
510    /// S4B.6.a: 3D chunk lookup for the future cross-chunk-Z DDA.
511    ///
512    /// Same dispatch contract as [`Self::chunk_at_xy`] but extended
513    /// to a z axis:
514    ///
515    /// * `chunk_grid: Some(&cg)` — multi-chunk view. Resolves
516    ///   `chunk_idx` against `cg.origin_chunk_xy` /
517    ///   `cg.origin_chunk_z` / `cg.chunks_x` / `cg.chunks_y` /
518    ///   `cg.chunks_z` and returns `cg.chunks[..]` at the matching
519    ///   slot (which may itself be `None` for empty chunks).
520    /// * `chunk_grid: None` — single-chunk view. Returns
521    ///   `Some(Self)` for `[0, 0, *]`, `None` otherwise. The z
522    ///   index is ignored because single-chunk callers carry an
523    ///   un-stacked world — the camera's chz, even when non-zero
524    ///   (e.g. camera at world z >= 256 below the chunk's bedrock
525    ///   with `treat_z_max_as_air`), still refers to the same one
526    ///   chunk. S4B.6.c will start treating chz as a separator
527    ///   only for multi-chunk grids.
528    #[must_use]
529    pub fn chunk_at_xyz(&self, chunk_idx: [i32; 3]) -> Option<GridView<'a>> {
530        if let Some(cg) = self.chunk_grid {
531            let dx = chunk_idx[0] - cg.origin_chunk_xy[0];
532            let dy = chunk_idx[1] - cg.origin_chunk_xy[1];
533            let dz = chunk_idx[2] - cg.origin_chunk_z;
534            if dx < 0 || dy < 0 || dz < 0 {
535                return None;
536            }
537            #[allow(clippy::cast_sign_loss)]
538            let (dx, dy, dz) = (dx as u32, dy as u32, dz as u32);
539            if dx >= cg.chunks_x || dy >= cg.chunks_y || dz >= cg.chunks_z {
540                return None;
541            }
542            let i = (dz as usize * cg.chunks_y as usize + dy as usize) * cg.chunks_x as usize
543                + dx as usize;
544            cg.chunks.get(i).copied().flatten()
545        } else if chunk_idx[0] == 0 && chunk_idx[1] == 0 {
546            Some(*self)
547        } else {
548            None
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn from_parts_preserves_fields_byte_identically() {
559        let slab = [0u8, 200, 254, 0];
560        let cols = [0u32, 4];
561        let mips = [0usize, 2];
562        let gv = GridView::from_parts(1, &slab, &cols, &mips);
563        assert_eq!(gv.vsid, 1);
564        assert_eq!(gv.slab_buf, &slab[..]);
565        assert_eq!(gv.column_offsets, &cols[..]);
566        assert_eq!(gv.mip_base_offsets, &mips[..]);
567    }
568
569    #[test]
570    fn grid_view_is_copy() {
571        // Compile-time check: GridView must be Copy so opticast +
572        // ScalarRasterizer can stash independent copies without a
573        // borrow-checker dance.
574        fn assert_copy<T: Copy>() {}
575        assert_copy::<GridView<'_>>();
576    }
577
578    #[test]
579    fn from_parts_defaults_chunk_size_xy_to_vsid() {
580        let mips = [0usize, 2];
581        let gv = GridView::from_parts(2048, &[], &[], &mips);
582        assert_eq!(gv.chunk_size_xy, 2048);
583    }
584
585    #[test]
586    fn chunk_at_xy_returns_self_for_origin_chunk() {
587        let slab = [0u8, 200, 254, 0];
588        let cols = [0u32, 4];
589        let mips = [0usize, 2];
590        let gv = GridView::from_parts(1, &slab, &cols, &mips);
591        let inner = gv.chunk_at_xy([0, 0]).expect("origin chunk present");
592        assert_eq!(inner.vsid, gv.vsid);
593        assert_eq!(inner.slab_buf, gv.slab_buf);
594        assert_eq!(inner.column_offsets, gv.column_offsets);
595    }
596
597    #[test]
598    fn chunk_at_xy_returns_none_for_off_origin_idx() {
599        let mips = [0usize, 2];
600        let gv = GridView::from_parts(1, &[], &[], &mips);
601        assert!(gv.chunk_at_xy([1, 0]).is_none());
602        assert!(gv.chunk_at_xy([-1, 0]).is_none());
603        assert!(gv.chunk_at_xy([0, 1]).is_none());
604        assert!(gv.chunk_at_xy([5, -7]).is_none());
605    }
606
607    #[test]
608    fn with_chunk_size_xy_overrides_default() {
609        let mips = [0usize, 2];
610        let gv = GridView::from_parts(2048, &[], &[], &mips).with_chunk_size_xy(128);
611        assert_eq!(gv.vsid, 2048);
612        assert_eq!(gv.chunk_size_xy, 128);
613    }
614
615    /// S4B.2.c.1: tiny 2-chunk-x-stripe ChunkGrid scaffolding for
616    /// the multi-chunk lookup tests. Two synthetic 1×1 chunks at
617    /// XY indices [0, 0] and [1, 0]. Their slab/column data is
618    /// distinct so the lookup tests can tell them apart.
619    #[allow(clippy::type_complexity)] // test fixture: a bag of parallel arrays
620    fn build_two_chunk_x_stripe() -> ([u8; 4], [u8; 4], [u32; 2], [u32; 2], [usize; 2], [usize; 2])
621    {
622        let slab_a = [10u8, 200, 254, 0];
623        let slab_b = [20u8, 200, 254, 0];
624        let cols_a = [0u32, 4];
625        let cols_b = [0u32, 4];
626        let mips_a = [0usize, 2];
627        let mips_b = [0usize, 2];
628        (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b)
629    }
630
631    #[test]
632    fn chunk_at_xy_via_chunk_grid_returns_in_range_chunks() {
633        let (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b) = build_two_chunk_x_stripe();
634        let chunks = [
635            Some(GridView::from_parts(64, &slab_a, &cols_a, &mips_a)),
636            Some(GridView::from_parts(64, &slab_b, &cols_b, &mips_b)),
637        ];
638        let cg = ChunkGrid {
639            chunks: &chunks,
640            origin_chunk_xy: [0, 0],
641            chunks_x: 2,
642            chunks_y: 1,
643            chunks_z: 1,
644            origin_chunk_z: 0,
645        };
646        let gv = GridView::from_chunk_grid(&cg, 64);
647        // [0, 0] resolves to chunk A.
648        let c0 = gv.chunk_at_xy([0, 0]).expect("chunk [0, 0] present");
649        assert_eq!(c0.slab_buf, &slab_a[..]);
650        // [1, 0] resolves to chunk B.
651        let c1 = gv.chunk_at_xy([1, 0]).expect("chunk [1, 0] present");
652        assert_eq!(c1.slab_buf, &slab_b[..]);
653        // [0, 0] and [1, 0] carry distinct slab buffers.
654        assert_ne!(c0.slab_buf, c1.slab_buf);
655    }
656
657    #[test]
658    fn chunk_at_xy_via_chunk_grid_returns_none_out_of_range() {
659        let (slab_a, _, cols_a, _, mips_a, _) = build_two_chunk_x_stripe();
660        let chunks = [
661            Some(GridView::from_parts(64, &slab_a, &cols_a, &mips_a)),
662            None,
663        ];
664        let cg = ChunkGrid {
665            chunks: &chunks,
666            origin_chunk_xy: [0, 0],
667            chunks_x: 2,
668            chunks_y: 1,
669            chunks_z: 1,
670            origin_chunk_z: 0,
671        };
672        let gv = GridView::from_chunk_grid(&cg, 64);
673        // [-1, 0] is below origin.
674        assert!(gv.chunk_at_xy([-1, 0]).is_none());
675        // [0, -1] is below origin (y).
676        assert!(gv.chunk_at_xy([0, -1]).is_none());
677        // [2, 0] is past chunks_x.
678        assert!(gv.chunk_at_xy([2, 0]).is_none());
679        // [0, 1] is past chunks_y.
680        assert!(gv.chunk_at_xy([0, 1]).is_none());
681        // [1, 0] is an empty slot inside the grid.
682        assert!(gv.chunk_at_xy([1, 0]).is_none());
683    }
684
685    #[test]
686    fn chunk_at_xy_handles_negative_origin() {
687        let (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b) = build_two_chunk_x_stripe();
688        // Origin at [-1, 0]: chunks live at XY indices [-1, 0] and [0, 0].
689        let chunks = [
690            Some(GridView::from_parts(64, &slab_a, &cols_a, &mips_a)),
691            Some(GridView::from_parts(64, &slab_b, &cols_b, &mips_b)),
692        ];
693        let cg = ChunkGrid {
694            chunks: &chunks,
695            origin_chunk_xy: [-1, 0],
696            chunks_x: 2,
697            chunks_y: 1,
698            chunks_z: 1,
699            origin_chunk_z: 0,
700        };
701        let gv = GridView::from_chunk_grid(&cg, 64);
702        let cm1 = gv.chunk_at_xy([-1, 0]).expect("chunk [-1, 0] present");
703        assert_eq!(cm1.slab_buf, &slab_a[..]);
704        let c0 = gv.chunk_at_xy([0, 0]).expect("chunk [0, 0] present");
705        assert_eq!(c0.slab_buf, &slab_b[..]);
706        // Past the right edge.
707        assert!(gv.chunk_at_xy([1, 0]).is_none());
708        // Past the left edge.
709        assert!(gv.chunk_at_xy([-2, 0]).is_none());
710    }
711
712    #[test]
713    fn from_chunk_grid_seeds_flat_fields_from_first_populated_chunk() {
714        let (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b) = build_two_chunk_x_stripe();
715        // First slot empty, second populated. Flat fields should
716        // seed from chunk B.
717        let chunks = [
718            None,
719            Some(GridView::from_parts(64, &slab_b, &cols_b, &mips_b)),
720        ];
721        let cg = ChunkGrid {
722            chunks: &chunks,
723            origin_chunk_xy: [0, 0],
724            chunks_x: 2,
725            chunks_y: 1,
726            chunks_z: 1,
727            origin_chunk_z: 0,
728        };
729        let gv = GridView::from_chunk_grid(&cg, 64);
730        assert_eq!(gv.slab_buf, &slab_b[..]);
731        assert_eq!(gv.chunk_size_xy, 64);
732        // Single-chunk fields stay populated; tests beyond rely on
733        // opticast's prelude refreshing them via the camera lookup.
734        let _ = (slab_a, cols_a, mips_a);
735    }
736
737    #[test]
738    fn aabb_xy_single_chunk_returns_0_to_vsid() {
739        let mips = [0usize, 2];
740        let gv = GridView::from_parts(2048, &[], &[], &mips);
741        let (lo, hi) = gv.aabb_xy();
742        assert_eq!(lo, [0, 0]);
743        assert_eq!(hi, [2048, 2048]);
744    }
745
746    #[test]
747    fn aabb_xy_multi_chunk_covers_full_extent() {
748        let (slab_a, _, cols_a, _, mips_a, _) = build_two_chunk_x_stripe();
749        // 2-wide × 3-tall chunk grid starting at origin [-1, 0].
750        // Each chunk is 128² (matches the constructor's chunk_size_xy).
751        let chunks = [
752            Some(GridView::from_parts(128, &slab_a, &cols_a, &mips_a)),
753            None,
754            None,
755            None,
756            None,
757            None,
758        ];
759        let cg = ChunkGrid {
760            chunks: &chunks,
761            origin_chunk_xy: [-1, 0],
762            chunks_x: 2,
763            chunks_y: 3,
764            chunks_z: 1,
765            origin_chunk_z: 0,
766        };
767        let gv = GridView::from_chunk_grid(&cg, 128);
768        let (lo, hi) = gv.aabb_xy();
769        // xmin = -1 * 128 = -128; xmax = (-1 + 2) * 128 = 128.
770        // ymin = 0; ymax = 3 * 128 = 384.
771        assert_eq!(lo, [-128, 0]);
772        assert_eq!(hi, [128, 384]);
773    }
774
775    #[test]
776    fn from_chunk_grid_empty_table_falls_back_to_empty_slices() {
777        let chunks: [Option<GridView<'_>>; 1] = [None];
778        let cg = ChunkGrid {
779            chunks: &chunks,
780            origin_chunk_xy: [0, 0],
781            chunks_x: 1,
782            chunks_y: 1,
783            chunks_z: 1,
784            origin_chunk_z: 0,
785        };
786        let gv = GridView::from_chunk_grid(&cg, 128);
787        assert!(gv.slab_buf.is_empty());
788        assert!(gv.column_offsets.is_empty());
789        assert!(gv.mip_base_offsets.is_empty());
790        assert_eq!(gv.vsid, 128);
791        assert_eq!(gv.chunk_size_xy, 128);
792        // The chunk_grid backend still gates chunk_at_xy.
793        assert!(gv.chunk_at_xy([0, 0]).is_none());
794    }
795
796    /// S4B.6.a: `chunk_at_xyz` resolves the z axis correctly on a
797    /// stacked grid. Two chunks at chz=0 and chz=1 each have their
798    /// own slab buffer; xyz lookup must return the matching one.
799    #[test]
800    fn chunk_at_xyz_resolves_stacked_chunks() {
801        let slab0 = [0u8, 100, 100, 0];
802        let slab1 = [0u8, 200, 200, 0];
803        let cols = [0u32, 4];
804        let mips = [0usize, 2];
805        let c0 = GridView::from_parts(1, &slab0, &cols, &mips);
806        let c1 = GridView::from_parts(1, &slab1, &cols, &mips);
807        let chunks = [Some(c0), Some(c1)];
808        let cg = ChunkGrid {
809            chunks: &chunks,
810            origin_chunk_xy: [0, 0],
811            origin_chunk_z: 0,
812            chunks_x: 1,
813            chunks_y: 1,
814            chunks_z: 2,
815        };
816        let gv = GridView::from_chunk_grid(&cg, 1);
817        let v0 = gv.chunk_at_xyz([0, 0, 0]).expect("chz=0 present");
818        assert_eq!(v0.slab_buf, &slab0[..]);
819        let v1 = gv.chunk_at_xyz([0, 0, 1]).expect("chz=1 present");
820        assert_eq!(v1.slab_buf, &slab1[..]);
821        // OOR z returns None.
822        assert!(gv.chunk_at_xyz([0, 0, 2]).is_none());
823        assert!(gv.chunk_at_xyz([0, 0, -1]).is_none());
824    }
825
826    /// S4B.6.a: `chunk_at_xy` defers to `chunk_at_xyz` at
827    /// `origin_chunk_z`. For a stacked grid centred at chz=-1,
828    /// `chunk_at_xy` returns the chz=-1 layer.
829    #[test]
830    fn chunk_at_xy_returns_origin_z_layer() {
831        let slab_z_neg1 = [0u8, 50, 50, 0];
832        let slab_z_0 = [0u8, 100, 100, 0];
833        let cols = [0u32, 4];
834        let mips = [0usize, 2];
835        let cn = GridView::from_parts(1, &slab_z_neg1, &cols, &mips);
836        let c0 = GridView::from_parts(1, &slab_z_0, &cols, &mips);
837        let chunks = [Some(cn), Some(c0)];
838        let cg = ChunkGrid {
839            chunks: &chunks,
840            origin_chunk_xy: [0, 0],
841            origin_chunk_z: -1,
842            chunks_x: 1,
843            chunks_y: 1,
844            chunks_z: 2,
845        };
846        let gv = GridView::from_chunk_grid(&cg, 1);
847        // chunk_at_xy returns origin_chunk_z layer = chz=-1.
848        let via_xy = gv.chunk_at_xy([0, 0]).expect("origin layer present");
849        assert_eq!(via_xy.slab_buf, &slab_z_neg1[..]);
850        // chunk_at_xyz with explicit chz=0 returns the upper layer.
851        let v0 = gv.chunk_at_xyz([0, 0, 0]).expect("chz=0 present");
852        assert_eq!(v0.slab_buf, &slab_z_0[..]);
853    }
854}