1use core::fmt;
34use std::collections::HashMap;
35
36use crate::bytes::{Cursor, OutOfBounds};
37use crate::Rgb6;
38
39const VIS_NEG_X: u8 = 0x01;
47const VIS_POS_X: u8 = 0x02;
48const VIS_NEG_Y: u8 = 0x04;
49const VIS_POS_Y: u8 = 0x08;
50const VIS_POS_Z: u8 = 0x20;
54const VIS_NEG_Z: u8 = 0x10;
55
56pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct Voxel {
106 pub col: u32,
109 pub z: u16,
111 pub vis: u8,
114 pub dir: u8,
116}
117
118#[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 pub voxels: Vec<Voxel>,
130 pub xlen: Vec<u32>,
133 pub ylen: Vec<Vec<u16>>,
136 pub palette: Option<[Rgb6; 256]>,
138}
139
140impl Kv6 {
141 #[must_use]
163 pub fn from_fn<F: Fn(u32, u32, u32) -> Option<u32>>(
164 xsiz: u32,
165 ysiz: u32,
166 zsiz: u32,
167 fill: F,
168 ) -> Kv6 {
169 Self::build_inner(xsiz, ysiz, zsiz, fill, false, |_| false)
170 }
171
172 #[must_use]
188 pub fn from_fn_keep_interior<F, G>(
189 xsiz: u32,
190 ysiz: u32,
191 zsiz: u32,
192 fill: F,
193 keep_interior: G,
194 ) -> Kv6
195 where
196 F: Fn(u32, u32, u32) -> Option<u32>,
197 G: Fn(u32) -> bool,
198 {
199 Self::build_inner(xsiz, ysiz, zsiz, fill, false, keep_interior)
200 }
201
202 #[must_use]
216 pub fn from_fn_shaded<F: Fn(u32, u32, u32) -> Option<u32>>(
217 xsiz: u32,
218 ysiz: u32,
219 zsiz: u32,
220 fill: F,
221 ) -> Kv6 {
222 Self::build_inner(xsiz, ysiz, zsiz, fill, true, |_| false)
223 }
224
225 #[allow(
229 clippy::cast_possible_truncation,
230 clippy::cast_sign_loss,
231 clippy::cast_precision_loss
232 )]
233 fn build_inner<F, G>(
234 xsiz: u32,
235 ysiz: u32,
236 zsiz: u32,
237 fill: F,
238 shaded: bool,
239 keep_interior: G,
240 ) -> Kv6
241 where
242 F: Fn(u32, u32, u32) -> Option<u32>,
243 G: Fn(u32) -> bool,
244 {
245 let occupied = |x: i64, y: i64, z: i64| -> bool {
246 x >= 0
247 && y >= 0
248 && z >= 0
249 && (x as u32) < xsiz
250 && (y as u32) < ysiz
251 && (z as u32) < zsiz
252 && fill(x as u32, y as u32, z as u32).is_some()
253 };
254
255 let mut voxels: Vec<Voxel> = Vec::new();
256 let mut xlen: Vec<u32> = Vec::with_capacity(xsiz as usize);
257 let mut ylen: Vec<Vec<u16>> = Vec::with_capacity(xsiz as usize);
258
259 for x in 0..xsiz {
260 let mut col_counts: Vec<u16> = Vec::with_capacity(ysiz as usize);
261 for y in 0..ysiz {
262 let before = voxels.len();
263 for z in 0..zsiz {
264 let Some(col) = fill(x, y, z) else { continue };
265 let (xi, yi, zi) = (i64::from(x), i64::from(y), i64::from(z));
266 let exposed = !occupied(xi - 1, yi, zi)
267 || !occupied(xi + 1, yi, zi)
268 || !occupied(xi, yi - 1, zi)
269 || !occupied(xi, yi + 1, zi)
270 || !occupied(xi, yi, zi - 1)
271 || !occupied(xi, yi, zi + 1);
272 if exposed || keep_interior(col) {
273 let (vis, dir) = if shaded {
274 compute_vis_dir(&occupied, xi, yi, zi)
275 } else {
276 (63, 0)
277 };
278 voxels.push(Voxel {
279 col,
280 z: z as u16,
281 vis,
282 dir,
283 });
284 }
285 }
286 col_counts.push((voxels.len() - before) as u16);
287 }
288 xlen.push(col_counts.iter().map(|&c| u32::from(c)).sum());
289 ylen.push(col_counts);
290 }
291
292 Kv6 {
293 xsiz,
294 ysiz,
295 zsiz,
296 xpiv: xsiz as f32 * 0.5,
297 ypiv: ysiz as f32 * 0.5,
298 zpiv: zsiz as f32 * 0.5,
299 voxels,
300 xlen,
301 ylen,
302 palette: None,
303 }
304 }
305
306 #[allow(clippy::cast_possible_wrap)]
314 pub fn recompute_surface(&mut self, occupied: impl Fn(i32, i32, i32) -> bool) {
315 let xsiz = self.xsiz;
316 let ysiz = self.ysiz;
317 let zsiz = self.zsiz;
318 let occ = |x: i64, y: i64, z: i64| -> bool {
319 x >= 0
320 && y >= 0
321 && z >= 0
322 && (x as u32) < xsiz
323 && (y as u32) < ysiz
324 && (z as u32) < zsiz
325 && occupied(x as i32, y as i32, z as i32)
326 };
327 let mut vi = 0usize;
328 for x in 0..xsiz as usize {
329 for y in 0..ysiz as usize {
330 let len = self.ylen[x][y] as usize;
331 for _ in 0..len {
332 let z = i64::from(self.voxels[vi].z);
333 let (vis, dir) = compute_vis_dir(&occ, x as i64, y as i64, z);
334 self.voxels[vi].vis = vis;
335 self.voxels[vi].dir = dir;
336 vi += 1;
337 }
338 }
339 }
340 }
341
342 fn surface_color_map(&self) -> HashMap<(u32, u32, u32), u32> {
348 let mut map = HashMap::with_capacity(self.voxels.len());
349 let mut vi = 0usize;
350 for x in 0..self.xsiz as usize {
351 for y in 0..self.ysiz as usize {
352 let len = self.ylen[x][y] as usize;
353 for _ in 0..len {
354 let v = self.voxels[vi];
355 #[allow(clippy::cast_lossless)]
356 map.insert((x as u32, y as u32, u32::from(v.z)), v.col);
357 vi += 1;
358 }
359 }
360 }
361 map
362 }
363
364 pub fn carve_sphere_with_colfunc<S, C>(
395 &mut self,
396 centre: [i32; 3],
397 radius: u32,
398 solid: S,
399 colfunc: C,
400 ) where
401 S: Fn(i32, i32, i32) -> bool,
402 C: Fn(i32, i32, i32) -> u32,
403 {
404 let orig = self.surface_color_map();
405 let (xpiv, ypiv, zpiv) = (self.xpiv, self.ypiv, self.zpiv);
407 let palette = self.palette;
408
409 #[allow(clippy::cast_possible_wrap)]
410 let r = radius as i32;
411 let r_sq = r * r;
412 let (cx, cy, cz) = (centre[0], centre[1], centre[2]);
413 let inside = |x: i32, y: i32, z: i32| {
414 let (dx, dy, dz) = (x - cx, y - cy, z - cz);
415 dx * dx + dy * dy + dz * dz <= r_sq
416 };
417
418 let rebuilt = Kv6::from_fn_shaded(self.xsiz, self.ysiz, self.zsiz, |x, y, z| {
419 #[allow(clippy::cast_possible_wrap)]
420 let (xi, yi, zi) = (x as i32, y as i32, z as i32);
421 if inside(xi, yi, zi) || !solid(xi, yi, zi) {
422 return None;
423 }
424 Some(
427 orig.get(&(x, y, z))
428 .copied()
429 .unwrap_or_else(|| colfunc(xi, yi, zi)),
430 )
431 });
432
433 self.voxels = rebuilt.voxels;
434 self.xlen = rebuilt.xlen;
435 self.ylen = rebuilt.ylen;
436 self.xpiv = xpiv;
437 self.ypiv = ypiv;
438 self.zpiv = zpiv;
439 self.palette = palette;
440 }
441
442 #[must_use]
445 pub fn solid_box(xsiz: u32, ysiz: u32, zsiz: u32, col: u32) -> Kv6 {
446 Kv6::from_fn(xsiz, ysiz, zsiz, |_, _, _| Some(col))
447 }
448
449 #[must_use]
451 pub fn solid_cube(n: u32, col: u32) -> Kv6 {
452 Kv6::solid_box(n, n, n, col)
453 }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
458pub enum ParseError {
459 TooSmall { got: usize },
461 BadMagic { got: [u8; 4] },
463 Truncated { at: usize, need: usize },
466}
467
468impl fmt::Display for ParseError {
469 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
470 match *self {
471 Self::TooSmall { got } => write!(
472 f,
473 "kv6 file too small ({got} bytes; need at least 32 byte header)"
474 ),
475 Self::BadMagic { got } => write!(
476 f,
477 "kv6 bad magic: got [{:#04x},{:#04x},{:#04x},{:#04x}], expected b\"Kvxl\"",
478 got[0], got[1], got[2], got[3]
479 ),
480 Self::Truncated { at, need } => {
481 write!(f, "kv6 truncated: need {need} bytes at offset {at}")
482 }
483 }
484 }
485}
486
487impl std::error::Error for ParseError {}
488
489impl From<OutOfBounds> for ParseError {
490 fn from(e: OutOfBounds) -> Self {
491 Self::Truncated {
492 at: e.at,
493 need: e.need,
494 }
495 }
496}
497
498const HEADER_LEN: usize = 32;
499const MAGIC: &[u8; 4] = b"Kvxl";
500const PALETTE_MAGIC: &[u8; 4] = b"SPal";
501const PALETTE_LEN: usize = 768;
502
503pub fn parse(bytes: &[u8]) -> Result<Kv6, ParseError> {
533 if bytes.len() < HEADER_LEN {
534 return Err(ParseError::TooSmall { got: bytes.len() });
535 }
536
537 let mut cur = Cursor::new(bytes);
538 let magic = cur.read_bytes(4)?;
539 if magic != MAGIC {
540 return Err(ParseError::BadMagic {
541 got: [magic[0], magic[1], magic[2], magic[3]],
542 });
543 }
544 let xsiz = cur.read_u32()?;
545 let ysiz = cur.read_u32()?;
546 let zsiz = cur.read_u32()?;
547 let xpiv = cur.read_f32()?;
548 let ypiv = cur.read_f32()?;
549 let zpiv = cur.read_f32()?;
550 let numvoxs = cur.read_u32()?;
551
552 let mut voxels = Vec::with_capacity(cur.clamped_capacity(numvoxs as usize, 8));
556 for _ in 0..numvoxs {
557 let col = cur.read_u32()?;
558 let z = cur.read_u16()?;
559 let vis = cur.read_u8()?;
560 let dir = cur.read_u8()?;
561 voxels.push(Voxel { col, z, vis, dir });
562 }
563
564 let mut xlen = Vec::with_capacity(cur.clamped_capacity(xsiz as usize, 4));
565 for _ in 0..xsiz {
566 xlen.push(cur.read_u32()?);
567 }
568
569 let row_bytes = (ysiz as usize).saturating_mul(2);
570 let mut ylen = Vec::with_capacity(cur.clamped_capacity(xsiz as usize, row_bytes));
571 for _ in 0..xsiz {
572 let mut row = Vec::with_capacity(cur.clamped_capacity(ysiz as usize, 2));
573 for _ in 0..ysiz {
574 row.push(cur.read_u16()?);
575 }
576 ylen.push(row);
577 }
578
579 let palette =
581 if cur.remaining() >= 4 + PALETTE_LEN && cur.peek(4) == Some(PALETTE_MAGIC.as_slice()) {
582 cur.read_bytes(4)?;
583 let mut pal = [Rgb6::default(); 256];
584 for entry in &mut pal {
585 entry.r = cur.read_u8()?;
586 entry.g = cur.read_u8()?;
587 entry.b = cur.read_u8()?;
588 }
589 Some(pal)
590 } else {
591 None
592 };
593
594 Ok(Kv6 {
595 xsiz,
596 ysiz,
597 zsiz,
598 xpiv,
599 ypiv,
600 zpiv,
601 voxels,
602 xlen,
603 ylen,
604 palette,
605 })
606}
607
608#[must_use]
618pub fn serialize(kv6: &Kv6) -> Vec<u8> {
619 let pal_bytes = if kv6.palette.is_some() {
620 4 + PALETTE_LEN
621 } else {
622 0
623 };
624 let body_bytes = kv6.voxels.len() * 8
625 + kv6.xlen.len() * 4
626 + kv6.ylen.iter().map(|row| row.len() * 2).sum::<usize>();
627 let mut out = Vec::with_capacity(HEADER_LEN + body_bytes + pal_bytes);
628
629 out.extend_from_slice(MAGIC);
630 out.extend_from_slice(&kv6.xsiz.to_le_bytes());
631 out.extend_from_slice(&kv6.ysiz.to_le_bytes());
632 out.extend_from_slice(&kv6.zsiz.to_le_bytes());
633 out.extend_from_slice(&kv6.xpiv.to_le_bytes());
634 out.extend_from_slice(&kv6.ypiv.to_le_bytes());
635 out.extend_from_slice(&kv6.zpiv.to_le_bytes());
636 let numvoxs =
637 u32::try_from(kv6.voxels.len()).expect("kv6 numvoxs must fit in u32 (file format limit)");
638 out.extend_from_slice(&numvoxs.to_le_bytes());
639
640 for v in &kv6.voxels {
641 out.extend_from_slice(&v.col.to_le_bytes());
642 out.extend_from_slice(&v.z.to_le_bytes());
643 out.push(v.vis);
644 out.push(v.dir);
645 }
646 for v in &kv6.xlen {
647 out.extend_from_slice(&v.to_le_bytes());
648 }
649 for row in &kv6.ylen {
650 for v in row {
651 out.extend_from_slice(&v.to_le_bytes());
652 }
653 }
654 if let Some(pal) = &kv6.palette {
655 out.extend_from_slice(PALETTE_MAGIC);
656 for e in pal {
657 out.push(e.r);
658 out.push(e.g);
659 out.push(e.b);
660 }
661 }
662
663 out
664}
665
666#[cfg(test)]
669mod tests {
670 use super::*;
671
672 const COCO_KV6: &[u8] = include_bytes!("../../../assets/coco.kv6");
674
675 #[test]
676 fn solid_cube_builder_is_surface_only_and_consistent() {
677 let cube = Kv6::solid_cube(4, 0x8012_3456);
678 assert_eq!((cube.xsiz, cube.ysiz, cube.zsiz), (4, 4, 4));
679 assert!((cube.xpiv - 2.0).abs() < f32::EPSILON);
681
682 assert_eq!(cube.voxels.len(), 64 - 8);
685 assert!(cube
686 .voxels
687 .iter()
688 .all(|v| v.vis == 63 && v.col == 0x8012_3456));
689
690 assert_eq!(cube.xlen.len(), 4);
692 assert_eq!(cube.ylen.len(), 4);
693 assert!(cube.ylen.iter().all(|row| row.len() == 4));
694 let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
695 let ylen_sum: usize = cube
696 .ylen
697 .iter()
698 .flat_map(|r| r.iter())
699 .map(|&n| n as usize)
700 .sum();
701 assert_eq!(xlen_sum, cube.voxels.len());
702 assert_eq!(ylen_sum, cube.voxels.len());
703 }
704
705 #[test]
709 fn from_fn_keep_interior_retains_matching_interiors() {
710 let col = 0x8012_3456;
711 let shell = Kv6::from_fn(4, 4, 4, |_, _, _| Some(col));
714 assert_eq!(shell.voxels.len(), 64 - 8, "from_fn is surface-only");
715
716 let filled = Kv6::from_fn_keep_interior(4, 4, 4, |_, _, _| Some(col), |c| c == col);
717 assert_eq!(filled.voxels.len(), 64, "keep_interior retains all 64");
718 assert_eq!(color_at(&shell, 1, 1, 1), None);
721 assert_eq!(color_at(&filled, 1, 1, 1), Some(col));
722
723 let culled = Kv6::from_fn_keep_interior(4, 4, 4, |_, _, _| Some(col), |_| false);
725 assert_eq!(
726 culled.voxels.len(),
727 64 - 8,
728 "predicate=false ⇒ surface-only"
729 );
730 }
731
732 fn color_at(kv6: &Kv6, tx: u32, ty: u32, tz: u32) -> Option<u32> {
735 let mut vi = 0usize;
736 for x in 0..kv6.xsiz {
737 for y in 0..kv6.ysiz {
738 let len = kv6.ylen[x as usize][y as usize] as usize;
739 for _ in 0..len {
740 let v = kv6.voxels[vi];
741 if x == tx && y == ty && u32::from(v.z) == tz {
742 return Some(v.col);
743 }
744 vi += 1;
745 }
746 }
747 }
748 None
749 }
750
751 #[test]
752 fn carve_sphere_exposes_interior_with_colfunc() {
753 const BASE: u32 = 0x8011_2233;
754 let mut cube = Kv6::from_fn_shaded(16, 16, 16, |_, _, _| Some(BASE));
756 cube.xpiv = 1.0;
758 cube.ypiv = 2.0;
759 cube.zpiv = 3.0;
760
761 let encode = |x: i32, y: i32, z: i32| ((x << 16) | (y << 8) | z) as u32;
764 cube.carve_sphere_with_colfunc([8, 8, 8], 4, |_, _, _| true, encode);
765
766 assert_eq!(color_at(&cube, 8, 8, 8), None);
768 assert_eq!(color_at(&cube, 8, 8, 3), Some(encode(8, 8, 3)));
772 assert_eq!(color_at(&cube, 0, 8, 8), Some(BASE));
774
775 assert!((cube.xpiv - 1.0).abs() < f32::EPSILON);
777 assert!((cube.ypiv - 2.0).abs() < f32::EPSILON);
778 assert!((cube.zpiv - 3.0).abs() < f32::EPSILON);
779
780 let xlen_sum: usize = cube.xlen.iter().map(|&n| n as usize).sum();
782 assert_eq!(xlen_sum, cube.voxels.len());
783 }
784
785 #[test]
786 fn carve_sphere_respects_caller_solid_predicate() {
787 const BASE: u32 = 0x80AA_BBCC;
788 let solid = |x: i32, _y: i32, _z: i32| (0..8).contains(&x);
792 #[allow(clippy::cast_sign_loss)]
793 let mut m =
794 Kv6::from_fn_shaded(16, 16, 16, |x, _, _| solid(x as i32, 0, 0).then_some(BASE));
795 m.carve_sphere_with_colfunc([4, 8, 8], 3, solid, |_, _, _| 0x8000_FF00);
796 assert_eq!(color_at(&m, 12, 8, 8), None);
798 assert_eq!(color_at(&m, 4, 8, 8), None);
800 }
801
802 #[test]
803 fn built_cube_round_trips_through_serialize_parse() {
804 let cube = Kv6::solid_cube(5, 0x80AB_CDEF);
805 let bytes = serialize(&cube);
806 let back = parse(&bytes).expect("parse built cube");
807 assert_eq!(back.xsiz, cube.xsiz);
808 assert_eq!(back.voxels.len(), cube.voxels.len());
809 assert_eq!(
810 serialize(&back),
811 bytes,
812 "serialize is stable across round-trip"
813 );
814 }
815
816 #[test]
817 fn from_fn_skips_air_and_keeps_z_order() {
818 let kv6 = Kv6::from_fn(1, 1, 2, |_, _, _| Some(0x8000_FF00));
821 assert_eq!(kv6.voxels.len(), 2);
822 assert_eq!(kv6.voxels[0].z, 0);
823 assert_eq!(kv6.voxels[1].z, 1);
824 assert_eq!(kv6.xlen, vec![2]);
825 assert_eq!(kv6.ylen, vec![vec![2]]);
826 }
827
828 #[test]
829 fn parse_coco_header() {
830 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
831 assert_eq!(kv6.xsiz, 9);
832 assert_eq!(kv6.ysiz, 11);
833 assert_eq!(kv6.zsiz, 9);
834 assert!((kv6.xpiv - 2.0).abs() < f32::EPSILON);
836 assert!((kv6.ypiv - 3.0).abs() < f32::EPSILON);
837 assert!((kv6.zpiv - 9.0).abs() < f32::EPSILON);
838 assert_eq!(kv6.voxels.len(), 148);
839 }
840
841 #[test]
842 fn coco_voxel_counts_consistent() {
843 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
844 assert_eq!(kv6.xlen.len(), kv6.xsiz as usize);
845 assert_eq!(kv6.ylen.len(), kv6.xsiz as usize);
846 for row in &kv6.ylen {
847 assert_eq!(row.len(), kv6.ysiz as usize);
848 }
849 let xlen_sum: u64 = kv6.xlen.iter().map(|&n| u64::from(n)).sum();
850 let ylen_sum: u64 = kv6
851 .ylen
852 .iter()
853 .flat_map(|row| row.iter().map(|&n| u64::from(n)))
854 .sum();
855 let nv = kv6.voxels.len() as u64;
856 assert_eq!(xlen_sum, nv);
857 assert_eq!(ylen_sum, nv);
858 }
859
860 #[test]
861 fn from_fn_shaded_keeps_from_fn_geometry() {
862 let fill = |x: u32, y: u32, z: u32| {
865 let on_face = x == 0 || x == 4 || y == 0 || y == 4 || z == 0 || z == 4;
866 on_face.then_some(0x80_44_55_66u32)
867 };
868 let flat = Kv6::from_fn(5, 5, 5, fill);
869 let shaded = Kv6::from_fn_shaded(5, 5, 5, fill);
870 assert_eq!(flat.voxels.len(), shaded.voxels.len());
871 assert_eq!(flat.xlen, shaded.xlen);
872 assert_eq!(flat.ylen, shaded.ylen);
873 for (f, s) in flat.voxels.iter().zip(&shaded.voxels) {
874 assert_eq!((f.col, f.z), (s.col, s.z));
875 }
876 assert!(
878 shaded.voxels.iter().any(|v| v.dir != 0),
879 "from_fn_shaded left every dir flat"
880 );
881 assert!(flat.voxels.iter().all(|v| v.dir == 0 && v.vis == 63));
882 }
883
884 #[test]
885 fn from_fn_shaded_column_z_faces() {
886 let kv = Kv6::from_fn_shaded(1, 1, 2, |_, _, _| Some(0x80_80_80_80));
891 assert_eq!(kv.voxels.len(), 2);
892 let (lower, upper) = (&kv.voxels[0], &kv.voxels[1]); assert_eq!(lower.z, 0);
894 assert_eq!(upper.z, 1);
895 assert_eq!(lower.vis & VIS_POS_Z, 0, "lower +z should be internal");
896 assert_eq!(lower.vis & VIS_NEG_Z, VIS_NEG_Z, "lower -z exposed");
897 assert_eq!(upper.vis & VIS_NEG_Z, 0, "upper -z should be internal");
898 assert_eq!(upper.vis & VIS_POS_Z, VIS_POS_Z, "upper +z exposed");
899 let sides = VIS_NEG_X | VIS_POS_X | VIS_NEG_Y | VIS_POS_Y;
901 assert_eq!(lower.vis & sides, sides);
902 assert_eq!(upper.vis & sides, sides);
903 }
904
905 #[test]
915 fn coco_vis_matches_authored_all_faces() {
916 use std::collections::HashMap;
917 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
918 let mut pos: HashMap<(u32, u32, u32), u8> = HashMap::new();
919 let mut vi = 0usize;
920 for x in 0..kv6.xsiz {
921 for y in 0..kv6.ysiz {
922 let len = kv6.ylen[x as usize][y as usize] as usize;
923 for _ in 0..len {
924 pos.insert((x, y, u32::from(kv6.voxels[vi].z)), kv6.voxels[vi].vis);
925 vi += 1;
926 }
927 }
928 }
929 let mut checked = 0u32;
930 for (&(x, y, z), &vis) in &pos {
931 let mut chk = |present: bool, bit: u8, face: &str| {
932 if present {
933 assert_eq!(
934 vis & bit,
935 0,
936 "coco ({x},{y},{z}): {face} internal but bit set"
937 );
938 checked += 1;
939 }
940 };
941 chk(pos.contains_key(&(x + 1, y, z)), VIS_POS_X, "+x");
942 chk(x > 0 && pos.contains_key(&(x - 1, y, z)), VIS_NEG_X, "-x");
943 chk(pos.contains_key(&(x, y + 1, z)), VIS_POS_Y, "+y");
944 chk(y > 0 && pos.contains_key(&(x, y - 1, z)), VIS_NEG_Y, "-y");
945 chk(pos.contains_key(&(x, y, z + 1)), VIS_POS_Z, "+z");
946 chk(z > 0 && pos.contains_key(&(x, y, z - 1)), VIS_NEG_Z, "-z");
947 }
948 assert!(
949 checked > 100,
950 "expected many adjacent faces in coco, got {checked}"
951 );
952 }
953
954 #[test]
955 fn recompute_surface_matches_from_fn_shaded() {
956 let fill = |x: u32, y: u32, z: u32| {
959 let cx = x as f32 - 4.0;
960 let cy = y as f32 - 4.0;
961 let cz = z as f32 - 4.0;
962 (cx * cx + cy * cy + cz * cz <= 16.0).then_some(0x80_30_60_90u32)
963 };
964 let shaded = Kv6::from_fn_shaded(9, 9, 9, fill);
965 let mut edited = Kv6::from_fn(9, 9, 9, fill); edited.recompute_surface(|x, y, z| {
967 x >= 0 && y >= 0 && z >= 0 && fill(x as u32, y as u32, z as u32).is_some()
968 });
969 assert_eq!(edited.voxels.len(), shaded.voxels.len());
970 for (e, s) in edited.voxels.iter().zip(&shaded.voxels) {
971 assert_eq!((e.vis, e.dir), (s.vis, s.dir), "voxel z={}", e.z);
972 }
973 }
974
975 #[test]
976 fn from_fn_shaded_slab_top_normal_points_up() {
977 use crate::equivec::univec;
978 let kv = Kv6::from_fn_shaded(8, 8, 12, |_, _, z| {
982 (2..=9).contains(&z).then_some(0x80_aa_aa_aa)
983 });
984 let v = kv
985 .voxels
986 .iter()
987 .enumerate()
988 .find_map(|(i, v)| {
989 let mut acc = 0usize;
991 for x in 0..kv.xsiz as usize {
992 for y in 0..kv.ysiz as usize {
993 let len = kv.ylen[x][y] as usize;
994 if i < acc + len {
995 return (x == 4 && y == 4 && v.z == 2).then_some(*v);
996 }
997 acc += len;
998 }
999 }
1000 None
1001 })
1002 .expect("centre top-face voxel present");
1003 let n = univec()[v.dir as usize];
1004 assert!(
1005 n[2] < -0.5,
1006 "top-face normal should point -z (up), got {n:?}"
1007 );
1008 }
1009
1010 #[test]
1011 fn coco_palette_present_and_matches_kvx() {
1012 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1013 let pal = kv6.palette.as_ref().expect("SPal trailer present");
1014 assert_eq!((pal[0].r, pal[0].g, pal[0].b), (0x3f, 0x19, 0x19));
1016 }
1017
1018 #[test]
1019 fn coco_first_voxel_packed_colour() {
1020 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1021 let v0 = kv6.voxels[0];
1025 assert_eq!(v0.col, 0x80fc_a460);
1026 assert_eq!(v0.col & 0x8000_0000, 0x8000_0000);
1027 }
1028
1029 #[test]
1030 fn coco_roundtrips_byte_equal() {
1031 let kv6 = parse(COCO_KV6).expect("parse coco.kv6");
1032 let out = serialize(&kv6);
1033 assert_eq!(out.len(), COCO_KV6.len(), "length differs");
1034 assert_eq!(out.as_slice(), COCO_KV6, "byte content differs");
1035 }
1036
1037 #[test]
1038 fn parse_truncated_header_fails() {
1039 let r = parse(&[0u8; 16]);
1040 assert!(matches!(r, Err(ParseError::TooSmall { .. })));
1041 }
1042
1043 #[test]
1044 fn parse_bad_magic_fails() {
1045 let mut bad = COCO_KV6.to_vec();
1046 bad[0] = b'X';
1047 let r = parse(&bad);
1048 assert!(matches!(r, Err(ParseError::BadMagic { .. })));
1049 }
1050
1051 #[test]
1055 fn parse_survives_absurd_numvoxs_without_alloc_bomb() {
1056 let mut bytes = Vec::new();
1057 bytes.extend_from_slice(b"Kvxl");
1058 for dim in [1u32, 1, 1] {
1059 bytes.extend_from_slice(&dim.to_le_bytes());
1060 }
1061 for piv in [0f32, 0.0, 0.0] {
1062 bytes.extend_from_slice(&piv.to_le_bytes());
1063 }
1064 bytes.extend_from_slice(&u32::MAX.to_le_bytes()); assert!(matches!(parse(&bytes), Err(ParseError::Truncated { .. })));
1066 }
1067}