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}