Skip to main content

roxlap_formats/
kv6.rs

1//! `.kv6` voxel-sprite format (Voxlap voxel sprites).
2//!
3//! Reference: voxlaptest's `loadkv6` in `voxlap/voxlap5.c`. File layout
4//! (all multi-byte fields are little-endian):
5//!
6//! ```text
7//! offset  size                            description
8//! 0x00    4 bytes                         "Kvxl" magic
9//! 0x04    u32                             xsiz
10//! 0x08    u32                             ysiz
11//! 0x0c    u32                             zsiz
12//! 0x10    f32                             xpiv (pivot, voxel units)
13//! 0x14    f32                             ypiv
14//! 0x18    f32                             zpiv
15//! 0x1c    u32                             numvoxs
16//! 0x20    numvoxs × Voxel                 voxel records (8 bytes each)
17//! ...     u32 × xsiz                      xlen — voxels per x slice
18//! ...     u16 × xsiz × ysiz               ylen — voxels per (x, y) column
19//! ```
20//!
21//! Optional trailer (present in files produced by SLAB6 and similar
22//! tools; absent if the file ends after `ylen`):
23//!
24//! ```text
25//! ...     4 bytes                         "SPal" magic
26//! ...     256 × [r6 g6 b6]                palette (each component 0..=63)
27//! ```
28//!
29//! voxlaptest's loader ignores the trailer (per-voxel `Voxel::col`
30//! already carries the rendered colour); we still parse and round-trip
31//! it so byte equality holds.
32
33use core::fmt;
34use std::collections::HashMap;
35
36use crate::bytes::{Cursor, OutOfBounds};
37use crate::Rgb6;
38
39// Voxlap kv6 `vis` face bits. These must match the `mask` the CPU
40// sprite rasteriser ANDs `vis` with (`roxlap_core::sprite::kv6_iterate`
41// / `draw_boundcube_line`), which is the same convention an authored
42// `.kv6`'s `vis` uses. Derived from that mask construction and
43// calibrated against `coco.kv6` (see the `coco_vis_*` tests):
44//   x±/y± from the quadrant masks; z from the per-column z-run phases
45//   (`z < inz` ⇒ −z face uses 0x20; `z > inz` ⇒ +z face uses 0x10).
46const VIS_NEG_X: u8 = 0x01;
47const VIS_POS_X: u8 = 0x02;
48const VIS_NEG_Y: u8 = 0x04;
49const VIS_POS_Y: u8 = 0x08;
50// z bits calibrated against coco.kv6: 0x10 is the -z face, 0x20 the +z
51// face (the naive draw-order reading was reversed; see the test
52// `coco_vis_z_order_matches_authored`).
53const VIS_POS_Z: u8 = 0x20;
54const VIS_NEG_Z: u8 = 0x10;
55
56/// Per-voxel `(vis, dir)` for a surface voxel at local `(x, y, z)`,
57/// given an occupancy predicate `occ` (out-of-range ⇒ air). `vis` is
58/// the exposed-face bitmask; `dir` is the nearest voxlap direction
59/// ([`crate::equivec::nearest_dir`]) to the outward surface normal,
60/// estimated as the gradient of occupancy over the 3³ neighbourhood
61/// (summing the offsets to *empty* cells points away from the solid).
62pub(crate) fn compute_vis_dir(
63    occ: &impl Fn(i64, i64, i64) -> bool,
64    x: i64,
65    y: i64,
66    z: i64,
67) -> (u8, u8) {
68    let mut vis = 0u8;
69    if !occ(x - 1, y, z) {
70        vis |= VIS_NEG_X;
71    }
72    if !occ(x + 1, y, z) {
73        vis |= VIS_POS_X;
74    }
75    if !occ(x, y - 1, z) {
76        vis |= VIS_NEG_Y;
77    }
78    if !occ(x, y + 1, z) {
79        vis |= VIS_POS_Y;
80    }
81    if !occ(x, y, z - 1) {
82        vis |= VIS_NEG_Z;
83    }
84    if !occ(x, y, z + 1) {
85        vis |= VIS_POS_Z;
86    }
87
88    let mut n = [0.0f32; 3];
89    for dz in -1..=1 {
90        for dy in -1..=1 {
91            for dx in -1..=1 {
92                if (dx | dy | dz) != 0 && !occ(x + dx, y + dy, z + dz) {
93                    n[0] += dx as f32;
94                    n[1] += dy as f32;
95                    n[2] += dz as f32;
96                }
97            }
98        }
99    }
100    (vis, crate::equivec::nearest_dir(n))
101}
102
103/// One voxel record (`kv6voxtype` in voxlaptest).
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct Voxel {
106    /// Voxlap-style packed colour: `0x80RRGGBB` (alpha is the
107    /// brightness flag).
108    pub col: u32,
109    /// z coordinate in voxel units.
110    pub z: u16,
111    /// Visibility-flags byte. Bits encode which of the six cube faces
112    /// of this voxel are exposed.
113    pub vis: u8,
114    /// Index into the 256-entry surface-normal lookup table.
115    pub dir: u8,
116}
117
118/// Parsed `.kv6` model. Round-trips byte-equally via [`parse`] +
119/// [`serialize`].
120#[derive(Debug, Clone)]
121pub struct Kv6 {
122    pub xsiz: u32,
123    pub ysiz: u32,
124    pub zsiz: u32,
125    pub xpiv: f32,
126    pub ypiv: f32,
127    pub zpiv: f32,
128    /// Voxel records in file order (`numvoxs == voxels.len() as u32`).
129    pub voxels: Vec<Voxel>,
130    /// `xlen[x]` is the number of voxels in the x-th slice.
131    /// `xlen.len() == xsiz`. `xlen.iter().sum() == numvoxs`.
132    pub xlen: Vec<u32>,
133    /// `ylen[x][y]` is the number of voxels in column (x, y).
134    /// Outer length `xsiz`, inner `ysiz`.
135    pub ylen: Vec<Vec<u16>>,
136    /// Optional trailing 256-entry palette (`"SPal"` section).
137    pub palette: Option<[Rgb6; 256]>,
138}
139
140impl Kv6 {
141    /// Build a `Kv6` procedurally from a dense occupancy + colour
142    /// closure: `fill(x, y, z)` returns `Some(col)` for a solid voxel,
143    /// `None` for air. `col` is voxlap-packed `0x80RRGGBB` — the high
144    /// byte is **brightness**, not alpha, so `0x00…` renders black; use
145    /// `0x80…` for a flat-lit mid value.
146    ///
147    /// Only **surface** voxels are emitted (a voxel with at least one
148    /// of its six neighbours air or out of bounds), matching how a
149    /// `.kv6` stores a hull and how [`crate::sprite::Sprite`] expects to
150    /// be drawn; fully-enclosed interior voxels are skipped. Emitted
151    /// voxels get `vis = 63` (all faces) and `dir = 0`, mirroring
152    /// `roxlap_core::meltsphere`'s flat output — adequate for procedural
153    /// models that don't need per-face normals. The pivot is the
154    /// geometric centre.
155    ///
156    /// Voxels are emitted in the canonical x-major, then y, then
157    /// ascending-z order the format requires, with matching `xlen` /
158    /// `ylen` run tables.
159    // Dimensions are bounded by realistic model sizes: column/x counts
160    // fit u16/u32, sizes fit f32 exactly, and the closure-local i64
161    // neighbour coords are range-checked before the u32 cast.
162    #[must_use]
163    pub fn from_fn<F: Fn(u32, u32, u32) -> Option<u32>>(
164        xsiz: u32,
165        ysiz: u32,
166        zsiz: u32,
167        fill: F,
168    ) -> Kv6 {
169        Self::build_inner(xsiz, ysiz, zsiz, fill, false, |_| false)
170    }
171
172    /// Like [`Kv6::from_fn`], but keeps **interior** (fully-enclosed) voxels
173    /// whose colour `keep_interior` returns `true` for, instead of culling
174    /// them as the surface-only default does.
175    ///
176    /// `from_fn` stores only voxels with at least one exposed face — correct
177    /// for opaque models (you never see an enclosed voxel) and the reason a
178    /// solid cube is a hollow shell. But a **`BlendMode::Volumetric`**
179    /// (Beer–Lambert) volume needs its interior: the per-cell absorption that
180    /// makes a filled cloud read denser at its core than its rim only works if
181    /// the ray actually traverses the inner voxels (`crate::material`). This
182    /// variant keeps an interior voxel when `keep_interior(colour)` is true —
183    /// pass a predicate that matches your translucent/volumetric colours, so
184    /// opaque interiors are still dropped (the storage win) while translucent
185    /// bodies stay solid through. The kept interiors are flat (`vis = 63`,
186    /// `dir = 0`); translucent voxels render flat-lit anyway.
187    #[must_use]
188    pub fn from_fn_keep_interior<F, G>(
189        xsiz: u32,
190        ysiz: u32,
191        zsiz: u32,
192        fill: F,
193        keep_interior: G,
194    ) -> Kv6
195    where
196        F: Fn(u32, u32, u32) -> Option<u32>,
197        G: Fn(u32) -> bool,
198    {
199        Self::build_inner(xsiz, ysiz, zsiz, fill, false, keep_interior)
200    }
201
202    /// Like [`Kv6::from_fn`], but fills **real** per-voxel surface
203    /// normals ([`Voxel::dir`]) and face visibility ([`Voxel::vis`])
204    /// instead of the flat `dir = 0`, `vis = 63`. The CPU sprite
205    /// rasteriser shades each voxel by `dir` (`kv6colmul[dir]`), so a
206    /// `from_fn`-built model shades flat while a `from_fn_shaded` one
207    /// gets proper directional gradient shading — the difference an
208    /// authored `.kv6` shows.
209    ///
210    /// `dir` is the nearest voxlap direction
211    /// ([`crate::equivec::nearest_dir`]) to the voxel's outward surface
212    /// normal, estimated as the occupancy gradient over the 3³
213    /// neighbourhood (pointing toward empty space). `vis` is the bitmask
214    /// of the six exposed faces.
215    #[must_use]
216    pub fn from_fn_shaded<F: Fn(u32, u32, u32) -> Option<u32>>(
217        xsiz: u32,
218        ysiz: u32,
219        zsiz: u32,
220        fill: F,
221    ) -> Kv6 {
222        Self::build_inner(xsiz, ysiz, zsiz, fill, true, |_| false)
223    }
224
225    // Dimensions are bounded by realistic model sizes: column/x counts
226    // fit u16/u32, sizes fit f32 exactly, and the closure-local i64
227    // neighbour coords are range-checked before the u32 cast.
228    #[allow(
229        clippy::cast_possible_truncation,
230        clippy::cast_sign_loss,
231        clippy::cast_precision_loss
232    )]
233    fn build_inner<F, G>(
234        xsiz: u32,
235        ysiz: u32,
236        zsiz: u32,
237        fill: F,
238        shaded: bool,
239        keep_interior: G,
240    ) -> Kv6
241    where
242        F: Fn(u32, u32, u32) -> Option<u32>,
243        G: Fn(u32) -> bool,
244    {
245        let occupied = |x: i64, y: i64, z: i64| -> bool {
246            x >= 0
247                && y >= 0
248                && z >= 0
249                && (x as u32) < xsiz
250                && (y as u32) < ysiz
251                && (z as u32) < zsiz
252                && fill(x as u32, y as u32, z as u32).is_some()
253        };
254
255        let mut voxels: Vec<Voxel> = Vec::new();
256        let mut xlen: Vec<u32> = Vec::with_capacity(xsiz as usize);
257        let mut ylen: Vec<Vec<u16>> = Vec::with_capacity(xsiz as usize);
258
259        for x in 0..xsiz {
260            let mut col_counts: Vec<u16> = Vec::with_capacity(ysiz as usize);
261            for y in 0..ysiz {
262                let before = voxels.len();
263                for z in 0..zsiz {
264                    let Some(col) = fill(x, y, z) else { continue };
265                    let (xi, yi, zi) = (i64::from(x), i64::from(y), i64::from(z));
266                    let exposed = !occupied(xi - 1, yi, zi)
267                        || !occupied(xi + 1, yi, zi)
268                        || !occupied(xi, yi - 1, zi)
269                        || !occupied(xi, yi + 1, zi)
270                        || !occupied(xi, yi, zi - 1)
271                        || !occupied(xi, yi, zi + 1);
272                    if exposed || keep_interior(col) {
273                        let (vis, dir) = if shaded {
274                            compute_vis_dir(&occupied, xi, yi, zi)
275                        } else {
276                            (63, 0)
277                        };
278                        voxels.push(Voxel {
279                            col,
280                            z: z as u16,
281                            vis,
282                            dir,
283                        });
284                    }
285                }
286                col_counts.push((voxels.len() - before) as u16);
287            }
288            xlen.push(col_counts.iter().map(|&c| u32::from(c)).sum());
289            ylen.push(col_counts);
290        }
291
292        Kv6 {
293            xsiz,
294            ysiz,
295            zsiz,
296            xpiv: xsiz as f32 * 0.5,
297            ypiv: ysiz as f32 * 0.5,
298            zpiv: zsiz as f32 * 0.5,
299            voxels,
300            xlen,
301            ylen,
302            palette: None,
303        }
304    }
305
306    /// Recompute every stored voxel's [`Voxel::vis`] + [`Voxel::dir`]
307    /// from `occupied` (a predicate over the **full** solid in this
308    /// kv6's local coordinates; out-of-range / air ⇒ `false`). Use this
309    /// after editing a model's voxels to refresh its shading + face
310    /// visibility — the editor counterpart to building with
311    /// [`Kv6::from_fn_shaded`]. Geometry (positions, run tables) is left
312    /// untouched; only `vis`/`dir` change.
313    #[allow(clippy::cast_possible_wrap)]
314    pub fn recompute_surface(&mut self, occupied: impl Fn(i32, i32, i32) -> bool) {
315        let xsiz = self.xsiz;
316        let ysiz = self.ysiz;
317        let zsiz = self.zsiz;
318        let occ = |x: i64, y: i64, z: i64| -> bool {
319            x >= 0
320                && y >= 0
321                && z >= 0
322                && (x as u32) < xsiz
323                && (y as u32) < ysiz
324                && (z as u32) < zsiz
325                && occupied(x as i32, y as i32, z as i32)
326        };
327        let mut vi = 0usize;
328        for x in 0..xsiz as usize {
329            for y in 0..ysiz as usize {
330                let len = self.ylen[x][y] as usize;
331                for _ in 0..len {
332                    let z = i64::from(self.voxels[vi].z);
333                    let (vis, dir) = compute_vis_dir(&occ, x as i64, y as i64, z);
334                    self.voxels[vi].vis = vis;
335                    self.voxels[vi].dir = dir;
336                    vi += 1;
337                }
338            }
339        }
340    }
341
342    /// Map of every stored surface voxel's local `(x, y, z)` to its
343    /// colour, decoded from the run tables. Used by
344    /// [`Kv6::carve_sphere_with_colfunc`] to keep surviving surface
345    /// voxels at their authored colour while the cut repaints only the
346    /// freshly-exposed ones.
347    fn surface_color_map(&self) -> HashMap<(u32, u32, u32), u32> {
348        let mut map = HashMap::with_capacity(self.voxels.len());
349        let mut vi = 0usize;
350        for x in 0..self.xsiz as usize {
351            for y in 0..self.ysiz as usize {
352                let len = self.ylen[x][y] as usize;
353                for _ in 0..len {
354                    let v = self.voxels[vi];
355                    #[allow(clippy::cast_lossless)]
356                    map.insert((x as u32, y as u32, u32::from(v.z)), v.col);
357                    vi += 1;
358                }
359            }
360        }
361        map
362    }
363
364    /// Carve a sphere out of this model and control the colour of the
365    /// interior the cut exposes — the sprite counterpart of
366    /// [`roxlap_scene::Grid::set_sphere_with_colfunc`] /
367    /// [`crate::edit::set_sphere_with_colfunc`].
368    ///
369    /// **Why a `solid` predicate is required.** A `.kv6` stores only
370    /// its *surface* hull — fully-enclosed interior voxels are not
371    /// recorded (see [`Kv6::from_fn`]). A carve must therefore know the
372    /// model's *full* occupancy to expose meaningful interior walls,
373    /// which the data alone can't provide. The caller supplies it via
374    /// `solid(x, y, z) -> bool` in kv6-local voxel coords (e.g. the
375    /// same predicate used to build the model with
376    /// [`Kv6::from_fn_shaded`]). `solid` must report `true` for at
377    /// least every stored surface voxel.
378    ///
379    /// Behaviour:
380    /// - Voxels inside the sphere (`dx²+dy²+dz² <= r²`, matching
381    ///   [`crate::edit::set_sphere`]) become air.
382    /// - Voxels the cut newly exposes get their colour from
383    ///   `colfunc(x, y, z)` (kv6-local coords, voxlap-packed
384    ///   `0x80RRGGBB`). Pass `|_, _, _| col` for a flat crater colour.
385    /// - Voxels that were already on the surface keep their stored
386    ///   colour.
387    ///
388    /// `centre` / `radius` are in kv6-local voxel units. Dimensions,
389    /// pivot, and palette are preserved; the model is re-extracted with
390    /// real per-voxel normals + face visibility (as
391    /// [`Kv6::from_fn_shaded`]).
392    ///
393    /// [`roxlap_scene::Grid::set_sphere_with_colfunc`]: https://docs.rs/roxlap-scene
394    pub fn carve_sphere_with_colfunc<S, C>(
395        &mut self,
396        centre: [i32; 3],
397        radius: u32,
398        solid: S,
399        colfunc: C,
400    ) where
401        S: Fn(i32, i32, i32) -> bool,
402        C: Fn(i32, i32, i32) -> u32,
403    {
404        let orig = self.surface_color_map();
405        // Preserve identity fields the rebuild would otherwise reset.
406        let (xpiv, ypiv, zpiv) = (self.xpiv, self.ypiv, self.zpiv);
407        let palette = self.palette;
408
409        #[allow(clippy::cast_possible_wrap)]
410        let r = radius as i32;
411        let r_sq = r * r;
412        let (cx, cy, cz) = (centre[0], centre[1], centre[2]);
413        let inside = |x: i32, y: i32, z: i32| {
414            let (dx, dy, dz) = (x - cx, y - cy, z - cz);
415            dx * dx + dy * dy + dz * dz <= r_sq
416        };
417
418        let rebuilt = Kv6::from_fn_shaded(self.xsiz, self.ysiz, self.zsiz, |x, y, z| {
419            #[allow(clippy::cast_possible_wrap)]
420            let (xi, yi, zi) = (x as i32, y as i32, z as i32);
421            if inside(xi, yi, zi) || !solid(xi, yi, zi) {
422                return None;
423            }
424            // Surviving surface voxels keep their authored colour;
425            // anything else solid here is freshly exposed → colfunc.
426            Some(
427                orig.get(&(x, y, z))
428                    .copied()
429                    .unwrap_or_else(|| colfunc(xi, yi, zi)),
430            )
431        });
432
433        self.voxels = rebuilt.voxels;
434        self.xlen = rebuilt.xlen;
435        self.ylen = rebuilt.ylen;
436        self.xpiv = xpiv;
437        self.ypiv = ypiv;
438        self.zpiv = zpiv;
439        self.palette = palette;
440    }
441
442    /// A solid axis-aligned box of a single colour (voxlap-packed
443    /// `0x80RRGGBB`). Convenience over [`Kv6::from_fn`].
444    #[must_use]
445    pub fn solid_box(xsiz: u32, ysiz: u32, zsiz: u32, col: u32) -> Kv6 {
446        Kv6::from_fn(xsiz, ysiz, zsiz, |_, _, _| Some(col))
447    }
448
449    /// A solid `n³` cube of a single colour.
450    #[must_use]
451    pub fn solid_cube(n: u32, col: u32) -> Kv6 {
452        Kv6::solid_box(n, n, n, col)
453    }
454}
455
456/// Errors returned by [`parse`].
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub enum ParseError {
459    /// File too small to contain even the 32-byte header.
460    TooSmall { got: usize },
461    /// First 4 bytes are not the `"Kvxl"` magic.
462    BadMagic { got: [u8; 4] },
463    /// A read of `need` bytes at offset `at` would run past the end of
464    /// the buffer.
465    Truncated { at: usize, need: usize },
466}
467
468impl fmt::Display for ParseError {
469    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
470        match *self {
471            Self::TooSmall { got } => write!(
472                f,
473                "kv6 file too small ({got} bytes; need at least 32 byte header)"
474            ),
475            Self::BadMagic { got } => write!(
476                f,
477                "kv6 bad magic: got [{:#04x},{:#04x},{:#04x},{:#04x}], expected b\"Kvxl\"",
478                got[0], got[1], got[2], got[3]
479            ),
480            Self::Truncated { at, need } => {
481                write!(f, "kv6 truncated: need {need} bytes at offset {at}")
482            }
483        }
484    }
485}
486
487impl std::error::Error for ParseError {}
488
489impl From<OutOfBounds> for ParseError {
490    fn from(e: OutOfBounds) -> Self {
491        Self::Truncated {
492            at: e.at,
493            need: e.need,
494        }
495    }
496}
497
498const HEADER_LEN: usize = 32;
499const MAGIC: &[u8; 4] = b"Kvxl";
500const PALETTE_MAGIC: &[u8; 4] = b"SPal";
501const PALETTE_LEN: usize = 768;
502
503/// Parse a `.kv6` file's bytes into a [`Kv6`].
504///
505/// # Errors
506///
507/// Returns [`ParseError`] if `bytes` is shorter than the 32-byte
508/// header, if the `"Kvxl"` magic does not match, or if a sequential
509/// read for any of the voxel / xlen / ylen / palette regions runs past
510/// EOF.
511///
512/// # Examples
513///
514/// Round-trip a synthetic empty kv6 through [`serialize`] + [`parse`]:
515///
516/// ```
517/// use roxlap_formats::kv6::{self, Kv6};
518///
519/// let original = Kv6 {
520///     xsiz: 1, ysiz: 1, zsiz: 1,
521///     xpiv: 0.5, ypiv: 0.5, zpiv: 0.5,
522///     voxels: vec![],
523///     xlen: vec![0],
524///     ylen: vec![vec![0]],
525///     palette: None,
526/// };
527/// let bytes = kv6::serialize(&original);
528/// let parsed = kv6::parse(&bytes).unwrap();
529/// assert_eq!(parsed.xsiz, original.xsiz);
530/// assert_eq!(parsed.voxels.len(), 0);
531/// ```
532pub fn parse(bytes: &[u8]) -> Result<Kv6, ParseError> {
533    if bytes.len() < HEADER_LEN {
534        return Err(ParseError::TooSmall { got: bytes.len() });
535    }
536
537    let mut cur = Cursor::new(bytes);
538    let magic = cur.read_bytes(4)?;
539    if magic != MAGIC {
540        return Err(ParseError::BadMagic {
541            got: [magic[0], magic[1], magic[2], magic[3]],
542        });
543    }
544    let xsiz = cur.read_u32()?;
545    let ysiz = cur.read_u32()?;
546    let zsiz = cur.read_u32()?;
547    let xpiv = cur.read_f32()?;
548    let ypiv = cur.read_f32()?;
549    let zpiv = cur.read_f32()?;
550    let numvoxs = cur.read_u32()?;
551
552    // QE.6b — every capacity is clamped by the bytes actually left
553    // (8 B/voxel, 4 B/xlen, 2 B/ylen), so a crafted count errors as
554    // Truncated instead of allocation-bombing first.
555    let mut voxels = Vec::with_capacity(cur.clamped_capacity(numvoxs as usize, 8));
556    for _ in 0..numvoxs {
557        let col = cur.read_u32()?;
558        let z = cur.read_u16()?;
559        let vis = cur.read_u8()?;
560        let dir = cur.read_u8()?;
561        voxels.push(Voxel { col, z, vis, dir });
562    }
563
564    let mut xlen = Vec::with_capacity(cur.clamped_capacity(xsiz as usize, 4));
565    for _ in 0..xsiz {
566        xlen.push(cur.read_u32()?);
567    }
568
569    let row_bytes = (ysiz as usize).saturating_mul(2);
570    let mut ylen = Vec::with_capacity(cur.clamped_capacity(xsiz as usize, row_bytes));
571    for _ in 0..xsiz {
572        let mut row = Vec::with_capacity(cur.clamped_capacity(ysiz as usize, 2));
573        for _ in 0..ysiz {
574            row.push(cur.read_u16()?);
575        }
576        ylen.push(row);
577    }
578
579    // Optional "SPal" + 768-byte palette trailer.
580    let palette =
581        if cur.remaining() >= 4 + PALETTE_LEN && cur.peek(4) == Some(PALETTE_MAGIC.as_slice()) {
582            cur.read_bytes(4)?;
583            let mut pal = [Rgb6::default(); 256];
584            for entry in &mut pal {
585                entry.r = cur.read_u8()?;
586                entry.g = cur.read_u8()?;
587                entry.b = cur.read_u8()?;
588            }
589            Some(pal)
590        } else {
591            None
592        };
593
594    Ok(Kv6 {
595        xsiz,
596        ysiz,
597        zsiz,
598        xpiv,
599        ypiv,
600        zpiv,
601        voxels,
602        xlen,
603        ylen,
604        palette,
605    })
606}
607
608/// Serialise a [`Kv6`] back to bytes. The output round-trips byte-
609/// equally with the input that produced this `Kv6` via [`parse`],
610/// including the optional `"SPal"` palette trailer.
611///
612/// # Panics
613///
614/// Panics if `kv6.voxels.len()` does not fit in a `u32` (the on-disk
615/// `numvoxs` field is a `u32`). `Kv6` values produced by [`parse`]
616/// always satisfy this.
617#[must_use]
618pub fn serialize(kv6: &Kv6) -> Vec<u8> {
619    let pal_bytes = if kv6.palette.is_some() {
620        4 + PALETTE_LEN
621    } else {
622        0
623    };
624    let body_bytes = kv6.voxels.len() * 8
625        + kv6.xlen.len() * 4
626        + kv6.ylen.iter().map(|row| row.len() * 2).sum::<usize>();
627    let mut out = Vec::with_capacity(HEADER_LEN + body_bytes + pal_bytes);
628
629    out.extend_from_slice(MAGIC);
630    out.extend_from_slice(&kv6.xsiz.to_le_bytes());
631    out.extend_from_slice(&kv6.ysiz.to_le_bytes());
632    out.extend_from_slice(&kv6.zsiz.to_le_bytes());
633    out.extend_from_slice(&kv6.xpiv.to_le_bytes());
634    out.extend_from_slice(&kv6.ypiv.to_le_bytes());
635    out.extend_from_slice(&kv6.zpiv.to_le_bytes());
636    let numvoxs =
637        u32::try_from(kv6.voxels.len()).expect("kv6 numvoxs must fit in u32 (file format limit)");
638    out.extend_from_slice(&numvoxs.to_le_bytes());
639
640    for v in &kv6.voxels {
641        out.extend_from_slice(&v.col.to_le_bytes());
642        out.extend_from_slice(&v.z.to_le_bytes());
643        out.push(v.vis);
644        out.push(v.dir);
645    }
646    for v in &kv6.xlen {
647        out.extend_from_slice(&v.to_le_bytes());
648    }
649    for row in &kv6.ylen {
650        for v in row {
651            out.extend_from_slice(&v.to_le_bytes());
652        }
653    }
654    if let Some(pal) = &kv6.palette {
655        out.extend_from_slice(PALETTE_MAGIC);
656        for e in pal {
657            out.push(e.r);
658            out.push(e.g);
659            out.push(e.b);
660        }
661    }
662
663    out
664}
665
666// --- tests --------------------------------------------------------------
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    /// `assets/coco.kv6`, produced from `coco.kvx` via SLAB6.
673    const COCO_KV6: &[u8] = include_bytes!("../../../assets/coco.kv6");
674
675    #[test]
676    fn solid_cube_builder_is_surface_only_and_consistent() {
677        let cube = Kv6::solid_cube(4, 0x8012_3456);
678        assert_eq!((cube.xsiz, cube.ysiz, cube.zsiz), (4, 4, 4));
679        // Pivot at the geometric centre.
680        assert!((cube.xpiv - 2.0).abs() < f32::EPSILON);
681
682        // Surface-only: a solid 4³ has 64 voxels, minus the 2³ interior
683        // shell core (all six neighbours occupied) = 56 emitted.
684        assert_eq!(cube.voxels.len(), 64 - 8);
685        assert!(cube
686            .voxels
687            .iter()
688            .all(|v| v.vis == 63 && v.col == 0x8012_3456));
689
690        // Run tables match the format contract.
691        assert_eq!(cube.xlen.len(), 4);
692        assert_eq!(cube.ylen.len(), 4);
693        assert!(cube.ylen.iter().all(|row| row.len() == 4));
694        let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
695        let ylen_sum: usize = cube
696            .ylen
697            .iter()
698            .flat_map(|r| r.iter())
699            .map(|&n| n as usize)
700            .sum();
701        assert_eq!(xlen_sum, cube.voxels.len());
702        assert_eq!(ylen_sum, cube.voxels.len());
703    }
704
705    /// `from_fn_keep_interior` retains enclosed voxels whose colour the
706    /// predicate accepts — the storage policy for `BlendMode::Volumetric`
707    /// bodies (keep translucent interiors, still cull opaque ones).
708    #[test]
709    fn from_fn_keep_interior_retains_matching_interiors() {
710        let col = 0x8012_3456;
711        // All-solid 4³: from_fn culls the 2³ interior (56 voxels); keeping the
712        // interior gives the full 64.
713        let shell = Kv6::from_fn(4, 4, 4, |_, _, _| Some(col));
714        assert_eq!(shell.voxels.len(), 64 - 8, "from_fn is surface-only");
715
716        let filled = Kv6::from_fn_keep_interior(4, 4, 4, |_, _, _| Some(col), |c| c == col);
717        assert_eq!(filled.voxels.len(), 64, "keep_interior retains all 64");
718        // The dead-centre voxel (1,1,1) is fully enclosed — absent in the
719        // shell, present when kept.
720        assert_eq!(color_at(&shell, 1, 1, 1), None);
721        assert_eq!(color_at(&filled, 1, 1, 1), Some(col));
722
723        // A predicate that rejects the colour culls interiors as usual.
724        let culled = Kv6::from_fn_keep_interior(4, 4, 4, |_, _, _| Some(col), |_| false);
725        assert_eq!(
726            culled.voxels.len(),
727            64 - 8,
728            "predicate=false ⇒ surface-only"
729        );
730    }
731
732    /// Decode the colour stored at local `(tx, ty, tz)`, or `None` if
733    /// no surface voxel sits there.
734    fn color_at(kv6: &Kv6, tx: u32, ty: u32, tz: u32) -> Option<u32> {
735        let mut vi = 0usize;
736        for x in 0..kv6.xsiz {
737            for y in 0..kv6.ysiz {
738                let len = kv6.ylen[x as usize][y as usize] as usize;
739                for _ in 0..len {
740                    let v = kv6.voxels[vi];
741                    if x == tx && y == ty && u32::from(v.z) == tz {
742                        return Some(v.col);
743                    }
744                    vi += 1;
745                }
746            }
747        }
748        None
749    }
750
751    #[test]
752    fn carve_sphere_exposes_interior_with_colfunc() {
753        const BASE: u32 = 0x8011_2233;
754        // A full solid 16³ cube — its occupancy predicate is "all".
755        let mut cube = Kv6::from_fn_shaded(16, 16, 16, |_, _, _| Some(BASE));
756        // Custom pivot to verify carve preserves it.
757        cube.xpiv = 1.0;
758        cube.ypiv = 2.0;
759        cube.zpiv = 3.0;
760
761        // colfunc encodes kv6-local coords into the low 24 bits so we
762        // can assert the closure sees local (not some shifted) coords.
763        let encode = |x: i32, y: i32, z: i32| ((x << 16) | (y << 8) | z) as u32;
764        cube.carve_sphere_with_colfunc([8, 8, 8], 4, |_, _, _| true, encode);
765
766        // Sphere centre removed.
767        assert_eq!(color_at(&cube, 8, 8, 8), None);
768        // (8,8,3) sits just below the carved range (its +z neighbour
769        // (8,8,4) is carved): solid, freshly exposed → colfunc colour
770        // at its LOCAL coords.
771        assert_eq!(color_at(&cube, 8, 8, 3), Some(encode(8, 8, 3)));
772        // An original face voxel far from the cut keeps its colour.
773        assert_eq!(color_at(&cube, 0, 8, 8), Some(BASE));
774
775        // Pivot preserved across the rebuild.
776        assert!((cube.xpiv - 1.0).abs() < f32::EPSILON);
777        assert!((cube.ypiv - 2.0).abs() < f32::EPSILON);
778        assert!((cube.zpiv - 3.0).abs() < f32::EPSILON);
779
780        // Run tables stay consistent with the voxel list.
781        let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
782        assert_eq!(xlen_sum, cube.voxels.len());
783    }
784
785    #[test]
786    fn carve_sphere_respects_caller_solid_predicate() {
787        const BASE: u32 = 0x80AA_BBCC;
788        // Build from a half-solid predicate (only x < 8 solid), and
789        // pass the SAME predicate as `solid`. A carve centred in the
790        // solid half must not resurrect the air half.
791        let solid = |x: i32, _y: i32, _z: i32| (0..8).contains(&x);
792        #[allow(clippy::cast_sign_loss)]
793        let mut m =
794            Kv6::from_fn_shaded(16, 16, 16, |x, _, _| solid(x as i32, 0, 0).then_some(BASE));
795        m.carve_sphere_with_colfunc([4, 8, 8], 3, solid, |_, _, _| 0x8000_FF00);
796        // Air half stays air (never solid → never emitted).
797        assert_eq!(color_at(&m, 12, 8, 8), None);
798        // Carved centre gone.
799        assert_eq!(color_at(&m, 4, 8, 8), None);
800    }
801
802    #[test]
803    fn built_cube_round_trips_through_serialize_parse() {
804        let cube = Kv6::solid_cube(5, 0x80AB_CDEF);
805        let bytes = serialize(&cube);
806        let back = parse(&bytes).expect("parse built cube");
807        assert_eq!(back.xsiz, cube.xsiz);
808        assert_eq!(back.voxels.len(), cube.voxels.len());
809        assert_eq!(
810            serialize(&back),
811            bytes,
812            "serialize is stable across round-trip"
813        );
814    }
815
816    #[test]
817    fn from_fn_skips_air_and_keeps_z_order() {
818        // A single occupied column at (0,0,*): two voxels (z=0,1), both
819        // surface; ordered ascending z.
820        let kv6 = Kv6::from_fn(1, 1, 2, |_, _, _| Some(0x8000_FF00));
821        assert_eq!(kv6.voxels.len(), 2);
822        assert_eq!(kv6.voxels[0].z, 0);
823        assert_eq!(kv6.voxels[1].z, 1);
824        assert_eq!(kv6.xlen, vec![2]);
825        assert_eq!(kv6.ylen, vec![vec![2]]);
826    }
827
828    #[test]
829    fn parse_coco_header() {
830        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
831        assert_eq!(kv6.xsiz, 9);
832        assert_eq!(kv6.ysiz, 11);
833        assert_eq!(kv6.zsiz, 9);
834        // Pivots are stored as f32 in kv6 (vs 8.8 fixed in kvx).
835        assert!((kv6.xpiv - 2.0).abs() < f32::EPSILON);
836        assert!((kv6.ypiv - 3.0).abs() < f32::EPSILON);
837        assert!((kv6.zpiv - 9.0).abs() < f32::EPSILON);
838        assert_eq!(kv6.voxels.len(), 148);
839    }
840
841    #[test]
842    fn coco_voxel_counts_consistent() {
843        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
844        assert_eq!(kv6.xlen.len(), kv6.xsiz as usize);
845        assert_eq!(kv6.ylen.len(), kv6.xsiz as usize);
846        for row in &kv6.ylen {
847            assert_eq!(row.len(), kv6.ysiz as usize);
848        }
849        let xlen_sum: u64 = kv6.xlen.iter().map(|&n| u64::from(n)).sum();
850        let ylen_sum: u64 = kv6
851            .ylen
852            .iter()
853            .flat_map(|row| row.iter().map(|&n| u64::from(n)))
854            .sum();
855        let nv = kv6.voxels.len() as u64;
856        assert_eq!(xlen_sum, nv);
857        assert_eq!(ylen_sum, nv);
858    }
859
860    #[test]
861    fn from_fn_shaded_keeps_from_fn_geometry() {
862        // Shading must not change which voxels are emitted or the run
863        // tables — only vis/dir. (A hollow shell: surface of a 5³ cube.)
864        let fill = |x: u32, y: u32, z: u32| {
865            let on_face = x == 0 || x == 4 || y == 0 || y == 4 || z == 0 || z == 4;
866            on_face.then_some(0x80_44_55_66u32)
867        };
868        let flat = Kv6::from_fn(5, 5, 5, fill);
869        let shaded = Kv6::from_fn_shaded(5, 5, 5, fill);
870        assert_eq!(flat.voxels.len(), shaded.voxels.len());
871        assert_eq!(flat.xlen, shaded.xlen);
872        assert_eq!(flat.ylen, shaded.ylen);
873        for (f, s) in flat.voxels.iter().zip(&shaded.voxels) {
874            assert_eq!((f.col, f.z), (s.col, s.z));
875        }
876        // And shading actually varied dir (not all 0 like from_fn).
877        assert!(
878            shaded.voxels.iter().any(|v| v.dir != 0),
879            "from_fn_shaded left every dir flat"
880        );
881        assert!(flat.voxels.iter().all(|v| v.dir == 0 && v.vis == 63));
882    }
883
884    #[test]
885    fn from_fn_shaded_column_z_faces() {
886        // A 1×1×2 stack: lower voxel's +z face and upper's -z face are
887        // internal (the two touch); the four side faces + the outer z
888        // face are exposed. Validates the VIS_*_Z constants' internal
889        // consistency against the neighbour checks.
890        let kv = Kv6::from_fn_shaded(1, 1, 2, |_, _, _| Some(0x80_80_80_80));
891        assert_eq!(kv.voxels.len(), 2);
892        let (lower, upper) = (&kv.voxels[0], &kv.voxels[1]); // ascending z
893        assert_eq!(lower.z, 0);
894        assert_eq!(upper.z, 1);
895        assert_eq!(lower.vis & VIS_POS_Z, 0, "lower +z should be internal");
896        assert_eq!(lower.vis & VIS_NEG_Z, VIS_NEG_Z, "lower -z exposed");
897        assert_eq!(upper.vis & VIS_NEG_Z, 0, "upper -z should be internal");
898        assert_eq!(upper.vis & VIS_POS_Z, VIS_POS_Z, "upper +z exposed");
899        // All four side faces exposed on both.
900        let sides = VIS_NEG_X | VIS_POS_X | VIS_NEG_Y | VIS_POS_Y;
901        assert_eq!(lower.vis & sides, sides);
902        assert_eq!(upper.vis & sides, sides);
903    }
904
905    /// CALIBRATION: confirm every `vis` face bit matches voxlap's
906    /// authored convention, against `coco.kv6`. When two voxels are
907    /// stored adjacent along an axis, the face between them is internal,
908    /// so the corresponding bit must be clear in the authored `vis` —
909    /// interior-independent (the shared face is internal regardless of
910    /// any unstored solid), so it pins all six bits without needing
911    /// coco's full solid. (We don't assert the converse: a missing
912    /// stored neighbour may still be solid interior, leaving the bit
913    /// legitimately clear.)
914    #[test]
915    fn coco_vis_matches_authored_all_faces() {
916        use std::collections::HashMap;
917        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
918        let mut pos: HashMap<(u32, u32, u32), u8> = HashMap::new();
919        let mut vi = 0usize;
920        for x in 0..kv6.xsiz {
921            for y in 0..kv6.ysiz {
922                let len = kv6.ylen[x as usize][y as usize] as usize;
923                for _ in 0..len {
924                    pos.insert((x, y, u32::from(kv6.voxels[vi].z)), kv6.voxels[vi].vis);
925                    vi += 1;
926                }
927            }
928        }
929        let mut checked = 0u32;
930        for (&(x, y, z), &vis) in &pos {
931            let mut chk = |present: bool, bit: u8, face: &str| {
932                if present {
933                    assert_eq!(
934                        vis & bit,
935                        0,
936                        "coco ({x},{y},{z}): {face} internal but bit set"
937                    );
938                    checked += 1;
939                }
940            };
941            chk(pos.contains_key(&(x + 1, y, z)), VIS_POS_X, "+x");
942            chk(x > 0 && pos.contains_key(&(x - 1, y, z)), VIS_NEG_X, "-x");
943            chk(pos.contains_key(&(x, y + 1, z)), VIS_POS_Y, "+y");
944            chk(y > 0 && pos.contains_key(&(x, y - 1, z)), VIS_NEG_Y, "-y");
945            chk(pos.contains_key(&(x, y, z + 1)), VIS_POS_Z, "+z");
946            chk(z > 0 && pos.contains_key(&(x, y, z - 1)), VIS_NEG_Z, "-z");
947        }
948        assert!(
949            checked > 100,
950            "expected many adjacent faces in coco, got {checked}"
951        );
952    }
953
954    #[test]
955    fn recompute_surface_matches_from_fn_shaded() {
956        // recompute_surface on a flat-built model must reproduce exactly
957        // what from_fn_shaded would have emitted (same vis + dir).
958        let fill = |x: u32, y: u32, z: u32| {
959            let cx = x as f32 - 4.0;
960            let cy = y as f32 - 4.0;
961            let cz = z as f32 - 4.0;
962            (cx * cx + cy * cy + cz * cz <= 16.0).then_some(0x80_30_60_90u32)
963        };
964        let shaded = Kv6::from_fn_shaded(9, 9, 9, fill);
965        let mut edited = Kv6::from_fn(9, 9, 9, fill); // flat vis/dir
966        edited.recompute_surface(|x, y, z| {
967            x >= 0 && y >= 0 && z >= 0 && fill(x as u32, y as u32, z as u32).is_some()
968        });
969        assert_eq!(edited.voxels.len(), shaded.voxels.len());
970        for (e, s) in edited.voxels.iter().zip(&shaded.voxels) {
971            assert_eq!((e.vis, e.dir), (s.vis, s.dir), "voxel z={}", e.z);
972        }
973    }
974
975    #[test]
976    fn from_fn_shaded_slab_top_normal_points_up() {
977        use crate::equivec::univec;
978        // A solid slab filling z in [2,9]; the z=2 surface (smallest z =
979        // "up" in voxlap z-down) faces empty above, so its outward normal
980        // points toward -z. Check an interior-of-face voxel.
981        let kv = Kv6::from_fn_shaded(8, 8, 12, |_, _, z| {
982            (2..=9).contains(&z).then_some(0x80_aa_aa_aa)
983        });
984        let v = kv
985            .voxels
986            .iter()
987            .enumerate()
988            .find_map(|(i, v)| {
989                // recover (x,y) for voxel i
990                let mut acc = 0usize;
991                for x in 0..kv.xsiz as usize {
992                    for y in 0..kv.ysiz as usize {
993                        let len = kv.ylen[x][y] as usize;
994                        if i < acc + len {
995                            return (x == 4 && y == 4 && v.z == 2).then_some(*v);
996                        }
997                        acc += len;
998                    }
999                }
1000                None
1001            })
1002            .expect("centre top-face voxel present");
1003        let n = univec()[v.dir as usize];
1004        assert!(
1005            n[2] < -0.5,
1006            "top-face normal should point -z (up), got {n:?}"
1007        );
1008    }
1009
1010    #[test]
1011    fn coco_palette_present_and_matches_kvx() {
1012        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1013        let pal = kv6.palette.as_ref().expect("SPal trailer present");
1014        // First palette entry from the hex dump matches coco.kvx's first.
1015        assert_eq!((pal[0].r, pal[0].g, pal[0].b), (0x3f, 0x19, 0x19));
1016    }
1017
1018    #[test]
1019    fn coco_first_voxel_packed_colour() {
1020        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1021        // From the hex dump: 60 a4 fc 80 → little-endian u32 0x80fca460.
1022        // High bit 0x80000000 is the brightness flag the engine sets on
1023        // every coloured voxel.
1024        let v0 = kv6.voxels[0];
1025        assert_eq!(v0.col, 0x80fc_a460);
1026        assert_eq!(v0.col & 0x8000_0000, 0x8000_0000);
1027    }
1028
1029    #[test]
1030    fn coco_roundtrips_byte_equal() {
1031        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1032        let out = serialize(&kv6);
1033        assert_eq!(out.len(), COCO_KV6.len(), "length differs");
1034        assert_eq!(out.as_slice(), COCO_KV6, "byte content differs");
1035    }
1036
1037    #[test]
1038    fn parse_truncated_header_fails() {
1039        let r = parse(&[0u8; 16]);
1040        assert!(matches!(r, Err(ParseError::TooSmall { .. })));
1041    }
1042
1043    #[test]
1044    fn parse_bad_magic_fails() {
1045        let mut bad = COCO_KV6.to_vec();
1046        bad[0] = b'X';
1047        let r = parse(&bad);
1048        assert!(matches!(r, Err(ParseError::BadMagic { .. })));
1049    }
1050
1051    /// QE.6b — a 36-byte file claiming `u32::MAX` voxels (≈32 GiB of
1052    /// records) must fail as `Truncated`, not allocation-bomb the
1053    /// process on `Vec::with_capacity` first.
1054    #[test]
1055    fn parse_survives_absurd_numvoxs_without_alloc_bomb() {
1056        let mut bytes = Vec::new();
1057        bytes.extend_from_slice(b"Kvxl");
1058        for dim in [1u32, 1, 1] {
1059            bytes.extend_from_slice(&dim.to_le_bytes());
1060        }
1061        for piv in [0f32, 0.0, 0.0] {
1062            bytes.extend_from_slice(&piv.to_le_bytes());
1063        }
1064        bytes.extend_from_slice(&u32::MAX.to_le_bytes()); // numvoxs
1065        assert!(matches!(parse(&bytes), Err(ParseError::Truncated { .. })));
1066    }
1067}