1use crate::bytes::{Cursor, OutOfBounds};
44use crate::kv6::{compute_vis_dir, Kv6, Voxel};
45
46const MAGIC: [u8; 4] = *b"RVCL";
47const VERSION: u16 = 2;
49const VERSION_LEGACY: u16 = 1;
52
53const TAG_META: [u8; 4] = *b"META";
54const TAG_FRMS: [u8; 4] = *b"FRMS";
55const TAG_TIME: [u8; 4] = *b"TIME";
56
57const CHUNK_FLAG_DEFLATED: u8 = 0x01;
59const MAX_CHUNK_INFLATE: usize = 64 << 20; const DEFLATE_LEVEL: u8 = 8;
66
67const FRAME_KIND_KEY: u8 = 0;
68const FRAME_KIND_DELTA: u8 = 1;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum LoopMode {
73 Loop,
75 Once,
77 PingPong,
79}
80
81impl LoopMode {
82 fn to_u8(self) -> u8 {
83 match self {
84 Self::Loop => 0,
85 Self::Once => 1,
86 Self::PingPong => 2,
87 }
88 }
89 fn from_u8(v: u8) -> Option<Self> {
90 match v {
91 0 => Some(Self::Loop),
92 1 => Some(Self::Once),
93 2 => Some(Self::PingPong),
94 _ => None,
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct VoxelFrame {
103 pub occupancy: Vec<u32>,
108 pub colors: Vec<u32>,
111 pub color_offsets: Vec<u32>,
114}
115
116impl VoxelFrame {
117 #[must_use]
131 #[allow(clippy::cast_possible_truncation)]
132 pub fn from_kv6(kv6: &Kv6) -> Self {
133 let dims = [kv6.xsiz, kv6.ysiz, kv6.zsiz];
134 let (nx, ny) = (dims[0] as usize, dims[1] as usize);
135 let cols = nx * ny;
136 let owpc = occ_words_per_col(dims) as usize;
137 let zmax = dims[2];
138
139 let mut per_col: Vec<Vec<(u16, u32)>> = vec![Vec::new(); cols];
145 let mut vi = 0usize;
146 for x in 0..nx {
147 let col_counts = kv6.ylen.get(x).map_or(&[][..], Vec::as_slice);
148 for (y, &cnt) in col_counts.iter().enumerate() {
149 let start = vi.min(kv6.voxels.len());
150 let end = (start + cnt as usize).min(kv6.voxels.len());
151 if y < ny {
152 let col = x + y * nx; for v in &kv6.voxels[start..end] {
154 if u32::from(v.z) < zmax {
155 per_col[col].push((v.z, v.col));
156 }
157 }
158 }
159 vi = end;
160 }
161 }
162
163 let mut occupancy = vec![0u32; cols * owpc];
164 let mut colors = Vec::new();
165 let mut color_offsets = Vec::with_capacity(cols + 1);
166 color_offsets.push(0u32);
167 for (col, run) in per_col.iter_mut().enumerate() {
168 run.sort_by_key(|&(z, _)| z);
169 for &(z, c) in run.iter() {
170 let zi = z as usize;
171 occupancy[col * owpc + zi / 32] |= 1u32 << (zi % 32);
172 colors.push(c);
173 }
174 color_offsets.push(colors.len() as u32);
175 }
176
177 Self {
178 occupancy,
179 colors,
180 color_offsets,
181 }
182 }
183
184 #[must_use]
191 #[allow(clippy::cast_possible_truncation)]
192 pub fn to_kv6(&self, dims: [u32; 3], pivot: [f32; 3]) -> Kv6 {
193 let (nx, ny) = (dims[0] as usize, dims[1] as usize);
194 let owpc = occ_words_per_col(dims) as usize;
195 let mut voxels = Vec::new();
196 let mut xlen = Vec::with_capacity(nx);
197 let mut ylen = Vec::with_capacity(nx);
198
199 for x in 0..nx {
202 let mut col_counts: Vec<u16> = Vec::with_capacity(ny);
203 let mut xcount = 0u32;
204 for y in 0..ny {
205 let col = x + y * nx;
206 let run = self.column_colors(col);
207 let occ = &self.occupancy[col * owpc..(col + 1) * owpc];
208 let before = voxels.len();
209 let mut ci = 0usize;
210 for z in 0..dims[2] {
211 if (occ[(z >> 5) as usize] >> (z & 31)) & 1 != 0 {
212 voxels.push(Voxel {
213 col: run[ci],
214 z: z as u16,
215 vis: 63,
216 dir: 0,
217 });
218 ci += 1;
219 }
220 }
221 let c = (voxels.len() - before) as u16;
222 col_counts.push(c);
223 xcount += u32::from(c);
224 }
225 xlen.push(xcount);
226 ylen.push(col_counts);
227 }
228
229 Kv6 {
230 xsiz: dims[0],
231 ysiz: dims[1],
232 zsiz: dims[2],
233 xpiv: pivot[0],
234 ypiv: pivot[1],
235 zpiv: pivot[2],
236 voxels,
237 xlen,
238 ylen,
239 palette: None,
240 }
241 }
242
243 #[must_use]
249 pub fn dirs(&self, dims: [u32; 3]) -> Vec<u32> {
250 frame_dirs(self, dims, occ_words_per_col(dims) as usize)
251 }
252
253 pub fn validate(&self, dims: [u32; 3]) -> Result<(), FrameError> {
261 let cols = (dims[0] as usize) * (dims[1] as usize);
262 let owpc = occ_words_per_col(dims) as usize;
263 if self.occupancy.len() != cols * owpc {
264 return Err(FrameError::OccupancyLen);
265 }
266 if self.color_offsets.len() != cols + 1 {
267 return Err(FrameError::OffsetsLen);
268 }
269 if self.color_offsets[0] != 0
270 || *self.color_offsets.last().unwrap() as usize != self.colors.len()
271 {
272 return Err(FrameError::OffsetsBounds);
273 }
274 for col in 0..cols {
275 let lo = self.color_offsets[col];
276 let hi = self.color_offsets[col + 1];
277 if hi < lo {
278 return Err(FrameError::OffsetsMonotonic);
279 }
280 let run = (hi - lo) as usize;
281 let mut popcount = 0usize;
282 for w in 0..owpc {
283 popcount += self.occupancy[col * owpc + w].count_ones() as usize;
284 }
285 if popcount != run {
286 return Err(FrameError::OccupancyColorMismatch(col));
287 }
288 }
289 Ok(())
290 }
291
292 fn column_colors(&self, col: usize) -> &[u32] {
294 &self.colors[self.color_offsets[col] as usize..self.color_offsets[col + 1] as usize]
295 }
296
297 fn column_occ(&self, col: usize, owpc: usize) -> &[u32] {
299 &self.occupancy[col * owpc..(col + 1) * owpc]
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct ColumnDelta {
307 pub col: u32,
308 pub occ: Vec<u32>,
309 pub colors: Vec<u32>,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum EncodedFrame {
316 Key(VoxelFrame),
318 Delta(Vec<ColumnDelta>),
320}
321
322#[derive(Debug, Clone, PartialEq)]
326pub struct VoxelClip {
327 pub dims: [u32; 3],
328 pub pivot: [f32; 3],
329 pub voxel_world_size: f32,
330 pub loop_mode: LoopMode,
331 pub default_frame_ms: u32,
333 pub frames: Vec<EncodedFrame>,
335 pub durations: Vec<u32>,
337 pub extra_chunks: Vec<([u8; 4], Vec<u8>)>,
339}
340
341#[derive(Debug, Clone)]
345pub struct DecodedClip {
346 pub dims: [u32; 3],
347 pub pivot: [f32; 3],
348 pub voxel_world_size: f32,
349 pub occ_words_per_col: u32,
350 pub loop_mode: LoopMode,
351 pub frames: Vec<VoxelFrame>,
352 pub dirs: Vec<Vec<u32>>,
355 pub durations: Vec<u32>,
356}
357
358impl DecodedClip {
359 #[must_use]
360 pub fn frame_count(&self) -> usize {
361 self.frames.len()
362 }
363
364 #[must_use]
367 pub fn total_ms(&self) -> u32 {
368 self.durations
369 .iter()
370 .fold(0u32, |acc, &d| acc.saturating_add(d))
371 }
372
373 #[must_use]
376 pub fn pad_stats(&self) -> PadStats {
377 pad_stats(self.dims, &self.frames)
378 }
379
380 #[must_use]
389 pub fn frame_at(&self, elapsed_ms: u32) -> usize {
390 frame_at(&self.durations, self.loop_mode, elapsed_ms)
391 }
392}
393
394#[must_use]
404pub fn frame_at(durations: &[u32], loop_mode: LoopMode, elapsed_ms: u32) -> usize {
405 let n = durations.len();
406 if n <= 1 {
407 return 0;
408 }
409 let total: u64 = durations.iter().map(|&d| u64::from(d)).sum();
412 if total == 0 {
413 return 0;
414 }
415 let elapsed = u64::from(elapsed_ms);
416 let t = match loop_mode {
418 LoopMode::Loop => elapsed % total,
419 LoopMode::Once => elapsed.min(total - 1),
420 LoopMode::PingPong => {
421 let p = elapsed % (2 * total);
422 if p < total {
423 p
424 } else {
425 2 * total - 1 - p
427 }
428 }
429 };
430 let mut acc = 0u64;
432 for (i, &d) in durations.iter().enumerate() {
433 acc += u64::from(d);
434 if t < acc {
435 return i;
436 }
437 }
438 n - 1
439}
440
441#[must_use]
443pub fn occ_words_per_col(dims: [u32; 3]) -> u32 {
444 dims[2].div_ceil(32).max(1)
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub struct PadStats {
465 pub dims: [u32; 3],
467 pub content_dims: [u32; 3],
471 pub solid_voxels: u64,
473}
474
475impl PadStats {
476 #[must_use]
481 pub fn pad_ratio(&self) -> f32 {
482 let content = vol(self.content_dims);
483 if content == 0 {
484 1.0
485 } else {
486 vol(self.dims) as f32 / content as f32
487 }
488 }
489
490 #[must_use]
494 pub fn is_wasteful(&self) -> bool {
495 self.pad_ratio() >= 2.0
496 }
497}
498
499fn vol(d: [u32; 3]) -> u64 {
500 u64::from(d[0]) * u64::from(d[1]) * u64::from(d[2])
501}
502
503#[must_use]
508pub fn pad_stats(dims: [u32; 3], frames: &[VoxelFrame]) -> PadStats {
509 let owpc = occ_words_per_col(dims) as usize;
510 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
511 let mut min = [u32::MAX; 3];
512 let mut max = [0u32; 3];
513 let mut solid_voxels = 0u64;
514 let mut any = false;
515
516 for f in frames {
517 for y in 0..my {
518 for x in 0..mx {
519 let base = (x + y * mx) as usize * owpc;
520 let occ = match f.occupancy.get(base..base + owpc) {
521 Some(o) if o.iter().any(|&w| w != 0) => o,
522 _ => continue, };
524 for z in 0..mz {
525 if (occ[(z >> 5) as usize] >> (z & 31)) & 1 != 0 {
526 any = true;
527 solid_voxels += 1;
528 min[0] = min[0].min(x);
529 min[1] = min[1].min(y);
530 min[2] = min[2].min(z);
531 max[0] = max[0].max(x);
532 max[1] = max[1].max(y);
533 max[2] = max[2].max(z);
534 }
535 }
536 }
537 }
538 }
539
540 let content_dims = if any {
541 [
542 max[0] - min[0] + 1,
543 max[1] - min[1] + 1,
544 max[2] - min[2] + 1,
545 ]
546 } else {
547 [0; 3]
548 };
549 PadStats {
550 dims,
551 content_dims,
552 solid_voxels,
553 }
554}
555
556#[derive(Debug, Clone)]
570pub struct StreamingClip {
571 dims: [u32; 3],
572 pivot: [f32; 3],
573 voxel_world_size: f32,
574 loop_mode: LoopMode,
575 owpc: usize,
576 cols: usize,
577 frames: Vec<EncodedFrame>,
579 durations: Vec<u32>,
580 keyframes: Vec<usize>,
582 work_occ: Vec<u32>,
584 work_cols: Vec<Vec<u32>>,
585 current: usize,
587 cur_frame: VoxelFrame,
588 cur_dirs: Vec<u32>,
589}
590
591impl StreamingClip {
592 pub fn new(clip: &VoxelClip) -> Result<Self, DecodeError> {
599 if !matches!(clip.frames.first(), Some(EncodedFrame::Key(_))) {
600 return Err(DecodeError::DeltaBeforeKey);
601 }
602 let owpc = occ_words_per_col(clip.dims) as usize;
603 let cols = (clip.dims[0] as usize) * (clip.dims[1] as usize);
604 let keyframes = clip
605 .frames
606 .iter()
607 .enumerate()
608 .filter_map(|(i, f)| matches!(f, EncodedFrame::Key(_)).then_some(i))
609 .collect();
610 let durations = if clip.durations.is_empty() {
611 vec![clip.default_frame_ms; clip.frames.len()]
612 } else {
613 clip.durations.clone()
614 };
615 let mut s = Self {
616 dims: clip.dims,
617 pivot: clip.pivot,
618 voxel_world_size: clip.voxel_world_size,
619 loop_mode: clip.loop_mode,
620 owpc,
621 cols,
622 frames: clip.frames.clone(),
623 durations,
624 keyframes,
625 work_occ: vec![0u32; cols * owpc],
626 work_cols: vec![Vec::new(); cols],
627 current: 0,
628 cur_frame: VoxelFrame {
629 occupancy: Vec::new(),
630 colors: Vec::new(),
631 color_offsets: Vec::new(),
632 },
633 cur_dirs: Vec::new(),
634 };
635 s.reconstruct(0)?;
636 Ok(s)
637 }
638
639 #[must_use]
640 pub fn frame_count(&self) -> usize {
641 self.frames.len()
642 }
643 #[must_use]
644 pub fn dims(&self) -> [u32; 3] {
645 self.dims
646 }
647 #[must_use]
648 pub fn pivot(&self) -> [f32; 3] {
649 self.pivot
650 }
651 #[must_use]
652 pub fn voxel_world_size(&self) -> f32 {
653 self.voxel_world_size
654 }
655 #[must_use]
656 pub fn loop_mode(&self) -> LoopMode {
657 self.loop_mode
658 }
659 #[must_use]
660 pub fn durations(&self) -> &[u32] {
661 &self.durations
662 }
663 #[must_use]
665 pub fn current_index(&self) -> usize {
666 self.current
667 }
668 #[must_use]
670 pub fn current_frame(&self) -> &VoxelFrame {
671 &self.cur_frame
672 }
673 #[must_use]
676 pub fn current_dirs(&self) -> &[u32] {
677 &self.cur_dirs
678 }
679
680 pub fn seek(&mut self, frame: usize) -> Result<&VoxelFrame, DecodeError> {
688 let target = frame.min(self.frame_count() - 1);
689 if target != self.current || self.cur_frame.occupancy.is_empty() {
690 self.reconstruct(target)?;
691 }
692 Ok(&self.cur_frame)
693 }
694
695 fn keyframe_at_or_before(&self, target: usize) -> usize {
697 let pp = self.keyframes.partition_point(|&k| k <= target);
698 self.keyframes[pp - 1]
699 }
700
701 fn reconstruct(&mut self, target: usize) -> Result<(), DecodeError> {
703 let start = if target > self.current && !self.cur_frame.occupancy.is_empty() {
706 self.current + 1
707 } else {
708 let kf = self.keyframe_at_or_before(target);
709 let mut started = false;
710 apply_frame(
711 &self.frames[kf],
712 &mut self.work_occ,
713 &mut self.work_cols,
714 self.dims,
715 self.owpc,
716 self.cols,
717 &mut started,
718 )?;
719 kf + 1
720 };
721 let mut started = true;
722 for i in start..=target {
723 let ef = &self.frames[i];
725 apply_frame(
726 ef,
727 &mut self.work_occ,
728 &mut self.work_cols,
729 self.dims,
730 self.owpc,
731 self.cols,
732 &mut started,
733 )?;
734 }
735 self.current = target;
736 self.cur_frame = flatten(&self.work_occ, &self.work_cols, self.cols);
737 self.cur_frame
738 .validate(self.dims)
739 .map_err(DecodeError::Frame)?;
740 self.cur_dirs = frame_dirs(&self.cur_frame, self.dims, self.owpc);
741 Ok(())
742 }
743}
744
745const KEYFRAME_COST_PCT: usize = 60;
750
751fn column_diff(
755 prev: &VoxelFrame,
756 frame: &VoxelFrame,
757 cols: usize,
758 owpc: usize,
759) -> Vec<ColumnDelta> {
760 let mut changed = Vec::new();
761 for col in 0..cols {
762 if prev.column_occ(col, owpc) != frame.column_occ(col, owpc)
763 || prev.column_colors(col) != frame.column_colors(col)
764 {
765 changed.push(ColumnDelta {
766 col: col as u32,
767 occ: frame.column_occ(col, owpc).to_vec(),
768 colors: frame.column_colors(col).to_vec(),
769 });
770 }
771 }
772 changed
773}
774
775fn key_words(frame: &VoxelFrame) -> usize {
777 frame.occupancy.len() + frame.color_offsets.len() + frame.colors.len() + 3
779}
780
781fn delta_words(changed: &[ColumnDelta], owpc: usize) -> usize {
783 1 + changed
785 .iter()
786 .map(|d| 1 + owpc + 1 + d.colors.len())
787 .sum::<usize>()
788}
789
790impl VoxelClip {
791 #[must_use]
793 pub fn occ_words_per_col(&self) -> u32 {
794 occ_words_per_col(self.dims)
795 }
796
797 #[must_use]
798 pub fn frame_count(&self) -> usize {
799 self.frames.len()
800 }
801
802 pub fn from_kv6_frames(
817 frames: &[Kv6],
818 voxel_world_size: f32,
819 loop_mode: LoopMode,
820 durations: &[u32],
821 default_frame_ms: u32,
822 keyframe_interval: u32,
823 ) -> Result<Self, Kv6ImportError> {
824 let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
825 Ok(Self::from_frames(
826 dims,
827 pivot,
828 voxel_world_size,
829 loop_mode,
830 &vframes,
831 durations,
832 default_frame_ms,
833 keyframe_interval,
834 ))
835 }
836
837 pub fn from_kv6_frames_auto(
846 frames: &[Kv6],
847 voxel_world_size: f32,
848 loop_mode: LoopMode,
849 durations: &[u32],
850 default_frame_ms: u32,
851 max_keyframe_gap: u32,
852 ) -> Result<Self, Kv6ImportError> {
853 let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
854 Ok(Self::from_frames_auto(
855 dims,
856 pivot,
857 voxel_world_size,
858 loop_mode,
859 &vframes,
860 durations,
861 default_frame_ms,
862 max_keyframe_gap,
863 ))
864 }
865
866 #[allow(clippy::type_complexity)]
869 fn kv6_frames_prepare(
870 frames: &[Kv6],
871 ) -> Result<([u32; 3], [f32; 3], Vec<VoxelFrame>), Kv6ImportError> {
872 let Some(first) = frames.first() else {
873 return Err(Kv6ImportError::Empty);
874 };
875 let dims = [first.xsiz, first.ysiz, first.zsiz];
876 for (i, k) in frames.iter().enumerate() {
877 let d = [k.xsiz, k.ysiz, k.zsiz];
878 if d != dims {
879 return Err(Kv6ImportError::DimsMismatch {
880 frame: i,
881 dims: d,
882 expected: dims,
883 });
884 }
885 }
886 let pivot = [first.xpiv, first.ypiv, first.zpiv];
887 let vframes = frames.iter().map(VoxelFrame::from_kv6).collect();
888 Ok((dims, pivot, vframes))
889 }
890
891 #[must_use]
903 pub fn from_frames(
904 dims: [u32; 3],
905 pivot: [f32; 3],
906 voxel_world_size: f32,
907 loop_mode: LoopMode,
908 frames: &[VoxelFrame],
909 durations: &[u32],
910 default_frame_ms: u32,
911 keyframe_interval: u32,
912 ) -> Self {
913 for (i, f) in frames.iter().enumerate() {
914 f.validate(dims)
915 .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
916 }
917 assert!(
918 durations.is_empty() || durations.len() == frames.len(),
919 "durations must be empty or one per frame",
920 );
921 let owpc = occ_words_per_col(dims) as usize;
922 let cols = (dims[0] as usize) * (dims[1] as usize);
923
924 let mut encoded = Vec::with_capacity(frames.len());
925 for (i, frame) in frames.iter().enumerate() {
926 let is_key = i == 0 || (keyframe_interval != 0 && (i as u32) % keyframe_interval == 0);
927 if is_key {
928 encoded.push(EncodedFrame::Key(frame.clone()));
929 } else {
930 encoded.push(EncodedFrame::Delta(column_diff(
931 &frames[i - 1],
932 frame,
933 cols,
934 owpc,
935 )));
936 }
937 }
938
939 Self {
940 dims,
941 pivot,
942 voxel_world_size,
943 loop_mode,
944 default_frame_ms,
945 frames: encoded,
946 durations: durations.to_vec(),
947 extra_chunks: Vec::new(),
948 }
949 }
950
951 #[must_use]
964 pub fn from_frames_auto(
965 dims: [u32; 3],
966 pivot: [f32; 3],
967 voxel_world_size: f32,
968 loop_mode: LoopMode,
969 frames: &[VoxelFrame],
970 durations: &[u32],
971 default_frame_ms: u32,
972 max_keyframe_gap: u32,
973 ) -> Self {
974 for (i, f) in frames.iter().enumerate() {
975 f.validate(dims)
976 .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
977 }
978 assert!(
979 durations.is_empty() || durations.len() == frames.len(),
980 "durations must be empty or one per frame",
981 );
982 let owpc = occ_words_per_col(dims) as usize;
983 let cols = (dims[0] as usize) * (dims[1] as usize);
984
985 let mut encoded = Vec::with_capacity(frames.len());
986 let mut since_key = 0u32;
987 for (i, frame) in frames.iter().enumerate() {
988 if i == 0 {
989 encoded.push(EncodedFrame::Key(frame.clone()));
990 since_key = 0;
991 continue;
992 }
993 let changed = column_diff(&frames[i - 1], frame, cols, owpc);
994 let gap_forces_key = max_keyframe_gap != 0 && since_key + 1 >= max_keyframe_gap;
995 let delta_too_big =
996 delta_words(&changed, owpc) * 100 >= key_words(frame) * KEYFRAME_COST_PCT;
997 if gap_forces_key || delta_too_big {
998 encoded.push(EncodedFrame::Key(frame.clone()));
999 since_key = 0;
1000 } else {
1001 encoded.push(EncodedFrame::Delta(changed));
1002 since_key += 1;
1003 }
1004 }
1005
1006 Self {
1007 dims,
1008 pivot,
1009 voxel_world_size,
1010 loop_mode,
1011 default_frame_ms,
1012 frames: encoded,
1013 durations: durations.to_vec(),
1014 extra_chunks: Vec::new(),
1015 }
1016 }
1017
1018 pub fn decode(&self) -> Result<DecodedClip, DecodeError> {
1027 let owpc = occ_words_per_col(self.dims) as usize;
1028 let cols = (self.dims[0] as usize) * (self.dims[1] as usize);
1029
1030 let mut work_occ = vec![0u32; cols * owpc];
1033 let mut work_cols: Vec<Vec<u32>> = vec![Vec::new(); cols];
1034 let mut frames: Vec<VoxelFrame> = Vec::with_capacity(self.frames.len());
1035 let mut started = false;
1036
1037 for ef in &self.frames {
1038 apply_frame(
1039 ef,
1040 &mut work_occ,
1041 &mut work_cols,
1042 self.dims,
1043 owpc,
1044 cols,
1045 &mut started,
1046 )?;
1047 frames.push(flatten(&work_occ, &work_cols, cols));
1048 }
1049
1050 let mut dirs = Vec::with_capacity(frames.len());
1052 for f in &frames {
1053 f.validate(self.dims).map_err(DecodeError::Frame)?;
1054 dirs.push(frame_dirs(f, self.dims, owpc));
1055 }
1056
1057 let durations = if self.durations.is_empty() {
1058 vec![self.default_frame_ms; frames.len()]
1059 } else {
1060 self.durations.clone()
1061 };
1062
1063 Ok(DecodedClip {
1064 dims: self.dims,
1065 pivot: self.pivot,
1066 voxel_world_size: self.voxel_world_size,
1067 occ_words_per_col: owpc as u32,
1068 loop_mode: self.loop_mode,
1069 frames,
1070 dirs,
1071 durations,
1072 })
1073 }
1074
1075 #[must_use]
1078 pub fn serialize(&self) -> Vec<u8> {
1079 let mut out = Vec::new();
1080 out.extend_from_slice(&MAGIC);
1081 out.extend_from_slice(&VERSION.to_le_bytes());
1082
1083 write_chunk(&mut out, TAG_META, |b| {
1084 for v in self.dims {
1085 b.extend_from_slice(&v.to_le_bytes());
1086 }
1087 for v in self.pivot {
1088 b.extend_from_slice(&v.to_le_bytes());
1089 }
1090 b.extend_from_slice(&self.voxel_world_size.to_le_bytes());
1091 b.push(self.loop_mode.to_u8());
1092 b.extend_from_slice(&self.default_frame_ms.to_le_bytes());
1093 let fc = u32::try_from(self.frames.len()).expect("frame count fits u32");
1094 b.extend_from_slice(&fc.to_le_bytes());
1095 });
1096
1097 write_chunk(&mut out, TAG_FRMS, |b| {
1098 for ef in &self.frames {
1099 match ef {
1100 EncodedFrame::Key(frame) => {
1101 b.push(FRAME_KIND_KEY);
1102 write_u32_vec(b, &frame.occupancy);
1103 write_u32_vec(b, &frame.color_offsets);
1104 write_u32_vec(b, &frame.colors);
1105 }
1106 EncodedFrame::Delta(changed) => {
1107 b.push(FRAME_KIND_DELTA);
1108 let n = u32::try_from(changed.len()).expect("changed count fits u32");
1109 b.extend_from_slice(&n.to_le_bytes());
1110 for d in changed {
1111 b.extend_from_slice(&d.col.to_le_bytes());
1112 for w in &d.occ {
1114 b.extend_from_slice(&w.to_le_bytes());
1115 }
1116 write_u32_vec(b, &d.colors);
1117 }
1118 }
1119 }
1120 }
1121 });
1122
1123 if !self.durations.is_empty() {
1124 write_chunk(&mut out, TAG_TIME, |b| {
1125 for d in &self.durations {
1126 b.extend_from_slice(&d.to_le_bytes());
1127 }
1128 });
1129 }
1130
1131 for (tag, payload) in &self.extra_chunks {
1132 write_chunk(&mut out, *tag, |b| b.extend_from_slice(payload));
1133 }
1134
1135 out
1136 }
1137
1138 pub fn parse(bytes: &[u8]) -> Result<VoxelClip, ParseError> {
1144 let mut cur = Cursor::new(bytes);
1145 let magic = cur.read_bytes(4)?;
1146 if magic != MAGIC {
1147 return Err(ParseError::BadMagic {
1148 got: [magic[0], magic[1], magic[2], magic[3]],
1149 });
1150 }
1151 let version = cur.read_u16()?;
1152 if version != VERSION && version != VERSION_LEGACY {
1153 return Err(ParseError::UnsupportedVersion(version));
1154 }
1155 let has_flags = version >= VERSION;
1157
1158 let mut meta: Option<Vec<u8>> = None;
1159 let mut frms: Option<Vec<u8>> = None;
1160 let mut time: Option<Vec<u8>> = None;
1161 let mut extra_chunks = Vec::new();
1162 while cur.remaining() > 0 {
1163 let tag_buf = cur.read_bytes(4)?;
1164 let tag = [tag_buf[0], tag_buf[1], tag_buf[2], tag_buf[3]];
1165 let flags = if has_flags { cur.read_u8()? } else { 0 };
1166 let len = cur.read_u32()? as usize;
1167 let stored = cur.read_bytes(len)?;
1168 let payload = if flags & CHUNK_FLAG_DEFLATED != 0 {
1169 inflate_chunk(stored)?
1170 } else {
1171 stored.to_vec()
1172 };
1173 match tag {
1174 TAG_META => meta = Some(payload),
1175 TAG_FRMS => frms = Some(payload),
1176 TAG_TIME => time = Some(payload),
1177 _ => extra_chunks.push((tag, payload)),
1178 }
1179 }
1180
1181 let meta = meta.ok_or(ParseError::MissingChunk(TAG_META))?;
1182 let frms = frms.ok_or(ParseError::MissingChunk(TAG_FRMS))?;
1183
1184 let (dims, pivot, voxel_world_size, loop_mode, default_frame_ms, frame_count) =
1185 parse_meta(&meta)?;
1186 let frames = parse_frms(&frms, dims, frame_count)?;
1187 let durations = match time {
1188 Some(p) => parse_time(&p, frame_count)?,
1189 None => Vec::new(),
1190 };
1191
1192 Ok(VoxelClip {
1193 dims,
1194 pivot,
1195 voxel_world_size,
1196 loop_mode,
1197 default_frame_ms,
1198 frames,
1199 durations,
1200 extra_chunks,
1201 })
1202 }
1203}
1204
1205fn apply_frame(
1212 ef: &EncodedFrame,
1213 work_occ: &mut [u32],
1214 work_cols: &mut [Vec<u32>],
1215 dims: [u32; 3],
1216 owpc: usize,
1217 cols: usize,
1218 started: &mut bool,
1219) -> Result<(), DecodeError> {
1220 match ef {
1221 EncodedFrame::Key(frame) => {
1222 frame.validate(dims).map_err(DecodeError::Frame)?;
1223 work_occ.copy_from_slice(&frame.occupancy);
1224 for (col, wc) in work_cols.iter_mut().enumerate() {
1225 wc.clear();
1226 wc.extend_from_slice(frame.column_colors(col));
1227 }
1228 *started = true;
1229 }
1230 EncodedFrame::Delta(changed) => {
1231 if !*started {
1232 return Err(DecodeError::DeltaBeforeKey);
1233 }
1234 for d in changed {
1235 let col = d.col as usize;
1236 if col >= cols || d.occ.len() != owpc {
1237 return Err(DecodeError::ColumnOutOfRange(d.col));
1238 }
1239 work_occ[col * owpc..(col + 1) * owpc].copy_from_slice(&d.occ);
1240 work_cols[col].clear();
1241 work_cols[col].extend_from_slice(&d.colors);
1242 }
1243 }
1244 }
1245 Ok(())
1246}
1247
1248fn flatten(occ: &[u32], cols_colors: &[Vec<u32>], cols: usize) -> VoxelFrame {
1250 let mut color_offsets = Vec::with_capacity(cols + 1);
1251 let mut colors = Vec::new();
1252 for run in cols_colors {
1253 color_offsets.push(colors.len() as u32);
1254 colors.extend_from_slice(run);
1255 }
1256 color_offsets.push(colors.len() as u32);
1257 VoxelFrame {
1258 occupancy: occ.to_vec(),
1259 colors,
1260 color_offsets,
1261 }
1262}
1263
1264fn frame_dirs(frame: &VoxelFrame, dims: [u32; 3], owpc: usize) -> Vec<u32> {
1267 let (mx, my, mz) = (dims[0] as i64, dims[1] as i64, dims[2] as i64);
1268 let solid = |x: i64, y: i64, z: i64| -> bool {
1269 if x < 0 || y < 0 || z < 0 || x >= mx || y >= my || z >= mz {
1270 return false;
1271 }
1272 let col = (x + y * mx) as usize;
1273 let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1274 (word >> (z & 31)) & 1 != 0
1275 };
1276 let mut dirs = Vec::with_capacity(frame.colors.len());
1277 for y in 0..my {
1278 for x in 0..mx {
1279 let col = (x + y * mx) as usize;
1280 for z in 0..mz {
1282 let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1283 if (word >> (z & 31)) & 1 != 0 {
1284 let (_vis, dir) = compute_vis_dir(&solid, x, y, z);
1285 dirs.push(u32::from(dir));
1286 }
1287 }
1288 }
1289 }
1290 dirs
1291}
1292
1293fn write_chunk(out: &mut Vec<u8>, tag: [u8; 4], body: impl FnOnce(&mut Vec<u8>)) {
1300 let mut raw = Vec::new();
1301 body(&mut raw);
1302 out.extend_from_slice(&tag);
1303
1304 let deflated = miniz_oxide::deflate::compress_to_vec(&raw, DEFLATE_LEVEL);
1305 if deflated.len() + 4 < raw.len() {
1307 out.push(CHUNK_FLAG_DEFLATED);
1308 let len = u32::try_from(deflated.len() + 4).expect("chunk length fits u32");
1309 out.extend_from_slice(&len.to_le_bytes());
1310 let raw_len = u32::try_from(raw.len()).expect("raw length fits u32");
1311 out.extend_from_slice(&raw_len.to_le_bytes());
1312 out.extend_from_slice(&deflated);
1313 } else {
1314 out.push(0);
1315 let len = u32::try_from(raw.len()).expect("chunk length fits u32");
1316 out.extend_from_slice(&len.to_le_bytes());
1317 out.extend_from_slice(&raw);
1318 }
1319}
1320
1321fn inflate_chunk(payload: &[u8]) -> Result<Vec<u8>, ParseError> {
1325 if payload.len() < 4 {
1326 return Err(ParseError::BadDeflate);
1327 }
1328 let raw_len = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
1329 if raw_len > MAX_CHUNK_INFLATE {
1333 return Err(ParseError::BadDeflate);
1334 }
1335 let out = miniz_oxide::inflate::decompress_to_vec_with_limit(&payload[4..], raw_len)
1336 .map_err(|_| ParseError::BadDeflate)?;
1337 if out.len() != raw_len {
1338 return Err(ParseError::BadDeflate);
1339 }
1340 Ok(out)
1341}
1342
1343fn write_u32_vec(out: &mut Vec<u8>, v: &[u32]) {
1345 let n = u32::try_from(v.len()).expect("u32 array length fits u32");
1346 out.extend_from_slice(&n.to_le_bytes());
1347 for w in v {
1348 out.extend_from_slice(&w.to_le_bytes());
1349 }
1350}
1351
1352fn read_u32_vec(cur: &mut Cursor) -> Result<Vec<u32>, ParseError> {
1353 let n = cur.read_u32()? as usize;
1354 let mut v = Vec::with_capacity(n);
1355 for _ in 0..n {
1356 v.push(cur.read_u32()?);
1357 }
1358 Ok(v)
1359}
1360
1361#[allow(clippy::type_complexity)]
1362fn parse_meta(payload: &[u8]) -> Result<([u32; 3], [f32; 3], f32, LoopMode, u32, u32), ParseError> {
1363 let mut cur = Cursor::new(payload);
1364 let dims = [cur.read_u32()?, cur.read_u32()?, cur.read_u32()?];
1365 let pivot = [cur.read_f32()?, cur.read_f32()?, cur.read_f32()?];
1366 let voxel_world_size = cur.read_f32()?;
1367 let loop_mode = LoopMode::from_u8(cur.read_u8()?).ok_or(ParseError::BadLoopMode)?;
1368 let default_frame_ms = cur.read_u32()?;
1369 let frame_count = cur.read_u32()?;
1370 Ok((
1371 dims,
1372 pivot,
1373 voxel_world_size,
1374 loop_mode,
1375 default_frame_ms,
1376 frame_count,
1377 ))
1378}
1379
1380fn parse_frms(
1381 payload: &[u8],
1382 dims: [u32; 3],
1383 frame_count: u32,
1384) -> Result<Vec<EncodedFrame>, ParseError> {
1385 let owpc = occ_words_per_col(dims) as usize;
1386 let mut cur = Cursor::new(payload);
1387 let mut frames = Vec::with_capacity(frame_count as usize);
1388 for _ in 0..frame_count {
1389 let kind = cur.read_u8()?;
1390 match kind {
1391 FRAME_KIND_KEY => {
1392 let occupancy = read_u32_vec(&mut cur)?;
1393 let color_offsets = read_u32_vec(&mut cur)?;
1394 let colors = read_u32_vec(&mut cur)?;
1395 frames.push(EncodedFrame::Key(VoxelFrame {
1396 occupancy,
1397 colors,
1398 color_offsets,
1399 }));
1400 }
1401 FRAME_KIND_DELTA => {
1402 let n = cur.read_u32()? as usize;
1403 let mut changed = Vec::with_capacity(n);
1404 for _ in 0..n {
1405 let col = cur.read_u32()?;
1406 let mut occ = Vec::with_capacity(owpc);
1407 for _ in 0..owpc {
1408 occ.push(cur.read_u32()?);
1409 }
1410 let colors = read_u32_vec(&mut cur)?;
1411 changed.push(ColumnDelta { col, occ, colors });
1412 }
1413 frames.push(EncodedFrame::Delta(changed));
1414 }
1415 other => return Err(ParseError::BadFrameKind(other)),
1416 }
1417 }
1418 Ok(frames)
1419}
1420
1421fn parse_time(payload: &[u8], frame_count: u32) -> Result<Vec<u32>, ParseError> {
1422 let mut cur = Cursor::new(payload);
1423 let mut durations = Vec::with_capacity(frame_count as usize);
1424 for _ in 0..frame_count {
1425 durations.push(cur.read_u32()?);
1426 }
1427 Ok(durations)
1428}
1429
1430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1434pub enum Kv6ImportError {
1435 Empty,
1437 DimsMismatch {
1439 frame: usize,
1440 dims: [u32; 3],
1441 expected: [u32; 3],
1442 },
1443}
1444
1445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1447pub enum FrameError {
1448 OccupancyLen,
1449 OffsetsLen,
1450 OffsetsBounds,
1451 OffsetsMonotonic,
1452 OccupancyColorMismatch(usize),
1454}
1455
1456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1457pub enum ParseError {
1458 BadMagic {
1459 got: [u8; 4],
1460 },
1461 UnsupportedVersion(u16),
1462 Truncated,
1463 MissingChunk([u8; 4]),
1464 BadLoopMode,
1465 BadFrameKind(u8),
1466 BadDeflate,
1469}
1470
1471impl From<OutOfBounds> for ParseError {
1472 fn from(_: OutOfBounds) -> Self {
1473 ParseError::Truncated
1474 }
1475}
1476
1477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1479pub enum DecodeError {
1480 DeltaBeforeKey,
1482 ColumnOutOfRange(u32),
1484 Frame(FrameError),
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490 use super::*;
1491
1492 fn frame_from_fn(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1495 let owpc = occ_words_per_col(dims) as usize;
1496 let cols = (dims[0] as usize) * (dims[1] as usize);
1497 let mut occupancy = vec![0u32; cols * owpc];
1498 let mut color_offsets = vec![0u32; cols + 1];
1499 let mut colors = Vec::new();
1500 for y in 0..dims[1] {
1501 for x in 0..dims[0] {
1502 let col = (x + y * dims[0]) as usize;
1503 color_offsets[col] = colors.len() as u32;
1504 for z in 0..dims[2] {
1505 if let Some(c) = fill(x, y, z) {
1506 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1507 colors.push(c);
1508 }
1509 }
1510 }
1511 }
1512 color_offsets[cols] = colors.len() as u32;
1513 VoxelFrame {
1514 occupancy,
1515 colors,
1516 color_offsets,
1517 }
1518 }
1519
1520 fn flame_clip(
1524 dims: [u32; 3],
1525 n_frames: u32,
1526 keyframe_interval: u32,
1527 ) -> (VoxelClip, Vec<VoxelFrame>) {
1528 let frames: Vec<VoxelFrame> = (0..n_frames)
1529 .map(|fi| {
1530 frame_from_fn(dims, |x, y, z| {
1531 let cx = dims[0] / 2;
1532 let cy = dims[1] / 2;
1533 let stem = x == cx && y == cy && z < dims[2] - 2;
1535 let tip = x == cx && y == cy && z == dims[2] - 2 - (fi % 2);
1537 if stem || tip {
1538 Some(0x80FF_8000 | (fi & 0xF)) } else {
1540 None
1541 }
1542 })
1543 })
1544 .collect();
1545 let clip = VoxelClip::from_frames(
1546 dims,
1547 [
1548 dims[0] as f32 * 0.5,
1549 dims[1] as f32 * 0.5,
1550 dims[2] as f32 * 0.5,
1551 ],
1552 1.0,
1553 LoopMode::Loop,
1554 &frames,
1555 &[],
1556 33,
1557 keyframe_interval,
1558 );
1559 (clip, frames)
1560 }
1561
1562 #[test]
1563 fn occ_words_per_col_matches_sprite_model() {
1564 assert_eq!(occ_words_per_col([8, 8, 1]), 1);
1565 assert_eq!(occ_words_per_col([8, 8, 32]), 1);
1566 assert_eq!(occ_words_per_col([8, 8, 33]), 2);
1567 assert_eq!(occ_words_per_col([8, 8, 256]), 8);
1568 }
1569
1570 #[test]
1571 fn frame_validate_catches_mismatch() {
1572 let dims = [4, 4, 8];
1573 let mut f = frame_from_fn(dims, |x, y, z| {
1574 (x == 0 && y == 0 && z < 3).then_some(0x8000_00FF)
1575 });
1576 assert!(f.validate(dims).is_ok());
1577 f.occupancy[0] &= !1u32;
1580 assert!(matches!(
1581 f.validate(dims),
1582 Err(FrameError::OccupancyColorMismatch(0))
1583 ));
1584 }
1585
1586 #[test]
1587 fn decode_reconstructs_every_frame() {
1588 let dims = [9, 9, 40];
1589 let (clip, original) = flame_clip(dims, 8, 4);
1590 let decoded = clip.decode().expect("decode");
1591 assert_eq!(decoded.frame_count(), original.len());
1592 for (i, (got, want)) in decoded.frames.iter().zip(&original).enumerate() {
1593 assert_eq!(got, want, "frame {i} mismatch");
1594 assert_eq!(
1596 decoded.dirs[i].len(),
1597 got.colors.len(),
1598 "frame {i} dirs len"
1599 );
1600 }
1601 }
1602
1603 #[test]
1604 fn diff_frames_are_smaller_than_keyframes() {
1605 let dims = [9, 9, 40];
1606 let (clip, _) = flame_clip(dims, 8, 0); let keys = clip
1608 .frames
1609 .iter()
1610 .filter(|f| matches!(f, EncodedFrame::Key(_)))
1611 .count();
1612 assert_eq!(keys, 1, "keyframe_interval=0 ⇒ exactly one keyframe");
1613 for f in &clip.frames {
1616 if let EncodedFrame::Delta(changed) = f {
1617 assert!(
1618 changed.len() < (dims[0] * dims[1]) as usize,
1619 "delta should be sparse, got {} columns",
1620 changed.len()
1621 );
1622 }
1623 }
1624 }
1625
1626 #[test]
1627 fn serialize_parse_round_trips() {
1628 let dims = [9, 9, 40];
1629 let (clip, _) = flame_clip(dims, 8, 4);
1630 let bytes = clip.serialize();
1631 let parsed = VoxelClip::parse(&bytes).expect("parse");
1632 assert_eq!(parsed, clip);
1633 assert_eq!(parsed.serialize(), bytes);
1635 let a = clip.decode().expect("decode a");
1637 let b = parsed.decode().expect("decode b");
1638 assert_eq!(a.frames, b.frames);
1639 }
1640
1641 #[test]
1642 fn durations_default_when_time_chunk_absent() {
1643 let dims = [4, 4, 8];
1644 let (clip, _) = flame_clip(dims, 4, 2);
1645 assert!(clip.durations.is_empty());
1646 let decoded = clip.decode().expect("decode");
1647 assert_eq!(decoded.durations, vec![33; 4]);
1648 assert_eq!(decoded.total_ms(), 33 * 4);
1649 }
1650
1651 #[test]
1652 fn explicit_durations_round_trip() {
1653 let dims = [4, 4, 8];
1654 let frames: Vec<VoxelFrame> = (0..3)
1655 .map(|fi| {
1656 frame_from_fn(dims, move |x, y, z| {
1657 (x == 0 && y == 0 && z == fi).then_some(0x8011_2233)
1658 })
1659 })
1660 .collect();
1661 let clip = VoxelClip::from_frames(
1662 dims,
1663 [0.0; 3],
1664 1.0,
1665 LoopMode::Once,
1666 &frames,
1667 &[10, 20, 30],
1668 33,
1669 0,
1670 );
1671 let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1672 assert_eq!(parsed.durations, vec![10, 20, 30]);
1673 assert_eq!(parsed.decode().unwrap().durations, vec![10, 20, 30]);
1674 assert_eq!(parsed.loop_mode, LoopMode::Once);
1675 }
1676
1677 #[test]
1678 fn unknown_chunks_preserved() {
1679 let dims = [4, 4, 8];
1680 let (mut clip, _) = flame_clip(dims, 2, 0);
1681 clip.extra_chunks.push((*b"XTRA", vec![1, 2, 3, 4, 5]));
1682 let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1683 assert_eq!(parsed.extra_chunks, vec![(*b"XTRA", vec![1, 2, 3, 4, 5])]);
1684 }
1685
1686 #[test]
1687 fn bad_magic_and_version_rejected() {
1688 let dims = [4, 4, 8];
1689 let (clip, _) = flame_clip(dims, 2, 0);
1690 let mut bytes = clip.serialize();
1691 let good = bytes.clone();
1692 bytes[0] = b'X';
1693 assert!(matches!(
1694 VoxelClip::parse(&bytes),
1695 Err(ParseError::BadMagic { .. })
1696 ));
1697 let mut v = good.clone();
1698 v[4] = 9; assert!(matches!(
1700 VoxelClip::parse(&v),
1701 Err(ParseError::UnsupportedVersion(_))
1702 ));
1703 }
1704
1705 #[test]
1706 fn frame_at_honours_loop_modes() {
1707 let dims = [4, 4, 8];
1709 let frames: Vec<VoxelFrame> = (0..3)
1710 .map(|fi| {
1711 frame_from_fn(dims, move |x, y, z| {
1712 (x == 0 && y == 0 && z == fi).then_some(0x8011_2233)
1713 })
1714 })
1715 .collect();
1716 let mk = |mode| {
1717 VoxelClip::from_frames(dims, [0.0; 3], 1.0, mode, &frames, &[10, 10, 10], 33, 0)
1718 .decode()
1719 .unwrap()
1720 };
1721
1722 let loop_c = mk(LoopMode::Loop);
1723 assert_eq!(loop_c.frame_at(0), 0);
1724 assert_eq!(loop_c.frame_at(9), 0);
1725 assert_eq!(loop_c.frame_at(10), 1);
1726 assert_eq!(loop_c.frame_at(25), 2);
1727 assert_eq!(loop_c.frame_at(30), 0, "wraps at total");
1728 assert_eq!(loop_c.frame_at(45), 1);
1729
1730 let once = mk(LoopMode::Once);
1731 assert_eq!(once.frame_at(25), 2);
1732 assert_eq!(once.frame_at(1000), 2, "holds the last frame");
1733
1734 let ping = mk(LoopMode::PingPong);
1735 assert_eq!(ping.frame_at(5), 0);
1736 assert_eq!(ping.frame_at(25), 2);
1737 assert_eq!(ping.frame_at(35), 2, "mirror: 35→ frame 2");
1738 assert_eq!(ping.frame_at(55), 0, "mirror back to 0 near 2·total");
1739 }
1740
1741 #[test]
1742 fn delta_before_key_rejected() {
1743 let dims = [4, 4, 8];
1744 let clip = VoxelClip {
1745 dims,
1746 pivot: [0.0; 3],
1747 voxel_world_size: 1.0,
1748 loop_mode: LoopMode::Loop,
1749 default_frame_ms: 33,
1750 frames: vec![EncodedFrame::Delta(Vec::new())],
1751 durations: Vec::new(),
1752 extra_chunks: Vec::new(),
1753 };
1754 assert!(matches!(clip.decode(), Err(DecodeError::DeltaBeforeKey)));
1755 }
1756
1757 fn isolated_fill(x: u32, y: u32, z: u32) -> Option<u32> {
1764 (x % 2 == 0 && y % 2 == 0 && z % 2 == 0).then_some(0x8000_0000 | (x << 16) | (y << 8) | z)
1765 }
1766
1767 #[test]
1768 fn from_kv6_matches_dense_reference() {
1769 let dims = [3u32, 2, 41];
1772 let kv6 = Kv6::from_fn(dims[0], dims[1], dims[2], isolated_fill);
1773 let imported = VoxelFrame::from_kv6(&kv6);
1774 let expected = frame_from_fn(dims, isolated_fill);
1775 assert_eq!(imported, expected);
1776 imported.validate(dims).expect("imported frame is valid");
1777 }
1778
1779 #[test]
1780 fn from_kv6_packs_z_across_word_boundary() {
1781 let kv6 = Kv6::from_fn(1, 1, 41, |_, _, z| match z {
1783 0 => Some(0x80FF_0000),
1784 5 => Some(0x8000_FF00),
1785 33 => Some(0x8000_00FF),
1786 40 => Some(0x80FF_FF00),
1787 _ => None,
1788 });
1789 let f = VoxelFrame::from_kv6(&kv6);
1790 assert_eq!(f.occupancy, vec![(1 << 0) | (1 << 5), (1 << 1) | (1 << 8)]);
1792 assert_eq!(
1794 f.colors,
1795 vec![0x80FF_0000, 0x8000_FF00, 0x8000_00FF, 0x80FF_FF00]
1796 );
1797 assert_eq!(f.color_offsets, vec![0, 4]);
1798 f.validate([1, 1, 41]).expect("valid");
1799 }
1800
1801 #[test]
1802 fn from_kv6_frames_round_trips_through_clip() {
1803 let dims = [2u32, 2, 3];
1804 let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1806 (z == 0).then_some(0x80FF_0000)
1807 });
1808 let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1809 (z == 2).then_some(0x8000_FF00)
1810 });
1811 let clip = VoxelClip::from_kv6_frames(
1812 &[ka.clone(), kb.clone()],
1813 2.0,
1814 LoopMode::Loop,
1815 &[100, 200],
1816 0,
1817 0,
1818 )
1819 .expect("import");
1820 assert_eq!(clip.dims, dims);
1821 assert_eq!(clip.voxel_world_size, 2.0);
1822 assert_eq!(clip.pivot, [ka.xpiv, ka.ypiv, ka.zpiv]);
1823 assert_eq!(clip.durations, vec![100, 200]);
1824
1825 let decoded = clip.decode().expect("decode");
1826 assert_eq!(decoded.frames.len(), 2);
1827 assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
1828 assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
1829 }
1830
1831 #[test]
1832 fn from_kv6_frames_rejects_empty() {
1833 let err = VoxelClip::from_kv6_frames(&[], 1.0, LoopMode::Loop, &[], 50, 0)
1834 .expect_err("empty must fail");
1835 assert_eq!(err, Kv6ImportError::Empty);
1836 }
1837
1838 #[test]
1839 fn from_kv6_frames_rejects_dims_mismatch() {
1840 let ka = Kv6::from_fn(2, 2, 2, |_, _, z| (z == 0).then_some(0x80FF_FFFF));
1841 let kb = Kv6::from_fn(3, 2, 2, |_, _, z| (z == 0).then_some(0x80FF_FFFF));
1842 let err = VoxelClip::from_kv6_frames(&[ka, kb], 1.0, LoopMode::Loop, &[], 50, 0)
1843 .expect_err("mismatch must fail");
1844 assert_eq!(
1845 err,
1846 Kv6ImportError::DimsMismatch {
1847 frame: 1,
1848 dims: [3, 2, 2],
1849 expected: [2, 2, 2],
1850 }
1851 );
1852 }
1853
1854 #[test]
1855 fn to_kv6_inverts_from_kv6() {
1856 let dims = [3u32, 2, 40];
1859 let frame = frame_from_fn(dims, |x, y, z| {
1860 (z <= (x + y) * 6 + 3).then_some(0x8000_0000 | (z << 8) | (x * 16 + y))
1861 });
1862 let kv6 = frame.to_kv6(dims, [1.0, 0.5, 20.0]);
1863 assert_eq!([kv6.xsiz, kv6.ysiz, kv6.zsiz], dims);
1864 assert_eq!([kv6.xpiv, kv6.ypiv, kv6.zpiv], [1.0, 0.5, 20.0]);
1865 assert_eq!(VoxelFrame::from_kv6(&kv6), frame);
1867 }
1868
1869 #[test]
1870 fn voxel_frame_dirs_match_decoded() {
1871 let dims = [4u32, 3, 8];
1875 let frame = frame_from_fn(dims, |x, y, z| (z <= x + y).then_some(0x80FF_0000));
1876 let clip = VoxelClip::from_frames(
1877 dims,
1878 [0.0; 3],
1879 1.0,
1880 LoopMode::Loop,
1881 std::slice::from_ref(&frame),
1882 &[],
1883 33,
1884 0,
1885 );
1886 let decoded = clip.decode().unwrap();
1887 assert_eq!(frame.dirs(dims), decoded.dirs[0]);
1888 }
1889
1890 #[test]
1893 fn compressed_clip_round_trips_and_shrinks() {
1894 let dims = [16u32, 16, 32];
1897 let frame = frame_from_fn(dims, |_, _, _| Some(0x80AB_CDEF));
1898 let clip = VoxelClip::from_frames(
1899 dims,
1900 [8.0, 8.0, 16.0],
1901 1.0,
1902 LoopMode::Loop,
1903 &[frame],
1904 &[],
1905 33,
1906 0,
1907 );
1908 let bytes = clip.serialize();
1909 let raw_colors_bytes = (dims[0] * dims[1] * dims[2]) as usize * 4;
1912 assert!(
1913 bytes.len() < raw_colors_bytes / 4,
1914 "expected compression: {} serialized bytes vs {raw_colors_bytes} raw colour bytes",
1915 bytes.len(),
1916 );
1917 assert_eq!(&bytes[4..6], &VERSION.to_le_bytes());
1920 let parsed = VoxelClip::parse(&bytes).expect("parse");
1921 assert_eq!(parsed, clip);
1922 assert_eq!(parsed.serialize(), bytes);
1923 }
1924
1925 fn serialize_v1(clip: &VoxelClip) -> Vec<u8> {
1928 fn chunk(out: &mut Vec<u8>, tag: [u8; 4], payload: &[u8]) {
1929 out.extend_from_slice(&tag);
1930 out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1931 out.extend_from_slice(payload);
1932 }
1933 fn u32_vec(out: &mut Vec<u8>, v: &[u32]) {
1934 out.extend_from_slice(&(v.len() as u32).to_le_bytes());
1935 for w in v {
1936 out.extend_from_slice(&w.to_le_bytes());
1937 }
1938 }
1939 let mut out = Vec::new();
1940 out.extend_from_slice(b"RVCL");
1941 out.extend_from_slice(&1u16.to_le_bytes());
1942
1943 let mut meta = Vec::new();
1944 for v in clip.dims {
1945 meta.extend_from_slice(&v.to_le_bytes());
1946 }
1947 for v in clip.pivot {
1948 meta.extend_from_slice(&v.to_le_bytes());
1949 }
1950 meta.extend_from_slice(&clip.voxel_world_size.to_le_bytes());
1951 meta.push(clip.loop_mode.to_u8());
1952 meta.extend_from_slice(&clip.default_frame_ms.to_le_bytes());
1953 meta.extend_from_slice(&(clip.frames.len() as u32).to_le_bytes());
1954 chunk(&mut out, *b"META", &meta);
1955
1956 let mut frms = Vec::new();
1957 for ef in &clip.frames {
1958 let EncodedFrame::Key(f) = ef else {
1959 panic!("serialize_v1 test helper handles keyframes only");
1960 };
1961 frms.push(FRAME_KIND_KEY);
1962 u32_vec(&mut frms, &f.occupancy);
1963 u32_vec(&mut frms, &f.color_offsets);
1964 u32_vec(&mut frms, &f.colors);
1965 }
1966 chunk(&mut out, *b"FRMS", &frms);
1967 out
1968 }
1969
1970 #[test]
1971 fn legacy_v1_file_still_parses() {
1972 let dims = [2u32, 2, 3];
1973 let frame = frame_from_fn(dims, |_, _, z| (z == 0).then_some(0x80FF_0000));
1974 let clip =
1975 VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Once, &[frame], &[], 50, 0);
1976 let v1 = serialize_v1(&clip);
1977 assert_eq!(&v1[4..6], &1u16.to_le_bytes(), "helper writes version 1");
1978 let parsed = VoxelClip::parse(&v1).expect("v1 must still parse");
1979 assert_eq!(parsed, clip);
1980 }
1981
1982 #[test]
1983 fn bad_deflate_payload_is_rejected() {
1984 let mut bytes = Vec::new();
1986 bytes.extend_from_slice(b"RVCL");
1987 bytes.extend_from_slice(&VERSION.to_le_bytes());
1988 bytes.extend_from_slice(b"META");
1989 bytes.push(CHUNK_FLAG_DEFLATED);
1990 let payload = [99u8, 0, 0, 0, 0xDE, 0xAD, 0xBE, 0xEF]; bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1992 bytes.extend_from_slice(&payload);
1993 assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
1994 }
1995
1996 fn build_varied_clip() -> VoxelClip {
2003 let dims = [4u32, 3, 40];
2004 let frames: Vec<VoxelFrame> = (0..7u32)
2005 .map(|i| {
2006 let h = 5 + i * 5;
2007 frame_from_fn(dims, move |_x, _y, z| {
2008 (z < h).then_some(0x8000_0000 | (i * 0x10))
2009 })
2010 })
2011 .collect();
2012 VoxelClip::from_frames(
2013 dims,
2014 [2.0, 1.5, 20.0],
2015 1.0,
2016 LoopMode::Loop,
2017 &frames,
2018 &[],
2019 33,
2020 3,
2021 )
2022 }
2023
2024 #[test]
2025 fn streaming_matches_decoded_forward_and_random() {
2026 let clip = build_varied_clip();
2027 let decoded = clip.decode().expect("decode");
2028 let mut stream = StreamingClip::new(&clip).expect("stream");
2029 assert_eq!(stream.frame_count(), decoded.frames.len());
2030 assert_eq!(stream.dims(), decoded.dims);
2031 assert_eq!(stream.pivot(), decoded.pivot);
2032
2033 for (i, want) in decoded.frames.iter().enumerate() {
2035 let got = stream.seek(i).expect("seek").clone();
2036 assert_eq!(&got, want, "frame {i} (forward)");
2037 assert_eq!(
2038 stream.current_dirs(),
2039 decoded.dirs[i].as_slice(),
2040 "dirs {i}"
2041 );
2042 assert_eq!(stream.current_index(), i);
2043 }
2044 for &i in &[6usize, 0, 4, 1, 5, 2, 3, 0, 6] {
2046 let got = stream.seek(i).expect("seek").clone();
2047 assert_eq!(&got, &decoded.frames[i], "frame {i} (random)");
2048 assert_eq!(stream.current_dirs(), decoded.dirs[i].as_slice());
2049 }
2050 }
2051
2052 #[test]
2053 fn streaming_seek_clamps_past_end() {
2054 let clip = build_varied_clip();
2055 let decoded = clip.decode().unwrap();
2056 let mut stream = StreamingClip::new(&clip).unwrap();
2057 let last = decoded.frames.len() - 1;
2058 let got = stream.seek(999).unwrap().clone();
2059 assert_eq!(got, decoded.frames[last]);
2060 assert_eq!(stream.current_index(), last);
2061 }
2062
2063 #[test]
2064 fn streaming_rejects_empty_and_delta_first() {
2065 let dims = [1u32, 1, 1];
2066 let mk = |frames: Vec<EncodedFrame>| VoxelClip {
2067 dims,
2068 pivot: [0.0; 3],
2069 voxel_world_size: 1.0,
2070 loop_mode: LoopMode::Loop,
2071 default_frame_ms: 1,
2072 frames,
2073 durations: Vec::new(),
2074 extra_chunks: Vec::new(),
2075 };
2076 assert_eq!(
2077 StreamingClip::new(&mk(Vec::new())).map(|_| ()),
2078 Err(DecodeError::DeltaBeforeKey),
2079 );
2080 assert_eq!(
2081 StreamingClip::new(&mk(vec![EncodedFrame::Delta(Vec::new())])).map(|_| ()),
2082 Err(DecodeError::DeltaBeforeKey),
2083 );
2084 }
2085
2086 #[test]
2089 fn pad_stats_tight_clip_is_not_wasteful() {
2090 let dims = [8u32, 8, 8];
2092 let frame = frame_from_fn(dims, |x, y, z| {
2093 ((x == 0 && y == 0 && z == 0) || (x == 7 && y == 7 && z == 7)).then_some(0x80FF_FFFF)
2094 });
2095 let s = pad_stats(dims, std::slice::from_ref(&frame));
2096 assert_eq!(s.content_dims, dims);
2097 assert_eq!(s.solid_voxels, 2);
2098 assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2099 assert!(!s.is_wasteful());
2100 }
2101
2102 #[test]
2103 fn pad_stats_padded_clip_is_wasteful() {
2104 let dims = [40u32, 40, 40];
2106 let frames: Vec<VoxelFrame> = (0..3)
2107 .map(|_| {
2108 frame_from_fn(dims, |x, y, z| {
2109 (x < 10 && y < 10 && z < 10).then_some(0x80FF_0000)
2110 })
2111 })
2112 .collect();
2113 let s = pad_stats(dims, &frames);
2114 assert_eq!(s.content_dims, [10, 10, 10]);
2115 assert_eq!(s.solid_voxels, 3 * 1000);
2116 assert!((s.pad_ratio() - 64.0).abs() < 1e-3);
2118 assert!(s.is_wasteful());
2119 }
2120
2121 #[test]
2122 fn pad_stats_empty_clip_is_not_wasteful() {
2123 let dims = [4u32, 4, 4];
2124 let empty = frame_from_fn(dims, |_, _, _| None);
2125 let s = pad_stats(dims, std::slice::from_ref(&empty));
2126 assert_eq!(s.content_dims, [0, 0, 0]);
2127 assert_eq!(s.solid_voxels, 0);
2128 assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2129 assert!(!s.is_wasteful());
2130 }
2131
2132 #[test]
2133 fn decoded_clip_pad_stats_delegates() {
2134 let dims = [20u32, 4, 4];
2135 let frame = frame_from_fn(dims, |x, _, _| (x < 4).then_some(0x80FF_FFFF));
2136 let clip =
2137 VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Loop, &[frame], &[], 33, 0);
2138 let s = clip.decode().unwrap().pad_stats();
2139 assert_eq!(s.content_dims, [4, 4, 4]);
2140 assert!((s.pad_ratio() - 5.0).abs() < 1e-3);
2142 assert!(s.is_wasteful());
2143 }
2144
2145 fn is_key(e: &EncodedFrame) -> bool {
2148 matches!(e, EncodedFrame::Key(_))
2149 }
2150 fn key_positions(clip: &VoxelClip) -> Vec<usize> {
2151 clip.frames
2152 .iter()
2153 .enumerate()
2154 .filter_map(|(i, e)| is_key(e).then_some(i))
2155 .collect()
2156 }
2157
2158 #[test]
2159 fn from_frames_auto_round_trips() {
2160 let dims = [4u32, 3, 40];
2161 let frames: Vec<VoxelFrame> = (0..7u32)
2162 .map(|i| {
2163 let h = 5 + i * 5;
2164 frame_from_fn(dims, move |_, _, z| {
2165 (z < h).then_some(0x8000_0000 | (i * 0x10))
2166 })
2167 })
2168 .collect();
2169 let clip =
2170 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2171 assert_eq!(clip.decode().unwrap().frames, frames);
2173 assert!(is_key(&clip.frames[0]), "frame 0 is always a keyframe");
2174 }
2175
2176 #[test]
2177 fn from_frames_auto_keyframes_scene_change_but_deltas_small_change() {
2178 let dims = [4u32, 4, 8];
2179 let a = frame_from_fn(dims, |_, _, z| (z < 4).then_some(0x80FF_0000));
2180 let scene_cut = frame_from_fn(dims, |_, _, z| (z >= 4).then_some(0x8000_FF00));
2182 let small = frame_from_fn(dims, |x, y, z| {
2184 ((z < 4) || (x == 0 && y == 0 && z == 4)).then_some(0x80FF_0000)
2185 });
2186
2187 let cut = VoxelClip::from_frames_auto(
2188 dims,
2189 [0.0; 3],
2190 1.0,
2191 LoopMode::Loop,
2192 &[a.clone(), scene_cut],
2193 &[],
2194 33,
2195 0,
2196 );
2197 assert!(is_key(&cut.frames[1]), "scene change → keyframe");
2198
2199 let tweak = VoxelClip::from_frames_auto(
2200 dims,
2201 [0.0; 3],
2202 1.0,
2203 LoopMode::Loop,
2204 &[a, small],
2205 &[],
2206 33,
2207 0,
2208 );
2209 assert!(!is_key(&tweak.frames[1]), "small change → delta");
2210 }
2211
2212 #[test]
2213 fn from_frames_auto_gap_caps_keyframe_spacing() {
2214 let dims = [2u32, 2, 4];
2215 let f = frame_from_fn(dims, |_, _, z| (z < 2).then_some(0x80FF_FFFF));
2216 let frames = vec![f; 7]; let none =
2220 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2221 assert_eq!(key_positions(&none), vec![0]);
2222
2223 let capped =
2225 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 3);
2226 assert_eq!(key_positions(&capped), vec![0, 3, 6]);
2227 for (i, e) in capped.frames.iter().enumerate() {
2228 if let EncodedFrame::Delta(d) = e {
2229 assert!(d.is_empty(), "identical frame {i} → empty delta");
2230 }
2231 }
2232 }
2233
2234 #[test]
2235 fn from_kv6_frames_auto_round_trips() {
2236 let dims = [3u32, 3, 6];
2237 let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2238 (z == 0).then_some(0x80FF_0000)
2239 });
2240 let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2241 (z >= 3).then_some(0x8000_FF00)
2242 });
2243 let clip = VoxelClip::from_kv6_frames_auto(
2244 &[ka.clone(), kb.clone()],
2245 1.0,
2246 LoopMode::Loop,
2247 &[],
2248 33,
2249 0,
2250 )
2251 .expect("import");
2252 let decoded = clip.decode().unwrap();
2253 assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
2254 assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
2255 }
2256
2257 #[test]
2260 fn from_kv6_does_not_panic_on_malformed_kv6() {
2261 let bad = Kv6 {
2264 xsiz: 2,
2265 ysiz: 2,
2266 zsiz: 4,
2267 xpiv: 0.0,
2268 ypiv: 0.0,
2269 zpiv: 0.0,
2270 voxels: vec![Voxel {
2271 col: 0x80FF_FFFF,
2272 z: 0,
2273 vis: 63,
2274 dir: 0,
2275 }],
2276 xlen: vec![5, 5], ylen: vec![vec![3, 3], vec![3, 3, 99]], palette: None,
2279 };
2280 let f = VoxelFrame::from_kv6(&bad); f.validate([2, 2, 4]).expect("frame is well-formed");
2284 assert_eq!(f.colors, vec![0x80FF_FFFF]);
2285 }
2286
2287 #[test]
2288 fn inflate_rejects_oversized_raw_len() {
2289 let mut bytes = Vec::new();
2292 bytes.extend_from_slice(b"RVCL");
2293 bytes.extend_from_slice(&VERSION.to_le_bytes());
2294 bytes.extend_from_slice(b"META");
2295 bytes.push(CHUNK_FLAG_DEFLATED);
2296 let mut payload = u32::MAX.to_le_bytes().to_vec(); payload.extend_from_slice(&[0x01, 0x00, 0x00]); bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2299 bytes.extend_from_slice(&payload);
2300 assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2301 }
2302
2303 #[test]
2304 fn frame_at_no_overflow_for_huge_durations() {
2305 let durations = vec![u32::MAX / 2, u32::MAX / 2];
2308 let f = frame_at(&durations, LoopMode::PingPong, u32::MAX);
2309 assert!(f < durations.len());
2310 assert!(frame_at(&durations, LoopMode::Loop, u32::MAX) < durations.len());
2312 assert!(frame_at(&durations, LoopMode::Once, u32::MAX) < durations.len());
2313 }
2314}