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