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 that [`crate::opticast`] and
5//! [`crate::scalar_rasterizer::ScalarRasterizer`] both need. Today
6//! always represents a single chunk so callers building from a
7//! [`roxlap_formats::vxl::Vxl`] keep the existing flat-world
8//! semantics byte-identically.
9//!
10//! Substage S4B.0 introduced the shape as a pure rename — opticast
11//! drove a single flat world behind a typed borrow. Subsequent
12//! S4B.x sub-substages grow this into a multi-chunk view:
13//!
14//! * S4B.1 — carry the camera's chunk index alongside the borrow.
15//! * S4B.2.a — `chunk_size_xy` field + `chunk_at_xy` method.
16//!   Today's single-chunk callers set `chunk_size_xy = vsid` and
17//!   the lookup only succeeds for `[0, 0]`.
18//! * S4B.2.b — grouscan column-step calls `chunk_at_xy` and swaps
19//!   active per-chunk `(slab_buf, column_offsets)` when `(cx, cy)`
20//!   crosses a chunk boundary. Single-chunk goldens byte-identical.
21//! * S4B.2.c.1 (this file) — [`ChunkGrid`] backend so `chunk_at_xy`
22//!   can resolve real per-chunk borrows. Pure infrastructure; no
23//!   caller integration yet.
24//! * S4B.2.c.2 — scene-side multi-chunk constructor + 32×32
25//!   ground seam test.
26//! * S4B.3 — chunk-z extent + handoff for cross-chunk-Z rays.
27//!
28//! See `project_s4_b_plan.md` for the full sub-substage plan.
29
30use roxlap_formats::vxl::Vxl;
31
32/// Per-frame world borrow that opticast + the rasterizer share.
33///
34/// Today: a single chunk's `(vsid, slab_buf, column_offsets,
35/// mip_base_offsets)`. `Copy` so callers can pass it to opticast
36/// and stash it on the rasterizer without ceremony — every field
37/// is a borrow or a `u32`.
38///
39/// Fields are public on purpose. External callers usually go
40/// through the [`from_single_vxl`](Self::from_single_vxl) /
41/// [`from_parts`](Self::from_parts) constructors, but the engine's
42/// internals destructure directly. Keeping the fields exposed
43/// avoids a layer of accessor methods that the borrow checker
44/// would otherwise force at every read.
45/// Z extent of a single chunk's slab table, in voxels. Voxlap's
46/// slab byte format encodes z as `u8`, so each chunk covers exactly
47/// 256 voxels along Z. Tall worlds stack chunks vertically (see
48/// `memory/project_s4b_6_z_stacking_plan.md`).
49pub const CHUNK_SIZE_Z: u32 = 256;
50
51#[derive(Clone, Copy)]
52pub struct GridView<'a> {
53    /// Square dimension of the currently-active chunk view (matches
54    /// the source `Vxl`'s `vsid` for single-chunk callers). The
55    /// per-chunk `column_offsets` table holds `(vsid² + 1)` entries.
56    pub vsid: u32,
57    /// S4B.2.a: square dimension of each chunk in XY voxel units.
58    /// For today's single-chunk callers, `chunk_size_xy == vsid` so
59    /// `(cx, cy)` in `[0, vsid)` never crosses a chunk boundary.
60    /// For multi-chunk callers (S4B.2.c+), `chunk_size_xy` is the
61    /// per-chunk dimension (typically 128) and `vsid` is the same
62    /// per-chunk value — they may diverge in S4B.4 when `GridView`
63    /// stops carrying a "default" chunk's flat fields.
64    pub chunk_size_xy: u32,
65    /// S4B.6.a: Z extent of each chunk in voxel units. Locked to
66    /// `MAXZDIM = 256` for every chunk (voxlap's slab byte format
67    /// uses a `u8` z field). Tall worlds stack chunks vertically
68    /// rather than extending this constant — see
69    /// `memory/project_s4b_6_z_stacking_plan.md`. Pre-S4B.6.a
70    /// callers set this to `256` (the only valid value) and the
71    /// rasterizer ignores chunk-z boundaries; S4B.6.c will start
72    /// consuming the field for cross-chunk-z column walks.
73    pub chunk_size_z: u32,
74    /// Flat slab byte buffer for every column at every built mip.
75    pub slab_buf: &'a [u8],
76    /// Per-column byte offsets into [`Self::slab_buf`], concatenated
77    /// across every mip's sub-table. Mip-0 occupies indices
78    /// `mip_base_offsets[0]..mip_base_offsets[1]`.
79    pub column_offsets: &'a [u32],
80    /// Mip-level boundaries inside [`Self::column_offsets`].
81    /// Length `mip_count + 1`; trailing sentinel equals
82    /// `column_offsets.len()`. Single-mip callers pass
83    /// `&[0, vsid² + 1]`.
84    pub mip_base_offsets: &'a [usize],
85    /// S4B.2.c.1: chunk-grid backend. `None` for single-chunk views
86    /// (e.g. anything built via [`Self::from_single_vxl`] /
87    /// [`Self::from_parts`]) — [`Self::chunk_at_xy`] falls back to
88    /// the `Some(Self) for [0, 0]` behaviour. `Some(&...)` for
89    /// multi-chunk views built via [`Self::from_chunk_grid`] —
90    /// [`Self::chunk_at_xy`] consults the table.
91    pub chunk_grid: Option<&'a ChunkGrid<'a>>,
92}
93
94/// S4B.2.c.1: chunk-grid metadata for multi-chunk [`GridView`]
95/// lookups.
96///
97/// Stores a 2D table of optional per-chunk [`GridView`] borrows so
98/// [`GridView::chunk_at_xy`] can resolve an XY chunk index to the
99/// matching chunk's `(slab_buf, column_offsets, ...)` view. Empty
100/// table entries (`None`) signal "no chunk at that index" — the
101/// grouscan column-step treats them as fully-air (the chunk renders
102/// as sky / empty until the ray crosses into a populated chunk).
103///
104/// Sized to match the chunk grid's full XYZ footprint:
105/// `chunks.len() == chunks_x * chunks_y * chunks_z`. Chunk at
106/// relative position `(dx, dy, dz)` from
107/// `(origin_chunk_xy, origin_chunk_z)` lives at index
108/// `(dz * chunks_y + dy) * chunks_x + dx`. The per-chunk
109/// [`GridView`] entries should have `chunk_grid: None` (they
110/// describe individual chunks, not the parent grid).
111///
112/// S4B.6.a: `chunks_z` + `origin_chunk_z` added for tall worlds.
113/// Pre-S4B.6 callers built `ChunkGrid` with implicit `chunks_z=1
114/// origin_chunk_z=0`; the new layout is backwards-compatible —
115/// `dz=0` substitution gives the same index `dy * chunks_x + dx`,
116/// so a flat `chunks_x * chunks_y` slice indexes identically.
117#[derive(Clone, Copy)]
118pub struct ChunkGrid<'a> {
119    /// Per-chunk views. Length `chunks_x * chunks_y * chunks_z`.
120    /// Index layout: `[(dz * chunks_y + dy) * chunks_x + dx]`.
121    pub chunks: &'a [Option<GridView<'a>>],
122    /// XY index of the chunk at `chunks[0]`. Subsequent chunks lie
123    /// along `+x` (next in the row) then `+y` (next row).
124    pub origin_chunk_xy: [i32; 2],
125    /// Z index of the chunk at `chunks[0]`. Subsequent z slabs lie
126    /// at `+chunks_x * chunks_y` strides into [`Self::chunks`].
127    pub origin_chunk_z: i32,
128    /// Number of chunks along the X axis. Row stride in
129    /// [`Self::chunks`].
130    pub chunks_x: u32,
131    /// Number of chunks along the Y axis.
132    pub chunks_y: u32,
133    /// Number of chunks along the Z axis. `1` for non-stacked
134    /// worlds (matches every pre-S4B.6.a caller).
135    pub chunks_z: u32,
136}
137
138impl<'a> GridView<'a> {
139    /// Build from explicit fields. Test fixtures use this directly;
140    /// production callers usually go through
141    /// [`from_single_vxl`](Self::from_single_vxl).
142    ///
143    /// Sets `chunk_size_xy = vsid` (single-chunk semantics). Use
144    /// [`with_chunk_size_xy`](Self::with_chunk_size_xy) to mark the
145    /// view as part of a chunk grid.
146    #[must_use]
147    pub fn from_parts(
148        vsid: u32,
149        slab_buf: &'a [u8],
150        column_offsets: &'a [u32],
151        mip_base_offsets: &'a [usize],
152    ) -> Self {
153        Self {
154            vsid,
155            chunk_size_xy: vsid,
156            chunk_size_z: CHUNK_SIZE_Z,
157            slab_buf,
158            column_offsets,
159            mip_base_offsets,
160            chunk_grid: None,
161        }
162    }
163
164    /// Borrow a parsed `.vxl` map as a single-chunk grid view. The
165    /// scene-graph stage's eventual multi-chunk constructor will
166    /// live alongside this one (`from_grid` over
167    /// `roxlap_scene::Grid`).
168    #[must_use]
169    pub fn from_single_vxl(vxl: &'a Vxl) -> Self {
170        Self {
171            vsid: vxl.vsid,
172            chunk_size_xy: vxl.vsid,
173            chunk_size_z: CHUNK_SIZE_Z,
174            slab_buf: &vxl.data,
175            column_offsets: &vxl.column_offset,
176            mip_base_offsets: &vxl.mip_base_offsets,
177            chunk_grid: None,
178        }
179    }
180
181    /// S4B.2.c.1: build a multi-chunk view from a [`ChunkGrid`].
182    ///
183    /// The returned [`GridView`]'s flat `(vsid, slab_buf,
184    /// column_offsets, mip_base_offsets)` fields are seeded from
185    /// the first populated chunk in the grid (so opticast's prelude
186    /// has a sensible default before its camera-chunk lookup
187    /// refreshes them). `chunk_size_xy` carries the caller-supplied
188    /// per-chunk dimension; [`Self::chunk_grid`] points at
189    /// `chunk_grid` so [`Self::chunk_at_xy`] resolves every
190    /// in-range index to its actual chunk borrow.
191    ///
192    /// Empty-grid case: if every chunk is `None`, the flat fields
193    /// fall back to empty slices and `vsid = chunk_size_xy`. The
194    /// grouscan column-step swap will see `chunk_at_xy → None` for
195    /// every index and render the whole grid as sky.
196    #[must_use]
197    pub fn from_chunk_grid(chunk_grid: &'a ChunkGrid<'a>, chunk_size_xy: u32) -> Self {
198        let default_chunk = chunk_grid.chunks.iter().find_map(|c| c.as_ref()).copied();
199        match default_chunk {
200            Some(c) => Self {
201                vsid: c.vsid,
202                chunk_size_xy,
203                chunk_size_z: CHUNK_SIZE_Z,
204                slab_buf: c.slab_buf,
205                column_offsets: c.column_offsets,
206                mip_base_offsets: c.mip_base_offsets,
207                chunk_grid: Some(chunk_grid),
208            },
209            None => Self {
210                vsid: chunk_size_xy,
211                chunk_size_xy,
212                chunk_size_z: CHUNK_SIZE_Z,
213                slab_buf: &[],
214                column_offsets: &[],
215                mip_base_offsets: &[],
216                chunk_grid: Some(chunk_grid),
217            },
218        }
219    }
220
221    /// S4B.2.a builder: override [`Self::chunk_size_xy`]. Multi-chunk
222    /// callers (S4B.2.c+) use this to mark the view as one chunk of
223    /// a larger grid. Today no caller needs it; the existence makes
224    /// the seam testable in isolation.
225    #[must_use]
226    pub fn with_chunk_size_xy(mut self, chunk_size_xy: u32) -> Self {
227        self.chunk_size_xy = chunk_size_xy;
228        self
229    }
230
231    /// S4B.2.d: voxel-space XY axis-aligned bounding box of the
232    /// grid. Returns `([xmin, ymin], [xmax, ymax])` in voxel units;
233    /// the grid contains voxels with coordinates in
234    /// `[xmin, xmax) × [ymin, ymax)`.
235    ///
236    /// - Single-chunk (`chunk_grid: None`): returns
237    ///   `([0, 0], [vsid, vsid])`. Byte-identical to the historical
238    ///   single-chunk world-edge math.
239    /// - Multi-chunk (`chunk_grid: Some(&cg)`): derived from
240    ///   `cg.origin_chunk_xy + cg.chunks_x/y * chunk_size_xy`.
241    ///
242    /// Consumed by [`crate::opticast_prelude::recompute_in_bounds_xy`]
243    /// (camera-inside-grid check) and the rasterizer's gline
244    /// world-edge gxmax clip.
245    #[must_use]
246    pub fn aabb_xy(&self) -> ([i32; 2], [i32; 2]) {
247        if let Some(cg) = self.chunk_grid {
248            #[allow(clippy::cast_possible_wrap)]
249            let cs = self.chunk_size_xy as i32;
250            #[allow(clippy::cast_possible_wrap)]
251            let chunks_x = cg.chunks_x as i32;
252            #[allow(clippy::cast_possible_wrap)]
253            let chunks_y = cg.chunks_y as i32;
254            let xmin = cg.origin_chunk_xy[0] * cs;
255            let ymin = cg.origin_chunk_xy[1] * cs;
256            let xmax = xmin + chunks_x * cs;
257            let ymax = ymin + chunks_y * cs;
258            ([xmin, ymin], [xmax, ymax])
259        } else {
260            #[allow(clippy::cast_possible_wrap)]
261            let v = self.vsid as i32;
262            ([0, 0], [v, v])
263        }
264    }
265
266    /// S4B.2.a: chunk lookup for the cross-chunk-XY DDA.
267    ///
268    /// Returns the [`GridView`] for the chunk at XY index
269    /// `chunk_idx` if one exists, `None` otherwise. Routes by
270    /// [`Self::chunk_grid`]:
271    ///
272    /// * `chunk_grid: Some(&cg)` — multi-chunk view. Resolves
273    ///   `chunk_idx` against `cg.origin_chunk_xy` /
274    ///   `cg.chunks_x` / `cg.chunks_y` and returns `cg.chunks[..]`
275    ///   at the matching slot (which may itself be `None` for
276    ///   empty chunks).
277    /// * `chunk_grid: None` — single-chunk view. Returns `Some(Self)`
278    ///   for `[0, 0]`, `None` for any other index. Matches today's
279    ///   single-chunk callers (every `from_single_vxl` /
280    ///   `from_parts` consumer).
281    ///
282    /// The grouscan column-step treats `None` as an empty chunk
283    /// (renders as sky / empty until the ray re-enters a populated
284    /// chunk).
285    #[must_use]
286    pub fn chunk_at_xy(&self, chunk_idx: [i32; 2]) -> Option<GridView<'a>> {
287        // Defer to chunk_at_xyz at the grid's `origin_chunk_z` (=
288        // 0 for non-stacked worlds, the "current" z layer for
289        // S4B.6+ stacked worlds). Pre-S4B.6.a behaviour is preserved
290        // because `chunks_z=1, origin_chunk_z=0` is the default.
291        let z = self.chunk_grid.map_or(0, |cg| cg.origin_chunk_z);
292        self.chunk_at_xyz([chunk_idx[0], chunk_idx[1], z])
293    }
294
295    /// S4B.6.a: 3D chunk lookup for the future cross-chunk-Z DDA.
296    ///
297    /// Same dispatch contract as [`Self::chunk_at_xy`] but extended
298    /// to a z axis:
299    ///
300    /// * `chunk_grid: Some(&cg)` — multi-chunk view. Resolves
301    ///   `chunk_idx` against `cg.origin_chunk_xy` /
302    ///   `cg.origin_chunk_z` / `cg.chunks_x` / `cg.chunks_y` /
303    ///   `cg.chunks_z` and returns `cg.chunks[..]` at the matching
304    ///   slot (which may itself be `None` for empty chunks).
305    /// * `chunk_grid: None` — single-chunk view. Returns
306    ///   `Some(Self)` for `[0, 0, *]`, `None` otherwise. The z
307    ///   index is ignored because single-chunk callers carry an
308    ///   un-stacked world — the camera's chz, even when non-zero
309    ///   (e.g. camera at world z >= 256 below the chunk's bedrock
310    ///   with `treat_z_max_as_air`), still refers to the same one
311    ///   chunk. S4B.6.c will start treating chz as a separator
312    ///   only for multi-chunk grids.
313    #[must_use]
314    pub fn chunk_at_xyz(&self, chunk_idx: [i32; 3]) -> Option<GridView<'a>> {
315        if let Some(cg) = self.chunk_grid {
316            let dx = chunk_idx[0] - cg.origin_chunk_xy[0];
317            let dy = chunk_idx[1] - cg.origin_chunk_xy[1];
318            let dz = chunk_idx[2] - cg.origin_chunk_z;
319            if dx < 0 || dy < 0 || dz < 0 {
320                return None;
321            }
322            #[allow(clippy::cast_sign_loss)]
323            let (dx, dy, dz) = (dx as u32, dy as u32, dz as u32);
324            if dx >= cg.chunks_x || dy >= cg.chunks_y || dz >= cg.chunks_z {
325                return None;
326            }
327            let i = (dz as usize * cg.chunks_y as usize + dy as usize) * cg.chunks_x as usize
328                + dx as usize;
329            cg.chunks.get(i).copied().flatten()
330        } else if chunk_idx[0] == 0 && chunk_idx[1] == 0 {
331            Some(*self)
332        } else {
333            None
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn from_parts_preserves_fields_byte_identically() {
344        let slab = [0u8, 200, 254, 0];
345        let cols = [0u32, 4];
346        let mips = [0usize, 2];
347        let gv = GridView::from_parts(1, &slab, &cols, &mips);
348        assert_eq!(gv.vsid, 1);
349        assert_eq!(gv.slab_buf, &slab[..]);
350        assert_eq!(gv.column_offsets, &cols[..]);
351        assert_eq!(gv.mip_base_offsets, &mips[..]);
352    }
353
354    #[test]
355    fn grid_view_is_copy() {
356        // Compile-time check: GridView must be Copy so opticast +
357        // ScalarRasterizer can stash independent copies without a
358        // borrow-checker dance.
359        fn assert_copy<T: Copy>() {}
360        assert_copy::<GridView<'_>>();
361    }
362
363    #[test]
364    fn from_parts_defaults_chunk_size_xy_to_vsid() {
365        let mips = [0usize, 2];
366        let gv = GridView::from_parts(2048, &[], &[], &mips);
367        assert_eq!(gv.chunk_size_xy, 2048);
368    }
369
370    #[test]
371    fn chunk_at_xy_returns_self_for_origin_chunk() {
372        let slab = [0u8, 200, 254, 0];
373        let cols = [0u32, 4];
374        let mips = [0usize, 2];
375        let gv = GridView::from_parts(1, &slab, &cols, &mips);
376        let inner = gv.chunk_at_xy([0, 0]).expect("origin chunk present");
377        assert_eq!(inner.vsid, gv.vsid);
378        assert_eq!(inner.slab_buf, gv.slab_buf);
379        assert_eq!(inner.column_offsets, gv.column_offsets);
380    }
381
382    #[test]
383    fn chunk_at_xy_returns_none_for_off_origin_idx() {
384        let mips = [0usize, 2];
385        let gv = GridView::from_parts(1, &[], &[], &mips);
386        assert!(gv.chunk_at_xy([1, 0]).is_none());
387        assert!(gv.chunk_at_xy([-1, 0]).is_none());
388        assert!(gv.chunk_at_xy([0, 1]).is_none());
389        assert!(gv.chunk_at_xy([5, -7]).is_none());
390    }
391
392    #[test]
393    fn with_chunk_size_xy_overrides_default() {
394        let mips = [0usize, 2];
395        let gv = GridView::from_parts(2048, &[], &[], &mips).with_chunk_size_xy(128);
396        assert_eq!(gv.vsid, 2048);
397        assert_eq!(gv.chunk_size_xy, 128);
398    }
399
400    /// S4B.2.c.1: tiny 2-chunk-x-stripe ChunkGrid scaffolding for
401    /// the multi-chunk lookup tests. Two synthetic 1×1 chunks at
402    /// XY indices [0, 0] and [1, 0]. Their slab/column data is
403    /// distinct so the lookup tests can tell them apart.
404    #[allow(clippy::type_complexity)] // test fixture: a bag of parallel arrays
405    fn build_two_chunk_x_stripe() -> ([u8; 4], [u8; 4], [u32; 2], [u32; 2], [usize; 2], [usize; 2])
406    {
407        let slab_a = [10u8, 200, 254, 0];
408        let slab_b = [20u8, 200, 254, 0];
409        let cols_a = [0u32, 4];
410        let cols_b = [0u32, 4];
411        let mips_a = [0usize, 2];
412        let mips_b = [0usize, 2];
413        (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b)
414    }
415
416    #[test]
417    fn chunk_at_xy_via_chunk_grid_returns_in_range_chunks() {
418        let (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b) = build_two_chunk_x_stripe();
419        let chunks = [
420            Some(GridView::from_parts(64, &slab_a, &cols_a, &mips_a)),
421            Some(GridView::from_parts(64, &slab_b, &cols_b, &mips_b)),
422        ];
423        let cg = ChunkGrid {
424            chunks: &chunks,
425            origin_chunk_xy: [0, 0],
426            chunks_x: 2,
427            chunks_y: 1,
428            chunks_z: 1,
429            origin_chunk_z: 0,
430        };
431        let gv = GridView::from_chunk_grid(&cg, 64);
432        // [0, 0] resolves to chunk A.
433        let c0 = gv.chunk_at_xy([0, 0]).expect("chunk [0, 0] present");
434        assert_eq!(c0.slab_buf, &slab_a[..]);
435        // [1, 0] resolves to chunk B.
436        let c1 = gv.chunk_at_xy([1, 0]).expect("chunk [1, 0] present");
437        assert_eq!(c1.slab_buf, &slab_b[..]);
438        // [0, 0] and [1, 0] carry distinct slab buffers.
439        assert_ne!(c0.slab_buf, c1.slab_buf);
440    }
441
442    #[test]
443    fn chunk_at_xy_via_chunk_grid_returns_none_out_of_range() {
444        let (slab_a, _, cols_a, _, mips_a, _) = build_two_chunk_x_stripe();
445        let chunks = [
446            Some(GridView::from_parts(64, &slab_a, &cols_a, &mips_a)),
447            None,
448        ];
449        let cg = ChunkGrid {
450            chunks: &chunks,
451            origin_chunk_xy: [0, 0],
452            chunks_x: 2,
453            chunks_y: 1,
454            chunks_z: 1,
455            origin_chunk_z: 0,
456        };
457        let gv = GridView::from_chunk_grid(&cg, 64);
458        // [-1, 0] is below origin.
459        assert!(gv.chunk_at_xy([-1, 0]).is_none());
460        // [0, -1] is below origin (y).
461        assert!(gv.chunk_at_xy([0, -1]).is_none());
462        // [2, 0] is past chunks_x.
463        assert!(gv.chunk_at_xy([2, 0]).is_none());
464        // [0, 1] is past chunks_y.
465        assert!(gv.chunk_at_xy([0, 1]).is_none());
466        // [1, 0] is an empty slot inside the grid.
467        assert!(gv.chunk_at_xy([1, 0]).is_none());
468    }
469
470    #[test]
471    fn chunk_at_xy_handles_negative_origin() {
472        let (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b) = build_two_chunk_x_stripe();
473        // Origin at [-1, 0]: chunks live at XY indices [-1, 0] and [0, 0].
474        let chunks = [
475            Some(GridView::from_parts(64, &slab_a, &cols_a, &mips_a)),
476            Some(GridView::from_parts(64, &slab_b, &cols_b, &mips_b)),
477        ];
478        let cg = ChunkGrid {
479            chunks: &chunks,
480            origin_chunk_xy: [-1, 0],
481            chunks_x: 2,
482            chunks_y: 1,
483            chunks_z: 1,
484            origin_chunk_z: 0,
485        };
486        let gv = GridView::from_chunk_grid(&cg, 64);
487        let cm1 = gv.chunk_at_xy([-1, 0]).expect("chunk [-1, 0] present");
488        assert_eq!(cm1.slab_buf, &slab_a[..]);
489        let c0 = gv.chunk_at_xy([0, 0]).expect("chunk [0, 0] present");
490        assert_eq!(c0.slab_buf, &slab_b[..]);
491        // Past the right edge.
492        assert!(gv.chunk_at_xy([1, 0]).is_none());
493        // Past the left edge.
494        assert!(gv.chunk_at_xy([-2, 0]).is_none());
495    }
496
497    #[test]
498    fn from_chunk_grid_seeds_flat_fields_from_first_populated_chunk() {
499        let (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b) = build_two_chunk_x_stripe();
500        // First slot empty, second populated. Flat fields should
501        // seed from chunk B.
502        let chunks = [
503            None,
504            Some(GridView::from_parts(64, &slab_b, &cols_b, &mips_b)),
505        ];
506        let cg = ChunkGrid {
507            chunks: &chunks,
508            origin_chunk_xy: [0, 0],
509            chunks_x: 2,
510            chunks_y: 1,
511            chunks_z: 1,
512            origin_chunk_z: 0,
513        };
514        let gv = GridView::from_chunk_grid(&cg, 64);
515        assert_eq!(gv.slab_buf, &slab_b[..]);
516        assert_eq!(gv.chunk_size_xy, 64);
517        // Single-chunk fields stay populated; tests beyond rely on
518        // opticast's prelude refreshing them via the camera lookup.
519        let _ = (slab_a, cols_a, mips_a);
520    }
521
522    #[test]
523    fn aabb_xy_single_chunk_returns_0_to_vsid() {
524        let mips = [0usize, 2];
525        let gv = GridView::from_parts(2048, &[], &[], &mips);
526        let (lo, hi) = gv.aabb_xy();
527        assert_eq!(lo, [0, 0]);
528        assert_eq!(hi, [2048, 2048]);
529    }
530
531    #[test]
532    fn aabb_xy_multi_chunk_covers_full_extent() {
533        let (slab_a, _, cols_a, _, mips_a, _) = build_two_chunk_x_stripe();
534        // 2-wide × 3-tall chunk grid starting at origin [-1, 0].
535        // Each chunk is 128² (matches the constructor's chunk_size_xy).
536        let chunks = [
537            Some(GridView::from_parts(128, &slab_a, &cols_a, &mips_a)),
538            None,
539            None,
540            None,
541            None,
542            None,
543        ];
544        let cg = ChunkGrid {
545            chunks: &chunks,
546            origin_chunk_xy: [-1, 0],
547            chunks_x: 2,
548            chunks_y: 3,
549            chunks_z: 1,
550            origin_chunk_z: 0,
551        };
552        let gv = GridView::from_chunk_grid(&cg, 128);
553        let (lo, hi) = gv.aabb_xy();
554        // xmin = -1 * 128 = -128; xmax = (-1 + 2) * 128 = 128.
555        // ymin = 0; ymax = 3 * 128 = 384.
556        assert_eq!(lo, [-128, 0]);
557        assert_eq!(hi, [128, 384]);
558    }
559
560    #[test]
561    fn from_chunk_grid_empty_table_falls_back_to_empty_slices() {
562        let chunks: [Option<GridView<'_>>; 1] = [None];
563        let cg = ChunkGrid {
564            chunks: &chunks,
565            origin_chunk_xy: [0, 0],
566            chunks_x: 1,
567            chunks_y: 1,
568            chunks_z: 1,
569            origin_chunk_z: 0,
570        };
571        let gv = GridView::from_chunk_grid(&cg, 128);
572        assert!(gv.slab_buf.is_empty());
573        assert!(gv.column_offsets.is_empty());
574        assert!(gv.mip_base_offsets.is_empty());
575        assert_eq!(gv.vsid, 128);
576        assert_eq!(gv.chunk_size_xy, 128);
577        // The chunk_grid backend still gates chunk_at_xy.
578        assert!(gv.chunk_at_xy([0, 0]).is_none());
579    }
580
581    /// S4B.6.a: `chunk_at_xyz` resolves the z axis correctly on a
582    /// stacked grid. Two chunks at chz=0 and chz=1 each have their
583    /// own slab buffer; xyz lookup must return the matching one.
584    #[test]
585    fn chunk_at_xyz_resolves_stacked_chunks() {
586        let slab0 = [0u8, 100, 100, 0];
587        let slab1 = [0u8, 200, 200, 0];
588        let cols = [0u32, 4];
589        let mips = [0usize, 2];
590        let c0 = GridView::from_parts(1, &slab0, &cols, &mips);
591        let c1 = GridView::from_parts(1, &slab1, &cols, &mips);
592        let chunks = [Some(c0), Some(c1)];
593        let cg = ChunkGrid {
594            chunks: &chunks,
595            origin_chunk_xy: [0, 0],
596            origin_chunk_z: 0,
597            chunks_x: 1,
598            chunks_y: 1,
599            chunks_z: 2,
600        };
601        let gv = GridView::from_chunk_grid(&cg, 1);
602        let v0 = gv.chunk_at_xyz([0, 0, 0]).expect("chz=0 present");
603        assert_eq!(v0.slab_buf, &slab0[..]);
604        let v1 = gv.chunk_at_xyz([0, 0, 1]).expect("chz=1 present");
605        assert_eq!(v1.slab_buf, &slab1[..]);
606        // OOR z returns None.
607        assert!(gv.chunk_at_xyz([0, 0, 2]).is_none());
608        assert!(gv.chunk_at_xyz([0, 0, -1]).is_none());
609    }
610
611    /// S4B.6.a: `chunk_at_xy` defers to `chunk_at_xyz` at
612    /// `origin_chunk_z`. For a stacked grid centred at chz=-1,
613    /// `chunk_at_xy` returns the chz=-1 layer.
614    #[test]
615    fn chunk_at_xy_returns_origin_z_layer() {
616        let slab_z_neg1 = [0u8, 50, 50, 0];
617        let slab_z_0 = [0u8, 100, 100, 0];
618        let cols = [0u32, 4];
619        let mips = [0usize, 2];
620        let cn = GridView::from_parts(1, &slab_z_neg1, &cols, &mips);
621        let c0 = GridView::from_parts(1, &slab_z_0, &cols, &mips);
622        let chunks = [Some(cn), Some(c0)];
623        let cg = ChunkGrid {
624            chunks: &chunks,
625            origin_chunk_xy: [0, 0],
626            origin_chunk_z: -1,
627            chunks_x: 1,
628            chunks_y: 1,
629            chunks_z: 2,
630        };
631        let gv = GridView::from_chunk_grid(&cg, 1);
632        // chunk_at_xy returns origin_chunk_z layer = chz=-1.
633        let via_xy = gv.chunk_at_xy([0, 0]).expect("origin layer present");
634        assert_eq!(via_xy.slab_buf, &slab_z_neg1[..]);
635        // chunk_at_xyz with explicit chz=0 returns the upper layer.
636        let v0 = gv.chunk_at_xyz([0, 0, 0]).expect("chz=0 present");
637        assert_eq!(v0.slab_buf, &slab_z_0[..]);
638    }
639}