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