1use core::fmt;
34use std::collections::HashMap;
35
36use crate::bytes::{Cursor, OutOfBounds};
37use crate::color::VoxColor;
38use crate::Rgb6;
39
40const VIS_NEG_X: u8 = 0x01;
48const VIS_POS_X: u8 = 0x02;
49const VIS_NEG_Y: u8 = 0x04;
50const VIS_POS_Y: u8 = 0x08;
51const VIS_POS_Z: u8 = 0x20;
55const VIS_NEG_Z: u8 = 0x10;
56
57pub(crate) fn compute_vis_dir(
64 occ: &impl Fn(i64, i64, i64) -> bool,
65 x: i64,
66 y: i64,
67 z: i64,
68) -> (u8, u8) {
69 let mut vis = 0u8;
70 if !occ(x - 1, y, z) {
71 vis |= VIS_NEG_X;
72 }
73 if !occ(x + 1, y, z) {
74 vis |= VIS_POS_X;
75 }
76 if !occ(x, y - 1, z) {
77 vis |= VIS_NEG_Y;
78 }
79 if !occ(x, y + 1, z) {
80 vis |= VIS_POS_Y;
81 }
82 if !occ(x, y, z - 1) {
83 vis |= VIS_NEG_Z;
84 }
85 if !occ(x, y, z + 1) {
86 vis |= VIS_POS_Z;
87 }
88
89 let mut n = [0.0f32; 3];
90 for dz in -1..=1 {
91 for dy in -1..=1 {
92 for dx in -1..=1 {
93 if (dx | dy | dz) != 0 && !occ(x + dx, y + dy, z + dz) {
94 n[0] += dx as f32;
95 n[1] += dy as f32;
96 n[2] += dz as f32;
97 }
98 }
99 }
100 }
101 (vis, crate::equivec::nearest_dir(n))
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct Voxel {
107 pub col: u32,
110 pub z: u16,
112 pub vis: u8,
115 pub dir: u8,
117}
118
119#[derive(Debug, Clone)]
122pub struct Kv6 {
123 pub xsiz: u32,
125 pub ysiz: u32,
127 pub zsiz: u32,
130 pub xpiv: f32,
134 pub ypiv: f32,
136 pub zpiv: f32,
138 pub voxels: Vec<Voxel>,
140 pub xlen: Vec<u32>,
143 pub ylen: Vec<Vec<u16>>,
146 pub palette: Option<[Rgb6; 256]>,
148}
149
150impl Kv6 {
151 #[must_use]
173 pub fn from_fn<F: Fn(u32, u32, u32) -> Option<VoxColor>>(
174 xsiz: u32,
175 ysiz: u32,
176 zsiz: u32,
177 fill: F,
178 ) -> Kv6 {
179 Self::build_inner(xsiz, ysiz, zsiz, fill, false, |_| false)
180 }
181
182 #[must_use]
198 pub fn from_fn_keep_interior<F, G>(
199 xsiz: u32,
200 ysiz: u32,
201 zsiz: u32,
202 fill: F,
203 keep_interior: G,
204 ) -> Kv6
205 where
206 F: Fn(u32, u32, u32) -> Option<VoxColor>,
207 G: Fn(VoxColor) -> bool,
208 {
209 Self::build_inner(xsiz, ysiz, zsiz, fill, false, keep_interior)
210 }
211
212 #[must_use]
226 pub fn from_fn_shaded<F: Fn(u32, u32, u32) -> Option<VoxColor>>(
227 xsiz: u32,
228 ysiz: u32,
229 zsiz: u32,
230 fill: F,
231 ) -> Kv6 {
232 Self::build_inner(xsiz, ysiz, zsiz, fill, true, |_| false)
233 }
234
235 #[allow(
239 clippy::cast_possible_truncation,
240 clippy::cast_sign_loss,
241 clippy::cast_precision_loss
242 )]
243 fn build_inner<F, G>(
244 xsiz: u32,
245 ysiz: u32,
246 zsiz: u32,
247 fill: F,
248 shaded: bool,
249 keep_interior: G,
250 ) -> Kv6
251 where
252 F: Fn(u32, u32, u32) -> Option<VoxColor>,
253 G: Fn(VoxColor) -> bool,
254 {
255 let occupied = |x: i64, y: i64, z: i64| -> bool {
256 x >= 0
257 && y >= 0
258 && z >= 0
259 && (x as u32) < xsiz
260 && (y as u32) < ysiz
261 && (z as u32) < zsiz
262 && fill(x as u32, y as u32, z as u32).is_some()
263 };
264
265 let mut voxels: Vec<Voxel> = Vec::new();
266 let mut xlen: Vec<u32> = Vec::with_capacity(xsiz as usize);
267 let mut ylen: Vec<Vec<u16>> = Vec::with_capacity(xsiz as usize);
268
269 for x in 0..xsiz {
270 let mut col_counts: Vec<u16> = Vec::with_capacity(ysiz as usize);
271 for y in 0..ysiz {
272 let before = voxels.len();
273 for z in 0..zsiz {
274 let Some(col) = fill(x, y, z) else { continue };
275 let (xi, yi, zi) = (i64::from(x), i64::from(y), i64::from(z));
276 let exposed = !occupied(xi - 1, yi, zi)
277 || !occupied(xi + 1, yi, zi)
278 || !occupied(xi, yi - 1, zi)
279 || !occupied(xi, yi + 1, zi)
280 || !occupied(xi, yi, zi - 1)
281 || !occupied(xi, yi, zi + 1);
282 if exposed || keep_interior(col) {
283 let (vis, dir) = if shaded {
284 compute_vis_dir(&occupied, xi, yi, zi)
285 } else {
286 (63, 0)
287 };
288 voxels.push(Voxel {
289 col: col.0,
290 z: z as u16,
291 vis,
292 dir,
293 });
294 }
295 }
296 col_counts.push((voxels.len() - before) as u16);
297 }
298 xlen.push(col_counts.iter().map(|&c| u32::from(c)).sum());
299 ylen.push(col_counts);
300 }
301
302 Kv6 {
303 xsiz,
304 ysiz,
305 zsiz,
306 xpiv: xsiz as f32 * 0.5,
307 ypiv: ysiz as f32 * 0.5,
308 zpiv: zsiz as f32 * 0.5,
309 voxels,
310 xlen,
311 ylen,
312 palette: None,
313 }
314 }
315
316 #[allow(clippy::cast_possible_wrap)]
324 pub fn recompute_surface(&mut self, occupied: impl Fn(i32, i32, i32) -> bool) {
325 let xsiz = self.xsiz;
326 let ysiz = self.ysiz;
327 let zsiz = self.zsiz;
328 let occ = |x: i64, y: i64, z: i64| -> bool {
329 x >= 0
330 && y >= 0
331 && z >= 0
332 && (x as u32) < xsiz
333 && (y as u32) < ysiz
334 && (z as u32) < zsiz
335 && occupied(x as i32, y as i32, z as i32)
336 };
337 let mut vi = 0usize;
338 for x in 0..xsiz as usize {
339 for y in 0..ysiz as usize {
340 let len = self.ylen[x][y] as usize;
341 for _ in 0..len {
342 let z = i64::from(self.voxels[vi].z);
343 let (vis, dir) = compute_vis_dir(&occ, x as i64, y as i64, z);
344 self.voxels[vi].vis = vis;
345 self.voxels[vi].dir = dir;
346 vi += 1;
347 }
348 }
349 }
350 }
351
352 fn surface_color_map(&self) -> HashMap<(u32, u32, u32), u32> {
358 let mut map = HashMap::with_capacity(self.voxels.len());
359 let mut vi = 0usize;
360 for x in 0..self.xsiz as usize {
361 for y in 0..self.ysiz as usize {
362 let len = self.ylen[x][y] as usize;
363 for _ in 0..len {
364 let v = self.voxels[vi];
365 #[allow(clippy::cast_lossless)]
366 map.insert((x as u32, y as u32, u32::from(v.z)), v.col);
367 vi += 1;
368 }
369 }
370 }
371 map
372 }
373
374 pub fn carve_sphere_with_colfunc<S, C>(
405 &mut self,
406 centre: [i32; 3],
407 radius: u32,
408 solid: S,
409 colfunc: C,
410 ) where
411 S: Fn(i32, i32, i32) -> bool,
412 C: Fn(i32, i32, i32) -> VoxColor,
413 {
414 let orig = self.surface_color_map();
415 let (xpiv, ypiv, zpiv) = (self.xpiv, self.ypiv, self.zpiv);
417 let palette = self.palette;
418
419 #[allow(clippy::cast_possible_wrap)]
420 let r = radius as i32;
421 let r_sq = r * r;
422 let (cx, cy, cz) = (centre[0], centre[1], centre[2]);
423 let inside = |x: i32, y: i32, z: i32| {
424 let (dx, dy, dz) = (x - cx, y - cy, z - cz);
425 dx * dx + dy * dy + dz * dz <= r_sq
426 };
427
428 let rebuilt = Kv6::from_fn_shaded(self.xsiz, self.ysiz, self.zsiz, |x, y, z| {
429 #[allow(clippy::cast_possible_wrap)]
430 let (xi, yi, zi) = (x as i32, y as i32, z as i32);
431 if inside(xi, yi, zi) || !solid(xi, yi, zi) {
432 return None;
433 }
434 Some(
437 orig.get(&(x, y, z))
438 .copied()
439 .map_or_else(|| colfunc(xi, yi, zi), VoxColor),
440 )
441 });
442
443 self.voxels = rebuilt.voxels;
444 self.xlen = rebuilt.xlen;
445 self.ylen = rebuilt.ylen;
446 self.xpiv = xpiv;
447 self.ypiv = ypiv;
448 self.zpiv = zpiv;
449 self.palette = palette;
450 }
451
452 #[must_use]
455 pub fn solid_box(xsiz: u32, ysiz: u32, zsiz: u32, col: VoxColor) -> Kv6 {
456 Kv6::from_fn(xsiz, ysiz, zsiz, |_, _, _| Some(col))
457 }
458
459 #[must_use]
461 pub fn solid_cube(n: u32, col: VoxColor) -> Kv6 {
462 Kv6::solid_box(n, n, n, col)
463 }
464}
465
466#[derive(Debug, Clone, PartialEq, Eq)]
468pub enum ParseError {
469 TooSmall {
471 got: usize,
473 },
474 BadMagic {
476 got: [u8; 4],
478 },
479 Truncated {
482 at: usize,
484 need: usize,
486 },
487}
488
489impl fmt::Display for ParseError {
490 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491 match *self {
492 Self::TooSmall { got } => write!(
493 f,
494 "kv6 file too small ({got} bytes; need at least 32 byte header)"
495 ),
496 Self::BadMagic { got } => write!(
497 f,
498 "kv6 bad magic: got [{:#04x},{:#04x},{:#04x},{:#04x}], expected b\"Kvxl\"",
499 got[0], got[1], got[2], got[3]
500 ),
501 Self::Truncated { at, need } => {
502 write!(f, "kv6 truncated: need {need} bytes at offset {at}")
503 }
504 }
505 }
506}
507
508impl std::error::Error for ParseError {}
509
510impl From<OutOfBounds> for ParseError {
511 fn from(e: OutOfBounds) -> Self {
512 Self::Truncated {
513 at: e.at,
514 need: e.need,
515 }
516 }
517}
518
519const HEADER_LEN: usize = 32;
520const MAGIC: &[u8; 4] = b"Kvxl";
521const PALETTE_MAGIC: &[u8; 4] = b"SPal";
522const PALETTE_LEN: usize = 768;
523
524pub fn parse(bytes: &[u8]) -> Result<Kv6, ParseError> {
554 if bytes.len() < HEADER_LEN {
555 return Err(ParseError::TooSmall { got: bytes.len() });
556 }
557
558 let mut cur = Cursor::new(bytes);
559 let magic = cur.read_bytes(4)?;
560 if magic != MAGIC {
561 return Err(ParseError::BadMagic {
562 got: [magic[0], magic[1], magic[2], magic[3]],
563 });
564 }
565 let xsiz = cur.read_u32()?;
566 let ysiz = cur.read_u32()?;
567 let zsiz = cur.read_u32()?;
568 let xpiv = cur.read_f32()?;
569 let ypiv = cur.read_f32()?;
570 let zpiv = cur.read_f32()?;
571 let numvoxs = cur.read_u32()?;
572
573 let mut voxels = Vec::with_capacity(cur.clamped_capacity(numvoxs as usize, 8));
577 for _ in 0..numvoxs {
578 let col = cur.read_u32()?;
579 let z = cur.read_u16()?;
580 let vis = cur.read_u8()?;
581 let dir = cur.read_u8()?;
582 voxels.push(Voxel { col, z, vis, dir });
583 }
584
585 let mut xlen = Vec::with_capacity(cur.clamped_capacity(xsiz as usize, 4));
586 for _ in 0..xsiz {
587 xlen.push(cur.read_u32()?);
588 }
589
590 let row_bytes = (ysiz as usize).saturating_mul(2);
591 let mut ylen = Vec::with_capacity(cur.clamped_capacity(xsiz as usize, row_bytes));
592 for _ in 0..xsiz {
593 let mut row = Vec::with_capacity(cur.clamped_capacity(ysiz as usize, 2));
594 for _ in 0..ysiz {
595 row.push(cur.read_u16()?);
596 }
597 ylen.push(row);
598 }
599
600 let palette =
602 if cur.remaining() >= 4 + PALETTE_LEN && cur.peek(4) == Some(PALETTE_MAGIC.as_slice()) {
603 cur.read_bytes(4)?;
604 let mut pal = [Rgb6::default(); 256];
605 for entry in &mut pal {
606 entry.r = cur.read_u8()?;
607 entry.g = cur.read_u8()?;
608 entry.b = cur.read_u8()?;
609 }
610 Some(pal)
611 } else {
612 None
613 };
614
615 Ok(Kv6 {
616 xsiz,
617 ysiz,
618 zsiz,
619 xpiv,
620 ypiv,
621 zpiv,
622 voxels,
623 xlen,
624 ylen,
625 palette,
626 })
627}
628
629#[must_use]
639pub fn serialize(kv6: &Kv6) -> Vec<u8> {
640 let pal_bytes = if kv6.palette.is_some() {
641 4 + PALETTE_LEN
642 } else {
643 0
644 };
645 let body_bytes = kv6.voxels.len() * 8
646 + kv6.xlen.len() * 4
647 + kv6.ylen.iter().map(|row| row.len() * 2).sum::<usize>();
648 let mut out = Vec::with_capacity(HEADER_LEN + body_bytes + pal_bytes);
649
650 out.extend_from_slice(MAGIC);
651 out.extend_from_slice(&kv6.xsiz.to_le_bytes());
652 out.extend_from_slice(&kv6.ysiz.to_le_bytes());
653 out.extend_from_slice(&kv6.zsiz.to_le_bytes());
654 out.extend_from_slice(&kv6.xpiv.to_le_bytes());
655 out.extend_from_slice(&kv6.ypiv.to_le_bytes());
656 out.extend_from_slice(&kv6.zpiv.to_le_bytes());
657 let numvoxs =
658 u32::try_from(kv6.voxels.len()).expect("kv6 numvoxs must fit in u32 (file format limit)");
659 out.extend_from_slice(&numvoxs.to_le_bytes());
660
661 for v in &kv6.voxels {
662 out.extend_from_slice(&v.col.to_le_bytes());
663 out.extend_from_slice(&v.z.to_le_bytes());
664 out.push(v.vis);
665 out.push(v.dir);
666 }
667 for v in &kv6.xlen {
668 out.extend_from_slice(&v.to_le_bytes());
669 }
670 for row in &kv6.ylen {
671 for v in row {
672 out.extend_from_slice(&v.to_le_bytes());
673 }
674 }
675 if let Some(pal) = &kv6.palette {
676 out.extend_from_slice(PALETTE_MAGIC);
677 for e in pal {
678 out.push(e.r);
679 out.push(e.g);
680 out.push(e.b);
681 }
682 }
683
684 out
685}
686
687#[cfg(test)]
690mod tests {
691 use super::*;
692
693 const COCO_KV6: &[u8] = include_bytes!("../../../assets/coco.kv6");
695
696 #[test]
697 fn solid_cube_builder_is_surface_only_and_consistent() {
698 let cube = Kv6::solid_cube(4, VoxColor(0x8012_3456));
699 assert_eq!((cube.xsiz, cube.ysiz, cube.zsiz), (4, 4, 4));
700 assert!((cube.xpiv - 2.0).abs() < f32::EPSILON);
702
703 assert_eq!(cube.voxels.len(), 64 - 8);
706 assert!(cube
707 .voxels
708 .iter()
709 .all(|v| v.vis == 63 && v.col == 0x8012_3456));
710
711 assert_eq!(cube.xlen.len(), 4);
713 assert_eq!(cube.ylen.len(), 4);
714 assert!(cube.ylen.iter().all(|row| row.len() == 4));
715 let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
716 let ylen_sum: usize = cube
717 .ylen
718 .iter()
719 .flat_map(|r| r.iter())
720 .map(|&n| n as usize)
721 .sum();
722 assert_eq!(xlen_sum, cube.voxels.len());
723 assert_eq!(ylen_sum, cube.voxels.len());
724 }
725
726 #[test]
730 fn from_fn_keep_interior_retains_matching_interiors() {
731 let col = VoxColor(0x8012_3456);
732 let shell = Kv6::from_fn(4, 4, 4, |_, _, _| Some(col));
735 assert_eq!(shell.voxels.len(), 64 - 8, "from_fn is surface-only");
736
737 let filled = Kv6::from_fn_keep_interior(4, 4, 4, |_, _, _| Some(col), |c| c == col);
738 assert_eq!(filled.voxels.len(), 64, "keep_interior retains all 64");
739 assert_eq!(color_at(&shell, 1, 1, 1), None);
742 assert_eq!(color_at(&filled, 1, 1, 1), Some(col));
743
744 let culled = Kv6::from_fn_keep_interior(4, 4, 4, |_, _, _| Some(col), |_| false);
746 assert_eq!(
747 culled.voxels.len(),
748 64 - 8,
749 "predicate=false ⇒ surface-only"
750 );
751 }
752
753 fn color_at(kv6: &Kv6, tx: u32, ty: u32, tz: u32) -> Option<VoxColor> {
756 let mut vi = 0usize;
757 for x in 0..kv6.xsiz {
758 for y in 0..kv6.ysiz {
759 let len = kv6.ylen[x as usize][y as usize] as usize;
760 for _ in 0..len {
761 let v = kv6.voxels[vi];
762 if x == tx && y == ty && u32::from(v.z) == tz {
763 return Some(VoxColor(v.col));
764 }
765 vi += 1;
766 }
767 }
768 }
769 None
770 }
771
772 #[test]
773 fn carve_sphere_exposes_interior_with_colfunc() {
774 const BASE: VoxColor = VoxColor(0x8011_2233);
775 let mut cube = Kv6::from_fn_shaded(16, 16, 16, |_, _, _| Some(BASE));
777 cube.xpiv = 1.0;
779 cube.ypiv = 2.0;
780 cube.zpiv = 3.0;
781
782 let encode = |x: i32, y: i32, z: i32| VoxColor(((x << 16) | (y << 8) | z) as u32);
785 cube.carve_sphere_with_colfunc([8, 8, 8], 4, |_, _, _| true, encode);
786
787 assert_eq!(color_at(&cube, 8, 8, 8), None);
789 assert_eq!(color_at(&cube, 8, 8, 3), Some(encode(8, 8, 3)));
793 assert_eq!(color_at(&cube, 0, 8, 8), Some(BASE));
795
796 assert!((cube.xpiv - 1.0).abs() < f32::EPSILON);
798 assert!((cube.ypiv - 2.0).abs() < f32::EPSILON);
799 assert!((cube.zpiv - 3.0).abs() < f32::EPSILON);
800
801 let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
803 assert_eq!(xlen_sum, cube.voxels.len());
804 }
805
806 #[test]
807 fn carve_sphere_respects_caller_solid_predicate() {
808 const BASE: VoxColor = VoxColor(0x80AA_BBCC);
809 let solid = |x: i32, _y: i32, _z: i32| (0..8).contains(&x);
813 #[allow(clippy::cast_sign_loss)]
814 let mut m =
815 Kv6::from_fn_shaded(16, 16, 16, |x, _, _| solid(x as i32, 0, 0).then_some(BASE));
816 m.carve_sphere_with_colfunc([4, 8, 8], 3, solid, |_, _, _| VoxColor(0x8000_FF00));
817 assert_eq!(color_at(&m, 12, 8, 8), None);
819 assert_eq!(color_at(&m, 4, 8, 8), None);
821 }
822
823 #[test]
824 fn built_cube_round_trips_through_serialize_parse() {
825 let cube = Kv6::solid_cube(5, VoxColor(0x80AB_CDEF));
826 let bytes = serialize(&cube);
827 let back = parse(&bytes).expect("parse built cube");
828 assert_eq!(back.xsiz, cube.xsiz);
829 assert_eq!(back.voxels.len(), cube.voxels.len());
830 assert_eq!(
831 serialize(&back),
832 bytes,
833 "serialize is stable across round-trip"
834 );
835 }
836
837 #[test]
838 fn from_fn_skips_air_and_keeps_z_order() {
839 let kv6 = Kv6::from_fn(1, 1, 2, |_, _, _| Some(VoxColor(0x8000_FF00)));
842 assert_eq!(kv6.voxels.len(), 2);
843 assert_eq!(kv6.voxels[0].z, 0);
844 assert_eq!(kv6.voxels[1].z, 1);
845 assert_eq!(kv6.xlen, vec![2]);
846 assert_eq!(kv6.ylen, vec![vec![2]]);
847 }
848
849 #[test]
850 fn parse_coco_header() {
851 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
852 assert_eq!(kv6.xsiz, 9);
853 assert_eq!(kv6.ysiz, 11);
854 assert_eq!(kv6.zsiz, 9);
855 assert!((kv6.xpiv - 2.0).abs() < f32::EPSILON);
857 assert!((kv6.ypiv - 3.0).abs() < f32::EPSILON);
858 assert!((kv6.zpiv - 9.0).abs() < f32::EPSILON);
859 assert_eq!(kv6.voxels.len(), 148);
860 }
861
862 #[test]
863 fn coco_voxel_counts_consistent() {
864 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
865 assert_eq!(kv6.xlen.len(), kv6.xsiz as usize);
866 assert_eq!(kv6.ylen.len(), kv6.xsiz as usize);
867 for row in &kv6.ylen {
868 assert_eq!(row.len(), kv6.ysiz as usize);
869 }
870 let xlen_sum: u64 = kv6.xlen.iter().map(|&n| u64::from(n)).sum();
871 let ylen_sum: u64 = kv6
872 .ylen
873 .iter()
874 .flat_map(|row| row.iter().map(|&n| u64::from(n)))
875 .sum();
876 let nv = kv6.voxels.len() as u64;
877 assert_eq!(xlen_sum, nv);
878 assert_eq!(ylen_sum, nv);
879 }
880
881 #[test]
882 fn from_fn_shaded_keeps_from_fn_geometry() {
883 let fill = |x: u32, y: u32, z: u32| {
886 let on_face = x == 0 || x == 4 || y == 0 || y == 4 || z == 0 || z == 4;
887 on_face.then_some(VoxColor(0x80_44_55_66))
888 };
889 let flat = Kv6::from_fn(5, 5, 5, fill);
890 let shaded = Kv6::from_fn_shaded(5, 5, 5, fill);
891 assert_eq!(flat.voxels.len(), shaded.voxels.len());
892 assert_eq!(flat.xlen, shaded.xlen);
893 assert_eq!(flat.ylen, shaded.ylen);
894 for (f, s) in flat.voxels.iter().zip(&shaded.voxels) {
895 assert_eq!((f.col, f.z), (s.col, s.z));
896 }
897 assert!(
899 shaded.voxels.iter().any(|v| v.dir != 0),
900 "from_fn_shaded left every dir flat"
901 );
902 assert!(flat.voxels.iter().all(|v| v.dir == 0 && v.vis == 63));
903 }
904
905 #[test]
906 fn from_fn_shaded_column_z_faces() {
907 let kv = Kv6::from_fn_shaded(1, 1, 2, |_, _, _| Some(VoxColor(0x80_80_80_80)));
912 assert_eq!(kv.voxels.len(), 2);
913 let (lower, upper) = (&kv.voxels[0], &kv.voxels[1]); assert_eq!(lower.z, 0);
915 assert_eq!(upper.z, 1);
916 assert_eq!(lower.vis & VIS_POS_Z, 0, "lower +z should be internal");
917 assert_eq!(lower.vis & VIS_NEG_Z, VIS_NEG_Z, "lower -z exposed");
918 assert_eq!(upper.vis & VIS_NEG_Z, 0, "upper -z should be internal");
919 assert_eq!(upper.vis & VIS_POS_Z, VIS_POS_Z, "upper +z exposed");
920 let sides = VIS_NEG_X | VIS_POS_X | VIS_NEG_Y | VIS_POS_Y;
922 assert_eq!(lower.vis & sides, sides);
923 assert_eq!(upper.vis & sides, sides);
924 }
925
926 #[test]
936 fn coco_vis_matches_authored_all_faces() {
937 use std::collections::HashMap;
938 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
939 let mut pos: HashMap<(u32, u32, u32), u8> = HashMap::new();
940 let mut vi = 0usize;
941 for x in 0..kv6.xsiz {
942 for y in 0..kv6.ysiz {
943 let len = kv6.ylen[x as usize][y as usize] as usize;
944 for _ in 0..len {
945 pos.insert((x, y, u32::from(kv6.voxels[vi].z)), kv6.voxels[vi].vis);
946 vi += 1;
947 }
948 }
949 }
950 let mut checked = 0u32;
951 for (&(x, y, z), &vis) in &pos {
952 let mut chk = |present: bool, bit: u8, face: &str| {
953 if present {
954 assert_eq!(
955 vis & bit,
956 0,
957 "coco ({x},{y},{z}): {face} internal but bit set"
958 );
959 checked += 1;
960 }
961 };
962 chk(pos.contains_key(&(x + 1, y, z)), VIS_POS_X, "+x");
963 chk(x > 0 && pos.contains_key(&(x - 1, y, z)), VIS_NEG_X, "-x");
964 chk(pos.contains_key(&(x, y + 1, z)), VIS_POS_Y, "+y");
965 chk(y > 0 && pos.contains_key(&(x, y - 1, z)), VIS_NEG_Y, "-y");
966 chk(pos.contains_key(&(x, y, z + 1)), VIS_POS_Z, "+z");
967 chk(z > 0 && pos.contains_key(&(x, y, z - 1)), VIS_NEG_Z, "-z");
968 }
969 assert!(
970 checked > 100,
971 "expected many adjacent faces in coco, got {checked}"
972 );
973 }
974
975 #[test]
976 fn recompute_surface_matches_from_fn_shaded() {
977 let fill = |x: u32, y: u32, z: u32| {
980 let cx = x as f32 - 4.0;
981 let cy = y as f32 - 4.0;
982 let cz = z as f32 - 4.0;
983 (cx * cx + cy * cy + cz * cz <= 16.0).then_some(VoxColor(0x80_30_60_90))
984 };
985 let shaded = Kv6::from_fn_shaded(9, 9, 9, fill);
986 let mut edited = Kv6::from_fn(9, 9, 9, fill); edited.recompute_surface(|x, y, z| {
988 x >= 0 && y >= 0 && z >= 0 && fill(x as u32, y as u32, z as u32).is_some()
989 });
990 assert_eq!(edited.voxels.len(), shaded.voxels.len());
991 for (e, s) in edited.voxels.iter().zip(&shaded.voxels) {
992 assert_eq!((e.vis, e.dir), (s.vis, s.dir), "voxel z={}", e.z);
993 }
994 }
995
996 #[test]
997 fn from_fn_shaded_slab_top_normal_points_up() {
998 use crate::equivec::univec;
999 let kv = Kv6::from_fn_shaded(8, 8, 12, |_, _, z| {
1003 (2..=9).contains(&z).then_some(VoxColor(0x80_aa_aa_aa))
1004 });
1005 let v = kv
1006 .voxels
1007 .iter()
1008 .enumerate()
1009 .find_map(|(i, v)| {
1010 let mut acc = 0usize;
1012 for x in 0..kv.xsiz as usize {
1013 for y in 0..kv.ysiz as usize {
1014 let len = kv.ylen[x][y] as usize;
1015 if i < acc + len {
1016 return (x == 4 && y == 4 && v.z == 2).then_some(*v);
1017 }
1018 acc += len;
1019 }
1020 }
1021 None
1022 })
1023 .expect("centre top-face voxel present");
1024 let n = univec()[v.dir as usize];
1025 assert!(
1026 n[2] < -0.5,
1027 "top-face normal should point -z (up), got {n:?}"
1028 );
1029 }
1030
1031 #[test]
1032 fn coco_palette_present_and_matches_kvx() {
1033 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1034 let pal = kv6.palette.as_ref().expect("SPal trailer present");
1035 assert_eq!((pal[0].r, pal[0].g, pal[0].b), (0x3f, 0x19, 0x19));
1037 }
1038
1039 #[test]
1040 fn coco_first_voxel_packed_colour() {
1041 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1042 let v0 = kv6.voxels[0];
1046 assert_eq!(v0.col, 0x80fc_a460);
1047 assert_eq!(v0.col & 0x8000_0000, 0x8000_0000);
1048 }
1049
1050 #[test]
1051 fn coco_roundtrips_byte_equal() {
1052 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1053 let out = serialize(&kv6);
1054 assert_eq!(out.len(), COCO_KV6.len(), "length differs");
1055 assert_eq!(out.as_slice(), COCO_KV6, "byte content differs");
1056 }
1057
1058 #[test]
1059 fn parse_truncated_header_fails() {
1060 let r = parse(&[0u8; 16]);
1061 assert!(matches!(r, Err(ParseError::TooSmall { .. })));
1062 }
1063
1064 #[test]
1065 fn parse_bad_magic_fails() {
1066 let mut bad = COCO_KV6.to_vec();
1067 bad[0] = b'X';
1068 let r = parse(&bad);
1069 assert!(matches!(r, Err(ParseError::BadMagic { .. })));
1070 }
1071
1072 #[test]
1076 fn parse_survives_absurd_numvoxs_without_alloc_bomb() {
1077 let mut bytes = Vec::new();
1078 bytes.extend_from_slice(b"Kvxl");
1079 for dim in [1u32, 1, 1] {
1080 bytes.extend_from_slice(&dim.to_le_bytes());
1081 }
1082 for piv in [0f32, 0.0, 0.0] {
1083 bytes.extend_from_slice(&piv.to_le_bytes());
1084 }
1085 bytes.extend_from_slice(&u32::MAX.to_le_bytes()); assert!(matches!(parse(&bytes), Err(ParseError::Truncated { .. })));
1087 }
1088}