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