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)
170    }
171
172    /// Like [`Kv6::from_fn`], but fills **real** per-voxel surface
173    /// normals ([`Voxel::dir`]) and face visibility ([`Voxel::vis`])
174    /// instead of the flat `dir = 0`, `vis = 63`. The CPU sprite
175    /// rasteriser shades each voxel by `dir` (`kv6colmul[dir]`), so a
176    /// `from_fn`-built model shades flat while a `from_fn_shaded` one
177    /// gets proper directional gradient shading — the difference an
178    /// authored `.kv6` shows.
179    ///
180    /// `dir` is the nearest voxlap direction
181    /// ([`crate::equivec::nearest_dir`]) to the voxel's outward surface
182    /// normal, estimated as the occupancy gradient over the 3³
183    /// neighbourhood (pointing toward empty space). `vis` is the bitmask
184    /// of the six exposed faces.
185    #[must_use]
186    pub fn from_fn_shaded<F: Fn(u32, u32, u32) -> Option<u32>>(
187        xsiz: u32,
188        ysiz: u32,
189        zsiz: u32,
190        fill: F,
191    ) -> Kv6 {
192        Self::build_inner(xsiz, ysiz, zsiz, fill, true)
193    }
194
195    // Dimensions are bounded by realistic model sizes: column/x counts
196    // fit u16/u32, sizes fit f32 exactly, and the closure-local i64
197    // neighbour coords are range-checked before the u32 cast.
198    #[allow(
199        clippy::cast_possible_truncation,
200        clippy::cast_sign_loss,
201        clippy::cast_precision_loss
202    )]
203    fn build_inner<F: Fn(u32, u32, u32) -> Option<u32>>(
204        xsiz: u32,
205        ysiz: u32,
206        zsiz: u32,
207        fill: F,
208        shaded: bool,
209    ) -> Kv6 {
210        let occupied = |x: i64, y: i64, z: i64| -> bool {
211            x >= 0
212                && y >= 0
213                && z >= 0
214                && (x as u32) < xsiz
215                && (y as u32) < ysiz
216                && (z as u32) < zsiz
217                && fill(x as u32, y as u32, z as u32).is_some()
218        };
219
220        let mut voxels: Vec<Voxel> = Vec::new();
221        let mut xlen: Vec<u32> = Vec::with_capacity(xsiz as usize);
222        let mut ylen: Vec<Vec<u16>> = Vec::with_capacity(xsiz as usize);
223
224        for x in 0..xsiz {
225            let mut col_counts: Vec<u16> = Vec::with_capacity(ysiz as usize);
226            for y in 0..ysiz {
227                let before = voxels.len();
228                for z in 0..zsiz {
229                    let Some(col) = fill(x, y, z) else { continue };
230                    let (xi, yi, zi) = (i64::from(x), i64::from(y), i64::from(z));
231                    let exposed = !occupied(xi - 1, yi, zi)
232                        || !occupied(xi + 1, yi, zi)
233                        || !occupied(xi, yi - 1, zi)
234                        || !occupied(xi, yi + 1, zi)
235                        || !occupied(xi, yi, zi - 1)
236                        || !occupied(xi, yi, zi + 1);
237                    if exposed {
238                        let (vis, dir) = if shaded {
239                            compute_vis_dir(&occupied, xi, yi, zi)
240                        } else {
241                            (63, 0)
242                        };
243                        voxels.push(Voxel {
244                            col,
245                            z: z as u16,
246                            vis,
247                            dir,
248                        });
249                    }
250                }
251                col_counts.push((voxels.len() - before) as u16);
252            }
253            xlen.push(col_counts.iter().map(|&c| u32::from(c)).sum());
254            ylen.push(col_counts);
255        }
256
257        Kv6 {
258            xsiz,
259            ysiz,
260            zsiz,
261            xpiv: xsiz as f32 * 0.5,
262            ypiv: ysiz as f32 * 0.5,
263            zpiv: zsiz as f32 * 0.5,
264            voxels,
265            xlen,
266            ylen,
267            palette: None,
268        }
269    }
270
271    /// Recompute every stored voxel's [`Voxel::vis`] + [`Voxel::dir`]
272    /// from `occupied` (a predicate over the **full** solid in this
273    /// kv6's local coordinates; out-of-range / air ⇒ `false`). Use this
274    /// after editing a model's voxels to refresh its shading + face
275    /// visibility — the editor counterpart to building with
276    /// [`Kv6::from_fn_shaded`]. Geometry (positions, run tables) is left
277    /// untouched; only `vis`/`dir` change.
278    #[allow(clippy::cast_possible_wrap)]
279    pub fn recompute_surface(&mut self, occupied: impl Fn(i32, i32, i32) -> bool) {
280        let xsiz = self.xsiz;
281        let ysiz = self.ysiz;
282        let zsiz = self.zsiz;
283        let occ = |x: i64, y: i64, z: i64| -> bool {
284            x >= 0
285                && y >= 0
286                && z >= 0
287                && (x as u32) < xsiz
288                && (y as u32) < ysiz
289                && (z as u32) < zsiz
290                && occupied(x as i32, y as i32, z as i32)
291        };
292        let mut vi = 0usize;
293        for x in 0..xsiz as usize {
294            for y in 0..ysiz as usize {
295                let len = self.ylen[x][y] as usize;
296                for _ in 0..len {
297                    let z = i64::from(self.voxels[vi].z);
298                    let (vis, dir) = compute_vis_dir(&occ, x as i64, y as i64, z);
299                    self.voxels[vi].vis = vis;
300                    self.voxels[vi].dir = dir;
301                    vi += 1;
302                }
303            }
304        }
305    }
306
307    /// Map of every stored surface voxel's local `(x, y, z)` to its
308    /// colour, decoded from the run tables. Used by
309    /// [`Kv6::carve_sphere_with_colfunc`] to keep surviving surface
310    /// voxels at their authored colour while the cut repaints only the
311    /// freshly-exposed ones.
312    fn surface_color_map(&self) -> HashMap<(u32, u32, u32), u32> {
313        let mut map = HashMap::with_capacity(self.voxels.len());
314        let mut vi = 0usize;
315        for x in 0..self.xsiz as usize {
316            for y in 0..self.ysiz as usize {
317                let len = self.ylen[x][y] as usize;
318                for _ in 0..len {
319                    let v = self.voxels[vi];
320                    #[allow(clippy::cast_lossless)]
321                    map.insert((x as u32, y as u32, u32::from(v.z)), v.col);
322                    vi += 1;
323                }
324            }
325        }
326        map
327    }
328
329    /// Carve a sphere out of this model and control the colour of the
330    /// interior the cut exposes — the sprite counterpart of
331    /// [`roxlap_scene::Grid::set_sphere_with_colfunc`] /
332    /// [`crate::edit::set_sphere_with_colfunc`].
333    ///
334    /// **Why a `solid` predicate is required.** A `.kv6` stores only
335    /// its *surface* hull — fully-enclosed interior voxels are not
336    /// recorded (see [`Kv6::from_fn`]). A carve must therefore know the
337    /// model's *full* occupancy to expose meaningful interior walls,
338    /// which the data alone can't provide. The caller supplies it via
339    /// `solid(x, y, z) -> bool` in kv6-local voxel coords (e.g. the
340    /// same predicate used to build the model with
341    /// [`Kv6::from_fn_shaded`]). `solid` must report `true` for at
342    /// least every stored surface voxel.
343    ///
344    /// Behaviour:
345    /// - Voxels inside the sphere (`dx²+dy²+dz² <= r²`, matching
346    ///   [`crate::edit::set_sphere`]) become air.
347    /// - Voxels the cut newly exposes get their colour from
348    ///   `colfunc(x, y, z)` (kv6-local coords, voxlap-packed
349    ///   `0x80RRGGBB`). Pass `|_, _, _| col` for a flat crater colour.
350    /// - Voxels that were already on the surface keep their stored
351    ///   colour.
352    ///
353    /// `centre` / `radius` are in kv6-local voxel units. Dimensions,
354    /// pivot, and palette are preserved; the model is re-extracted with
355    /// real per-voxel normals + face visibility (as
356    /// [`Kv6::from_fn_shaded`]).
357    ///
358    /// [`roxlap_scene::Grid::set_sphere_with_colfunc`]: https://docs.rs/roxlap-scene
359    pub fn carve_sphere_with_colfunc<S, C>(
360        &mut self,
361        centre: [i32; 3],
362        radius: u32,
363        solid: S,
364        colfunc: C,
365    ) where
366        S: Fn(i32, i32, i32) -> bool,
367        C: Fn(i32, i32, i32) -> u32,
368    {
369        let orig = self.surface_color_map();
370        // Preserve identity fields the rebuild would otherwise reset.
371        let (xpiv, ypiv, zpiv) = (self.xpiv, self.ypiv, self.zpiv);
372        let palette = self.palette;
373
374        #[allow(clippy::cast_possible_wrap)]
375        let r = radius as i32;
376        let r_sq = r * r;
377        let (cx, cy, cz) = (centre[0], centre[1], centre[2]);
378        let inside = |x: i32, y: i32, z: i32| {
379            let (dx, dy, dz) = (x - cx, y - cy, z - cz);
380            dx * dx + dy * dy + dz * dz <= r_sq
381        };
382
383        let rebuilt = Kv6::from_fn_shaded(self.xsiz, self.ysiz, self.zsiz, |x, y, z| {
384            #[allow(clippy::cast_possible_wrap)]
385            let (xi, yi, zi) = (x as i32, y as i32, z as i32);
386            if inside(xi, yi, zi) || !solid(xi, yi, zi) {
387                return None;
388            }
389            // Surviving surface voxels keep their authored colour;
390            // anything else solid here is freshly exposed → colfunc.
391            Some(
392                orig.get(&(x, y, z))
393                    .copied()
394                    .unwrap_or_else(|| colfunc(xi, yi, zi)),
395            )
396        });
397
398        self.voxels = rebuilt.voxels;
399        self.xlen = rebuilt.xlen;
400        self.ylen = rebuilt.ylen;
401        self.xpiv = xpiv;
402        self.ypiv = ypiv;
403        self.zpiv = zpiv;
404        self.palette = palette;
405    }
406
407    /// A solid axis-aligned box of a single colour (voxlap-packed
408    /// `0x80RRGGBB`). Convenience over [`Kv6::from_fn`].
409    #[must_use]
410    pub fn solid_box(xsiz: u32, ysiz: u32, zsiz: u32, col: u32) -> Kv6 {
411        Kv6::from_fn(xsiz, ysiz, zsiz, |_, _, _| Some(col))
412    }
413
414    /// A solid `n³` cube of a single colour.
415    #[must_use]
416    pub fn solid_cube(n: u32, col: u32) -> Kv6 {
417        Kv6::solid_box(n, n, n, col)
418    }
419}
420
421/// Errors returned by [`parse`].
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub enum ParseError {
424    /// File too small to contain even the 32-byte header.
425    TooSmall { got: usize },
426    /// First 4 bytes are not the `"Kvxl"` magic.
427    BadMagic { got: [u8; 4] },
428    /// A read of `need` bytes at offset `at` would run past the end of
429    /// the buffer.
430    Truncated { at: usize, need: usize },
431}
432
433impl fmt::Display for ParseError {
434    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435        match *self {
436            Self::TooSmall { got } => write!(
437                f,
438                "kv6 file too small ({got} bytes; need at least 32 byte header)"
439            ),
440            Self::BadMagic { got } => write!(
441                f,
442                "kv6 bad magic: got [{:#04x},{:#04x},{:#04x},{:#04x}], expected b\"Kvxl\"",
443                got[0], got[1], got[2], got[3]
444            ),
445            Self::Truncated { at, need } => {
446                write!(f, "kv6 truncated: need {need} bytes at offset {at}")
447            }
448        }
449    }
450}
451
452impl std::error::Error for ParseError {}
453
454impl From<OutOfBounds> for ParseError {
455    fn from(e: OutOfBounds) -> Self {
456        Self::Truncated {
457            at: e.at,
458            need: e.need,
459        }
460    }
461}
462
463const HEADER_LEN: usize = 32;
464const MAGIC: &[u8; 4] = b"Kvxl";
465const PALETTE_MAGIC: &[u8; 4] = b"SPal";
466const PALETTE_LEN: usize = 768;
467
468/// Parse a `.kv6` file's bytes into a [`Kv6`].
469///
470/// # Errors
471///
472/// Returns [`ParseError`] if `bytes` is shorter than the 32-byte
473/// header, if the `"Kvxl"` magic does not match, or if a sequential
474/// read for any of the voxel / xlen / ylen / palette regions runs past
475/// EOF.
476///
477/// # Examples
478///
479/// Round-trip a synthetic empty kv6 through [`serialize`] + [`parse`]:
480///
481/// ```
482/// use roxlap_formats::kv6::{self, Kv6};
483///
484/// let original = Kv6 {
485///     xsiz: 1, ysiz: 1, zsiz: 1,
486///     xpiv: 0.5, ypiv: 0.5, zpiv: 0.5,
487///     voxels: vec![],
488///     xlen: vec![0],
489///     ylen: vec![vec![0]],
490///     palette: None,
491/// };
492/// let bytes = kv6::serialize(&original);
493/// let parsed = kv6::parse(&bytes).unwrap();
494/// assert_eq!(parsed.xsiz, original.xsiz);
495/// assert_eq!(parsed.voxels.len(), 0);
496/// ```
497pub fn parse(bytes: &[u8]) -> Result<Kv6, ParseError> {
498    if bytes.len() < HEADER_LEN {
499        return Err(ParseError::TooSmall { got: bytes.len() });
500    }
501
502    let mut cur = Cursor::new(bytes);
503    let magic = cur.read_bytes(4)?;
504    if magic != MAGIC {
505        return Err(ParseError::BadMagic {
506            got: [magic[0], magic[1], magic[2], magic[3]],
507        });
508    }
509    let xsiz = cur.read_u32()?;
510    let ysiz = cur.read_u32()?;
511    let zsiz = cur.read_u32()?;
512    let xpiv = cur.read_f32()?;
513    let ypiv = cur.read_f32()?;
514    let zpiv = cur.read_f32()?;
515    let numvoxs = cur.read_u32()?;
516
517    let mut voxels = Vec::with_capacity(numvoxs as usize);
518    for _ in 0..numvoxs {
519        let col = cur.read_u32()?;
520        let z = cur.read_u16()?;
521        let vis = cur.read_u8()?;
522        let dir = cur.read_u8()?;
523        voxels.push(Voxel { col, z, vis, dir });
524    }
525
526    let mut xlen = Vec::with_capacity(xsiz as usize);
527    for _ in 0..xsiz {
528        xlen.push(cur.read_u32()?);
529    }
530
531    let mut ylen = Vec::with_capacity(xsiz as usize);
532    for _ in 0..xsiz {
533        let mut row = Vec::with_capacity(ysiz as usize);
534        for _ in 0..ysiz {
535            row.push(cur.read_u16()?);
536        }
537        ylen.push(row);
538    }
539
540    // Optional "SPal" + 768-byte palette trailer.
541    let palette =
542        if cur.remaining() >= 4 + PALETTE_LEN && cur.peek(4) == Some(PALETTE_MAGIC.as_slice()) {
543            cur.read_bytes(4)?;
544            let mut pal = [Rgb6::default(); 256];
545            for entry in &mut pal {
546                entry.r = cur.read_u8()?;
547                entry.g = cur.read_u8()?;
548                entry.b = cur.read_u8()?;
549            }
550            Some(pal)
551        } else {
552            None
553        };
554
555    Ok(Kv6 {
556        xsiz,
557        ysiz,
558        zsiz,
559        xpiv,
560        ypiv,
561        zpiv,
562        voxels,
563        xlen,
564        ylen,
565        palette,
566    })
567}
568
569/// Serialise a [`Kv6`] back to bytes. The output round-trips byte-
570/// equally with the input that produced this `Kv6` via [`parse`],
571/// including the optional `"SPal"` palette trailer.
572///
573/// # Panics
574///
575/// Panics if `kv6.voxels.len()` does not fit in a `u32` (the on-disk
576/// `numvoxs` field is a `u32`). `Kv6` values produced by [`parse`]
577/// always satisfy this.
578#[must_use]
579pub fn serialize(kv6: &Kv6) -> Vec<u8> {
580    let pal_bytes = if kv6.palette.is_some() {
581        4 + PALETTE_LEN
582    } else {
583        0
584    };
585    let body_bytes = kv6.voxels.len() * 8
586        + kv6.xlen.len() * 4
587        + kv6.ylen.iter().map(|row| row.len() * 2).sum::<usize>();
588    let mut out = Vec::with_capacity(HEADER_LEN + body_bytes + pal_bytes);
589
590    out.extend_from_slice(MAGIC);
591    out.extend_from_slice(&kv6.xsiz.to_le_bytes());
592    out.extend_from_slice(&kv6.ysiz.to_le_bytes());
593    out.extend_from_slice(&kv6.zsiz.to_le_bytes());
594    out.extend_from_slice(&kv6.xpiv.to_le_bytes());
595    out.extend_from_slice(&kv6.ypiv.to_le_bytes());
596    out.extend_from_slice(&kv6.zpiv.to_le_bytes());
597    let numvoxs =
598        u32::try_from(kv6.voxels.len()).expect("kv6 numvoxs must fit in u32 (file format limit)");
599    out.extend_from_slice(&numvoxs.to_le_bytes());
600
601    for v in &kv6.voxels {
602        out.extend_from_slice(&v.col.to_le_bytes());
603        out.extend_from_slice(&v.z.to_le_bytes());
604        out.push(v.vis);
605        out.push(v.dir);
606    }
607    for v in &kv6.xlen {
608        out.extend_from_slice(&v.to_le_bytes());
609    }
610    for row in &kv6.ylen {
611        for v in row {
612            out.extend_from_slice(&v.to_le_bytes());
613        }
614    }
615    if let Some(pal) = &kv6.palette {
616        out.extend_from_slice(PALETTE_MAGIC);
617        for e in pal {
618            out.push(e.r);
619            out.push(e.g);
620            out.push(e.b);
621        }
622    }
623
624    out
625}
626
627// --- tests --------------------------------------------------------------
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    /// `assets/coco.kv6`, produced from `coco.kvx` via SLAB6.
634    const COCO_KV6: &[u8] = include_bytes!("../../../assets/coco.kv6");
635
636    #[test]
637    fn solid_cube_builder_is_surface_only_and_consistent() {
638        let cube = Kv6::solid_cube(4, 0x8012_3456);
639        assert_eq!((cube.xsiz, cube.ysiz, cube.zsiz), (4, 4, 4));
640        // Pivot at the geometric centre.
641        assert!((cube.xpiv - 2.0).abs() < f32::EPSILON);
642
643        // Surface-only: a solid 4³ has 64 voxels, minus the 2³ interior
644        // shell core (all six neighbours occupied) = 56 emitted.
645        assert_eq!(cube.voxels.len(), 64 - 8);
646        assert!(cube
647            .voxels
648            .iter()
649            .all(|v| v.vis == 63 && v.col == 0x8012_3456));
650
651        // Run tables match the format contract.
652        assert_eq!(cube.xlen.len(), 4);
653        assert_eq!(cube.ylen.len(), 4);
654        assert!(cube.ylen.iter().all(|row| row.len() == 4));
655        let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
656        let ylen_sum: usize = cube
657            .ylen
658            .iter()
659            .flat_map(|r| r.iter())
660            .map(|&n| n as usize)
661            .sum();
662        assert_eq!(xlen_sum, cube.voxels.len());
663        assert_eq!(ylen_sum, cube.voxels.len());
664    }
665
666    /// Decode the colour stored at local `(tx, ty, tz)`, or `None` if
667    /// no surface voxel sits there.
668    fn color_at(kv6: &Kv6, tx: u32, ty: u32, tz: u32) -> Option<u32> {
669        let mut vi = 0usize;
670        for x in 0..kv6.xsiz {
671            for y in 0..kv6.ysiz {
672                let len = kv6.ylen[x as usize][y as usize] as usize;
673                for _ in 0..len {
674                    let v = kv6.voxels[vi];
675                    if x == tx && y == ty && u32::from(v.z) == tz {
676                        return Some(v.col);
677                    }
678                    vi += 1;
679                }
680            }
681        }
682        None
683    }
684
685    #[test]
686    fn carve_sphere_exposes_interior_with_colfunc() {
687        const BASE: u32 = 0x8011_2233;
688        // A full solid 16³ cube — its occupancy predicate is "all".
689        let mut cube = Kv6::from_fn_shaded(16, 16, 16, |_, _, _| Some(BASE));
690        // Custom pivot to verify carve preserves it.
691        cube.xpiv = 1.0;
692        cube.ypiv = 2.0;
693        cube.zpiv = 3.0;
694
695        // colfunc encodes kv6-local coords into the low 24 bits so we
696        // can assert the closure sees local (not some shifted) coords.
697        let encode = |x: i32, y: i32, z: i32| ((x << 16) | (y << 8) | z) as u32;
698        cube.carve_sphere_with_colfunc([8, 8, 8], 4, |_, _, _| true, encode);
699
700        // Sphere centre removed.
701        assert_eq!(color_at(&cube, 8, 8, 8), None);
702        // (8,8,3) sits just below the carved range (its +z neighbour
703        // (8,8,4) is carved): solid, freshly exposed → colfunc colour
704        // at its LOCAL coords.
705        assert_eq!(color_at(&cube, 8, 8, 3), Some(encode(8, 8, 3)));
706        // An original face voxel far from the cut keeps its colour.
707        assert_eq!(color_at(&cube, 0, 8, 8), Some(BASE));
708
709        // Pivot preserved across the rebuild.
710        assert!((cube.xpiv - 1.0).abs() < f32::EPSILON);
711        assert!((cube.ypiv - 2.0).abs() < f32::EPSILON);
712        assert!((cube.zpiv - 3.0).abs() < f32::EPSILON);
713
714        // Run tables stay consistent with the voxel list.
715        let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
716        assert_eq!(xlen_sum, cube.voxels.len());
717    }
718
719    #[test]
720    fn carve_sphere_respects_caller_solid_predicate() {
721        const BASE: u32 = 0x80AA_BBCC;
722        // Build from a half-solid predicate (only x < 8 solid), and
723        // pass the SAME predicate as `solid`. A carve centred in the
724        // solid half must not resurrect the air half.
725        let solid = |x: i32, _y: i32, _z: i32| (0..8).contains(&x);
726        #[allow(clippy::cast_sign_loss)]
727        let mut m =
728            Kv6::from_fn_shaded(16, 16, 16, |x, _, _| solid(x as i32, 0, 0).then_some(BASE));
729        m.carve_sphere_with_colfunc([4, 8, 8], 3, solid, |_, _, _| 0x8000_FF00);
730        // Air half stays air (never solid → never emitted).
731        assert_eq!(color_at(&m, 12, 8, 8), None);
732        // Carved centre gone.
733        assert_eq!(color_at(&m, 4, 8, 8), None);
734    }
735
736    #[test]
737    fn built_cube_round_trips_through_serialize_parse() {
738        let cube = Kv6::solid_cube(5, 0x80AB_CDEF);
739        let bytes = serialize(&cube);
740        let back = parse(&bytes).expect("parse built cube");
741        assert_eq!(back.xsiz, cube.xsiz);
742        assert_eq!(back.voxels.len(), cube.voxels.len());
743        assert_eq!(
744            serialize(&back),
745            bytes,
746            "serialize is stable across round-trip"
747        );
748    }
749
750    #[test]
751    fn from_fn_skips_air_and_keeps_z_order() {
752        // A single occupied column at (0,0,*): two voxels (z=0,1), both
753        // surface; ordered ascending z.
754        let kv6 = Kv6::from_fn(1, 1, 2, |_, _, _| Some(0x8000_FF00));
755        assert_eq!(kv6.voxels.len(), 2);
756        assert_eq!(kv6.voxels[0].z, 0);
757        assert_eq!(kv6.voxels[1].z, 1);
758        assert_eq!(kv6.xlen, vec![2]);
759        assert_eq!(kv6.ylen, vec![vec![2]]);
760    }
761
762    #[test]
763    fn parse_coco_header() {
764        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
765        assert_eq!(kv6.xsiz, 9);
766        assert_eq!(kv6.ysiz, 11);
767        assert_eq!(kv6.zsiz, 9);
768        // Pivots are stored as f32 in kv6 (vs 8.8 fixed in kvx).
769        assert!((kv6.xpiv - 2.0).abs() < f32::EPSILON);
770        assert!((kv6.ypiv - 3.0).abs() < f32::EPSILON);
771        assert!((kv6.zpiv - 9.0).abs() < f32::EPSILON);
772        assert_eq!(kv6.voxels.len(), 148);
773    }
774
775    #[test]
776    fn coco_voxel_counts_consistent() {
777        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
778        assert_eq!(kv6.xlen.len(), kv6.xsiz as usize);
779        assert_eq!(kv6.ylen.len(), kv6.xsiz as usize);
780        for row in &kv6.ylen {
781            assert_eq!(row.len(), kv6.ysiz as usize);
782        }
783        let xlen_sum: u64 = kv6.xlen.iter().map(|&n| u64::from(n)).sum();
784        let ylen_sum: u64 = kv6
785            .ylen
786            .iter()
787            .flat_map(|row| row.iter().map(|&n| u64::from(n)))
788            .sum();
789        let nv = kv6.voxels.len() as u64;
790        assert_eq!(xlen_sum, nv);
791        assert_eq!(ylen_sum, nv);
792    }
793
794    #[test]
795    fn from_fn_shaded_keeps_from_fn_geometry() {
796        // Shading must not change which voxels are emitted or the run
797        // tables — only vis/dir. (A hollow shell: surface of a 5³ cube.)
798        let fill = |x: u32, y: u32, z: u32| {
799            let on_face = x == 0 || x == 4 || y == 0 || y == 4 || z == 0 || z == 4;
800            on_face.then_some(0x80_44_55_66u32)
801        };
802        let flat = Kv6::from_fn(5, 5, 5, fill);
803        let shaded = Kv6::from_fn_shaded(5, 5, 5, fill);
804        assert_eq!(flat.voxels.len(), shaded.voxels.len());
805        assert_eq!(flat.xlen, shaded.xlen);
806        assert_eq!(flat.ylen, shaded.ylen);
807        for (f, s) in flat.voxels.iter().zip(&shaded.voxels) {
808            assert_eq!((f.col, f.z), (s.col, s.z));
809        }
810        // And shading actually varied dir (not all 0 like from_fn).
811        assert!(
812            shaded.voxels.iter().any(|v| v.dir != 0),
813            "from_fn_shaded left every dir flat"
814        );
815        assert!(flat.voxels.iter().all(|v| v.dir == 0 && v.vis == 63));
816    }
817
818    #[test]
819    fn from_fn_shaded_column_z_faces() {
820        // A 1×1×2 stack: lower voxel's +z face and upper's -z face are
821        // internal (the two touch); the four side faces + the outer z
822        // face are exposed. Validates the VIS_*_Z constants' internal
823        // consistency against the neighbour checks.
824        let kv = Kv6::from_fn_shaded(1, 1, 2, |_, _, _| Some(0x80_80_80_80));
825        assert_eq!(kv.voxels.len(), 2);
826        let (lower, upper) = (&kv.voxels[0], &kv.voxels[1]); // ascending z
827        assert_eq!(lower.z, 0);
828        assert_eq!(upper.z, 1);
829        assert_eq!(lower.vis & VIS_POS_Z, 0, "lower +z should be internal");
830        assert_eq!(lower.vis & VIS_NEG_Z, VIS_NEG_Z, "lower -z exposed");
831        assert_eq!(upper.vis & VIS_NEG_Z, 0, "upper -z should be internal");
832        assert_eq!(upper.vis & VIS_POS_Z, VIS_POS_Z, "upper +z exposed");
833        // All four side faces exposed on both.
834        let sides = VIS_NEG_X | VIS_POS_X | VIS_NEG_Y | VIS_POS_Y;
835        assert_eq!(lower.vis & sides, sides);
836        assert_eq!(upper.vis & sides, sides);
837    }
838
839    /// CALIBRATION: confirm every `vis` face bit matches voxlap's
840    /// authored convention, against `coco.kv6`. When two voxels are
841    /// stored adjacent along an axis, the face between them is internal,
842    /// so the corresponding bit must be clear in the authored `vis` —
843    /// interior-independent (the shared face is internal regardless of
844    /// any unstored solid), so it pins all six bits without needing
845    /// coco's full solid. (We don't assert the converse: a missing
846    /// stored neighbour may still be solid interior, leaving the bit
847    /// legitimately clear.)
848    #[test]
849    fn coco_vis_matches_authored_all_faces() {
850        use std::collections::HashMap;
851        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
852        let mut pos: HashMap<(u32, u32, u32), u8> = HashMap::new();
853        let mut vi = 0usize;
854        for x in 0..kv6.xsiz {
855            for y in 0..kv6.ysiz {
856                let len = kv6.ylen[x as usize][y as usize] as usize;
857                for _ in 0..len {
858                    pos.insert((x, y, u32::from(kv6.voxels[vi].z)), kv6.voxels[vi].vis);
859                    vi += 1;
860                }
861            }
862        }
863        let mut checked = 0u32;
864        for (&(x, y, z), &vis) in &pos {
865            let mut chk = |present: bool, bit: u8, face: &str| {
866                if present {
867                    assert_eq!(
868                        vis & bit,
869                        0,
870                        "coco ({x},{y},{z}): {face} internal but bit set"
871                    );
872                    checked += 1;
873                }
874            };
875            chk(pos.contains_key(&(x + 1, y, z)), VIS_POS_X, "+x");
876            chk(x > 0 && pos.contains_key(&(x - 1, y, z)), VIS_NEG_X, "-x");
877            chk(pos.contains_key(&(x, y + 1, z)), VIS_POS_Y, "+y");
878            chk(y > 0 && pos.contains_key(&(x, y - 1, z)), VIS_NEG_Y, "-y");
879            chk(pos.contains_key(&(x, y, z + 1)), VIS_POS_Z, "+z");
880            chk(z > 0 && pos.contains_key(&(x, y, z - 1)), VIS_NEG_Z, "-z");
881        }
882        assert!(
883            checked > 100,
884            "expected many adjacent faces in coco, got {checked}"
885        );
886    }
887
888    #[test]
889    fn recompute_surface_matches_from_fn_shaded() {
890        // recompute_surface on a flat-built model must reproduce exactly
891        // what from_fn_shaded would have emitted (same vis + dir).
892        let fill = |x: u32, y: u32, z: u32| {
893            let cx = x as f32 - 4.0;
894            let cy = y as f32 - 4.0;
895            let cz = z as f32 - 4.0;
896            (cx * cx + cy * cy + cz * cz <= 16.0).then_some(0x80_30_60_90u32)
897        };
898        let shaded = Kv6::from_fn_shaded(9, 9, 9, fill);
899        let mut edited = Kv6::from_fn(9, 9, 9, fill); // flat vis/dir
900        edited.recompute_surface(|x, y, z| {
901            x >= 0 && y >= 0 && z >= 0 && fill(x as u32, y as u32, z as u32).is_some()
902        });
903        assert_eq!(edited.voxels.len(), shaded.voxels.len());
904        for (e, s) in edited.voxels.iter().zip(&shaded.voxels) {
905            assert_eq!((e.vis, e.dir), (s.vis, s.dir), "voxel z={}", e.z);
906        }
907    }
908
909    #[test]
910    fn from_fn_shaded_slab_top_normal_points_up() {
911        use crate::equivec::univec;
912        // A solid slab filling z in [2,9]; the z=2 surface (smallest z =
913        // "up" in voxlap z-down) faces empty above, so its outward normal
914        // points toward -z. Check an interior-of-face voxel.
915        let kv = Kv6::from_fn_shaded(8, 8, 12, |_, _, z| {
916            (2..=9).contains(&z).then_some(0x80_aa_aa_aa)
917        });
918        let v = kv
919            .voxels
920            .iter()
921            .enumerate()
922            .find_map(|(i, v)| {
923                // recover (x,y) for voxel i
924                let mut acc = 0usize;
925                for x in 0..kv.xsiz as usize {
926                    for y in 0..kv.ysiz as usize {
927                        let len = kv.ylen[x][y] as usize;
928                        if i < acc + len {
929                            return (x == 4 && y == 4 && v.z == 2).then_some(*v);
930                        }
931                        acc += len;
932                    }
933                }
934                None
935            })
936            .expect("centre top-face voxel present");
937        let n = univec()[v.dir as usize];
938        assert!(
939            n[2] < -0.5,
940            "top-face normal should point -z (up), got {n:?}"
941        );
942    }
943
944    #[test]
945    fn coco_palette_present_and_matches_kvx() {
946        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
947        let pal = kv6.palette.as_ref().expect("SPal trailer present");
948        // First palette entry from the hex dump matches coco.kvx's first.
949        assert_eq!((pal[0].r, pal[0].g, pal[0].b), (0x3f, 0x19, 0x19));
950    }
951
952    #[test]
953    fn coco_first_voxel_packed_colour() {
954        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
955        // From the hex dump: 60 a4 fc 80 → little-endian u32 0x80fca460.
956        // High bit 0x80000000 is the brightness flag the engine sets on
957        // every coloured voxel.
958        let v0 = kv6.voxels[0];
959        assert_eq!(v0.col, 0x80fc_a460);
960        assert_eq!(v0.col & 0x8000_0000, 0x8000_0000);
961    }
962
963    #[test]
964    fn coco_roundtrips_byte_equal() {
965        let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
966        let out = serialize(&kv6);
967        assert_eq!(out.len(), COCO_KV6.len(), "length differs");
968        assert_eq!(out.as_slice(), COCO_KV6, "byte content differs");
969    }
970
971    #[test]
972    fn parse_truncated_header_fails() {
973        let r = parse(&[0u8; 16]);
974        assert!(matches!(r, Err(ParseError::TooSmall { .. })));
975    }
976
977    #[test]
978    fn parse_bad_magic_fails() {
979        let mut bad = COCO_KV6.to_vec();
980        bad[0] = b'X';
981        let r = parse(&bad);
982        assert!(matches!(r, Err(ParseError::BadMagic { .. })));
983    }
984}