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    fn build_two_chunk_x_stripe() -> ([u8; 4], [u8; 4], [u32; 2], [u32; 2], [usize; 2], [usize; 2])
405    {
406        let slab_a = [10u8, 200, 254, 0];
407        let slab_b = [20u8, 200, 254, 0];
408        let cols_a = [0u32, 4];
409        let cols_b = [0u32, 4];
410        let mips_a = [0usize, 2];
411        let mips_b = [0usize, 2];
412        (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b)
413    }
414
415    #[test]
416    fn chunk_at_xy_via_chunk_grid_returns_in_range_chunks() {
417        let (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b) = build_two_chunk_x_stripe();
418        let chunks = [
419            Some(GridView::from_parts(64, &slab_a, &cols_a, &mips_a)),
420            Some(GridView::from_parts(64, &slab_b, &cols_b, &mips_b)),
421        ];
422        let cg = ChunkGrid {
423            chunks: &chunks,
424            origin_chunk_xy: [0, 0],
425            chunks_x: 2,
426            chunks_y: 1,
427            chunks_z: 1,
428            origin_chunk_z: 0,
429        };
430        let gv = GridView::from_chunk_grid(&cg, 64);
431        // [0, 0] resolves to chunk A.
432        let c0 = gv.chunk_at_xy([0, 0]).expect("chunk [0, 0] present");
433        assert_eq!(c0.slab_buf, &slab_a[..]);
434        // [1, 0] resolves to chunk B.
435        let c1 = gv.chunk_at_xy([1, 0]).expect("chunk [1, 0] present");
436        assert_eq!(c1.slab_buf, &slab_b[..]);
437        // [0, 0] and [1, 0] carry distinct slab buffers.
438        assert_ne!(c0.slab_buf, c1.slab_buf);
439    }
440
441    #[test]
442    fn chunk_at_xy_via_chunk_grid_returns_none_out_of_range() {
443        let (slab_a, _, cols_a, _, mips_a, _) = build_two_chunk_x_stripe();
444        let chunks = [
445            Some(GridView::from_parts(64, &slab_a, &cols_a, &mips_a)),
446            None,
447        ];
448        let cg = ChunkGrid {
449            chunks: &chunks,
450            origin_chunk_xy: [0, 0],
451            chunks_x: 2,
452            chunks_y: 1,
453            chunks_z: 1,
454            origin_chunk_z: 0,
455        };
456        let gv = GridView::from_chunk_grid(&cg, 64);
457        // [-1, 0] is below origin.
458        assert!(gv.chunk_at_xy([-1, 0]).is_none());
459        // [0, -1] is below origin (y).
460        assert!(gv.chunk_at_xy([0, -1]).is_none());
461        // [2, 0] is past chunks_x.
462        assert!(gv.chunk_at_xy([2, 0]).is_none());
463        // [0, 1] is past chunks_y.
464        assert!(gv.chunk_at_xy([0, 1]).is_none());
465        // [1, 0] is an empty slot inside the grid.
466        assert!(gv.chunk_at_xy([1, 0]).is_none());
467    }
468
469    #[test]
470    fn chunk_at_xy_handles_negative_origin() {
471        let (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b) = build_two_chunk_x_stripe();
472        // Origin at [-1, 0]: chunks live at XY indices [-1, 0] and [0, 0].
473        let chunks = [
474            Some(GridView::from_parts(64, &slab_a, &cols_a, &mips_a)),
475            Some(GridView::from_parts(64, &slab_b, &cols_b, &mips_b)),
476        ];
477        let cg = ChunkGrid {
478            chunks: &chunks,
479            origin_chunk_xy: [-1, 0],
480            chunks_x: 2,
481            chunks_y: 1,
482            chunks_z: 1,
483            origin_chunk_z: 0,
484        };
485        let gv = GridView::from_chunk_grid(&cg, 64);
486        let cm1 = gv.chunk_at_xy([-1, 0]).expect("chunk [-1, 0] present");
487        assert_eq!(cm1.slab_buf, &slab_a[..]);
488        let c0 = gv.chunk_at_xy([0, 0]).expect("chunk [0, 0] present");
489        assert_eq!(c0.slab_buf, &slab_b[..]);
490        // Past the right edge.
491        assert!(gv.chunk_at_xy([1, 0]).is_none());
492        // Past the left edge.
493        assert!(gv.chunk_at_xy([-2, 0]).is_none());
494    }
495
496    #[test]
497    fn from_chunk_grid_seeds_flat_fields_from_first_populated_chunk() {
498        let (slab_a, slab_b, cols_a, cols_b, mips_a, mips_b) = build_two_chunk_x_stripe();
499        // First slot empty, second populated. Flat fields should
500        // seed from chunk B.
501        let chunks = [
502            None,
503            Some(GridView::from_parts(64, &slab_b, &cols_b, &mips_b)),
504        ];
505        let cg = ChunkGrid {
506            chunks: &chunks,
507            origin_chunk_xy: [0, 0],
508            chunks_x: 2,
509            chunks_y: 1,
510            chunks_z: 1,
511            origin_chunk_z: 0,
512        };
513        let gv = GridView::from_chunk_grid(&cg, 64);
514        assert_eq!(gv.slab_buf, &slab_b[..]);
515        assert_eq!(gv.chunk_size_xy, 64);
516        // Single-chunk fields stay populated; tests beyond rely on
517        // opticast's prelude refreshing them via the camera lookup.
518        let _ = (slab_a, cols_a, mips_a);
519    }
520
521    #[test]
522    fn aabb_xy_single_chunk_returns_0_to_vsid() {
523        let mips = [0usize, 2];
524        let gv = GridView::from_parts(2048, &[], &[], &mips);
525        let (lo, hi) = gv.aabb_xy();
526        assert_eq!(lo, [0, 0]);
527        assert_eq!(hi, [2048, 2048]);
528    }
529
530    #[test]
531    fn aabb_xy_multi_chunk_covers_full_extent() {
532        let (slab_a, _, cols_a, _, mips_a, _) = build_two_chunk_x_stripe();
533        // 2-wide × 3-tall chunk grid starting at origin [-1, 0].
534        // Each chunk is 128² (matches the constructor's chunk_size_xy).
535        let chunks = [
536            Some(GridView::from_parts(128, &slab_a, &cols_a, &mips_a)),
537            None,
538            None,
539            None,
540            None,
541            None,
542        ];
543        let cg = ChunkGrid {
544            chunks: &chunks,
545            origin_chunk_xy: [-1, 0],
546            chunks_x: 2,
547            chunks_y: 3,
548            chunks_z: 1,
549            origin_chunk_z: 0,
550        };
551        let gv = GridView::from_chunk_grid(&cg, 128);
552        let (lo, hi) = gv.aabb_xy();
553        // xmin = -1 * 128 = -128; xmax = (-1 + 2) * 128 = 128.
554        // ymin = 0; ymax = 3 * 128 = 384.
555        assert_eq!(lo, [-128, 0]);
556        assert_eq!(hi, [128, 384]);
557    }
558
559    #[test]
560    fn from_chunk_grid_empty_table_falls_back_to_empty_slices() {
561        let chunks: [Option<GridView<'_>>; 1] = [None];
562        let cg = ChunkGrid {
563            chunks: &chunks,
564            origin_chunk_xy: [0, 0],
565            chunks_x: 1,
566            chunks_y: 1,
567            chunks_z: 1,
568            origin_chunk_z: 0,
569        };
570        let gv = GridView::from_chunk_grid(&cg, 128);
571        assert!(gv.slab_buf.is_empty());
572        assert!(gv.column_offsets.is_empty());
573        assert!(gv.mip_base_offsets.is_empty());
574        assert_eq!(gv.vsid, 128);
575        assert_eq!(gv.chunk_size_xy, 128);
576        // The chunk_grid backend still gates chunk_at_xy.
577        assert!(gv.chunk_at_xy([0, 0]).is_none());
578    }
579
580    /// S4B.6.a: `chunk_at_xyz` resolves the z axis correctly on a
581    /// stacked grid. Two chunks at chz=0 and chz=1 each have their
582    /// own slab buffer; xyz lookup must return the matching one.
583    #[test]
584    fn chunk_at_xyz_resolves_stacked_chunks() {
585        let slab0 = [0u8, 100, 100, 0];
586        let slab1 = [0u8, 200, 200, 0];
587        let cols = [0u32, 4];
588        let mips = [0usize, 2];
589        let c0 = GridView::from_parts(1, &slab0, &cols, &mips);
590        let c1 = GridView::from_parts(1, &slab1, &cols, &mips);
591        let chunks = [Some(c0), Some(c1)];
592        let cg = ChunkGrid {
593            chunks: &chunks,
594            origin_chunk_xy: [0, 0],
595            origin_chunk_z: 0,
596            chunks_x: 1,
597            chunks_y: 1,
598            chunks_z: 2,
599        };
600        let gv = GridView::from_chunk_grid(&cg, 1);
601        let v0 = gv.chunk_at_xyz([0, 0, 0]).expect("chz=0 present");
602        assert_eq!(v0.slab_buf, &slab0[..]);
603        let v1 = gv.chunk_at_xyz([0, 0, 1]).expect("chz=1 present");
604        assert_eq!(v1.slab_buf, &slab1[..]);
605        // OOR z returns None.
606        assert!(gv.chunk_at_xyz([0, 0, 2]).is_none());
607        assert!(gv.chunk_at_xyz([0, 0, -1]).is_none());
608    }
609
610    /// S4B.6.a: `chunk_at_xy` defers to `chunk_at_xyz` at
611    /// `origin_chunk_z`. For a stacked grid centred at chz=-1,
612    /// `chunk_at_xy` returns the chz=-1 layer.
613    #[test]
614    fn chunk_at_xy_returns_origin_z_layer() {
615        let slab_z_neg1 = [0u8, 50, 50, 0];
616        let slab_z_0 = [0u8, 100, 100, 0];
617        let cols = [0u32, 4];
618        let mips = [0usize, 2];
619        let cn = GridView::from_parts(1, &slab_z_neg1, &cols, &mips);
620        let c0 = GridView::from_parts(1, &slab_z_0, &cols, &mips);
621        let chunks = [Some(cn), Some(c0)];
622        let cg = ChunkGrid {
623            chunks: &chunks,
624            origin_chunk_xy: [0, 0],
625            origin_chunk_z: -1,
626            chunks_x: 1,
627            chunks_y: 1,
628            chunks_z: 2,
629        };
630        let gv = GridView::from_chunk_grid(&cg, 1);
631        // chunk_at_xy returns origin_chunk_z layer = chz=-1.
632        let via_xy = gv.chunk_at_xy([0, 0]).expect("origin layer present");
633        assert_eq!(via_xy.slab_buf, &slab_z_neg1[..]);
634        // chunk_at_xyz with explicit chz=0 returns the upper layer.
635        let v0 = gv.chunk_at_xyz([0, 0, 0]).expect("chz=0 present");
636        assert_eq!(v0.slab_buf, &slab_z_0[..]);
637    }
638}