Skip to main content

roxlap_formats/
edit.rs

1//! Voxel-edit primitives: column z-range buffer manipulation.
2//!
3//! While editing, each `.vxl` column is held as a flat `[i32]` list of
4//! solid z-runs (`spans`):
5//!
6//! ```text
7//! [top0, bot0, top1, bot1, ..., top_sentinel, bot_sentinel]
8//! ```
9//!
10//! Each `(top_k, bot_k)` pair is a contiguous SOLID region
11//! `[top_k, bot_k)`. The `.vxl` z-axis grows downward (z=0 is sky), so
12//! `top_k < bot_k` and `bot_k` is exclusive. The list ends in a
13//! sentinel pair whose `bot` is `>= MAXZDIM`; air gaps live between
14//! adjacent runs (`bot_k..top_{k+1}`).
15//!
16//! The buffer is owned by the caller; the helpers run in place and
17//! assume it was sized with enough tail capacity for the worst-case
18//! growth (one extra run pair per [`delslab`] split; [`insslab`] only
19//! ever collapses). The [`ScumCtx`] driver sizes its row cache at
20//! `SPAN_STRIDE * 3` ints per column to honour that.
21
22#![allow(dead_code)]
23
24/// World z is one byte → at most 256 voxels tall.
25pub const MAXZDIM: i32 = 256;
26
27/// Carve voxels in `[y0, y1)` to air on the span list `spans`,
28/// mutated in place.
29///
30/// - `y0 >= y1` is a no-op.
31/// - `y1 >= MAXZDIM` is clamped to `MAXZDIM - 1`.
32/// - an empty `spans` returns early.
33///
34/// In the worst case the carve splits one solid run in two, growing
35/// the list by one pair; the caller must have sized `spans` to absorb
36/// it. Does not allocate.
37pub fn delslab(spans: &mut [i32], y0: i32, mut y1: i32) {
38    if y1 >= MAXZDIM {
39        y1 = MAXZDIM - 1;
40    }
41    if y0 >= y1 || spans.is_empty() {
42        return;
43    }
44    let mut z = 0usize;
45    while y0 >= spans[z + 1] {
46        z += 2;
47    }
48    if y0 > spans[z] {
49        if y1 < spans[z + 1] {
50            // Carve sits strictly inside slab z: split it in two and
51            // shift the rest of the list right by one pair to make
52            // room.
53            let mut i = z;
54            while spans[i + 1] < MAXZDIM {
55                i += 2;
56            }
57            while i > z {
58                spans[i + 3] = spans[i + 1];
59                spans[i + 2] = spans[i];
60                i -= 2;
61            }
62            spans[z + 3] = spans[z + 1];
63            spans[z + 1] = y0;
64            spans[z + 2] = y1;
65            return;
66        }
67        // y1 reaches into (or past) the bottom of slab z: shrink slab
68        // z's bot to y0, then move on to handle slabs below.
69        spans[z + 1] = y0;
70        z += 2;
71    }
72    if y1 >= spans[z + 1] {
73        // y1 spans through slab z (and possibly further). Find the
74        // slab i that y1 lands in (above its bottom), adopt it as
75        // the new slab z, and shift the tail back to close the gap.
76        let mut i = z + 2;
77        while y1 >= spans[i + 1] {
78            i += 2;
79        }
80        let delta = i - z;
81        spans[z] = spans[i];
82        spans[z + 1] = spans[i + 1];
83        while spans[i + 1] < MAXZDIM {
84            i += 2;
85            spans[i - delta] = spans[i];
86            spans[i - delta + 1] = spans[i + 1];
87        }
88    }
89    if y1 > spans[z] {
90        // y1 falls inside slab z: clamp top.
91        spans[z] = y1;
92    }
93}
94
95/// Insert solid voxels in `[y0, y1)` on the column `spans`.
96///
97/// Mirrors the shape of
98/// [`delslab`]: walks `spans` to find where `[y0, y1)` lands and either
99/// inserts a fresh slab into an air gap or merges with adjacent
100/// slabs.
101///
102/// - `y0 >= y1` is a no-op.
103/// - `spans.is_empty()` returns early (matches the C null-pointer
104///   guard).
105/// - Unlike `delslab`, `insslab` does **not** clamp `y1` against
106///   `MAXZDIM`; the algorithm relies on the caller for that. A `y1` value
107///   `>= MAXZDIM` collapses the column into a single solid slab
108///   that acts as the sentinel.
109pub fn insslab(spans: &mut [i32], y0: i32, y1: i32) {
110    if y0 >= y1 || spans.is_empty() {
111        return;
112    }
113    let mut z = 0usize;
114    while y0 > spans[z + 1] {
115        z += 2;
116    }
117    if y1 < spans[z] {
118        // [y0, y1) lives entirely in the air gap above slab z.
119        // Shift slabs [z..=last] right by one pair, then drop the
120        // new slab into slot z.
121        let mut i = z;
122        while spans[i + 1] < MAXZDIM {
123            i += 2;
124        }
125        loop {
126            spans[i + 3] = spans[i + 1];
127            spans[i + 2] = spans[i];
128            if i == z {
129                break;
130            }
131            i -= 2;
132        }
133        spans[z + 1] = y1;
134        spans[z] = y0;
135        return;
136    }
137    if y0 < spans[z] {
138        // [y0, y1) overlaps the top of slab z: extend the top up.
139        spans[z] = y0;
140    }
141    if y1 >= spans[z + 2] && spans[z + 1] < MAXZDIM {
142        // The insert reaches into slab z+2 (or further); merge slabs
143        // z..i into a single slab, where i is the last slab whose
144        // top is at or below y1.
145        let mut i = z + 2;
146        while y1 >= spans[i + 2] && spans[i + 1] < MAXZDIM {
147            i += 2;
148        }
149        let delta = i - z;
150        spans[z + 1] = spans[i + 1];
151        while spans[i + 1] < MAXZDIM {
152            i += 2;
153            spans[i - delta] = spans[i];
154            spans[i - delta + 1] = spans[i + 1];
155        }
156        // Stamp a sentinel at the now-vacated `i+2-delta` slot.
157        // The shift loop above exits with `spans[i+1] >= MAXZDIM`
158        // (slab `i` is the sentinel) WITHOUT copying it forward —
159        // so the merged slabs' old top/bot values are left in
160        // place between `spans[z+2]` and `spans[i+1]`. Walkers using
161        // `spans[i] < MAXZDIM` (top-check, e.g. `voxel_is_solid`)
162        // and the subsequent compilerle re-emit then see phantom
163        // overlapping runs that corrupt the column.
164        //
165        // Writing both top and bot ensures BOTH walker conventions
166        // (top-check and bot-check `< MAXZDIM`) terminate here.
167        spans[i + 2 - delta] = MAXZDIM;
168        spans[i + 3 - delta] = MAXZDIM;
169    }
170    if y1 > spans[z + 1] {
171        // y1 reaches past the bottom of slab z: extend the bot down.
172        spans[z + 1] = y1;
173    }
174}
175
176/// Decode a column's slab bytes into the `spans` z-range buffer.
177///
178/// Decode a `.vxl` slab column into a per-z solid-run list. Walks the slab chain, writes
179/// `[top0, bot0, top1, bot1, ..., MAXZDIM_sentinel]` into `uind`.
180/// `uind` MUST be sized to hold every solid run plus the sentinel pair
181/// — we allocate `MAXZDIM` slots, which is the worst-case bound
182/// (one slab per z value).
183///
184/// The `if (v[3] >= v[1]) continue` branch handles a degenerate slab
185/// where ceiling-z is at or below floor-z (no air gap above this
186/// slab); it merges implicitly into the previous solid run by
187/// skipping the slab in `uind`.
188pub fn expandrle(slab: &[u8], uind: &mut [i32]) {
189    uind[0] = i32::from(slab[1]);
190    let mut i = 2usize;
191    let mut v = 0usize;
192    while slab[v] != 0 {
193        v += usize::from(slab[v]) * 4;
194        if slab[v + 3] >= slab[v + 1] {
195            continue;
196        }
197        uind[i - 1] = i32::from(slab[v + 3]);
198        uind[i] = i32::from(slab[v + 1]);
199        i += 2;
200    }
201    uind[i - 1] = MAXZDIM;
202}
203
204/// One color-lookup record: original column's color for `z` in
205/// `[z_start, z_end)`. Built from a column's slab bytes by
206/// [`build_color_table`].
207#[derive(Debug)]
208struct ColorRange<'s> {
209    z_start: i32,
210    z_end: i32,
211    /// Colors for `[z_start, z_end)`, BGRA, 4 bytes per voxel,
212    /// ordered by `z`. Length `(z_end - z_start) * 4`.
213    colors: &'s [u8],
214}
215
216/// Build the colour-lookup table for a column. The
217/// initial loop in `compilerle` (-4174). For each slab
218/// emits a floor-color range and (for non-first slabs) a ceiling-
219/// color range. Sentinel-terminated by a record at `z_start = MAXZDIM`.
220fn build_color_table(slab: &[u8]) -> Vec<ColorRange<'_>> {
221    let mut ranges = Vec::new();
222    let mut v = 0usize;
223    loop {
224        let z_start = i32::from(slab[v + 1]);
225        let z1c = i32::from(slab[v + 2]);
226        let z_end = z1c + 1;
227        let n_voxels = usize::try_from((z_end - z_start).max(0)).expect("voxel count >= 0");
228        let off = v + 4;
229        ranges.push(ColorRange {
230            z_start,
231            z_end,
232            colors: &slab[off..off + n_voxels * 4],
233        });
234
235        let nextptr = slab[v];
236        if nextptr == 0 {
237            break;
238        }
239        let prev_v = v;
240        v += usize::from(nextptr) * 4;
241        let ze = i32::from(slab[v + 3]);
242        // Ceiling color list of new slab. The format stores these in the
243        // tail of the *previous* slab's bytes — between its floor
244        // colors and the new slab's header.
245        //
246        // C: ic[0] = ze + p.z - ia - i + 2, ic[1] = ze, ic[2] = v - ze*4
247        // where p.z = z1c_prev, ia = z1_prev, i = nextptr_prev.
248        let prev_z1 = i32::from(slab[prev_v + 1]);
249        let prev_z1c = i32::from(slab[prev_v + 2]);
250        let prev_nextptr = i32::from(slab[prev_v]);
251        let ceil_z_start = ze + prev_z1c - prev_z1 - prev_nextptr + 2;
252        let ceil_z_end = ze;
253        let ceil_n =
254            usize::try_from((ceil_z_end - ceil_z_start).max(0)).expect("ceiling voxel count >= 0");
255        // Colors live at slab[v - ceil_n*4 .. v].
256        let ceil_start = v - ceil_n * 4;
257        ranges.push(ColorRange {
258            z_start: ceil_z_start,
259            z_end: ceil_z_end,
260            colors: &slab[ceil_start..v],
261        });
262    }
263    ranges.push(ColorRange {
264        z_start: MAXZDIM,
265        z_end: MAXZDIM,
266        colors: &[],
267    });
268    ranges
269}
270
271/// Re-encode a column's `spans` z-range buffer to slab bytes.
272///
273/// Encode a column's span list back to `.vxl` slab bytes. Walks `n0` (this column's
274/// spans) voxel-by-voxel, writing one BGRA color record per exposed
275/// solid voxel into `cbuf`. Color values are pulled from the
276/// `original_column`'s slab bytes (where present) or from
277/// `colfunc(px, py, z)` for newly-exposed voxels created by edits.
278///
279/// `n1..n4` are the four neighbor columns' spans buffers (left, right,
280/// north, south, in N/E/W/S order); they drive the "exposed" flag
281/// `ia` that decides whether each voxel needs a color record.
282///
283/// Returns the number of bytes written to `cbuf`. We size
284/// `cbuf` to `MAXCSIZ = 1028` bytes — the caller must do the same.
285///
286/// # Panics
287///
288/// Panics if a `spans` z value (always in `0..=MAXZDIM`) doesn't fit in
289/// `u8` — would indicate a malformed spans buffer.
290#[allow(
291    clippy::too_many_arguments,
292    clippy::too_many_lines,
293    clippy::missing_panics_doc
294)]
295pub(crate) fn compilerle(
296    n0: &[i32],
297    n1: &[i32],
298    n2: &[i32],
299    n3: &[i32],
300    n4: &[i32],
301    cbuf: &mut [u8],
302    original_column: &[u8],
303    px: i32,
304    py: i32,
305    colfunc: &mut dyn FnMut(i32, i32, i32) -> i32,
306) -> usize {
307    let tbuf2 = build_color_table(original_column);
308
309    let mut p_z: i32 = n0[0];
310    // Char narrowing semantics: cbuf is byte-addressed, and
311    // `cbuf[n+1] = n0[i]` in C truncates to the low 8 bits. The
312    // sentinel run at the tail of `n0` carries MAXZDIM (=256) which
313    // wraps to 0 — that is part of the terminator slab.
314    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
315    let to_u8 = |v: i32| (v & 0xff) as u8;
316
317    cbuf[1] = to_u8(p_z);
318    let mut ze: i32 = n0[1];
319    cbuf[2] = to_u8(ze - 1);
320    cbuf[3] = 0;
321
322    let mut i = 0usize;
323    let mut onext = 0usize;
324    let mut ic = 0usize;
325    let mut ia: i32 = 15;
326    let mut n = 4usize;
327    let mut zend = if ze == MAXZDIM { -1 } else { ze - 1 };
328
329    let mut n1_idx = 0usize;
330    let mut n2_idx = 0usize;
331    let mut n3_idx = 0usize;
332    let mut n4_idx = 0usize;
333
334    'outer: loop {
335        let mut dacnt = 0;
336        'middle: loop {
337            // do { write voxel; ... } while (ia || p_z == zend)
338            let exit_to_rlendit2 = loop {
339                while p_z >= tbuf2[ic].z_end {
340                    ic += 1;
341                }
342                let color: i32 = if p_z >= tbuf2[ic].z_start {
343                    let off =
344                        usize::try_from((p_z - tbuf2[ic].z_start) * 4).expect("color offset >= 0");
345                    let bytes = &tbuf2[ic].colors[off..off + 4];
346                    i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
347                } else {
348                    colfunc(px, py, p_z)
349                };
350                cbuf[n..n + 4].copy_from_slice(&color.to_le_bytes());
351                n += 4;
352                p_z += 1;
353                if p_z >= ze {
354                    break true; // goto rlendit2
355                }
356                while p_z >= n1[n1_idx] {
357                    n1_idx += 1;
358                    ia ^= 1;
359                }
360                while p_z >= n2[n2_idx] {
361                    n2_idx += 1;
362                    ia ^= 2;
363                }
364                while p_z >= n3[n3_idx] {
365                    n3_idx += 1;
366                    ia ^= 4;
367                }
368                while p_z >= n4[n4_idx] {
369                    n4_idx += 1;
370                    ia ^= 8;
371                }
372                if !(ia != 0 || p_z == zend) {
373                    break false; // exit do-while: buried voxel
374                }
375            };
376
377            if exit_to_rlendit2 {
378                if ze >= MAXZDIM {
379                    break 'outer;
380                }
381                i += 2;
382                cbuf[onext] = u8::try_from((n - onext) >> 2).expect("slab dword count fits in u8");
383                onext = n;
384                p_z = n0[i];
385                cbuf[n + 1] = to_u8(p_z);
386                cbuf[n + 3] = to_u8(ze);
387                ze = n0[i + 1];
388                cbuf[n + 2] = to_u8(ze - 1);
389                n += 4;
390                zend = if ze == MAXZDIM { -1 } else { ze - 1 };
391                break 'middle; // restart 'outer with dacnt = 0
392            }
393
394            // Buried voxel: close the floor list (or open a sub-slab
395            // for the next exposed run).
396            if dacnt == 0 {
397                cbuf[onext + 2] = to_u8(p_z - 1);
398                dacnt = 1;
399            } else {
400                cbuf[onext] = u8::try_from((n - onext) >> 2).expect("slab dword count fits in u8");
401                onext = n;
402                cbuf[n + 1] = to_u8(p_z);
403                cbuf[n + 2] = to_u8(p_z - 1);
404                cbuf[n + 3] = to_u8(p_z);
405                n += 4;
406            }
407
408            // Skip forward to the smallest neighbor breakpoint.
409            let n1_v = n1[n1_idx];
410            let n2_v = n2[n2_idx];
411            let n3_v = n3[n3_idx];
412            let n4_v = n4[n4_idx];
413            if n1_v < n2_v && n1_v < n3_v && n1_v < n4_v {
414                if n1_v >= ze {
415                    p_z = ze - 1;
416                } else {
417                    p_z = n1_v;
418                    n1_idx += 1;
419                    ia ^= 1;
420                }
421            } else if n2_v < n3_v && n2_v < n4_v {
422                if n2_v >= ze {
423                    p_z = ze - 1;
424                } else {
425                    p_z = n2_v;
426                    n2_idx += 1;
427                    ia ^= 2;
428                }
429            } else if n3_v < n4_v {
430                if n3_v >= ze {
431                    p_z = ze - 1;
432                } else {
433                    p_z = n3_v;
434                    n3_idx += 1;
435                    ia ^= 4;
436                }
437            } else if n4_v >= ze {
438                p_z = ze - 1;
439            } else {
440                p_z = n4_v;
441                n4_idx += 1;
442                ia ^= 8;
443            }
444
445            if p_z == MAXZDIM - 1 {
446                break 'outer;
447            }
448            // continue 'middle: re-enter inner do-while with same dacnt
449        }
450    }
451
452    cbuf[onext] = 0;
453    n
454}
455
456// ====================================================================
457// ScumCtx: scum2_line + scum2_finish + scum2 dispatcher (CD.2.5)
458// ====================================================================
459//
460// The column-edit batch context.
461//
462// Acquires a `&mut Vxl` for the duration of an edit batch, manages
463// the rolling 3-row spans buffer cache (`row_cache`), and re-encodes each
464// finished y-row through `compilerle` + `voxalloc`/`voxdealloc` /
465// `column_offset` updates.
466//
467// User flow (set_spans / set_sphere / set_cube):
468//
469// ```ignore
470// vxl.reserve_edit_capacity(headroom);
471// let mut ctx = ScumCtx::new(&mut vxl);
472// ctx.set_colfunc(|x, y, z| 0xff_8080_80);
473// for span in spans {
474//     let spans = ctx.scum2(span.x, span.y).unwrap();
475//     insslab(spans, span.z0, span.z1);
476// }
477// ctx.finish();
478// ```
479//
480// Edits are accumulated per-column, then flushed when the y row
481// advances (so the 3-row neighborhood is stable). `finish` drains
482// the last 2 rows.
483
484use crate::vxl::Vxl;
485
486/// Per-column, per-row stride
487/// (in i32 units) inside the row_cache buffer. spans buffer for column X
488/// in a row whose base offset in row_cache is R lives at
489/// `row_cache[R + X * SPAN_STRIDE * 3 .. R + X * SPAN_STRIDE * 3 + SPAN_STRIDE]`.
490pub(crate) const SPAN_STRIDE: usize = 256;
491
492/// Maximum bytes a single
493/// column can occupy after re-encoding through [`compilerle`].
494pub(crate) const MAXCSIZ: usize = 1028;
495
496/// Sentinel "no row started yet". Encoded as
497/// `0x80000000` = `i32::MIN`.
498const SCOY_NONE: i32 = i32::MIN;
499
500/// Initial row_cache offset for `cur_row_base` (`SPAN_STRIDE*6`).
501const ROW_BASE_INITIAL: usize = SPAN_STRIDE * 6;
502
503/// When `cur_row_base` reaches this offset, wrap back to
504/// `ROW_BASE_INITIAL` (wrap at `SPAN_STRIDE*9`).
505const ROW_BASE_WRAP: usize = SPAN_STRIDE * 9;
506
507/// Column-edit batch context. Construct via [`ScumCtx::new`] after
508/// calling [`Vxl::reserve_edit_capacity`] on the world.
509///
510/// Holds a `&mut Vxl` borrow plus the rolling 3-row spans buffer cache.
511/// Mutate columns via [`ScumCtx::scum2`] — it returns the spans buffer
512/// for the requested column; mutate via [`delslab`] / [`insslab`].
513/// Edits are committed when the y row advances (or when
514/// [`ScumCtx::finish`] drains the last 2 rows).
515///
516/// Caller MUST invoke [`ScumCtx::finish`] explicitly. Drop without
517/// finish leaks the trailing 2 rows of edits — by contract.
518pub struct ScumCtx<'v> {
519    vxl: &'v mut Vxl,
520    /// Rolling spans buffer cache. Sized `(vsid + 4) * 3 * SPAN_STRIDE` ints.
521    row_cache: Vec<i32>,
522    /// `compilerle` output scratch.
523    cbuf: Vec<u8>,
524    /// Color callback (= the colour callback). Called by
525    /// `compilerle` for each newly-exposed voxel.
526    colfunc: Box<dyn FnMut(i32, i32, i32) -> i32 + 'v>,
527
528    // ---- rolling state ----------------------------------------------
529    scoy: i32,
530    cur_row_base: usize,
531    scx0: i32,
532    scx1: i32,
533    scox0: i32,
534    scox1: i32,
535    scoox0: i32,
536    scoox1: i32,
537    scex0: i32,
538    scex1: i32,
539    sceox0: i32,
540    sceox1: i32,
541
542    /// `(x, y)` of the most-recent successful [`ScumCtx::scum2`] call,
543    /// or `None` if no column has been loaded since the last row
544    /// advance / context creation. Used by [`ScumCtx::with_column`]
545    /// to skip the redundant `expandrle` when successive edits hit
546    /// the same column — the per-column edit contract:
547    /// re-loading from `sptr` would wipe pending in-row_cache edits.
548    last_scum2: Option<(i32, i32)>,
549}
550
551#[allow(
552    clippy::cast_possible_truncation,
553    clippy::cast_possible_wrap,
554    clippy::cast_sign_loss,
555    clippy::if_not_else,
556    clippy::similar_names
557)]
558impl<'v> ScumCtx<'v> {
559    /// Open a new column-edit batch on a Vxl. The Vxl MUST have been
560    /// upgraded with [`Vxl::reserve_edit_capacity`] beforehand (the
561    /// slab allocator must be initialised).
562    ///
563    /// # Panics
564    ///
565    /// Panics if `vxl.vbit` is empty (no edit capacity reserved).
566    pub fn new(vxl: &'v mut Vxl) -> Self {
567        assert!(
568            !vxl.vbit.is_empty(),
569            "ScumCtx::new requires Vxl::reserve_edit_capacity to be called first"
570        );
571        let radar_size = (vxl.vsid as usize + 4) * 3 * SPAN_STRIDE;
572        Self {
573            vxl,
574            row_cache: vec![0i32; radar_size],
575            cbuf: vec![0u8; MAXCSIZ],
576            colfunc: Box::new(|_, _, _| 0),
577            scoy: SCOY_NONE,
578            cur_row_base: ROW_BASE_INITIAL,
579            scx0: 0,
580            scx1: 0,
581            scox0: 0,
582            scox1: 0,
583            scoox0: 0,
584            scoox1: 0,
585            scex0: 0,
586            scex1: 0,
587            sceox0: 0,
588            sceox1: 0,
589            last_scum2: None,
590        }
591    }
592
593    /// Install the colour callback. Called
594    /// for each newly-exposed voxel produced by edits.
595    pub fn set_colfunc<F>(&mut self, f: F)
596    where
597        F: FnMut(i32, i32, i32) -> i32 + 'v,
598    {
599        self.colfunc = Box::new(f);
600    }
601
602    /// Open column `(x, y)` for editing; returns its spans buffer.
603    /// Caller mutates via [`delslab`] / [`insslab`].
604    ///
605    /// Auto-flushes any prior y row that's no longer in the rolling
606    /// window. Returns `None` if `(x, y)` is out of world bounds.
607    pub fn scum2(&mut self, x: i32, y: i32) -> Option<&mut [i32]> {
608        let vsid = self.vxl.vsid as i32;
609        if x < 0 || x >= vsid || y < 0 || y >= vsid {
610            return None;
611        }
612
613        if y != self.scoy {
614            if self.scoy != SCOY_NONE {
615                self.scum2_line();
616                while self.scoy < y - 1 {
617                    self.scx0 = i32::MAX;
618                    self.scx1 = i32::MIN;
619                    self.advance_row();
620                    self.scum2_line();
621                }
622                self.advance_row();
623            } else {
624                self.scoox0 = i32::MAX;
625                self.scox0 = i32::MAX;
626                self.sceox0 = x + 1;
627                self.scex0 = x + 1;
628                self.sceox1 = x;
629                self.scex1 = x;
630                self.scoy = y;
631                self.cur_row_base = ROW_BASE_INITIAL;
632            }
633            self.scx0 = x;
634        } else {
635            // Same y row as previous call: fill any skipped columns
636            // between scx1 and x so the 3-row window stays continuous.
637            while self.scx1 < x - 1 {
638                self.scx1 += 1;
639                let scx1 = self.scx1;
640                self.expand_column_into_row(scx1, y, self.cur_row_base);
641            }
642        }
643
644        let radar_idx = self.cur_row_base + (x as usize) * SPAN_STRIDE * 3;
645        self.scx1 = x;
646        self.expand_column_into_row(x, y, self.cur_row_base);
647        self.last_scum2 = Some((x, y));
648        Some(&mut self.row_cache[radar_idx..radar_idx + SPAN_STRIDE])
649    }
650
651    /// Edit one column with closure-based access. If `(x, y)` matches
652    /// the immediately-previous successful [`ScumCtx::scum2`] /
653    /// `with_column` call, reuses the cached spans buffer in row_cache
654    /// (skipping the redundant `expandrle` that would wipe pending
655    /// edits). Otherwise calls `scum2` to load the column.
656    ///
657    /// This is the primary edit API for span-style batch operations
658    /// where multiple z ranges land on the same column —
659    /// [`set_spans`] is a thin wrapper. Returns `false` and skips
660    /// the closure if `(x, y)` is out of world bounds.
661    pub fn with_column<F>(&mut self, x: i32, y: i32, f: F) -> bool
662    where
663        F: FnOnce(&mut [i32]),
664    {
665        if self.last_scum2 != Some((x, y)) && self.scum2(x, y).is_none() {
666            return false;
667        }
668        // At this point either the cache hit or scum2 succeeded —
669        // both leave the column's spans at cur_row_base + x * SPAN_STRIDE * 3.
670        let radar_idx = self.cur_row_base + (x as usize) * SPAN_STRIDE * 3;
671        let spans = &mut self.row_cache[radar_idx..radar_idx + SPAN_STRIDE];
672        f(spans);
673        true
674    }
675
676    /// Drain the last 2 rows and consume the context. MUST be called
677    /// — Drop does not auto-finish (by contract).
678    pub fn finish(mut self) {
679        if self.scoy == SCOY_NONE {
680            return;
681        }
682        for _ in 0..2 {
683            self.scum2_line();
684            self.scx0 = i32::MAX;
685            self.scx1 = i32::MIN;
686            self.advance_row();
687        }
688        self.scum2_line();
689        self.scoy = SCOY_NONE;
690    }
691
692    /// Bump scoy by 1 and advance cur_row_base in the row_cache ring.
693    /// Invalidates the [`ScumCtx::with_column`] cache: the prior
694    /// row's column slots are still in the row_cache but their relative
695    /// offset to the new `cur_row_base` has shifted, so re-using the
696    /// cached `(x, y)` would index the wrong slot.
697    fn advance_row(&mut self) {
698        self.scoy += 1;
699        self.cur_row_base += SPAN_STRIDE;
700        if self.cur_row_base == ROW_BASE_WRAP {
701            self.cur_row_base = ROW_BASE_INITIAL;
702        }
703        self.last_scum2 = None;
704    }
705
706    /// Load column `(x, y)` from the slab pool into the row_cache slot
707    /// at `row_base + x * SPAN_STRIDE * 3`. Out-of-world columns get the
708    /// all-solid sentinel `[0, MAXZDIM]` (matches the slab decode
709    /// out-of-bounds behaviour).
710    fn expand_column_into_row(&mut self, x: i32, y: i32, row_base: usize) {
711        let vsid = self.vxl.vsid as i32;
712        // Radar offset; the algorithm relies on the prefix slack for x = -1.
713        let radar_idx_signed = (row_base as isize) + (x as isize) * (SPAN_STRIDE as isize) * 3;
714        if radar_idx_signed < 0 {
715            return;
716        }
717        #[allow(clippy::cast_sign_loss)]
718        let radar_idx = radar_idx_signed as usize;
719        if radar_idx + SPAN_STRIDE > self.row_cache.len() {
720            return;
721        }
722        if x < 0 || x >= vsid || y < 0 || y >= vsid {
723            self.row_cache[radar_idx] = 0;
724            self.row_cache[radar_idx + 1] = MAXZDIM;
725            return;
726        }
727        let idx = (y as usize) * (vsid as usize) + (x as usize);
728        let slab = self.vxl.column_data(idx);
729        expandrle(
730            slab,
731            &mut self.row_cache[radar_idx..radar_idx + SPAN_STRIDE],
732        );
733    }
734
735    /// Flush row `scoy - 1` (the middle of the rolling 3-row window).
736    ///
737    #[allow(clippy::too_many_lines)]
738    fn scum2_line(&mut self) {
739        let vsid = self.vxl.vsid as i32;
740
741        // x0 = min(scox0-1, min(scx0, scoox0)); x1 = max(scox1+1, max(scx1, scoox1))
742        let x0 = (self.scox0 - 1).min(self.scx0).min(self.scoox0);
743        self.scoox0 = self.scox0;
744        self.scox0 = self.scx0;
745        let x1 = (self.scox1 + 1).max(self.scx1).max(self.scoox1);
746        self.scoox1 = self.scox1;
747        self.scox1 = self.scx1;
748
749        let uptr = wrap_radar(self.cur_row_base + SPAN_STRIDE);
750        let mptr = wrap_radar(uptr + SPAN_STRIDE);
751
752        // Load row scoy-2 (uptr) for [x0, x1] minus [sceox0, sceox1].
753        let scoy_2 = self.scoy - 2;
754        if x1 < self.sceox0 || x0 > self.sceox1 {
755            for x in x0..=x1 {
756                self.expand_column_into_row(x, scoy_2, uptr);
757            }
758        } else {
759            for x in x0..self.sceox0 {
760                self.expand_column_into_row(x, scoy_2, uptr);
761            }
762            let mut x = x1;
763            while x > self.sceox1 {
764                self.expand_column_into_row(x, scoy_2, uptr);
765                x -= 1;
766            }
767        }
768
769        // Load row scoy-1 (mptr) for [x0-1, x1+1].
770        let scoy_1 = self.scoy - 1;
771        if (self.scex1 | x1) >= 0 {
772            for x in (x1 + 2)..self.scex0 {
773                self.expand_column_into_row(x, scoy_1, mptr);
774            }
775            let mut x = x0 - 2;
776            while x > self.scex1 {
777                self.expand_column_into_row(x, scoy_1, mptr);
778                x -= 1;
779            }
780        }
781        if x1 + 1 < self.scex0 || x0 - 1 > self.scex1 {
782            for x in (x0 - 1)..=(x1 + 1) {
783                self.expand_column_into_row(x, scoy_1, mptr);
784            }
785        } else {
786            for x in (x0 - 1)..self.scex0 {
787                self.expand_column_into_row(x, scoy_1, mptr);
788            }
789            let mut x = x1 + 1;
790            while x > self.scex1 {
791                self.expand_column_into_row(x, scoy_1, mptr);
792                x -= 1;
793            }
794        }
795        self.sceox0 = (x0 - 1).min(self.scex0);
796        self.sceox1 = (x1 + 1).max(self.scex1);
797
798        // Load row scoy (cur_row_base) for [x0, x1] minus [scx0, scx1].
799        let scoy_0 = self.scoy;
800        let cur_row_base = self.cur_row_base;
801        if x1 < self.scx0 || x0 > self.scx1 {
802            for x in x0..=x1 {
803                self.expand_column_into_row(x, scoy_0, cur_row_base);
804            }
805        } else {
806            for x in x0..self.scx0 {
807                self.expand_column_into_row(x, scoy_0, cur_row_base);
808            }
809            let mut x = x1;
810            while x > self.scx1 {
811                self.expand_column_into_row(x, scoy_0, cur_row_base);
812                x -= 1;
813            }
814        }
815        self.scex0 = x0;
816        self.scex1 = x1;
817
818        // Flush row scoy-1: re-encode each column in [x0, x1] within
819        // [0, vsid).
820        let y = self.scoy - 1;
821        if !(0..vsid).contains(&y) {
822            return;
823        }
824        let x0_clamped = x0.max(0);
825        let x1_clamped = x1.min(vsid - 1);
826
827        for x in x0_clamped..=x1_clamped {
828            self.flush_column(x, y, mptr, uptr, cur_row_base);
829        }
830    }
831
832    /// Re-encode column (x, y) using its spans buffer in `mptr` and
833    /// neighbor b2s in mptr (left/right) + uptr (above) + cur_row_base
834    /// (below). Commits the new bytes to the slab pool.
835    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
836    fn flush_column(&mut self, x: i32, y: i32, mptr: usize, uptr: usize, cur_row_base: usize) {
837        let vsid = self.vxl.vsid as usize;
838        let k = (x as usize) * SPAN_STRIDE * 3;
839        let n0_pos = mptr + k;
840        let n1_pos_signed = (mptr as isize) + (k as isize) - (SPAN_STRIDE as isize) * 3;
841        let n2_pos = mptr + k + SPAN_STRIDE * 3;
842        let n3_pos = uptr + k;
843        let n4_pos = cur_row_base + k;
844
845        // n1_pos may be at a negative offset for x = 0; we rely
846        // on row_cache prefix slack. Skip if the slot is outside our row_cache.
847        if n1_pos_signed < 0 {
848            return;
849        }
850        let n1_pos = n1_pos_signed as usize;
851
852        let idx = (y as usize) * vsid + (x as usize);
853
854        // Snapshot original column bytes — compilerle reads colors
855        // from them, and we overwrite them later via voxalloc + copy.
856        let original_bytes: Vec<u8> = self.vxl.column_data(idx).to_vec();
857
858        let written = {
859            let row_cache = &self.row_cache;
860            let n0 = &row_cache[n0_pos..n0_pos + SPAN_STRIDE];
861            let n1 = &row_cache[n1_pos..n1_pos + SPAN_STRIDE];
862            let n2 = &row_cache[n2_pos..n2_pos + SPAN_STRIDE];
863            let n3 = &row_cache[n3_pos..n3_pos + SPAN_STRIDE];
864            let n4 = &row_cache[n4_pos..n4_pos + SPAN_STRIDE];
865            compilerle(
866                n0,
867                n1,
868                n2,
869                n3,
870                n4,
871                &mut self.cbuf,
872                &original_bytes,
873                x,
874                y,
875                &mut *self.colfunc,
876            )
877        };
878
879        let old_offset = self.vxl.column_offset[idx];
880        self.vxl.voxdealloc(old_offset);
881        let new_offset = self.vxl.voxalloc(written as u32);
882        self.vxl.data[new_offset as usize..new_offset as usize + written]
883            .copy_from_slice(&self.cbuf[..written]);
884        self.vxl.column_offset[idx] = new_offset;
885    }
886}
887
888/// Wrap a row_cache offset back into the rolling-window range
889/// `[ROW_BASE_INITIAL, ROW_BASE_WRAP)`.
890fn wrap_radar(off: usize) -> usize {
891    if off == ROW_BASE_WRAP {
892        ROW_BASE_INITIAL
893    } else {
894        off
895    }
896}
897
898// ====================================================================
899// set_spans (CD.3) — thin wrapper over ScumCtx + delslab/insslab.
900// ====================================================================
901
902/// One vertical span on a column: `(x, y, z0..=z1)` solid voxels.
903///
904/// `z1` is INCLUSIVE per the `.vxl` span convention — the actual
905/// edited range is the half-open `[z0, z1 + 1)`. Same convention
906/// makes a `Vspan { z0: 100, z1: 100 }` carve / fill exactly one
907/// voxel at z=100.
908///
909/// `x` / `y` are full-world `u32` coordinates (no patch-relative +
910/// offset machinery — not needed here).
911#[derive(Debug, Clone, Copy, PartialEq, Eq)]
912pub struct Vspan {
913    pub x: u32,
914    pub y: u32,
915    pub z0: u8,
916    pub z1: u8,
917}
918
919/// Operation for span-style edits.
920///
921/// `Carve` flips the listed voxels to air (per-span [`delslab`]) —
922/// the colfunc is consulted by the internal RLE re-compile step for
923/// newly-exposed voxels just outside the carved range (above and
924/// below) which weren't previously in the column's color list.
925///
926/// `Insert` flips the listed voxels to solid (per-span [`insslab`])
927/// — the colfunc is consulted for the inserted voxels themselves.
928#[derive(Debug, Clone, Copy, PartialEq, Eq)]
929pub enum SpanOp {
930    Carve,
931    Insert,
932}
933
934/// Apply a list of column-aligned vertical spans with a custom colour
935/// callback — the closure can capture arbitrary state to implement
936/// flat, jittered, textured, or otherwise procedural colour patterns.
937///
938/// `colfunc(x, y, z) -> i32` returns the BGRA colour for any voxel
939/// that needs one: the inserted voxels (Insert op) or the newly-
940/// exposed voxels just outside the carved range (Carve op).
941///
942/// **Contract**: `spans` MUST be sorted ascending by `(y, x)` and,
943/// within each `(x, y)` group, ascending by `z0`. The driver relies on
944/// this for correct row-flush ordering and for `with_column`'s
945/// caching invariant. Out-of-bounds spans (x or y >= vsid) are
946/// silently skipped.
947///
948/// Empty input is a no-op (no `ScumCtx` is created).
949///
950/// # Panics
951///
952/// Panics if `world.vbit` is empty — call
953/// [`Vxl::reserve_edit_capacity`] first.
954#[allow(clippy::cast_possible_wrap)]
955pub fn set_spans_with_colfunc<F>(world: &mut Vxl, spans: &[Vspan], op: SpanOp, colfunc: F)
956where
957    F: FnMut(i32, i32, i32) -> i32,
958{
959    if spans.is_empty() {
960        return;
961    }
962    let inserting = op == SpanOp::Insert;
963    let mut ctx = ScumCtx::new(world);
964    ctx.set_colfunc(colfunc);
965    for span in spans {
966        let x = span.x as i32;
967        let y = span.y as i32;
968        let z0 = i32::from(span.z0);
969        let z1 = i32::from(span.z1) + 1; // inclusive → half-open exclusive
970        ctx.with_column(x, y, |spans| {
971            if inserting {
972                insslab(spans, z0, z1);
973            } else {
974                delslab(spans, z0, z1);
975            }
976        });
977    }
978    ctx.finish();
979}
980
981/// Apply a list of column-aligned vertical spans with a constant
982/// colour. Convenience wrapper over [`set_spans_with_colfunc`] for
983/// the common cases:
984///
985/// - `color = None` → carve. Newly-exposed voxels get colour 0.
986/// - `color = Some(c)` → insert. Inserted voxels get colour `c`.
987///
988/// For non-constant colours (jitter, texture-mapped, position-
989/// dependent, gradient by depth), use [`set_spans_with_colfunc`]
990/// directly with a closure capturing the relevant state.
991///
992/// See [`set_spans_with_colfunc`] for the sort-order contract and
993/// panic semantics.
994pub fn set_spans(world: &mut Vxl, spans: &[Vspan], color: Option<u32>) {
995    let op = if color.is_some() {
996        SpanOp::Insert
997    } else {
998        SpanOp::Carve
999    };
1000    #[allow(clippy::cast_possible_wrap)]
1001    let c_i32 = color.unwrap_or(0) as i32;
1002    set_spans_with_colfunc(world, spans, op, move |_, _, _| c_i32);
1003}
1004
1005// ====================================================================
1006// set_cube / set_rect / set_sphere (CD.4) — region wrappers.
1007// ====================================================================
1008
1009/// Edit a single voxel at `(x, y, z)`. (`setcube`).
1010///
1011/// `color = None` carves to air; `Some(c)` inserts solid coloured
1012/// `c`. Out-of-bounds coordinates are silently skipped.
1013///
1014/// **Note**: this skips the "exposed-solid in-place
1015/// colour overwrite" optimization — every call goes through the
1016/// scum2 + delslab/insslab + compilerle pipeline. Per-voxel edits
1017/// are rare in the cave-demo workload; the optimization can land
1018/// later if needed.
1019///
1020/// # Panics
1021///
1022/// Panics if `world.vbit` is empty — call
1023/// [`Vxl::reserve_edit_capacity`] first.
1024pub fn set_cube(world: &mut Vxl, x: i32, y: i32, z: i32, color: Option<u32>) {
1025    let op = if color.is_some() {
1026        SpanOp::Insert
1027    } else {
1028        SpanOp::Carve
1029    };
1030    #[allow(clippy::cast_possible_wrap)]
1031    let c_i32 = color.unwrap_or(0) as i32;
1032    set_cube_with_colfunc(world, x, y, z, op, move |_, _, _| c_i32);
1033}
1034
1035/// [`set_cube`] with a custom colour callback.
1036#[allow(
1037    clippy::cast_possible_truncation,
1038    clippy::cast_possible_wrap,
1039    clippy::cast_sign_loss
1040)]
1041pub fn set_cube_with_colfunc<F>(world: &mut Vxl, x: i32, y: i32, z: i32, op: SpanOp, colfunc: F)
1042where
1043    F: FnMut(i32, i32, i32) -> i32,
1044{
1045    let vsid = world.vsid as i32;
1046    if x < 0 || x >= vsid || y < 0 || y >= vsid || !(0..MAXZDIM).contains(&z) {
1047        return;
1048    }
1049    let span = Vspan {
1050        x: x as u32,
1051        y: y as u32,
1052        z0: z as u8,
1053        z1: z as u8,
1054    };
1055    set_spans_with_colfunc(world, &[span], op, colfunc);
1056}
1057
1058/// Edit an axis-aligned box `[lo, hi]` (inclusive on both ends in
1059/// every axis). (`setrect`).
1060///
1061/// `color = None` carves to air; `Some(c)` inserts solid coloured
1062/// `c`. The box is sorted and clamped to world bounds before
1063/// iteration; an empty box (any axis where lo > hi after clamp) is
1064/// a no-op.
1065///
1066/// # Panics
1067///
1068/// Panics if `world.vbit` is empty — call
1069/// [`Vxl::reserve_edit_capacity`] first.
1070pub fn set_rect(world: &mut Vxl, lo: [i32; 3], hi: [i32; 3], color: Option<u32>) {
1071    let op = if color.is_some() {
1072        SpanOp::Insert
1073    } else {
1074        SpanOp::Carve
1075    };
1076    #[allow(clippy::cast_possible_wrap)]
1077    let c_i32 = color.unwrap_or(0) as i32;
1078    set_rect_with_colfunc(world, lo, hi, op, move |_, _, _| c_i32);
1079}
1080
1081/// [`set_rect`] with a custom colour callback.
1082#[allow(
1083    clippy::cast_possible_truncation,
1084    clippy::cast_possible_wrap,
1085    clippy::cast_sign_loss
1086)]
1087pub fn set_rect_with_colfunc<F>(world: &mut Vxl, lo: [i32; 3], hi: [i32; 3], op: SpanOp, colfunc: F)
1088where
1089    F: FnMut(i32, i32, i32) -> i32,
1090{
1091    let vsid = world.vsid as i32;
1092    let xs = lo[0].min(hi[0]).max(0);
1093    let xe = lo[0].max(hi[0]).min(vsid - 1);
1094    let ys = lo[1].min(hi[1]).max(0);
1095    let ye = lo[1].max(hi[1]).min(vsid - 1);
1096    let zs = lo[2].min(hi[2]).max(0);
1097    let ze = lo[2].max(hi[2]).min(MAXZDIM - 1);
1098    if xs > xe || ys > ye || zs > ze {
1099        return;
1100    }
1101    let inserting = op == SpanOp::Insert;
1102    let mut ctx = ScumCtx::new(world);
1103    ctx.set_colfunc(colfunc);
1104    for y in ys..=ye {
1105        for x in xs..=xe {
1106            ctx.with_column(x, y, |spans| {
1107                if inserting {
1108                    insslab(spans, zs, ze + 1);
1109                } else {
1110                    delslab(spans, zs, ze + 1);
1111                }
1112            });
1113        }
1114    }
1115    ctx.finish();
1116}
1117
1118/// Edit a sphere of voxels centred at `center` with the given
1119/// `radius`. (`setsphere`).
1120///
1121/// Uses Euclidean distance (the
1122/// round-sphere case). For non-Euclidean shapes (octahedron at
1123/// `curpow = 1.0`, etc.) the user can drop down to [`ScumCtx`] and
1124/// roll their own iteration; the cave-demo's spherical bullet
1125/// impacts only need the Euclidean case.
1126///
1127/// `color = None` carves to air; `Some(c)` inserts solid coloured
1128/// `c`. The bounding box is clamped to world bounds; a sphere fully
1129/// outside the world is a no-op.
1130///
1131/// # Panics
1132///
1133/// Panics if `world.vbit` is empty — call
1134/// [`Vxl::reserve_edit_capacity`] first.
1135pub fn set_sphere(world: &mut Vxl, center: [i32; 3], radius: u32, color: Option<u32>) {
1136    let op = if color.is_some() {
1137        SpanOp::Insert
1138    } else {
1139        SpanOp::Carve
1140    };
1141    #[allow(clippy::cast_possible_wrap)]
1142    let c_i32 = color.unwrap_or(0) as i32;
1143    set_sphere_with_colfunc(world, center, radius, op, move |_, _, _| c_i32);
1144}
1145
1146/// [`set_sphere`] with a custom colour callback.
1147#[allow(
1148    clippy::cast_possible_truncation,
1149    clippy::cast_possible_wrap,
1150    clippy::cast_sign_loss,
1151    clippy::cast_precision_loss,
1152    clippy::similar_names
1153)]
1154pub fn set_sphere_with_colfunc<F>(
1155    world: &mut Vxl,
1156    center: [i32; 3],
1157    radius: u32,
1158    op: SpanOp,
1159    colfunc: F,
1160) where
1161    F: FnMut(i32, i32, i32) -> i32,
1162{
1163    let vsid = world.vsid as i32;
1164    let cx = center[0];
1165    let cy = center[1];
1166    let cz = center[2];
1167    let r = radius as i32;
1168    let xs = (cx - r).max(0);
1169    let xe = (cx + r).min(vsid - 1);
1170    let ys = (cy - r).max(0);
1171    let ye = (cy + r).min(vsid - 1);
1172    let zs = (cz - r).max(0);
1173    let ze = (cz + r).min(MAXZDIM - 1);
1174    if xs > xe || ys > ye || zs > ze {
1175        return;
1176    }
1177    let r_sq = r * r;
1178    let inserting = op == SpanOp::Insert;
1179    let mut ctx = ScumCtx::new(world);
1180    ctx.set_colfunc(colfunc);
1181    for y in ys..=ye {
1182        let dy = y - cy;
1183        let dy_sq = dy * dy;
1184        if dy_sq > r_sq {
1185            continue;
1186        }
1187        for x in xs..=xe {
1188            let dx = x - cx;
1189            let dx_sq = dx * dx;
1190            let xy_sq = dx_sq + dy_sq;
1191            if xy_sq > r_sq {
1192                continue;
1193            }
1194            // dz_max satisfies dx² + dy² + dz² <= r²; voxel range is
1195            // z = cz - dz_max ..= cz + dz_max.
1196            let dz_max_sq = r_sq - xy_sq;
1197            let dz_max = (dz_max_sq as f32).sqrt() as i32;
1198            let z_lo = (cz - dz_max).max(zs);
1199            let z_hi = (cz + dz_max).min(ze);
1200            if z_lo > z_hi {
1201                continue;
1202            }
1203            ctx.with_column(x, y, |spans| {
1204                if inserting {
1205                    insslab(spans, z_lo, z_hi + 1);
1206                } else {
1207                    delslab(spans, z_lo, z_hi + 1);
1208                }
1209            });
1210        }
1211    }
1212    ctx.finish();
1213}
1214
1215#[cfg(test)]
1216#[allow(
1217    clippy::cast_possible_truncation,
1218    clippy::cast_possible_wrap,
1219    clippy::cast_sign_loss,
1220    clippy::items_after_statements
1221)]
1222mod tests {
1223    use super::*;
1224
1225    /// Build a sentinel-terminated `spans` from a list of solid slabs.
1226    /// The buffer has slack at the tail so split-style ops have room
1227    /// to shift.
1228    fn build_b2(slabs: &[(i32, i32)]) -> Vec<i32> {
1229        let mut buf: Vec<i32> = Vec::new();
1230        for &(top, bot) in slabs {
1231            assert!(top < bot, "slab top must be < bot");
1232            assert!(bot < MAXZDIM, "slab bot must fit below MAXZDIM");
1233            buf.push(top);
1234            buf.push(bot);
1235        }
1236        // Sentinel pair. the slab decode terminates with
1237        // bot = MAXZDIM; top is unread (writes only).
1238        buf.push(MAXZDIM);
1239        buf.push(MAXZDIM);
1240        // Slack — accommodates worst-case growth for any test.
1241        buf.resize(buf.len() + 32, 0);
1242        buf
1243    }
1244
1245    /// Read back the slab list before the sentinel.
1246    fn read_slabs(spans: &[i32]) -> Vec<(i32, i32)> {
1247        let mut out = Vec::new();
1248        let mut i = 0;
1249        while spans[i + 1] < MAXZDIM {
1250            out.push((spans[i], spans[i + 1]));
1251            i += 2;
1252        }
1253        out
1254    }
1255
1256    // ---- delslab ----------------------------------------------------
1257
1258    #[test]
1259    fn delslab_noop_y0_ge_y1() {
1260        let mut spans = build_b2(&[(10, 20)]);
1261        delslab(&mut spans, 15, 15);
1262        assert_eq!(read_slabs(&spans), [(10, 20)]);
1263        delslab(&mut spans, 20, 10);
1264        assert_eq!(read_slabs(&spans), [(10, 20)]);
1265    }
1266
1267    #[test]
1268    fn delslab_split_inside_one_slab() {
1269        let mut spans = build_b2(&[(10, 30)]);
1270        delslab(&mut spans, 15, 20);
1271        assert_eq!(read_slabs(&spans), [(10, 15), (20, 30)]);
1272    }
1273
1274    #[test]
1275    fn delslab_shrink_bot_of_slab() {
1276        let mut spans = build_b2(&[(10, 30)]);
1277        delslab(&mut spans, 20, 30);
1278        assert_eq!(read_slabs(&spans), [(10, 20)]);
1279    }
1280
1281    #[test]
1282    fn delslab_shrink_top_of_slab() {
1283        let mut spans = build_b2(&[(10, 30)]);
1284        delslab(&mut spans, 5, 15);
1285        assert_eq!(read_slabs(&spans), [(15, 30)]);
1286    }
1287
1288    #[test]
1289    fn delslab_carve_full_slab() {
1290        let mut spans = build_b2(&[(10, 30)]);
1291        delslab(&mut spans, 5, 35);
1292        assert_eq!(read_slabs(&spans), Vec::<(i32, i32)>::new());
1293    }
1294
1295    #[test]
1296    fn delslab_in_air_noop() {
1297        let mut spans = build_b2(&[(10, 30)]);
1298        delslab(&mut spans, 0, 8);
1299        assert_eq!(read_slabs(&spans), [(10, 30)]);
1300        delslab(&mut spans, 35, 50);
1301        assert_eq!(read_slabs(&spans), [(10, 30)]);
1302    }
1303
1304    #[test]
1305    fn delslab_span_two_slabs_carve_middle() {
1306        let mut spans = build_b2(&[(10, 30), (50, 70)]);
1307        delslab(&mut spans, 20, 60);
1308        assert_eq!(read_slabs(&spans), [(10, 20), (60, 70)]);
1309    }
1310
1311    #[test]
1312    fn delslab_carve_two_full_slabs_keep_third() {
1313        let mut spans = build_b2(&[(10, 20), (30, 40), (50, 60)]);
1314        delslab(&mut spans, 5, 45);
1315        assert_eq!(read_slabs(&spans), [(50, 60)]);
1316    }
1317
1318    #[test]
1319    fn delslab_y1_clamped_to_maxzdim_minus_1() {
1320        let mut spans = build_b2(&[(10, 200)]);
1321        delslab(&mut spans, 100, MAXZDIM);
1322        assert_eq!(read_slabs(&spans), [(10, 100)]);
1323    }
1324
1325    #[test]
1326    fn delslab_carve_top_edge_of_slab() {
1327        // y1 == top of slab → should leave the slab untouched (the
1328        // carve range ends right at the surface).
1329        let mut spans = build_b2(&[(10, 30)]);
1330        delslab(&mut spans, 5, 10);
1331        assert_eq!(read_slabs(&spans), [(10, 30)]);
1332    }
1333
1334    #[test]
1335    fn delslab_carve_bot_edge_of_slab() {
1336        // y0 == bot of slab → no overlap.
1337        let mut spans = build_b2(&[(10, 30)]);
1338        delslab(&mut spans, 30, 35);
1339        assert_eq!(read_slabs(&spans), [(10, 30)]);
1340    }
1341
1342    #[test]
1343    fn delslab_carve_exact_full_slab_keeps_neighbors() {
1344        let mut spans = build_b2(&[(10, 20), (30, 40), (50, 60)]);
1345        delslab(&mut spans, 30, 40);
1346        assert_eq!(read_slabs(&spans), [(10, 20), (50, 60)]);
1347    }
1348
1349    // ---- insslab ----------------------------------------------------
1350
1351    #[test]
1352    fn insslab_noop_y0_ge_y1() {
1353        let mut spans = build_b2(&[(10, 20)]);
1354        insslab(&mut spans, 15, 15);
1355        assert_eq!(read_slabs(&spans), [(10, 20)]);
1356        insslab(&mut spans, 20, 10);
1357        assert_eq!(read_slabs(&spans), [(10, 20)]);
1358    }
1359
1360    #[test]
1361    fn insslab_into_pure_air() {
1362        let mut spans = build_b2(&[]);
1363        insslab(&mut spans, 10, 30);
1364        assert_eq!(read_slabs(&spans), [(10, 30)]);
1365    }
1366
1367    #[test]
1368    fn insslab_into_air_gap_above_slab() {
1369        let mut spans = build_b2(&[(50, 70)]);
1370        insslab(&mut spans, 10, 30);
1371        assert_eq!(read_slabs(&spans), [(10, 30), (50, 70)]);
1372    }
1373
1374    #[test]
1375    fn insslab_into_air_gap_between_slabs() {
1376        let mut spans = build_b2(&[(10, 20), (60, 70)]);
1377        insslab(&mut spans, 30, 50);
1378        assert_eq!(read_slabs(&spans), [(10, 20), (30, 50), (60, 70)]);
1379    }
1380
1381    #[test]
1382    fn insslab_into_air_gap_below_all_slabs() {
1383        let mut spans = build_b2(&[(10, 20)]);
1384        insslab(&mut spans, 30, 50);
1385        assert_eq!(read_slabs(&spans), [(10, 20), (30, 50)]);
1386    }
1387
1388    #[test]
1389    fn insslab_extend_top_of_slab() {
1390        let mut spans = build_b2(&[(50, 70)]);
1391        insslab(&mut spans, 30, 60);
1392        assert_eq!(read_slabs(&spans), [(30, 70)]);
1393    }
1394
1395    #[test]
1396    fn insslab_extend_bot_of_slab() {
1397        let mut spans = build_b2(&[(50, 70)]);
1398        insslab(&mut spans, 60, 80);
1399        assert_eq!(read_slabs(&spans), [(50, 80)]);
1400    }
1401
1402    #[test]
1403    fn insslab_merge_into_last_slab_writes_sentinel() {
1404        // Repro for the multi-call set_spans / terrain bug: inserting
1405        // [105, 255) into a column that already has runs (100, 105)
1406        // and (255, MAXZDIM) should produce a single run (100,
1407        // MAXZDIM), with a clean sentinel at spans[2..]. Pre-fix this
1408        // left phantom values at spans[2..4] = (255, MAXZDIM) that
1409        // compilerle then re-emitted as overlapping garbage (column
1410        // collapsed to just the first voxel at z=100).
1411        // Built manually because `build_b2` rejects bot == MAXZDIM —
1412        // expandrle does produce that pattern (the bedrock slab at
1413        // z=255 lands as the last run with bot=MAXZDIM).
1414        let mut spans: Vec<i32> = vec![100, 105, 255, MAXZDIM, MAXZDIM, MAXZDIM];
1415        spans.resize(spans.len() + 32, 0); // slack
1416        insslab(&mut spans, 105, 255);
1417        // After: single run from z=100 to the bedrock-equivalent
1418        // sentinel. read_slabs returns until spans[i+1] < MAXZDIM.
1419        assert_eq!(spans[0], 100);
1420        assert!(
1421            spans[1] >= MAXZDIM,
1422            "expected merged run to extend to MAXZDIM, got spans[1] = {}",
1423            spans[1]
1424        );
1425        // The phantom (255, MAXZDIM) slot must NOT still be there —
1426        // sentinel-stamping in the merge branch fix.
1427        assert!(
1428            spans[2] >= MAXZDIM,
1429            "spans[2] should be sentinel, got {} (pre-fix this was 255 from the un-shifted phantom slab)",
1430            spans[2]
1431        );
1432    }
1433
1434    #[test]
1435    fn insslab_touch_top_merges() {
1436        // y1 == top of slab → adjacent insert merges (extends top).
1437        let mut spans = build_b2(&[(50, 70)]);
1438        insslab(&mut spans, 30, 50);
1439        assert_eq!(read_slabs(&spans), [(30, 70)]);
1440    }
1441
1442    #[test]
1443    fn insslab_touch_bot_merges() {
1444        // y0 == bot of slab → adjacent insert merges (extends bot).
1445        let mut spans = build_b2(&[(50, 70)]);
1446        insslab(&mut spans, 70, 80);
1447        assert_eq!(read_slabs(&spans), [(50, 80)]);
1448    }
1449
1450    #[test]
1451    fn insslab_merge_two_slabs() {
1452        let mut spans = build_b2(&[(10, 30), (50, 70)]);
1453        insslab(&mut spans, 20, 60);
1454        assert_eq!(read_slabs(&spans), [(10, 70)]);
1455    }
1456
1457    #[test]
1458    fn insslab_engulf_inner_slabs() {
1459        let mut spans = build_b2(&[(10, 20), (30, 40), (50, 60)]);
1460        insslab(&mut spans, 5, 70);
1461        assert_eq!(read_slabs(&spans), [(5, 70)]);
1462    }
1463
1464    #[test]
1465    fn insslab_engulf_then_keep_lower() {
1466        let mut spans = build_b2(&[(10, 20), (30, 40), (60, 80)]);
1467        insslab(&mut spans, 5, 50);
1468        assert_eq!(read_slabs(&spans), [(5, 50), (60, 80)]);
1469    }
1470
1471    #[test]
1472    fn insslab_engulf_then_merge_lower() {
1473        let mut spans = build_b2(&[(10, 20), (30, 40), (60, 80)]);
1474        insslab(&mut spans, 5, 60);
1475        assert_eq!(read_slabs(&spans), [(5, 80)]);
1476    }
1477
1478    #[test]
1479    fn insslab_chain_of_touching_inserts() {
1480        let mut spans = build_b2(&[]);
1481        insslab(&mut spans, 10, 20);
1482        insslab(&mut spans, 20, 30);
1483        insslab(&mut spans, 30, 40);
1484        assert_eq!(read_slabs(&spans), [(10, 40)]);
1485    }
1486
1487    #[test]
1488    fn insslab_carve_then_insert_round_trip() {
1489        // Land on slab, carve the middle, fill it back: end result
1490        // is identical to the original.
1491        let original = [(10, 50)];
1492        let mut spans = build_b2(&original);
1493        delslab(&mut spans, 20, 30);
1494        assert_eq!(read_slabs(&spans), [(10, 20), (30, 50)]);
1495        insslab(&mut spans, 20, 30);
1496        assert_eq!(read_slabs(&spans), original);
1497    }
1498
1499    #[test]
1500    fn insslab_into_sentinel_only_buffer_with_z_advance() {
1501        // Insert below an existing slab — z advances past slab[0].
1502        let mut spans = build_b2(&[(10, 20)]);
1503        insslab(&mut spans, 100, 150);
1504        assert_eq!(read_slabs(&spans), [(10, 20), (100, 150)]);
1505    }
1506
1507    // ---- expandrle (CD.2.3) ------------------------------------------
1508
1509    /// Strip the `[top, bot, top, bot, ..., MAXZDIM]` decoded shape
1510    /// off a `spans` produced by [`expandrle`]. Last bot is the sentinel
1511    /// (== MAXZDIM); preceding pairs are the solid runs.
1512    fn read_uind(uind: &[i32]) -> Vec<(i32, i32)> {
1513        let mut out = Vec::new();
1514        let mut i = 0;
1515        while uind[i + 1] < MAXZDIM {
1516            out.push((uind[i], uind[i + 1]));
1517            i += 2;
1518        }
1519        // Last solid run terminated by sentinel.
1520        out.push((uind[i], uind[i + 1]));
1521        out
1522    }
1523
1524    #[test]
1525    fn expandrle_single_slab_fully_solid_column() {
1526        // On-disk encoding for solid [0, MAXZDIM) — the on-disk fixture
1527        // we see for "ground all the way down" columns.
1528        // [nextptr=0, z1=0, z1c=MAXZDIM-1, z0=0] + MAXZDIM × 4 colours.
1529        let z1c = u8::try_from(MAXZDIM - 1).expect("MAXZDIM-1 fits in u8");
1530        let mut slab = vec![0u8, 0, z1c, 0];
1531        slab.extend(std::iter::repeat_n(0u8, (MAXZDIM as usize) * 4));
1532        let mut uind = vec![0i32; 16];
1533        expandrle(&slab, &mut uind);
1534        // One solid run from z=0 to MAXZDIM, followed by sentinel.
1535        assert_eq!(uind[0], 0);
1536        assert_eq!(uind[1], MAXZDIM);
1537    }
1538
1539    #[test]
1540    fn expandrle_single_slab_partial_floor() {
1541        // [nextptr=0, z1=64, z1c=66, z0=0] + 3 colours (don't matter).
1542        // Solid run = [64, MAXZDIM) — the format treats below-floor as
1543        // implicit solid.
1544        let slab = [0u8, 64, 66, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0];
1545        let mut uind = vec![0i32; 16];
1546        expandrle(&slab, &mut uind);
1547        assert_eq!(uind[0], 64);
1548        assert_eq!(uind[1], MAXZDIM);
1549    }
1550
1551    #[test]
1552    fn expandrle_two_slabs_with_cave() {
1553        // Slab 0: nextptr=2 (advance 8 bytes), z1=10, z1c=12, z0=0.
1554        // Floor list: 3 colours = 12 bytes; total slab 0 = 4 + 12 - 4 = 12?
1555        // Actually slab 0 size = nextptr * 4 = 8 bytes. So with floor
1556        // list shorter than (z1c - z1 + 1) = 3 colours = 12 bytes, size
1557        // would be 16. To fit nextptr=2 (= 8 bytes), z1c-z1+1 must be 1
1558        // colour = 4 bytes (8 = 4 header + 4 colour).
1559        //
1560        // Layout: [2, 10, 10, 0, c0, c0, c0, c0, 0, 50, 52, 30, ...].
1561        // Slab 0: 1-voxel floor, then implicit solid [11, 30) (between
1562        // slab 0 floor end and slab 1 ceiling top).
1563        // Slab 1: ceiling [30, 50), floor [50, 53), implicit below.
1564        //
1565        // expandrle output:
1566        //   uind[0] = v[1]_s0 = 10
1567        //   advance to slab 1; v[3]=30 < v[1]=50 → write
1568        //   uind[1] = 30, uind[2] = 50, i = 4
1569        //   slab 1 nextptr=0 → exit loop
1570        //   uind[3] = MAXZDIM
1571        let slab = [
1572            2u8, 10, 10, 0, // slab 0 header
1573            0xaa, 0, 0, 0, // slab 0 1-voxel floor colour
1574            0, 50, 52, 30, // slab 1 (last) header
1575            0xbb, 0, 0, 0, // slab 1 floor 0
1576            0xcc, 0, 0, 0, // slab 1 floor 1
1577            0xdd, 0, 0, 0, // slab 1 floor 2
1578        ];
1579        let mut uind = vec![0i32; 16];
1580        expandrle(&slab, &mut uind);
1581        assert_eq!(uind[0], 10);
1582        assert_eq!(uind[1], 30);
1583        assert_eq!(uind[2], 50);
1584        assert_eq!(uind[3], MAXZDIM);
1585    }
1586
1587    #[test]
1588    fn expandrle_skips_degenerate_slab_with_no_ceiling_gap() {
1589        // Slab 1 has v[3] >= v[1]: ceiling collapses with floor → no
1590        // air gap above. We skip emitting a new
1591        // solid run for this slab, merging it with the previous run.
1592        //
1593        // Layout:
1594        //   slab 0: nextptr=2, z1=10, z1c=10, z0=0, 1 floor colour
1595        //   slab 1: nextptr=0, z1=20, z1c=22, z0=20 (z0 == z1 → skip)
1596        let slab = [
1597            2u8, 10, 10, 0, // slab 0 header
1598            0xaa, 0, 0, 0, // 1 floor colour
1599            0, 20, 22, 20, // slab 1 (degenerate: z0=z1=20)
1600            0xbb, 0, 0, 0, 0xcc, 0, 0, 0, 0xdd, 0, 0, 0, // floor colours
1601        ];
1602        let mut uind = vec![0i32; 16];
1603        expandrle(&slab, &mut uind);
1604        // Only one solid run (slab 0's), since slab 1 was skipped.
1605        assert_eq!(uind[0], 10);
1606        assert_eq!(uind[1], MAXZDIM);
1607    }
1608
1609    #[test]
1610    fn expandrle_round_trips_through_b2_helpers() {
1611        // Decode a 2-slab column, then verify delslab can carve a
1612        // hole into the air gap (the `read_uind` shape matches the spans
1613        // shape that delslab/insslab consume).
1614        let slab = [
1615            2u8, 10, 10, 0, 0xaa, 0, 0, 0, 0, 50, 52, 30, 0xbb, 0, 0, 0, 0xcc, 0, 0, 0, 0xdd, 0, 0,
1616            0,
1617        ];
1618        let mut uind = vec![0i32; 16];
1619        expandrle(&slab, &mut uind);
1620        let runs = read_uind(&uind[..4]);
1621        assert_eq!(runs, [(10, 30), (50, MAXZDIM)]);
1622    }
1623
1624    // ---- build_color_table + compilerle (CD.2.4) ----------------------
1625
1626    /// Build a sentinel-terminated all-air spans buffer for a neighbor.
1627    /// Sized with slack so compilerle's index walks don't run off
1628    /// the end (worst case = n0's voxel count + 1).
1629    fn all_air_neighbor() -> Vec<i32> {
1630        // Just the sentinel pair + slack.
1631        let mut buf = vec![MAXZDIM, MAXZDIM];
1632        buf.resize(buf.len() + MAXZDIM as usize, MAXZDIM);
1633        buf
1634    }
1635
1636    /// Build a sentinel-terminated spans from a list of solid runs.
1637    /// Compatible with [`compilerle`]'s input shape — the trailing
1638    /// sentinel must come last with bot == MAXZDIM.
1639    fn b2_from_runs(runs: &[(i32, i32)]) -> Vec<i32> {
1640        let mut buf = Vec::new();
1641        for &(top, bot) in runs {
1642            buf.push(top);
1643            buf.push(bot);
1644        }
1645        buf.push(MAXZDIM);
1646        buf.push(MAXZDIM);
1647        buf.resize(buf.len() + MAXZDIM as usize, MAXZDIM);
1648        buf
1649    }
1650
1651    #[test]
1652    fn build_color_table_single_slab_one_floor_voxel() {
1653        // [nextptr=0, z1=10, z1c=10, z0=0] + 1 colour.
1654        let slab = [0u8, 10, 10, 0, 0xa1, 0xa2, 0xa3, 0xa4];
1655        let table = build_color_table(&slab);
1656        assert_eq!(table.len(), 2);
1657        assert_eq!(table[0].z_start, 10);
1658        assert_eq!(table[0].z_end, 11);
1659        assert_eq!(table[0].colors, &[0xa1, 0xa2, 0xa3, 0xa4]);
1660        // Sentinel.
1661        assert_eq!(table[1].z_start, MAXZDIM);
1662        assert_eq!(table[1].z_end, MAXZDIM);
1663    }
1664
1665    #[test]
1666    fn build_color_table_two_slabs_with_ceiling() {
1667        // Slab 0: nextptr=4 (16 bytes total) — 4 hdr + 1 floor + 8 ceiling-of-slab1
1668        //   Layout: [4, 10, 10, 0, F0,F0,F0,F0, C0,C0,C0,C0, C1,C1,C1,C1]
1669        // Slab 1: [0, 50, 52, 30, ...]
1670        //   Slab 1's ceiling list = 2 voxels at z=28..30 (stored in slab 0's tail).
1671        let slab = [
1672            4u8, 10, 10, 0, // slab 0 header
1673            0xf0, 0xf0, 0xf0, 0xf0, // 1 floor color
1674            0xc0, 0xc0, 0xc0, 0xc0, // ceiling color for z=28
1675            0xc1, 0xc1, 0xc1, 0xc1, // ceiling color for z=29
1676            0u8, 50, 52, 30, // slab 1 header
1677            0xfa, 0xfa, 0xfa, 0xfa, // floor z=50
1678            0xfb, 0xfb, 0xfb, 0xfb, // floor z=51
1679            0xfc, 0xfc, 0xfc, 0xfc, // floor z=52
1680        ];
1681        let table = build_color_table(&slab);
1682        assert_eq!(table.len(), 4);
1683        // Slab 0 floor.
1684        assert_eq!(table[0].z_start, 10);
1685        assert_eq!(table[0].z_end, 11);
1686        assert_eq!(table[0].colors.len(), 4);
1687        // Slab 1 ceiling — 2 voxels at z=28..30.
1688        assert_eq!(table[1].z_start, 28);
1689        assert_eq!(table[1].z_end, 30);
1690        assert_eq!(
1691            table[1].colors,
1692            &[0xc0, 0xc0, 0xc0, 0xc0, 0xc1, 0xc1, 0xc1, 0xc1]
1693        );
1694        // Slab 1 floor.
1695        assert_eq!(table[2].z_start, 50);
1696        assert_eq!(table[2].z_end, 53);
1697        assert_eq!(table[2].colors.len(), 12);
1698        // Sentinel.
1699        assert_eq!(table[3].z_start, MAXZDIM);
1700    }
1701
1702    #[test]
1703    fn compilerle_round_trip_single_slab_solid_to_maxzdim() {
1704        // Original column: solid from z=10 to MAXZDIM, full floor
1705        // color list. compilerle with all-air neighbors should
1706        // re-encode bit-equivalently in spans shape.
1707        let mut slab = vec![0u8, 10, (MAXZDIM - 1) as u8, 0];
1708        for z in 10..MAXZDIM {
1709            // Distinct color per z so we can verify exact output bytes.
1710            slab.extend_from_slice(&[z as u8, (z + 1) as u8, (z + 2) as u8, 0]);
1711        }
1712
1713        // Decode to spans.
1714        let mut spans = vec![0i32; (MAXZDIM as usize) + 4];
1715        expandrle(&slab, &mut spans);
1716        assert_eq!(spans[0], 10);
1717        assert_eq!(spans[1], MAXZDIM);
1718
1719        // Re-encode with all-air neighbors → no buried-voxel skip.
1720        let n_air = all_air_neighbor();
1721        let mut cbuf = vec![0u8; 1028];
1722        let mut colfunc_called = 0;
1723        let mut colfunc = |_x: i32, _y: i32, _z: i32| -> i32 {
1724            colfunc_called += 1;
1725            0
1726        };
1727        let written = compilerle(
1728            &spans,
1729            &n_air,
1730            &n_air,
1731            &n_air,
1732            &n_air,
1733            &mut cbuf,
1734            &slab,
1735            0,
1736            0,
1737            &mut colfunc,
1738        );
1739        assert_eq!(colfunc_called, 0, "all colors should come from tbuf2");
1740
1741        // Output should match the input slab byte-for-byte (it's
1742        // already the minimal full-floor encoding).
1743        assert_eq!(written, slab.len());
1744        assert_eq!(&cbuf[..written], &slab[..]);
1745
1746        // And expandrle on the output reproduces the same spans.
1747        let mut b2_round = vec![0i32; (MAXZDIM as usize) + 4];
1748        expandrle(&cbuf[..written], &mut b2_round);
1749        assert_eq!(b2_round[0], 10);
1750        assert_eq!(b2_round[1], MAXZDIM);
1751    }
1752
1753    #[test]
1754    fn compilerle_round_trip_two_solid_runs_with_cave() {
1755        // spans = [10, 30, 50, MAXZDIM] — one cave between two solid runs.
1756        // Build a synthetic original column that has full floor color
1757        // lists for both slabs; ceiling list for slab 1 is non-empty.
1758        // For simplicity construct via compilerle from an all-air-
1759        // neighbor first encode of the desired spans, then round-trip.
1760
1761        // Step 1: build a SEED column. We'll compilerle from a
1762        // hand-rolled "all is colfunc" variant — colfunc returns z as
1763        // its color, deterministic.
1764        let dummy = vec![0u8, 0, (MAXZDIM - 1) as u8, 0];
1765        let mut dummy_full = dummy;
1766        dummy_full.extend(std::iter::repeat_n(0u8, (MAXZDIM as usize) * 4));
1767
1768        let n_air = all_air_neighbor();
1769        let spans = b2_from_runs(&[(10, 30), (50, MAXZDIM)]);
1770        let mut seed = vec![0u8; 1028];
1771        let mut colfunc = |_x: i32, _y: i32, z: i32| -> i32 { z };
1772        let seed_len = compilerle(
1773            &spans,
1774            &n_air,
1775            &n_air,
1776            &n_air,
1777            &n_air,
1778            &mut seed,
1779            &dummy_full,
1780            0,
1781            0,
1782            &mut colfunc,
1783        );
1784        seed.truncate(seed_len);
1785
1786        // Step 2: decode the seed back to spans.
1787        let mut b2_round = vec![0i32; (MAXZDIM as usize) + 4];
1788        expandrle(&seed, &mut b2_round);
1789        // Two solid runs followed by sentinel.
1790        assert_eq!(b2_round[0], 10);
1791        assert_eq!(b2_round[1], 30);
1792        assert_eq!(b2_round[2], 50);
1793        assert_eq!(b2_round[3], MAXZDIM);
1794
1795        // Step 3: compilerle again using the seed as the original
1796        // column — should produce byte-identical output (idempotent
1797        // round trip).
1798        let mut cbuf = vec![0u8; 1028];
1799        let mut never_called = 0;
1800        let mut colfunc2 = |_x: i32, _y: i32, _z: i32| -> i32 {
1801            never_called += 1;
1802            0
1803        };
1804        let written = compilerle(
1805            &spans,
1806            &n_air,
1807            &n_air,
1808            &n_air,
1809            &n_air,
1810            &mut cbuf,
1811            &seed,
1812            0,
1813            0,
1814            &mut colfunc2,
1815        );
1816        assert_eq!(never_called, 0, "second pass needs no colfunc");
1817        assert_eq!(written, seed_len);
1818        assert_eq!(&cbuf[..written], &seed[..]);
1819    }
1820
1821    #[test]
1822    fn compilerle_buried_voxel_optimization_with_all_solid_neighbors() {
1823        // All 4 neighbors solid means every voxel below the top one is
1824        // buried — compilerle's dacnt path closes the floor list right
1825        // after writing the first exposed voxel.
1826
1827        // Self column: solid [10, MAXZDIM). The spans convention has
1828        // the last real solid run extending to MAXZDIM (solid below is
1829        // implicit), so we never transition into the sentinel slab.
1830        let spans = b2_from_runs(&[(10, MAXZDIM)]);
1831        let n_solid = b2_from_runs(&[(0, MAXZDIM)]);
1832        // Original column over-encoded with a full floor color list so
1833        // colfunc is never needed (verifies tbuf2 lookup for buried
1834        // voxels we'd otherwise skip).
1835        let mut slab = vec![0u8, 10, (MAXZDIM - 1) as u8, 0];
1836        for z in 10..MAXZDIM {
1837            slab.extend_from_slice(&[z as u8, 0, 0, 0]);
1838        }
1839        let mut cbuf = vec![0u8; 1028];
1840        let mut colfunc_called = 0;
1841        let mut colfunc = |_x: i32, _y: i32, _z: i32| -> i32 {
1842            colfunc_called += 1;
1843            0
1844        };
1845        let written = compilerle(
1846            &spans,
1847            &n_solid,
1848            &n_solid,
1849            &n_solid,
1850            &n_solid,
1851            &mut cbuf,
1852            &slab,
1853            0,
1854            0,
1855            &mut colfunc,
1856        );
1857        assert_eq!(colfunc_called, 0, "tbuf2 should cover every voxel");
1858        // Compressed output: only the top voxel exposed (z=10) →
1859        // 4-byte header + 1 color = 8 bytes.
1860        assert_eq!(written, 8);
1861        assert_eq!(cbuf[0], 0); // terminator nextptr
1862        assert_eq!(cbuf[1], 10); // z1
1863        assert_eq!(cbuf[2], 10); // z1c (only one exposed voxel)
1864        assert_eq!(cbuf[3], 0); // z0 dummy
1865                                // expandrle on output reproduces the spans shape (still solid
1866                                // from z=10 onward, despite the compressed encoding).
1867        let mut b2_round = vec![0i32; (MAXZDIM as usize) + 4];
1868        expandrle(&cbuf[..written], &mut b2_round);
1869        assert_eq!(b2_round[0], 10);
1870        assert_eq!(b2_round[1], MAXZDIM);
1871    }
1872
1873    // ---- ScumCtx (CD.2.5) ---------------------------------------------
1874
1875    /// Build a 1×1 Vxl with a single fully-solid column. Minimal slab
1876    /// encoding: 1 floor color = 8 bytes total.
1877    fn build_1x1_min_solid_vxl() -> Vxl {
1878        let column = vec![0u8, 0, 0, 0, 0xff, 0x80, 0x40, 0x20];
1879        let column_offset = vec![0u32, column.len() as u32].into_boxed_slice();
1880        Vxl {
1881            vsid: 1,
1882            ipo: [0.0; 3],
1883            ist: [1.0, 0.0, 0.0],
1884            ihe: [0.0, 0.0, 1.0],
1885            ifo: [0.0, 1.0, 0.0],
1886            data: column.into_boxed_slice(),
1887            column_offset,
1888            mip_base_offsets: Box::new([0, 2]),
1889            vbit: Box::new([]),
1890            vbiti: 0,
1891        }
1892    }
1893
1894    #[test]
1895    fn scum2_no_edit_round_trip_1x1_minimal_column() {
1896        // Open a batch on a minimal-encoded 1×1 column, run scum2 +
1897        // finish without mutating, verify the column's spans shape is
1898        // preserved.
1899        let mut vxl = build_1x1_min_solid_vxl();
1900        vxl.reserve_edit_capacity(4096);
1901
1902        let mut ctx = ScumCtx::new(&mut vxl);
1903        let _b2 = ctx.scum2(0, 0).expect("column 0,0 in bounds");
1904        ctx.finish();
1905
1906        let column = vxl.column_data(0);
1907        let mut b2_after = vec![0i32; SPAN_STRIDE];
1908        expandrle(column, &mut b2_after);
1909        assert_eq!(b2_after[0], 0);
1910        assert_eq!(b2_after[1], MAXZDIM);
1911    }
1912
1913    #[test]
1914    fn scum2_carve_edit_1x1_creates_air_gap() {
1915        // Carve a hole in a fully-solid column; verify the post-edit
1916        // spans reflects the carve.
1917        let mut vxl = build_1x1_min_solid_vxl();
1918        vxl.reserve_edit_capacity(4096);
1919
1920        let mut ctx = ScumCtx::new(&mut vxl);
1921        ctx.set_colfunc(|_x, _y, _z| 0x80_60_40_20u32 as i32);
1922        {
1923            let spans = ctx.scum2(0, 0).expect("column 0,0 in bounds");
1924            // Carve [50, 100) to air.
1925            delslab(spans, 50, 100);
1926        }
1927        ctx.finish();
1928
1929        let column = vxl.column_data(0);
1930        let mut b2_after = vec![0i32; SPAN_STRIDE];
1931        expandrle(column, &mut b2_after);
1932        // Two solid runs now: [0, 50) and [100, MAXZDIM).
1933        assert_eq!(b2_after[0], 0);
1934        assert_eq!(b2_after[1], 50);
1935        assert_eq!(b2_after[2], 100);
1936        assert_eq!(b2_after[3], MAXZDIM);
1937    }
1938
1939    /// Build a 4×4 Vxl with all 16 columns sharing the same minimal
1940    /// fully-solid encoding. Useful for testing batch edits.
1941    fn build_4x4_min_solid_vxl() -> Vxl {
1942        const COL: [u8; 8] = [0, 0, 0, 0, 0xff, 0x80, 0x40, 0x20];
1943        let mut data = Vec::with_capacity(16 * 8);
1944        let mut offsets = Vec::with_capacity(17);
1945        for i in 0..16 {
1946            offsets.push((i * 8) as u32);
1947            data.extend_from_slice(&COL);
1948        }
1949        offsets.push((16 * 8) as u32);
1950        Vxl {
1951            vsid: 4,
1952            ipo: [0.0; 3],
1953            ist: [1.0, 0.0, 0.0],
1954            ihe: [0.0, 0.0, 1.0],
1955            ifo: [0.0, 1.0, 0.0],
1956            data: data.into_boxed_slice(),
1957            column_offset: offsets.into_boxed_slice(),
1958            mip_base_offsets: Box::new([0, 17]),
1959            vbit: Box::new([]),
1960            vbiti: 0,
1961        }
1962    }
1963
1964    #[test]
1965    fn scum2_batch_edits_multiple_columns_same_row() {
1966        // Edit columns (1, 2) and (2, 2) — same y row. Both should
1967        // get the same carve.
1968        let mut vxl = build_4x4_min_solid_vxl();
1969        vxl.reserve_edit_capacity(8192);
1970
1971        let mut ctx = ScumCtx::new(&mut vxl);
1972        ctx.set_colfunc(|_x, _y, _z| 0);
1973        {
1974            let spans = ctx.scum2(1, 2).unwrap();
1975            delslab(spans, 50, 100);
1976        }
1977        {
1978            let spans = ctx.scum2(2, 2).unwrap();
1979            delslab(spans, 50, 100);
1980        }
1981        ctx.finish();
1982
1983        for x in [1, 2] {
1984            let idx = 2 * 4 + x;
1985            let mut b2_after = vec![0i32; SPAN_STRIDE];
1986            expandrle(vxl.column_data(idx), &mut b2_after);
1987            assert_eq!(b2_after[0], 0);
1988            assert_eq!(b2_after[1], 50);
1989            assert_eq!(b2_after[2], 100);
1990            assert_eq!(b2_after[3], MAXZDIM);
1991        }
1992        // Untouched columns retain their original spans.
1993        for x in [0, 3] {
1994            let idx = 2 * 4 + x;
1995            let mut b2_after = vec![0i32; SPAN_STRIDE];
1996            expandrle(vxl.column_data(idx), &mut b2_after);
1997            assert_eq!(b2_after[0], 0);
1998            assert_eq!(b2_after[1], MAXZDIM);
1999        }
2000    }
2001
2002    #[test]
2003    fn scum2_batch_edits_across_rows() {
2004        // Edit column (1, 1) then column (1, 2) — y advances, prior
2005        // row gets flushed automatically.
2006        let mut vxl = build_4x4_min_solid_vxl();
2007        vxl.reserve_edit_capacity(8192);
2008
2009        let mut ctx = ScumCtx::new(&mut vxl);
2010        ctx.set_colfunc(|_x, _y, _z| 0);
2011        {
2012            let spans = ctx.scum2(1, 1).unwrap();
2013            delslab(spans, 60, 80);
2014        }
2015        {
2016            let spans = ctx.scum2(1, 2).unwrap();
2017            delslab(spans, 60, 80);
2018        }
2019        ctx.finish();
2020
2021        for y in [1, 2] {
2022            let idx = y * 4 + 1;
2023            let mut b2_after = vec![0i32; SPAN_STRIDE];
2024            expandrle(vxl.column_data(idx), &mut b2_after);
2025            assert_eq!(b2_after[0], 0);
2026            assert_eq!(b2_after[1], 60);
2027            assert_eq!(b2_after[2], 80);
2028            assert_eq!(b2_after[3], MAXZDIM);
2029        }
2030    }
2031
2032    #[test]
2033    fn scum2_finish_without_any_edit_is_noop() {
2034        // Begin then immediately finish without any scum2 call.
2035        let mut vxl = build_1x1_min_solid_vxl();
2036        vxl.reserve_edit_capacity(4096);
2037        let original = vxl.column_data(0).to_vec();
2038        let ctx = ScumCtx::new(&mut vxl);
2039        ctx.finish();
2040        assert_eq!(vxl.column_data(0), &original[..]);
2041    }
2042
2043    #[test]
2044    fn scum2_returns_none_for_out_of_bounds() {
2045        let mut vxl = build_1x1_min_solid_vxl();
2046        vxl.reserve_edit_capacity(4096);
2047        let mut ctx = ScumCtx::new(&mut vxl);
2048        assert!(ctx.scum2(-1, 0).is_none());
2049        assert!(ctx.scum2(0, -1).is_none());
2050        assert!(ctx.scum2(1, 0).is_none());
2051        assert!(ctx.scum2(0, 1).is_none());
2052        ctx.finish();
2053    }
2054
2055    // ---- set_spans (CD.3) --------------------------------------------
2056
2057    #[test]
2058    fn set_spans_empty_is_noop() {
2059        let mut vxl = build_1x1_min_solid_vxl();
2060        let original = vxl.column_data(0).to_vec();
2061        set_spans(&mut vxl, &[], None);
2062        // Nothing should have changed — and we never reserved edit
2063        // capacity, so this also tests that empty input is fully
2064        // short-circuited (no ScumCtx::new + assert).
2065        assert_eq!(vxl.column_data(0), &original[..]);
2066    }
2067
2068    #[test]
2069    fn set_spans_single_carve_creates_air_gap() {
2070        let mut vxl = build_1x1_min_solid_vxl();
2071        vxl.reserve_edit_capacity(4096);
2072        set_spans(
2073            &mut vxl,
2074            &[Vspan {
2075                x: 0,
2076                y: 0,
2077                z0: 50,
2078                z1: 99,
2079            }],
2080            None,
2081        );
2082        let mut spans = vec![0i32; SPAN_STRIDE];
2083        expandrle(vxl.column_data(0), &mut spans);
2084        // Half-open exclusive end is z1+1 = 100.
2085        assert_eq!(spans[0], 0);
2086        assert_eq!(spans[1], 50);
2087        assert_eq!(spans[2], 100);
2088        assert_eq!(spans[3], MAXZDIM);
2089    }
2090
2091    #[test]
2092    fn set_spans_multi_span_same_column_accumulates() {
2093        // Two non-overlapping carves on the same column. The
2094        // setspans correctness relies on the with_column dedup —
2095        // re-calling scum2 between spans would wipe the first carve.
2096        let mut vxl = build_1x1_min_solid_vxl();
2097        vxl.reserve_edit_capacity(4096);
2098        set_spans(
2099            &mut vxl,
2100            &[
2101                Vspan {
2102                    x: 0,
2103                    y: 0,
2104                    z0: 30,
2105                    z1: 49,
2106                },
2107                Vspan {
2108                    x: 0,
2109                    y: 0,
2110                    z0: 100,
2111                    z1: 119,
2112                },
2113            ],
2114            None,
2115        );
2116        let mut spans = vec![0i32; SPAN_STRIDE];
2117        expandrle(vxl.column_data(0), &mut spans);
2118        // Three solid runs: [0, 30), [50, 100), [120, MAXZDIM).
2119        assert_eq!(spans[0], 0);
2120        assert_eq!(spans[1], 30);
2121        assert_eq!(spans[2], 50);
2122        assert_eq!(spans[3], 100);
2123        assert_eq!(spans[4], 120);
2124        assert_eq!(spans[5], MAXZDIM);
2125    }
2126
2127    #[test]
2128    fn set_spans_insert_color_fills_air() {
2129        // Start with a column that's already partly carved, fill the
2130        // air gap back in with a known color.
2131        let mut vxl = build_1x1_min_solid_vxl();
2132        vxl.reserve_edit_capacity(4096);
2133        // First carve [50, 100) to create air.
2134        set_spans(
2135            &mut vxl,
2136            &[Vspan {
2137                x: 0,
2138                y: 0,
2139                z0: 50,
2140                z1: 99,
2141            }],
2142            None,
2143        );
2144        // Now fill [60, 80) back to solid with a known color.
2145        const FILL: u32 = 0x80_aa_bb_cc;
2146        set_spans(
2147            &mut vxl,
2148            &[Vspan {
2149                x: 0,
2150                y: 0,
2151                z0: 60,
2152                z1: 79,
2153            }],
2154            Some(FILL),
2155        );
2156        let mut spans = vec![0i32; SPAN_STRIDE];
2157        expandrle(vxl.column_data(0), &mut spans);
2158        // Three solid runs: [0, 50), [60, 80), [100, MAXZDIM).
2159        assert_eq!(spans[0], 0);
2160        assert_eq!(spans[1], 50);
2161        assert_eq!(spans[2], 60);
2162        assert_eq!(spans[3], 80);
2163        assert_eq!(spans[4], 100);
2164        assert_eq!(spans[5], MAXZDIM);
2165    }
2166
2167    #[test]
2168    fn set_spans_skips_out_of_bounds_silently() {
2169        let mut vxl = build_1x1_min_solid_vxl();
2170        vxl.reserve_edit_capacity(4096);
2171        set_spans(
2172            &mut vxl,
2173            &[Vspan {
2174                x: 7,
2175                y: 9,
2176                z0: 50,
2177                z1: 99,
2178            }],
2179            None,
2180        );
2181        // Column (0,0) untouched — out-of-bounds span had no effect.
2182        let mut spans = vec![0i32; SPAN_STRIDE];
2183        expandrle(vxl.column_data(0), &mut spans);
2184        assert_eq!(spans[0], 0);
2185        assert_eq!(spans[1], MAXZDIM);
2186    }
2187
2188    #[test]
2189    fn set_spans_with_colfunc_z_dependent_colour() {
2190        // Insert solid with a colour that depends on z. To make every
2191        // voxel in [60, 80) exposed (and thus needing a colfunc call
2192        // each), use a 4x4 world with neighbors carved to air at the
2193        // same z range. Center column (1, 1) is then surrounded by
2194        // air on all 4 sides at [60, 80), giving compilerle a full
2195        // floor list to fill via the closure.
2196        let mut vxl = build_4x4_min_solid_vxl();
2197        vxl.reserve_edit_capacity(8192);
2198        // Step 1: carve [50, 100) on every column so the insert sits
2199        // in air on every side.
2200        let carve_spans: Vec<Vspan> = (0..4)
2201            .flat_map(|y| {
2202                (0..4).map(move |x| Vspan {
2203                    x,
2204                    y,
2205                    z0: 50,
2206                    z1: 99,
2207                })
2208            })
2209            .collect();
2210        set_spans(&mut vxl, &carve_spans, None);
2211        // Step 2: insert [60, 80) on the center column with a
2212        // z-dependent colour.
2213        set_spans_with_colfunc(
2214            &mut vxl,
2215            &[Vspan {
2216                x: 1,
2217                y: 1,
2218                z0: 60,
2219                z1: 79,
2220            }],
2221            SpanOp::Insert,
2222            |_x, _y, z| (0x80ff_ff00u32 as i32) | z,
2223        );
2224        // Walk column (1,1) and find the slab with z1=60; verify each
2225        // floor colour matches the closure's output.
2226        let idx = 4 + 1; // y=1, x=1 in a 4-wide world
2227        let column = vxl.column_data(idx);
2228        let mut v = 0usize;
2229        let mut found = false;
2230        loop {
2231            let nextptr = column[v];
2232            let z1 = column[v + 1];
2233            if z1 == 60 {
2234                let z1c = column[v + 2];
2235                assert_eq!(z1c, 79, "z1c");
2236                let n_voxels = usize::from(z1c) - usize::from(z1) + 1;
2237                for i in 0..n_voxels {
2238                    let off = v + 4 + i * 4;
2239                    let c = u32::from_le_bytes([
2240                        column[off],
2241                        column[off + 1],
2242                        column[off + 2],
2243                        column[off + 3],
2244                    ]);
2245                    let z = u32::from(z1) + (i as u32);
2246                    assert_eq!(
2247                        c,
2248                        0x80ff_ff00 | z,
2249                        "z={z}: expected colour {:#010x}, got {:#010x}",
2250                        0x80ff_ff00 | z,
2251                        c
2252                    );
2253                }
2254                found = true;
2255                break;
2256            }
2257            if nextptr == 0 {
2258                break;
2259            }
2260            v += usize::from(nextptr) * 4;
2261        }
2262        assert!(found, "did not find a slab with z1=60");
2263    }
2264
2265    // ---- set_cube / set_rect / set_sphere (CD.4) ---------------------
2266
2267    #[test]
2268    fn set_cube_carves_single_voxel() {
2269        let mut vxl = build_4x4_min_solid_vxl();
2270        vxl.reserve_edit_capacity(4096);
2271        set_cube(&mut vxl, 1, 1, 100, None);
2272        let mut spans = vec![0i32; SPAN_STRIDE];
2273        expandrle(vxl.column_data(4 + 1), &mut spans);
2274        // Solid runs: [0, 100), [101, MAXZDIM).
2275        assert_eq!(spans[0], 0);
2276        assert_eq!(spans[1], 100);
2277        assert_eq!(spans[2], 101);
2278        assert_eq!(spans[3], MAXZDIM);
2279    }
2280
2281    #[test]
2282    fn set_cube_skips_oob() {
2283        let mut vxl = build_4x4_min_solid_vxl();
2284        vxl.reserve_edit_capacity(4096);
2285        // Negative x, oversized y, z >= MAXZDIM, all silently no-op.
2286        set_cube(&mut vxl, -1, 1, 100, None);
2287        set_cube(&mut vxl, 5, 1, 100, None);
2288        set_cube(&mut vxl, 1, 1, 256, None);
2289        // Column (1, 1) untouched.
2290        let mut spans = vec![0i32; SPAN_STRIDE];
2291        expandrle(vxl.column_data(4 + 1), &mut spans);
2292        assert_eq!(spans[0], 0);
2293        assert_eq!(spans[1], MAXZDIM);
2294    }
2295
2296    #[test]
2297    fn set_rect_carves_aabb() {
2298        let mut vxl = build_4x4_min_solid_vxl();
2299        vxl.reserve_edit_capacity(8192);
2300        // Carve a 2x2x50 box at (1..=2, 1..=2, 50..=99).
2301        set_rect(&mut vxl, [1, 1, 50], [2, 2, 99], None);
2302        for y in 1..=2 {
2303            for x in 1..=2 {
2304                let idx = (y * 4 + x) as usize;
2305                let mut spans = vec![0i32; SPAN_STRIDE];
2306                expandrle(vxl.column_data(idx), &mut spans);
2307                assert_eq!(spans[0], 0, "col ({x},{y})");
2308                assert_eq!(spans[1], 50, "col ({x},{y})");
2309                assert_eq!(spans[2], 100, "col ({x},{y})");
2310                assert_eq!(spans[3], MAXZDIM, "col ({x},{y})");
2311            }
2312        }
2313        // Untouched corners still solid through the full column.
2314        for &(x, y) in &[(0, 0), (3, 3)] {
2315            let idx = (y * 4 + x) as usize;
2316            let mut spans = vec![0i32; SPAN_STRIDE];
2317            expandrle(vxl.column_data(idx), &mut spans);
2318            assert_eq!(spans[0], 0);
2319            assert_eq!(spans[1], MAXZDIM);
2320        }
2321    }
2322
2323    #[test]
2324    fn set_rect_clamps_to_world() {
2325        let mut vxl = build_4x4_min_solid_vxl();
2326        vxl.reserve_edit_capacity(8192);
2327        // Box extends well past world bounds — clamps to [0, 3] in
2328        // each axis.
2329        set_rect(&mut vxl, [-10, -10, -10], [100, 100, 1000], None);
2330        // Every column carved over [0, MAXZDIM) → all-air.
2331        for idx in 0..16 {
2332            let mut spans = vec![0i32; SPAN_STRIDE];
2333            expandrle(vxl.column_data(idx), &mut spans);
2334            // delslab clamps z1 to MAXZDIM-1, leaving voxel at
2335            // z=MAXZDIM-1 solid. The spans reflects this: solid run
2336            // [255, MAXZDIM) only.
2337            assert_eq!(spans[0], 255, "col {idx}");
2338            assert_eq!(spans[1], MAXZDIM, "col {idx}");
2339        }
2340    }
2341
2342    #[test]
2343    fn set_sphere_carves_centred_sphere() {
2344        // 4x4 world; sphere radius 1 carves a "+" pattern at z=128
2345        // (center voxel + 4 axis-adjacent + 2 z-axis voxels).
2346        let mut vxl = build_4x4_min_solid_vxl();
2347        vxl.reserve_edit_capacity(8192);
2348        set_sphere(&mut vxl, [1, 1, 128], 1, None);
2349        // Voxels carved at: (1,1,127), (1,1,128), (1,1,129) [z axis],
2350        // (0,1,128), (2,1,128) [x axis], (1,0,128), (1,2,128) [y axis].
2351        // Center column (1,1) has z range [127, 130) carved.
2352        let mut spans = vec![0i32; SPAN_STRIDE];
2353        expandrle(vxl.column_data(4 + 1), &mut spans);
2354        assert_eq!(spans[0], 0);
2355        assert_eq!(spans[1], 127);
2356        assert_eq!(spans[2], 130);
2357        assert_eq!(spans[3], MAXZDIM);
2358        // Adjacent column (0, 1) has only z=128 carved.
2359        let mut spans = vec![0i32; SPAN_STRIDE];
2360        expandrle(vxl.column_data(4), &mut spans);
2361        assert_eq!(spans[0], 0);
2362        assert_eq!(spans[1], 128);
2363        assert_eq!(spans[2], 129);
2364        assert_eq!(spans[3], MAXZDIM);
2365    }
2366
2367    #[test]
2368    fn set_sphere_radius_zero_is_single_voxel() {
2369        let mut vxl = build_4x4_min_solid_vxl();
2370        vxl.reserve_edit_capacity(4096);
2371        set_sphere(&mut vxl, [1, 1, 100], 0, None);
2372        // Same as set_cube — only (1, 1, 100) carved.
2373        let mut spans = vec![0i32; SPAN_STRIDE];
2374        expandrle(vxl.column_data(4 + 1), &mut spans);
2375        assert_eq!(spans[0], 0);
2376        assert_eq!(spans[1], 100);
2377        assert_eq!(spans[2], 101);
2378        assert_eq!(spans[3], MAXZDIM);
2379    }
2380
2381    #[test]
2382    fn set_sphere_with_colfunc_position_dependent_color() {
2383        // Carve a cave first (so the inserted sphere has air on every
2384        // side, exposing its surface voxels), then insert a sphere
2385        // with a colfunc that returns z in the low byte. Verify
2386        // (a) spans reflects the sphere shape and (b) the top exposed
2387        // voxel's colour is the colfunc output.
2388        //
2389        // Note: the encoder stores colours only for EXPOSED
2390        // voxels (top of run + skip-forward landings); buried voxels
2391        // in the middle of a slab don't get colfunc-derived colours
2392        // recorded. So we can only verify colours for voxels at the
2393        // run boundary or near them.
2394        let mut vxl = build_4x4_min_solid_vxl();
2395        vxl.reserve_edit_capacity(8192);
2396        // Carve [50, 200) on every column.
2397        set_rect(&mut vxl, [0, 0, 50], [3, 3, 199], None);
2398        // Insert a sphere of radius 2 at (1, 1, 128). Center column
2399        // gets z=126..130 inclusive (5 voxels) inserted.
2400        set_sphere_with_colfunc(&mut vxl, [1, 1, 128], 2, SpanOp::Insert, |_, _, z| {
2401            (0x80ff_ff00u32 as i32) | z
2402        });
2403        // (a) spans has the expected three solid runs.
2404        let mut spans = vec![0i32; SPAN_STRIDE];
2405        expandrle(vxl.column_data(4 + 1), &mut spans);
2406        assert_eq!(spans[0], 0, "spans first run top");
2407        assert_eq!(spans[1], 50, "spans first run bot");
2408        assert_eq!(spans[2], 126, "spans sphere run top");
2409        assert_eq!(spans[3], 131, "spans sphere run bot");
2410        assert_eq!(spans[4], 200, "spans third run top");
2411        assert_eq!(spans[5], MAXZDIM, "spans third run bot");
2412
2413        // (b) top of the sphere (z=126) is exposed (air above from
2414        // the carve). Its colour is the FIRST byte of the slab's
2415        // floor list.
2416        let column = vxl.column_data(4 + 1).to_vec();
2417        let mut v = 0usize;
2418        let mut top_color = None;
2419        loop {
2420            let nextptr = column[v];
2421            let z1 = column[v + 1];
2422            if z1 == 126 {
2423                let off = v + 4;
2424                top_color = Some(u32::from_le_bytes([
2425                    column[off],
2426                    column[off + 1],
2427                    column[off + 2],
2428                    column[off + 3],
2429                ]));
2430                break;
2431            }
2432            if nextptr == 0 {
2433                break;
2434            }
2435            v += usize::from(nextptr) * 4;
2436        }
2437        assert_eq!(
2438            top_color,
2439            Some(0x80ff_ff7e),
2440            "exposed voxel at z=126 should have colfunc-derived colour"
2441        );
2442    }
2443
2444    #[test]
2445    fn set_spans_4x4_batch_carves_each_listed_column() {
2446        // Sorted (y, x) ascending; each column gets the same carve.
2447        let mut vxl = build_4x4_min_solid_vxl();
2448        vxl.reserve_edit_capacity(8192);
2449        let spans: Vec<Vspan> = (0..4)
2450            .flat_map(|y| {
2451                (0..4).map(move |x| Vspan {
2452                    x,
2453                    y,
2454                    z0: 50,
2455                    z1: 99,
2456                })
2457            })
2458            .collect();
2459        set_spans(&mut vxl, &spans, None);
2460        // Every column should have the [50, 100) carve.
2461        for idx in 0..16 {
2462            let mut spans = vec![0i32; SPAN_STRIDE];
2463            expandrle(vxl.column_data(idx), &mut spans);
2464            assert_eq!(spans[0], 0, "col {idx}");
2465            assert_eq!(spans[1], 50, "col {idx}");
2466            assert_eq!(spans[2], 100, "col {idx}");
2467            assert_eq!(spans[3], MAXZDIM, "col {idx}");
2468        }
2469    }
2470}