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