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    let mut voxels = Vec::with_capacity(numvoxs as usize);
553    for _ in 0..numvoxs {
554        let col = cur.read_u32()?;
555        let z = cur.read_u16()?;
556        let vis = cur.read_u8()?;
557        let dir = cur.read_u8()?;
558        voxels.push(Voxel { col, z, vis, dir });
559    }
560
561    let mut xlen = Vec::with_capacity(xsiz as usize);
562    for _ in 0..xsiz {
563        xlen.push(cur.read_u32()?);
564    }
565
566    let mut ylen = Vec::with_capacity(xsiz as usize);
567    for _ in 0..xsiz {
568        let mut row = Vec::with_capacity(ysiz as usize);
569        for _ in 0..ysiz {
570            row.push(cur.read_u16()?);
571        }
572        ylen.push(row);
573    }
574
575    // Optional "SPal" + 768-byte palette trailer.
576    let palette =
577        if cur.remaining() >= 4 + PALETTE_LEN && cur.peek(4) == Some(PALETTE_MAGIC.as_slice()) {
578            cur.read_bytes(4)?;
579            let mut pal = [Rgb6::default(); 256];
580            for entry in &mut pal {
581                entry.r = cur.read_u8()?;
582                entry.g = cur.read_u8()?;
583                entry.b = cur.read_u8()?;
584            }
585            Some(pal)
586        } else {
587            None
588        };
589
590    Ok(Kv6 {
591        xsiz,
592        ysiz,
593        zsiz,
594        xpiv,
595        ypiv,
596        zpiv,
597        voxels,
598        xlen,
599        ylen,
600        palette,
601    })
602}
603
604/// Serialise a [`Kv6`] back to bytes. The output round-trips byte-
605/// equally with the input that produced this `Kv6` via [`parse`],
606/// including the optional `"SPal"` palette trailer.
607///
608/// # Panics
609///
610/// Panics if `kv6.voxels.len()` does not fit in a `u32` (the on-disk
611/// `numvoxs` field is a `u32`). `Kv6` values produced by [`parse`]
612/// always satisfy this.
613#[must_use]
614pub fn serialize(kv6: &Kv6) -> Vec<u8> {
615    let pal_bytes = if kv6.palette.is_some() {
616        4 + PALETTE_LEN
617    } else {
618        0
619    };
620    let body_bytes = kv6.voxels.len() * 8
621        + kv6.xlen.len() * 4
622        + kv6.ylen.iter().map(|row| row.len() * 2).sum::<usize>();
623    let mut out = Vec::with_capacity(HEADER_LEN + body_bytes + pal_bytes);
624
625    out.extend_from_slice(MAGIC);
626    out.extend_from_slice(&kv6.xsiz.to_le_bytes());
627    out.extend_from_slice(&kv6.ysiz.to_le_bytes());
628    out.extend_from_slice(&kv6.zsiz.to_le_bytes());
629    out.extend_from_slice(&kv6.xpiv.to_le_bytes());
630    out.extend_from_slice(&kv6.ypiv.to_le_bytes());
631    out.extend_from_slice(&kv6.zpiv.to_le_bytes());
632    let numvoxs =
633        u32::try_from(kv6.voxels.len()).expect("kv6 numvoxs must fit in u32 (file format limit)");
634    out.extend_from_slice(&numvoxs.to_le_bytes());
635
636    for v in &kv6.voxels {
637        out.extend_from_slice(&v.col.to_le_bytes());
638        out.extend_from_slice(&v.z.to_le_bytes());
639        out.push(v.vis);
640        out.push(v.dir);
641    }
642    for v in &kv6.xlen {
643        out.extend_from_slice(&v.to_le_bytes());
644    }
645    for row in &kv6.ylen {
646        for v in row {
647            out.extend_from_slice(&v.to_le_bytes());
648        }
649    }
650    if let Some(pal) = &kv6.palette {
651        out.extend_from_slice(PALETTE_MAGIC);
652        for e in pal {
653            out.push(e.r);
654            out.push(e.g);
655            out.push(e.b);
656        }
657    }
658
659    out
660}
661
662// --- tests --------------------------------------------------------------
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    /// `assets/coco.kv6`, produced from `coco.kvx` via SLAB6.
669    const COCO_KV6: &[u8] = include_bytes!("../../../assets/coco.kv6");
670
671    #[test]
672    fn solid_cube_builder_is_surface_only_and_consistent() {
673        let cube = Kv6::solid_cube(4, 0x8012_3456);
674        assert_eq!((cube.xsiz, cube.ysiz, cube.zsiz), (4, 4, 4));
675        // Pivot at the geometric centre.
676        assert!((cube.xpiv - 2.0).abs() < f32::EPSILON);
677
678        // Surface-only: a solid 4³ has 64 voxels, minus the 2³ interior
679        // shell core (all six neighbours occupied) = 56 emitted.
680        assert_eq!(cube.voxels.len(), 64 - 8);
681        assert!(cube
682            .voxels
683            .iter()
684            .all(|v| v.vis == 63 && v.col == 0x8012_3456));
685
686        // Run tables match the format contract.
687        assert_eq!(cube.xlen.len(), 4);
688        assert_eq!(cube.ylen.len(), 4);
689        assert!(cube.ylen.iter().all(|row| row.len() == 4));
690        let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
691        let ylen_sum: usize = cube
692            .ylen
693            .iter()
694            .flat_map(|r| r.iter())
695            .map(|&n| n as usize)
696            .sum();
697        assert_eq!(xlen_sum, cube.voxels.len());
698        assert_eq!(ylen_sum, cube.voxels.len());
699    }
700
701    /// `from_fn_keep_interior` retains enclosed voxels whose colour the
702    /// predicate accepts — the storage policy for `BlendMode::Volumetric`
703    /// bodies (keep translucent interiors, still cull opaque ones).
704    #[test]
705    fn from_fn_keep_interior_retains_matching_interiors() {
706        let col = 0x8012_3456;
707        // All-solid 4³: from_fn culls the 2³ interior (56 voxels); keeping the
708        // interior gives the full 64.
709        let shell = Kv6::from_fn(4, 4, 4, |_, _, _| Some(col));
710        assert_eq!(shell.voxels.len(), 64 - 8, "from_fn is surface-only");
711
712        let filled = Kv6::from_fn_keep_interior(4, 4, 4, |_, _, _| Some(col), |c| c == col);
713        assert_eq!(filled.voxels.len(), 64, "keep_interior retains all 64");
714        // The dead-centre voxel (1,1,1) is fully enclosed — absent in the
715        // shell, present when kept.
716        assert_eq!(color_at(&shell, 1, 1, 1), None);
717        assert_eq!(color_at(&filled, 1, 1, 1), Some(col));
718
719        // A predicate that rejects the colour culls interiors as usual.
720        let culled = Kv6::from_fn_keep_interior(4, 4, 4, |_, _, _| Some(col), |_| false);
721        assert_eq!(
722            culled.voxels.len(),
723            64 - 8,
724            "predicate=false ⇒ surface-only"
725        );
726    }
727
728    /// Decode the colour stored at local `(tx, ty, tz)`, or `None` if
729    /// no surface voxel sits there.
730    fn color_at(kv6: &Kv6, tx: u32, ty: u32, tz: u32) -> Option<u32> {
731        let mut vi = 0usize;
732        for x in 0..kv6.xsiz {
733            for y in 0..kv6.ysiz {
734                let len = kv6.ylen[x as usize][y as usize] as usize;
735                for _ in 0..len {
736                    let v = kv6.voxels[vi];
737                    if x == tx && y == ty && u32::from(v.z) == tz {
738                        return Some(v.col);
739                    }
740                    vi += 1;
741                }
742            }
743        }
744        None
745    }
746
747    #[test]
748    fn carve_sphere_exposes_interior_with_colfunc() {
749        const BASE: u32 = 0x8011_2233;
750        // A full solid 16³ cube — its occupancy predicate is "all".
751        let mut cube = Kv6::from_fn_shaded(16, 16, 16, |_, _, _| Some(BASE));
752        // Custom pivot to verify carve preserves it.
753        cube.xpiv = 1.0;
754        cube.ypiv = 2.0;
755        cube.zpiv = 3.0;
756
757        // colfunc encodes kv6-local coords into the low 24 bits so we
758        // can assert the closure sees local (not some shifted) coords.
759        let encode = |x: i32, y: i32, z: i32| ((x << 16) | (y << 8) | z) as u32;
760        cube.carve_sphere_with_colfunc([8, 8, 8], 4, |_, _, _| true, encode);
761
762        // Sphere centre removed.
763        assert_eq!(color_at(&cube, 8, 8, 8), None);
764        // (8,8,3) sits just below the carved range (its +z neighbour
765        // (8,8,4) is carved): solid, freshly exposed → colfunc colour
766        // at its LOCAL coords.
767        assert_eq!(color_at(&cube, 8, 8, 3), Some(encode(8, 8, 3)));
768        // An original face voxel far from the cut keeps its colour.
769        assert_eq!(color_at(&cube, 0, 8, 8), Some(BASE));
770
771        // Pivot preserved across the rebuild.
772        assert!((cube.xpiv - 1.0).abs() < f32::EPSILON);
773        assert!((cube.ypiv - 2.0).abs() < f32::EPSILON);
774        assert!((cube.zpiv - 3.0).abs() < f32::EPSILON);
775
776        // Run tables stay consistent with the voxel list.
777        let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
778        assert_eq!(xlen_sum, cube.voxels.len());
779    }
780
781    #[test]
782    fn carve_sphere_respects_caller_solid_predicate() {
783        const BASE: u32 = 0x80AA_BBCC;
784        // Build from a half-solid predicate (only x < 8 solid), and
785        // pass the SAME predicate as `solid`. A carve centred in the
786        // solid half must not resurrect the air half.
787        let solid = |x: i32, _y: i32, _z: i32| (0..8).contains(&x);
788        #[allow(clippy::cast_sign_loss)]
789        let mut m =
790            Kv6::from_fn_shaded(16, 16, 16, |x, _, _| solid(x as i32, 0, 0).then_some(BASE));
791        m.carve_sphere_with_colfunc([4, 8, 8], 3, solid, |_, _, _| 0x8000_FF00);
792        // Air half stays air (never solid → never emitted).
793        assert_eq!(color_at(&m, 12, 8, 8), None);
794        // Carved centre gone.
795        assert_eq!(color_at(&m, 4, 8, 8), None);
796    }
797
798    #[test]
799    fn built_cube_round_trips_through_serialize_parse() {
800        let cube = Kv6::solid_cube(5, 0x80AB_CDEF);
801        let bytes = serialize(&cube);
802        let back = parse(&bytes).expect("parse built cube");
803        assert_eq!(back.xsiz, cube.xsiz);
804        assert_eq!(back.voxels.len(), cube.voxels.len());
805        assert_eq!(
806            serialize(&back),
807            bytes,
808            "serialize is stable across round-trip"
809        );
810    }
811
812    #[test]
813    fn from_fn_skips_air_and_keeps_z_order() {
814        // A single occupied column at (0,0,*): two voxels (z=0,1), both
815        // surface; ordered ascending z.
816        let kv6 = Kv6::from_fn(1, 1, 2, |_, _, _| Some(0x8000_FF00));
817        assert_eq!(kv6.voxels.len(), 2);
818        assert_eq!(kv6.voxels[0].z, 0);
819        assert_eq!(kv6.voxels[1].z, 1);
820        assert_eq!(kv6.xlen, vec![2]);
821        assert_eq!(kv6.ylen, vec![vec![2]]);
822    }
823
824    #[test]
825    fn parse_coco_header() {
826        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
827        assert_eq!(kv6.xsiz, 9);
828        assert_eq!(kv6.ysiz, 11);
829        assert_eq!(kv6.zsiz, 9);
830        // Pivots are stored as f32 in kv6 (vs 8.8 fixed in kvx).
831        assert!((kv6.xpiv - 2.0).abs() < f32::EPSILON);
832        assert!((kv6.ypiv - 3.0).abs() < f32::EPSILON);
833        assert!((kv6.zpiv - 9.0).abs() < f32::EPSILON);
834        assert_eq!(kv6.voxels.len(), 148);
835    }
836
837    #[test]
838    fn coco_voxel_counts_consistent() {
839        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
840        assert_eq!(kv6.xlen.len(), kv6.xsiz as usize);
841        assert_eq!(kv6.ylen.len(), kv6.xsiz as usize);
842        for row in &kv6.ylen {
843            assert_eq!(row.len(), kv6.ysiz as usize);
844        }
845        let xlen_sum: u64 = kv6.xlen.iter().map(|&n| u64::from(n)).sum();
846        let ylen_sum: u64 = kv6
847            .ylen
848            .iter()
849            .flat_map(|row| row.iter().map(|&n| u64::from(n)))
850            .sum();
851        let nv = kv6.voxels.len() as u64;
852        assert_eq!(xlen_sum, nv);
853        assert_eq!(ylen_sum, nv);
854    }
855
856    #[test]
857    fn from_fn_shaded_keeps_from_fn_geometry() {
858        // Shading must not change which voxels are emitted or the run
859        // tables — only vis/dir. (A hollow shell: surface of a 5³ cube.)
860        let fill = |x: u32, y: u32, z: u32| {
861            let on_face = x == 0 || x == 4 || y == 0 || y == 4 || z == 0 || z == 4;
862            on_face.then_some(0x80_44_55_66u32)
863        };
864        let flat = Kv6::from_fn(5, 5, 5, fill);
865        let shaded = Kv6::from_fn_shaded(5, 5, 5, fill);
866        assert_eq!(flat.voxels.len(), shaded.voxels.len());
867        assert_eq!(flat.xlen, shaded.xlen);
868        assert_eq!(flat.ylen, shaded.ylen);
869        for (f, s) in flat.voxels.iter().zip(&shaded.voxels) {
870            assert_eq!((f.col, f.z), (s.col, s.z));
871        }
872        // And shading actually varied dir (not all 0 like from_fn).
873        assert!(
874            shaded.voxels.iter().any(|v| v.dir != 0),
875            "from_fn_shaded left every dir flat"
876        );
877        assert!(flat.voxels.iter().all(|v| v.dir == 0 && v.vis == 63));
878    }
879
880    #[test]
881    fn from_fn_shaded_column_z_faces() {
882        // A 1×1×2 stack: lower voxel's +z face and upper's -z face are
883        // internal (the two touch); the four side faces + the outer z
884        // face are exposed. Validates the VIS_*_Z constants' internal
885        // consistency against the neighbour checks.
886        let kv = Kv6::from_fn_shaded(1, 1, 2, |_, _, _| Some(0x80_80_80_80));
887        assert_eq!(kv.voxels.len(), 2);
888        let (lower, upper) = (&kv.voxels[0], &kv.voxels[1]); // ascending z
889        assert_eq!(lower.z, 0);
890        assert_eq!(upper.z, 1);
891        assert_eq!(lower.vis & VIS_POS_Z, 0, "lower +z should be internal");
892        assert_eq!(lower.vis & VIS_NEG_Z, VIS_NEG_Z, "lower -z exposed");
893        assert_eq!(upper.vis & VIS_NEG_Z, 0, "upper -z should be internal");
894        assert_eq!(upper.vis & VIS_POS_Z, VIS_POS_Z, "upper +z exposed");
895        // All four side faces exposed on both.
896        let sides = VIS_NEG_X | VIS_POS_X | VIS_NEG_Y | VIS_POS_Y;
897        assert_eq!(lower.vis & sides, sides);
898        assert_eq!(upper.vis & sides, sides);
899    }
900
901    /// CALIBRATION: confirm every `vis` face bit matches voxlap's
902    /// authored convention, against `coco.kv6`. When two voxels are
903    /// stored adjacent along an axis, the face between them is internal,
904    /// so the corresponding bit must be clear in the authored `vis` —
905    /// interior-independent (the shared face is internal regardless of
906    /// any unstored solid), so it pins all six bits without needing
907    /// coco's full solid. (We don't assert the converse: a missing
908    /// stored neighbour may still be solid interior, leaving the bit
909    /// legitimately clear.)
910    #[test]
911    fn coco_vis_matches_authored_all_faces() {
912        use std::collections::HashMap;
913        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
914        let mut pos: HashMap<(u32, u32, u32), u8> = HashMap::new();
915        let mut vi = 0usize;
916        for x in 0..kv6.xsiz {
917            for y in 0..kv6.ysiz {
918                let len = kv6.ylen[x as usize][y as usize] as usize;
919                for _ in 0..len {
920                    pos.insert((x, y, u32::from(kv6.voxels[vi].z)), kv6.voxels[vi].vis);
921                    vi += 1;
922                }
923            }
924        }
925        let mut checked = 0u32;
926        for (&(x, y, z), &vis) in &pos {
927            let mut chk = |present: bool, bit: u8, face: &str| {
928                if present {
929                    assert_eq!(
930                        vis & bit,
931                        0,
932                        "coco ({x},{y},{z}): {face} internal but bit set"
933                    );
934                    checked += 1;
935                }
936            };
937            chk(pos.contains_key(&(x + 1, y, z)), VIS_POS_X, "+x");
938            chk(x > 0 && pos.contains_key(&(x - 1, y, z)), VIS_NEG_X, "-x");
939            chk(pos.contains_key(&(x, y + 1, z)), VIS_POS_Y, "+y");
940            chk(y > 0 && pos.contains_key(&(x, y - 1, z)), VIS_NEG_Y, "-y");
941            chk(pos.contains_key(&(x, y, z + 1)), VIS_POS_Z, "+z");
942            chk(z > 0 && pos.contains_key(&(x, y, z - 1)), VIS_NEG_Z, "-z");
943        }
944        assert!(
945            checked > 100,
946            "expected many adjacent faces in coco, got {checked}"
947        );
948    }
949
950    #[test]
951    fn recompute_surface_matches_from_fn_shaded() {
952        // recompute_surface on a flat-built model must reproduce exactly
953        // what from_fn_shaded would have emitted (same vis + dir).
954        let fill = |x: u32, y: u32, z: u32| {
955            let cx = x as f32 - 4.0;
956            let cy = y as f32 - 4.0;
957            let cz = z as f32 - 4.0;
958            (cx * cx + cy * cy + cz * cz <= 16.0).then_some(0x80_30_60_90u32)
959        };
960        let shaded = Kv6::from_fn_shaded(9, 9, 9, fill);
961        let mut edited = Kv6::from_fn(9, 9, 9, fill); // flat vis/dir
962        edited.recompute_surface(|x, y, z| {
963            x >= 0 && y >= 0 && z >= 0 && fill(x as u32, y as u32, z as u32).is_some()
964        });
965        assert_eq!(edited.voxels.len(), shaded.voxels.len());
966        for (e, s) in edited.voxels.iter().zip(&shaded.voxels) {
967            assert_eq!((e.vis, e.dir), (s.vis, s.dir), "voxel z={}", e.z);
968        }
969    }
970
971    #[test]
972    fn from_fn_shaded_slab_top_normal_points_up() {
973        use crate::equivec::univec;
974        // A solid slab filling z in [2,9]; the z=2 surface (smallest z =
975        // "up" in voxlap z-down) faces empty above, so its outward normal
976        // points toward -z. Check an interior-of-face voxel.
977        let kv = Kv6::from_fn_shaded(8, 8, 12, |_, _, z| {
978            (2..=9).contains(&z).then_some(0x80_aa_aa_aa)
979        });
980        let v = kv
981            .voxels
982            .iter()
983            .enumerate()
984            .find_map(|(i, v)| {
985                // recover (x,y) for voxel i
986                let mut acc = 0usize;
987                for x in 0..kv.xsiz as usize {
988                    for y in 0..kv.ysiz as usize {
989                        let len = kv.ylen[x][y] as usize;
990                        if i < acc + len {
991                            return (x == 4 && y == 4 && v.z == 2).then_some(*v);
992                        }
993                        acc += len;
994                    }
995                }
996                None
997            })
998            .expect("centre top-face voxel present");
999        let n = univec()[v.dir as usize];
1000        assert!(
1001            n[2] < -0.5,
1002            "top-face normal should point -z (up), got {n:?}"
1003        );
1004    }
1005
1006    #[test]
1007    fn coco_palette_present_and_matches_kvx() {
1008        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1009        let pal = kv6.palette.as_ref().expect("SPal trailer present");
1010        // First palette entry from the hex dump matches coco.kvx's first.
1011        assert_eq!((pal[0].r, pal[0].g, pal[0].b), (0x3f, 0x19, 0x19));
1012    }
1013
1014    #[test]
1015    fn coco_first_voxel_packed_colour() {
1016        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1017        // From the hex dump: 60 a4 fc 80 → little-endian u32 0x80fca460.
1018        // High bit 0x80000000 is the brightness flag the engine sets on
1019        // every coloured voxel.
1020        let v0 = kv6.voxels[0];
1021        assert_eq!(v0.col, 0x80fc_a460);
1022        assert_eq!(v0.col & 0x8000_0000, 0x8000_0000);
1023    }
1024
1025    #[test]
1026    fn coco_roundtrips_byte_equal() {
1027        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1028        let out = serialize(&kv6);
1029        assert_eq!(out.len(), COCO_KV6.len(), "length differs");
1030        assert_eq!(out.as_slice(), COCO_KV6, "byte content differs");
1031    }
1032
1033    #[test]
1034    fn parse_truncated_header_fails() {
1035        let r = parse(&[0u8; 16]);
1036        assert!(matches!(r, Err(ParseError::TooSmall { .. })));
1037    }
1038
1039    #[test]
1040    fn parse_bad_magic_fails() {
1041        let mut bad = COCO_KV6.to_vec();
1042        bad[0] = b'X';
1043        let r = parse(&bad);
1044        assert!(matches!(r, Err(ParseError::BadMagic { .. })));
1045    }
1046}