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