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}