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 MAX_CLIP_DIM: u32 = 4096;
70const DEFLATE_LEVEL: u8 = 8;
73
74const FRAME_KIND_KEY: u8 = 0;
75const FRAME_KIND_DELTA: u8 = 1;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum LoopMode {
80 Loop,
82 Once,
84 PingPong,
86}
87
88impl LoopMode {
89 fn to_u8(self) -> u8 {
90 match self {
91 Self::Loop => 0,
92 Self::Once => 1,
93 Self::PingPong => 2,
94 }
95 }
96 fn from_u8(v: u8) -> Option<Self> {
97 match v {
98 0 => Some(Self::Loop),
99 1 => Some(Self::Once),
100 2 => Some(Self::PingPong),
101 _ => None,
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct VoxelFrame {
110 pub occupancy: Vec<u32>,
115 pub colors: Vec<u32>,
118 pub color_offsets: Vec<u32>,
121}
122
123impl VoxelFrame {
124 #[must_use]
138 #[allow(clippy::cast_possible_truncation)]
139 pub fn from_kv6(kv6: &Kv6) -> Self {
140 let dims = [kv6.xsiz, kv6.ysiz, kv6.zsiz];
141 let (nx, ny) = (dims[0] as usize, dims[1] as usize);
142 let cols = nx * ny;
143 let owpc = occ_words_per_col(dims) as usize;
144 let zmax = dims[2];
145
146 let mut per_col: Vec<Vec<(u16, u32)>> = vec![Vec::new(); cols];
152 let mut vi = 0usize;
153 for x in 0..nx {
154 let col_counts = kv6.ylen.get(x).map_or(&[][..], Vec::as_slice);
155 for (y, &cnt) in col_counts.iter().enumerate() {
156 let start = vi.min(kv6.voxels.len());
157 let end = (start + cnt as usize).min(kv6.voxels.len());
158 if y < ny {
159 let col = x + y * nx; for v in &kv6.voxels[start..end] {
161 if u32::from(v.z) < zmax {
162 per_col[col].push((v.z, v.col));
163 }
164 }
165 }
166 vi = end;
167 }
168 }
169
170 let mut occupancy = vec![0u32; cols * owpc];
171 let mut colors = Vec::new();
172 let mut color_offsets = Vec::with_capacity(cols + 1);
173 color_offsets.push(0u32);
174 for (col, run) in per_col.iter_mut().enumerate() {
175 run.sort_by_key(|&(z, _)| z);
176 for &(z, c) in run.iter() {
177 let zi = z as usize;
178 occupancy[col * owpc + zi / 32] |= 1u32 << (zi % 32);
179 colors.push(c);
180 }
181 color_offsets.push(colors.len() as u32);
182 }
183
184 Self {
185 occupancy,
186 colors,
187 color_offsets,
188 }
189 }
190
191 #[must_use]
198 #[allow(clippy::cast_possible_truncation)]
199 pub fn to_kv6(&self, dims: [u32; 3], pivot: [f32; 3]) -> Kv6 {
200 let (nx, ny) = (dims[0] as usize, dims[1] as usize);
201 let owpc = occ_words_per_col(dims) as usize;
202 let mut voxels = Vec::new();
203 let mut xlen = Vec::with_capacity(nx);
204 let mut ylen = Vec::with_capacity(nx);
205
206 for x in 0..nx {
209 let mut col_counts: Vec<u16> = Vec::with_capacity(ny);
210 let mut xcount = 0u32;
211 for y in 0..ny {
212 let col = x + y * nx;
213 let run = self.column_colors(col);
214 let occ = &self.occupancy[col * owpc..(col + 1) * owpc];
215 let before = voxels.len();
216 let mut ci = 0usize;
217 for z in 0..dims[2] {
218 if (occ[(z >> 5) as usize] >> (z & 31)) & 1 != 0 {
219 voxels.push(Voxel {
220 col: run[ci],
221 z: z as u16,
222 vis: 63,
223 dir: 0,
224 });
225 ci += 1;
226 }
227 }
228 let c = (voxels.len() - before) as u16;
229 col_counts.push(c);
230 xcount += u32::from(c);
231 }
232 xlen.push(xcount);
233 ylen.push(col_counts);
234 }
235
236 Kv6 {
237 xsiz: dims[0],
238 ysiz: dims[1],
239 zsiz: dims[2],
240 xpiv: pivot[0],
241 ypiv: pivot[1],
242 zpiv: pivot[2],
243 voxels,
244 xlen,
245 ylen,
246 palette: None,
247 }
248 }
249
250 #[must_use]
256 pub fn dirs(&self, dims: [u32; 3]) -> Vec<u32> {
257 frame_dirs(self, dims, occ_words_per_col(dims) as usize)
258 }
259
260 pub fn validate(&self, dims: [u32; 3]) -> Result<(), FrameError> {
268 let cols = (dims[0] as usize) * (dims[1] as usize);
269 let owpc = occ_words_per_col(dims) as usize;
270 if self.occupancy.len() != cols * owpc {
271 return Err(FrameError::OccupancyLen);
272 }
273 if self.color_offsets.len() != cols + 1 {
274 return Err(FrameError::OffsetsLen);
275 }
276 if self.color_offsets[0] != 0
277 || *self.color_offsets.last().unwrap() as usize != self.colors.len()
278 {
279 return Err(FrameError::OffsetsBounds);
280 }
281 for col in 0..cols {
282 let lo = self.color_offsets[col];
283 let hi = self.color_offsets[col + 1];
284 if hi < lo {
285 return Err(FrameError::OffsetsMonotonic);
286 }
287 let run = (hi - lo) as usize;
288 let mut popcount = 0usize;
289 for w in 0..owpc {
290 popcount += self.occupancy[col * owpc + w].count_ones() as usize;
291 }
292 if popcount != run {
293 return Err(FrameError::OccupancyColorMismatch(col));
294 }
295 }
296 Ok(())
297 }
298
299 fn column_colors(&self, col: usize) -> &[u32] {
301 &self.colors[self.color_offsets[col] as usize..self.color_offsets[col + 1] as usize]
302 }
303
304 fn column_occ(&self, col: usize, owpc: usize) -> &[u32] {
306 &self.occupancy[col * owpc..(col + 1) * owpc]
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct ColumnDelta {
314 pub col: u32,
315 pub occ: Vec<u32>,
316 pub colors: Vec<u32>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum EncodedFrame {
323 Key(VoxelFrame),
325 Delta(Vec<ColumnDelta>),
327}
328
329#[derive(Debug, Clone, PartialEq)]
333pub struct VoxelClip {
334 pub dims: [u32; 3],
335 pub pivot: [f32; 3],
336 pub voxel_world_size: f32,
337 pub loop_mode: LoopMode,
338 pub default_frame_ms: u32,
340 pub frames: Vec<EncodedFrame>,
342 pub durations: Vec<u32>,
344 pub extra_chunks: Vec<([u8; 4], Vec<u8>)>,
346}
347
348#[derive(Debug, Clone)]
352pub struct DecodedClip {
353 pub dims: [u32; 3],
354 pub pivot: [f32; 3],
355 pub voxel_world_size: f32,
356 pub occ_words_per_col: u32,
357 pub loop_mode: LoopMode,
358 pub frames: Vec<VoxelFrame>,
359 pub dirs: Vec<Vec<u32>>,
362 pub durations: Vec<u32>,
363}
364
365impl DecodedClip {
366 #[must_use]
367 pub fn frame_count(&self) -> usize {
368 self.frames.len()
369 }
370
371 #[must_use]
374 pub fn total_ms(&self) -> u32 {
375 self.durations
376 .iter()
377 .fold(0u32, |acc, &d| acc.saturating_add(d))
378 }
379
380 #[must_use]
383 pub fn pad_stats(&self) -> PadStats {
384 pad_stats(self.dims, &self.frames)
385 }
386
387 #[must_use]
396 pub fn frame_at(&self, elapsed_ms: u32) -> usize {
397 frame_at(&self.durations, self.loop_mode, elapsed_ms)
398 }
399}
400
401#[must_use]
411pub fn frame_at(durations: &[u32], loop_mode: LoopMode, elapsed_ms: u32) -> usize {
412 let n = durations.len();
413 if n <= 1 {
414 return 0;
415 }
416 let total: u64 = durations.iter().map(|&d| u64::from(d)).sum();
419 if total == 0 {
420 return 0;
421 }
422 let elapsed = u64::from(elapsed_ms);
423 let t = match loop_mode {
425 LoopMode::Loop => elapsed % total,
426 LoopMode::Once => elapsed.min(total - 1),
427 LoopMode::PingPong => {
428 let p = elapsed % (2 * total);
429 if p < total {
430 p
431 } else {
432 2 * total - 1 - p
434 }
435 }
436 };
437 let mut acc = 0u64;
439 for (i, &d) in durations.iter().enumerate() {
440 acc += u64::from(d);
441 if t < acc {
442 return i;
443 }
444 }
445 n - 1
446}
447
448#[must_use]
454pub fn duration_prefix_sums(durations: &[u32]) -> Vec<u64> {
455 let mut acc = 0u64;
456 durations
457 .iter()
458 .map(|&d| {
459 acc += u64::from(d);
460 acc
461 })
462 .collect()
463}
464
465#[must_use]
470pub fn frame_at_prefix(prefix: &[u64], loop_mode: LoopMode, elapsed_ms: u32) -> usize {
471 let n = prefix.len();
472 if n <= 1 {
473 return 0;
474 }
475 let total = prefix[n - 1];
476 if total == 0 {
477 return 0;
478 }
479 let elapsed = u64::from(elapsed_ms);
480 let t = match loop_mode {
481 LoopMode::Loop => elapsed % total,
482 LoopMode::Once => elapsed.min(total - 1),
483 LoopMode::PingPong => {
484 let p = elapsed % (2 * total);
485 if p < total {
486 p
487 } else {
488 2 * total - 1 - p
489 }
490 }
491 };
492 prefix.partition_point(|&acc| acc <= t).min(n - 1)
496}
497
498#[must_use]
500pub fn occ_words_per_col(dims: [u32; 3]) -> u32 {
501 dims[2].div_ceil(32).max(1)
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521pub struct PadStats {
522 pub dims: [u32; 3],
524 pub content_dims: [u32; 3],
528 pub solid_voxels: u64,
530}
531
532impl PadStats {
533 #[must_use]
538 pub fn pad_ratio(&self) -> f32 {
539 let content = vol(self.content_dims);
540 if content == 0 {
541 1.0
542 } else {
543 vol(self.dims) as f32 / content as f32
544 }
545 }
546
547 #[must_use]
551 pub fn is_wasteful(&self) -> bool {
552 self.pad_ratio() >= 2.0
553 }
554}
555
556fn vol(d: [u32; 3]) -> u64 {
557 u64::from(d[0]) * u64::from(d[1]) * u64::from(d[2])
558}
559
560#[must_use]
565pub fn pad_stats(dims: [u32; 3], frames: &[VoxelFrame]) -> PadStats {
566 let owpc = occ_words_per_col(dims) as usize;
567 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
568 let mut min = [u32::MAX; 3];
569 let mut max = [0u32; 3];
570 let mut solid_voxels = 0u64;
571 let mut any = false;
572
573 for f in frames {
574 for y in 0..my {
575 for x in 0..mx {
576 let base = (x + y * mx) as usize * owpc;
577 let occ = match f.occupancy.get(base..base + owpc) {
578 Some(o) if o.iter().any(|&w| w != 0) => o,
579 _ => continue, };
581 for z in 0..mz {
582 if (occ[(z >> 5) as usize] >> (z & 31)) & 1 != 0 {
583 any = true;
584 solid_voxels += 1;
585 min[0] = min[0].min(x);
586 min[1] = min[1].min(y);
587 min[2] = min[2].min(z);
588 max[0] = max[0].max(x);
589 max[1] = max[1].max(y);
590 max[2] = max[2].max(z);
591 }
592 }
593 }
594 }
595 }
596
597 let content_dims = if any {
598 [
599 max[0] - min[0] + 1,
600 max[1] - min[1] + 1,
601 max[2] - min[2] + 1,
602 ]
603 } else {
604 [0; 3]
605 };
606 PadStats {
607 dims,
608 content_dims,
609 solid_voxels,
610 }
611}
612
613#[derive(Debug, Clone)]
627pub struct StreamingClip {
628 dims: [u32; 3],
629 pivot: [f32; 3],
630 voxel_world_size: f32,
631 loop_mode: LoopMode,
632 owpc: usize,
633 cols: usize,
634 frames: Vec<EncodedFrame>,
636 durations: Vec<u32>,
637 keyframes: Vec<usize>,
639 work_occ: Vec<u32>,
641 work_cols: Vec<Vec<u32>>,
642 current: usize,
644 cur_frame: VoxelFrame,
645 cur_dirs: Vec<u32>,
646}
647
648impl StreamingClip {
649 pub fn new(clip: &VoxelClip) -> Result<Self, DecodeError> {
656 if !matches!(clip.frames.first(), Some(EncodedFrame::Key(_))) {
657 return Err(DecodeError::DeltaBeforeKey);
658 }
659 let owpc = occ_words_per_col(clip.dims) as usize;
660 let cols = (clip.dims[0] as usize) * (clip.dims[1] as usize);
661 let keyframes = clip
662 .frames
663 .iter()
664 .enumerate()
665 .filter_map(|(i, f)| matches!(f, EncodedFrame::Key(_)).then_some(i))
666 .collect();
667 let durations = if clip.durations.is_empty() {
668 vec![clip.default_frame_ms; clip.frames.len()]
669 } else {
670 clip.durations.clone()
671 };
672 let mut s = Self {
673 dims: clip.dims,
674 pivot: clip.pivot,
675 voxel_world_size: clip.voxel_world_size,
676 loop_mode: clip.loop_mode,
677 owpc,
678 cols,
679 frames: clip.frames.clone(),
680 durations,
681 keyframes,
682 work_occ: vec![0u32; cols * owpc],
683 work_cols: vec![Vec::new(); cols],
684 current: 0,
685 cur_frame: VoxelFrame {
686 occupancy: Vec::new(),
687 colors: Vec::new(),
688 color_offsets: Vec::new(),
689 },
690 cur_dirs: Vec::new(),
691 };
692 s.reconstruct(0)?;
693 Ok(s)
694 }
695
696 #[must_use]
697 pub fn frame_count(&self) -> usize {
698 self.frames.len()
699 }
700 #[must_use]
701 pub fn dims(&self) -> [u32; 3] {
702 self.dims
703 }
704 #[must_use]
705 pub fn pivot(&self) -> [f32; 3] {
706 self.pivot
707 }
708 #[must_use]
709 pub fn voxel_world_size(&self) -> f32 {
710 self.voxel_world_size
711 }
712 #[must_use]
713 pub fn loop_mode(&self) -> LoopMode {
714 self.loop_mode
715 }
716 #[must_use]
717 pub fn durations(&self) -> &[u32] {
718 &self.durations
719 }
720 #[must_use]
722 pub fn current_index(&self) -> usize {
723 self.current
724 }
725 #[must_use]
727 pub fn current_frame(&self) -> &VoxelFrame {
728 &self.cur_frame
729 }
730 #[must_use]
733 pub fn current_dirs(&self) -> &[u32] {
734 &self.cur_dirs
735 }
736
737 pub fn seek(&mut self, frame: usize) -> Result<&VoxelFrame, DecodeError> {
745 let target = frame.min(self.frame_count() - 1);
746 if target != self.current || self.cur_frame.occupancy.is_empty() {
747 self.reconstruct(target)?;
748 }
749 Ok(&self.cur_frame)
750 }
751
752 fn keyframe_at_or_before(&self, target: usize) -> usize {
754 let pp = self.keyframes.partition_point(|&k| k <= target);
755 self.keyframes[pp - 1]
756 }
757
758 fn reconstruct(&mut self, target: usize) -> Result<(), DecodeError> {
760 let start = if target > self.current && !self.cur_frame.occupancy.is_empty() {
763 self.current + 1
764 } else {
765 let kf = self.keyframe_at_or_before(target);
766 let mut started = false;
767 apply_frame(
768 &self.frames[kf],
769 &mut self.work_occ,
770 &mut self.work_cols,
771 self.dims,
772 self.owpc,
773 self.cols,
774 &mut started,
775 )?;
776 kf + 1
777 };
778 let mut started = true;
779 for i in start..=target {
780 let ef = &self.frames[i];
782 apply_frame(
783 ef,
784 &mut self.work_occ,
785 &mut self.work_cols,
786 self.dims,
787 self.owpc,
788 self.cols,
789 &mut started,
790 )?;
791 }
792 self.current = target;
793 self.cur_frame = flatten(&self.work_occ, &self.work_cols, self.cols);
794 self.cur_frame
795 .validate(self.dims)
796 .map_err(DecodeError::Frame)?;
797 self.cur_dirs = frame_dirs(&self.cur_frame, self.dims, self.owpc);
798 Ok(())
799 }
800}
801
802const KEYFRAME_COST_PCT: usize = 60;
807
808fn column_diff(
812 prev: &VoxelFrame,
813 frame: &VoxelFrame,
814 cols: usize,
815 owpc: usize,
816) -> Vec<ColumnDelta> {
817 let mut changed = Vec::new();
818 for col in 0..cols {
819 if prev.column_occ(col, owpc) != frame.column_occ(col, owpc)
820 || prev.column_colors(col) != frame.column_colors(col)
821 {
822 changed.push(ColumnDelta {
823 col: col as u32,
824 occ: frame.column_occ(col, owpc).to_vec(),
825 colors: frame.column_colors(col).to_vec(),
826 });
827 }
828 }
829 changed
830}
831
832fn key_words(frame: &VoxelFrame) -> usize {
834 frame.occupancy.len() + frame.color_offsets.len() + frame.colors.len() + 3
836}
837
838fn delta_words(changed: &[ColumnDelta], owpc: usize) -> usize {
840 1 + changed
842 .iter()
843 .map(|d| 1 + owpc + 1 + d.colors.len())
844 .sum::<usize>()
845}
846
847impl VoxelClip {
848 #[must_use]
850 pub fn occ_words_per_col(&self) -> u32 {
851 occ_words_per_col(self.dims)
852 }
853
854 #[must_use]
855 pub fn frame_count(&self) -> usize {
856 self.frames.len()
857 }
858
859 pub fn from_kv6_frames(
874 frames: &[Kv6],
875 voxel_world_size: f32,
876 loop_mode: LoopMode,
877 durations: &[u32],
878 default_frame_ms: u32,
879 keyframe_interval: u32,
880 ) -> Result<Self, Kv6ImportError> {
881 let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
882 Ok(Self::from_frames(
883 dims,
884 pivot,
885 voxel_world_size,
886 loop_mode,
887 &vframes,
888 durations,
889 default_frame_ms,
890 keyframe_interval,
891 ))
892 }
893
894 pub fn from_kv6_frames_auto(
903 frames: &[Kv6],
904 voxel_world_size: f32,
905 loop_mode: LoopMode,
906 durations: &[u32],
907 default_frame_ms: u32,
908 max_keyframe_gap: u32,
909 ) -> Result<Self, Kv6ImportError> {
910 let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
911 Ok(Self::from_frames_auto(
912 dims,
913 pivot,
914 voxel_world_size,
915 loop_mode,
916 &vframes,
917 durations,
918 default_frame_ms,
919 max_keyframe_gap,
920 ))
921 }
922
923 #[allow(clippy::type_complexity)]
926 fn kv6_frames_prepare(
927 frames: &[Kv6],
928 ) -> Result<([u32; 3], [f32; 3], Vec<VoxelFrame>), Kv6ImportError> {
929 let Some(first) = frames.first() else {
930 return Err(Kv6ImportError::Empty);
931 };
932 let dims = [first.xsiz, first.ysiz, first.zsiz];
933 for (i, k) in frames.iter().enumerate() {
934 let d = [k.xsiz, k.ysiz, k.zsiz];
935 if d != dims {
936 return Err(Kv6ImportError::DimsMismatch {
937 frame: i,
938 dims: d,
939 expected: dims,
940 });
941 }
942 }
943 let pivot = [first.xpiv, first.ypiv, first.zpiv];
944 let vframes = frames.iter().map(VoxelFrame::from_kv6).collect();
945 Ok((dims, pivot, vframes))
946 }
947
948 #[must_use]
960 pub fn from_frames(
961 dims: [u32; 3],
962 pivot: [f32; 3],
963 voxel_world_size: f32,
964 loop_mode: LoopMode,
965 frames: &[VoxelFrame],
966 durations: &[u32],
967 default_frame_ms: u32,
968 keyframe_interval: u32,
969 ) -> Self {
970 for (i, f) in frames.iter().enumerate() {
971 f.validate(dims)
972 .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
973 }
974 assert!(
975 durations.is_empty() || durations.len() == frames.len(),
976 "durations must be empty or one per frame",
977 );
978 let owpc = occ_words_per_col(dims) as usize;
979 let cols = (dims[0] as usize) * (dims[1] as usize);
980
981 let mut encoded = Vec::with_capacity(frames.len());
982 for (i, frame) in frames.iter().enumerate() {
983 let is_key = i == 0 || (keyframe_interval != 0 && (i as u32) % keyframe_interval == 0);
984 if is_key {
985 encoded.push(EncodedFrame::Key(frame.clone()));
986 } else {
987 encoded.push(EncodedFrame::Delta(column_diff(
988 &frames[i - 1],
989 frame,
990 cols,
991 owpc,
992 )));
993 }
994 }
995
996 Self {
997 dims,
998 pivot,
999 voxel_world_size,
1000 loop_mode,
1001 default_frame_ms,
1002 frames: encoded,
1003 durations: durations.to_vec(),
1004 extra_chunks: Vec::new(),
1005 }
1006 }
1007
1008 #[must_use]
1021 pub fn from_frames_auto(
1022 dims: [u32; 3],
1023 pivot: [f32; 3],
1024 voxel_world_size: f32,
1025 loop_mode: LoopMode,
1026 frames: &[VoxelFrame],
1027 durations: &[u32],
1028 default_frame_ms: u32,
1029 max_keyframe_gap: u32,
1030 ) -> Self {
1031 for (i, f) in frames.iter().enumerate() {
1032 f.validate(dims)
1033 .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
1034 }
1035 assert!(
1036 durations.is_empty() || durations.len() == frames.len(),
1037 "durations must be empty or one per frame",
1038 );
1039 let owpc = occ_words_per_col(dims) as usize;
1040 let cols = (dims[0] as usize) * (dims[1] as usize);
1041
1042 let mut encoded = Vec::with_capacity(frames.len());
1043 let mut since_key = 0u32;
1044 for (i, frame) in frames.iter().enumerate() {
1045 if i == 0 {
1046 encoded.push(EncodedFrame::Key(frame.clone()));
1047 since_key = 0;
1048 continue;
1049 }
1050 let changed = column_diff(&frames[i - 1], frame, cols, owpc);
1051 let gap_forces_key = max_keyframe_gap != 0 && since_key + 1 >= max_keyframe_gap;
1052 let delta_too_big =
1053 delta_words(&changed, owpc) * 100 >= key_words(frame) * KEYFRAME_COST_PCT;
1054 if gap_forces_key || delta_too_big {
1055 encoded.push(EncodedFrame::Key(frame.clone()));
1056 since_key = 0;
1057 } else {
1058 encoded.push(EncodedFrame::Delta(changed));
1059 since_key += 1;
1060 }
1061 }
1062
1063 Self {
1064 dims,
1065 pivot,
1066 voxel_world_size,
1067 loop_mode,
1068 default_frame_ms,
1069 frames: encoded,
1070 durations: durations.to_vec(),
1071 extra_chunks: Vec::new(),
1072 }
1073 }
1074
1075 pub fn decode(&self) -> Result<DecodedClip, DecodeError> {
1084 let owpc = occ_words_per_col(self.dims) as usize;
1085 let cols = (self.dims[0] as usize) * (self.dims[1] as usize);
1086
1087 let mut work_occ = vec![0u32; cols * owpc];
1090 let mut work_cols: Vec<Vec<u32>> = vec![Vec::new(); cols];
1091 let mut frames: Vec<VoxelFrame> = Vec::with_capacity(self.frames.len());
1092 let mut started = false;
1093
1094 for ef in &self.frames {
1095 apply_frame(
1096 ef,
1097 &mut work_occ,
1098 &mut work_cols,
1099 self.dims,
1100 owpc,
1101 cols,
1102 &mut started,
1103 )?;
1104 frames.push(flatten(&work_occ, &work_cols, cols));
1105 }
1106
1107 let mut dirs = Vec::with_capacity(frames.len());
1109 for f in &frames {
1110 f.validate(self.dims).map_err(DecodeError::Frame)?;
1111 dirs.push(frame_dirs(f, self.dims, owpc));
1112 }
1113
1114 let durations = if self.durations.is_empty() {
1115 vec![self.default_frame_ms; frames.len()]
1116 } else {
1117 self.durations.clone()
1118 };
1119
1120 Ok(DecodedClip {
1121 dims: self.dims,
1122 pivot: self.pivot,
1123 voxel_world_size: self.voxel_world_size,
1124 occ_words_per_col: owpc as u32,
1125 loop_mode: self.loop_mode,
1126 frames,
1127 dirs,
1128 durations,
1129 })
1130 }
1131
1132 #[must_use]
1135 pub fn serialize(&self) -> Vec<u8> {
1136 let mut out = Vec::new();
1137 out.extend_from_slice(&MAGIC);
1138 out.extend_from_slice(&VERSION.to_le_bytes());
1139
1140 write_chunk(&mut out, TAG_META, |b| {
1141 for v in self.dims {
1142 b.extend_from_slice(&v.to_le_bytes());
1143 }
1144 for v in self.pivot {
1145 b.extend_from_slice(&v.to_le_bytes());
1146 }
1147 b.extend_from_slice(&self.voxel_world_size.to_le_bytes());
1148 b.push(self.loop_mode.to_u8());
1149 b.extend_from_slice(&self.default_frame_ms.to_le_bytes());
1150 let fc = u32::try_from(self.frames.len()).expect("frame count fits u32");
1151 b.extend_from_slice(&fc.to_le_bytes());
1152 });
1153
1154 write_chunk(&mut out, TAG_FRMS, |b| {
1155 for ef in &self.frames {
1156 match ef {
1157 EncodedFrame::Key(frame) => {
1158 b.push(FRAME_KIND_KEY);
1159 write_u32_vec(b, &frame.occupancy);
1160 write_u32_vec(b, &frame.color_offsets);
1161 write_u32_vec(b, &frame.colors);
1162 }
1163 EncodedFrame::Delta(changed) => {
1164 b.push(FRAME_KIND_DELTA);
1165 let n = u32::try_from(changed.len()).expect("changed count fits u32");
1166 b.extend_from_slice(&n.to_le_bytes());
1167 for d in changed {
1168 b.extend_from_slice(&d.col.to_le_bytes());
1169 for w in &d.occ {
1171 b.extend_from_slice(&w.to_le_bytes());
1172 }
1173 write_u32_vec(b, &d.colors);
1174 }
1175 }
1176 }
1177 }
1178 });
1179
1180 if !self.durations.is_empty() {
1181 write_chunk(&mut out, TAG_TIME, |b| {
1182 for d in &self.durations {
1183 b.extend_from_slice(&d.to_le_bytes());
1184 }
1185 });
1186 }
1187
1188 for (tag, payload) in &self.extra_chunks {
1189 write_chunk(&mut out, *tag, |b| b.extend_from_slice(payload));
1190 }
1191
1192 out
1193 }
1194
1195 pub fn parse(bytes: &[u8]) -> Result<VoxelClip, ParseError> {
1201 let mut cur = Cursor::new(bytes);
1202 let magic = cur.read_bytes(4)?;
1203 if magic != MAGIC {
1204 return Err(ParseError::BadMagic {
1205 got: [magic[0], magic[1], magic[2], magic[3]],
1206 });
1207 }
1208 let version = cur.read_u16()?;
1209 if version != VERSION && version != VERSION_LEGACY {
1210 return Err(ParseError::UnsupportedVersion(version));
1211 }
1212 let has_flags = version >= VERSION;
1214
1215 let mut meta: Option<Vec<u8>> = None;
1216 let mut frms: Option<Vec<u8>> = None;
1217 let mut time: Option<Vec<u8>> = None;
1218 let mut extra_chunks = Vec::new();
1219 while cur.remaining() > 0 {
1220 let tag_buf = cur.read_bytes(4)?;
1221 let tag = [tag_buf[0], tag_buf[1], tag_buf[2], tag_buf[3]];
1222 let flags = if has_flags { cur.read_u8()? } else { 0 };
1223 let len = cur.read_u32()? as usize;
1224 let stored = cur.read_bytes(len)?;
1225 let payload = if flags & CHUNK_FLAG_DEFLATED != 0 {
1226 inflate_chunk(stored)?
1227 } else {
1228 stored.to_vec()
1229 };
1230 match tag {
1231 TAG_META => meta = Some(payload),
1232 TAG_FRMS => frms = Some(payload),
1233 TAG_TIME => time = Some(payload),
1234 _ => extra_chunks.push((tag, payload)),
1235 }
1236 }
1237
1238 let meta = meta.ok_or(ParseError::MissingChunk(TAG_META))?;
1239 let frms = frms.ok_or(ParseError::MissingChunk(TAG_FRMS))?;
1240
1241 let (dims, pivot, voxel_world_size, loop_mode, default_frame_ms, frame_count) =
1242 parse_meta(&meta)?;
1243 let frames = parse_frms(&frms, dims, frame_count)?;
1244 let durations = match time {
1245 Some(p) => parse_time(&p, frame_count)?,
1246 None => Vec::new(),
1247 };
1248
1249 Ok(VoxelClip {
1250 dims,
1251 pivot,
1252 voxel_world_size,
1253 loop_mode,
1254 default_frame_ms,
1255 frames,
1256 durations,
1257 extra_chunks,
1258 })
1259 }
1260}
1261
1262fn apply_frame(
1269 ef: &EncodedFrame,
1270 work_occ: &mut [u32],
1271 work_cols: &mut [Vec<u32>],
1272 dims: [u32; 3],
1273 owpc: usize,
1274 cols: usize,
1275 started: &mut bool,
1276) -> Result<(), DecodeError> {
1277 match ef {
1278 EncodedFrame::Key(frame) => {
1279 frame.validate(dims).map_err(DecodeError::Frame)?;
1280 work_occ.copy_from_slice(&frame.occupancy);
1281 for (col, wc) in work_cols.iter_mut().enumerate() {
1282 wc.clear();
1283 wc.extend_from_slice(frame.column_colors(col));
1284 }
1285 *started = true;
1286 }
1287 EncodedFrame::Delta(changed) => {
1288 if !*started {
1289 return Err(DecodeError::DeltaBeforeKey);
1290 }
1291 for d in changed {
1292 let col = d.col as usize;
1293 if col >= cols || d.occ.len() != owpc {
1294 return Err(DecodeError::ColumnOutOfRange(d.col));
1295 }
1296 work_occ[col * owpc..(col + 1) * owpc].copy_from_slice(&d.occ);
1297 work_cols[col].clear();
1298 work_cols[col].extend_from_slice(&d.colors);
1299 }
1300 }
1301 }
1302 Ok(())
1303}
1304
1305fn flatten(occ: &[u32], cols_colors: &[Vec<u32>], cols: usize) -> VoxelFrame {
1307 let mut color_offsets = Vec::with_capacity(cols + 1);
1308 let mut colors = Vec::new();
1309 for run in cols_colors {
1310 color_offsets.push(colors.len() as u32);
1311 colors.extend_from_slice(run);
1312 }
1313 color_offsets.push(colors.len() as u32);
1314 VoxelFrame {
1315 occupancy: occ.to_vec(),
1316 colors,
1317 color_offsets,
1318 }
1319}
1320
1321fn frame_dirs(frame: &VoxelFrame, dims: [u32; 3], owpc: usize) -> Vec<u32> {
1324 let (mx, my, mz) = (dims[0] as i64, dims[1] as i64, dims[2] as i64);
1325 let solid = |x: i64, y: i64, z: i64| -> bool {
1326 if x < 0 || y < 0 || z < 0 || x >= mx || y >= my || z >= mz {
1327 return false;
1328 }
1329 let col = (x + y * mx) as usize;
1330 let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1331 (word >> (z & 31)) & 1 != 0
1332 };
1333 let mut dirs = Vec::with_capacity(frame.colors.len());
1334 for y in 0..my {
1335 for x in 0..mx {
1336 let col = (x + y * mx) as usize;
1337 for z in 0..mz {
1339 let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1340 if (word >> (z & 31)) & 1 != 0 {
1341 let (_vis, dir) = compute_vis_dir(&solid, x, y, z);
1342 dirs.push(u32::from(dir));
1343 }
1344 }
1345 }
1346 }
1347 dirs
1348}
1349
1350fn write_chunk(out: &mut Vec<u8>, tag: [u8; 4], body: impl FnOnce(&mut Vec<u8>)) {
1357 let mut raw = Vec::new();
1358 body(&mut raw);
1359 out.extend_from_slice(&tag);
1360
1361 let deflated = miniz_oxide::deflate::compress_to_vec(&raw, DEFLATE_LEVEL);
1362 if deflated.len() + 4 < raw.len() {
1364 out.push(CHUNK_FLAG_DEFLATED);
1365 let len = u32::try_from(deflated.len() + 4).expect("chunk length fits u32");
1366 out.extend_from_slice(&len.to_le_bytes());
1367 let raw_len = u32::try_from(raw.len()).expect("raw length fits u32");
1368 out.extend_from_slice(&raw_len.to_le_bytes());
1369 out.extend_from_slice(&deflated);
1370 } else {
1371 out.push(0);
1372 let len = u32::try_from(raw.len()).expect("chunk length fits u32");
1373 out.extend_from_slice(&len.to_le_bytes());
1374 out.extend_from_slice(&raw);
1375 }
1376}
1377
1378fn inflate_chunk(payload: &[u8]) -> Result<Vec<u8>, ParseError> {
1382 if payload.len() < 4 {
1383 return Err(ParseError::BadDeflate);
1384 }
1385 let raw_len = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
1386 if raw_len > MAX_CHUNK_INFLATE {
1390 return Err(ParseError::BadDeflate);
1391 }
1392 let out = miniz_oxide::inflate::decompress_to_vec_with_limit(&payload[4..], raw_len)
1393 .map_err(|_| ParseError::BadDeflate)?;
1394 if out.len() != raw_len {
1395 return Err(ParseError::BadDeflate);
1396 }
1397 Ok(out)
1398}
1399
1400fn write_u32_vec(out: &mut Vec<u8>, v: &[u32]) {
1402 let n = u32::try_from(v.len()).expect("u32 array length fits u32");
1403 out.extend_from_slice(&n.to_le_bytes());
1404 for w in v {
1405 out.extend_from_slice(&w.to_le_bytes());
1406 }
1407}
1408
1409fn read_u32_vec(cur: &mut Cursor) -> Result<Vec<u32>, ParseError> {
1410 let n = cur.read_u32()? as usize;
1411 let mut v = Vec::with_capacity(cur.clamped_capacity(n, 4));
1415 for _ in 0..n {
1416 v.push(cur.read_u32()?);
1417 }
1418 Ok(v)
1419}
1420
1421#[allow(clippy::type_complexity)]
1422fn parse_meta(payload: &[u8]) -> Result<([u32; 3], [f32; 3], f32, LoopMode, u32, u32), ParseError> {
1423 let mut cur = Cursor::new(payload);
1424 let dims = [cur.read_u32()?, cur.read_u32()?, cur.read_u32()?];
1425 if dims.iter().any(|&d| d == 0 || d > MAX_CLIP_DIM) {
1428 return Err(ParseError::BadDims { dims });
1429 }
1430 let pivot = [cur.read_f32()?, cur.read_f32()?, cur.read_f32()?];
1431 let voxel_world_size = cur.read_f32()?;
1432 let loop_mode = LoopMode::from_u8(cur.read_u8()?).ok_or(ParseError::BadLoopMode)?;
1433 let default_frame_ms = cur.read_u32()?;
1434 let frame_count = cur.read_u32()?;
1435 Ok((
1436 dims,
1437 pivot,
1438 voxel_world_size,
1439 loop_mode,
1440 default_frame_ms,
1441 frame_count,
1442 ))
1443}
1444
1445fn parse_frms(
1446 payload: &[u8],
1447 dims: [u32; 3],
1448 frame_count: u32,
1449) -> Result<Vec<EncodedFrame>, ParseError> {
1450 let owpc = occ_words_per_col(dims) as usize;
1451 let mut cur = Cursor::new(payload);
1452 let mut frames = Vec::with_capacity(frame_count as usize);
1453 for _ in 0..frame_count {
1454 let kind = cur.read_u8()?;
1455 match kind {
1456 FRAME_KIND_KEY => {
1457 let occupancy = read_u32_vec(&mut cur)?;
1458 let color_offsets = read_u32_vec(&mut cur)?;
1459 let colors = read_u32_vec(&mut cur)?;
1460 frames.push(EncodedFrame::Key(VoxelFrame {
1461 occupancy,
1462 colors,
1463 color_offsets,
1464 }));
1465 }
1466 FRAME_KIND_DELTA => {
1467 let n = cur.read_u32()? as usize;
1468 let mut changed = Vec::with_capacity(n);
1469 for _ in 0..n {
1470 let col = cur.read_u32()?;
1471 let mut occ = Vec::with_capacity(owpc);
1472 for _ in 0..owpc {
1473 occ.push(cur.read_u32()?);
1474 }
1475 let colors = read_u32_vec(&mut cur)?;
1476 changed.push(ColumnDelta { col, occ, colors });
1477 }
1478 frames.push(EncodedFrame::Delta(changed));
1479 }
1480 other => return Err(ParseError::BadFrameKind(other)),
1481 }
1482 }
1483 Ok(frames)
1484}
1485
1486fn parse_time(payload: &[u8], frame_count: u32) -> Result<Vec<u32>, ParseError> {
1487 let mut cur = Cursor::new(payload);
1488 let mut durations = Vec::with_capacity(frame_count as usize);
1489 for _ in 0..frame_count {
1490 durations.push(cur.read_u32()?);
1491 }
1492 Ok(durations)
1493}
1494
1495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1499pub enum Kv6ImportError {
1500 Empty,
1502 DimsMismatch {
1504 frame: usize,
1505 dims: [u32; 3],
1506 expected: [u32; 3],
1507 },
1508}
1509
1510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1512pub enum FrameError {
1513 OccupancyLen,
1514 OffsetsLen,
1515 OffsetsBounds,
1516 OffsetsMonotonic,
1517 OccupancyColorMismatch(usize),
1519}
1520
1521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1522pub enum ParseError {
1523 BadMagic {
1524 got: [u8; 4],
1525 },
1526 UnsupportedVersion(u16),
1527 Truncated,
1528 MissingChunk([u8; 4]),
1529 BadLoopMode,
1530 BadFrameKind(u8),
1531 BadDeflate,
1534 BadDims {
1537 dims: [u32; 3],
1538 },
1539}
1540
1541impl From<OutOfBounds> for ParseError {
1542 fn from(_: OutOfBounds) -> Self {
1543 ParseError::Truncated
1544 }
1545}
1546
1547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1549pub enum DecodeError {
1550 DeltaBeforeKey,
1552 ColumnOutOfRange(u32),
1554 Frame(FrameError),
1556}
1557
1558#[cfg(test)]
1559mod tests {
1560 use super::*;
1561
1562 fn frame_from_fn(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1565 let owpc = occ_words_per_col(dims) as usize;
1566 let cols = (dims[0] as usize) * (dims[1] as usize);
1567 let mut occupancy = vec![0u32; cols * owpc];
1568 let mut color_offsets = vec![0u32; cols + 1];
1569 let mut colors = Vec::new();
1570 for y in 0..dims[1] {
1571 for x in 0..dims[0] {
1572 let col = (x + y * dims[0]) as usize;
1573 color_offsets[col] = colors.len() as u32;
1574 for z in 0..dims[2] {
1575 if let Some(c) = fill(x, y, z) {
1576 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1577 colors.push(c);
1578 }
1579 }
1580 }
1581 }
1582 color_offsets[cols] = colors.len() as u32;
1583 VoxelFrame {
1584 occupancy,
1585 colors,
1586 color_offsets,
1587 }
1588 }
1589
1590 fn flame_clip(
1594 dims: [u32; 3],
1595 n_frames: u32,
1596 keyframe_interval: u32,
1597 ) -> (VoxelClip, Vec<VoxelFrame>) {
1598 let frames: Vec<VoxelFrame> = (0..n_frames)
1599 .map(|fi| {
1600 frame_from_fn(dims, |x, y, z| {
1601 let cx = dims[0] / 2;
1602 let cy = dims[1] / 2;
1603 let stem = x == cx && y == cy && z < dims[2] - 2;
1605 let tip = x == cx && y == cy && z == dims[2] - 2 - (fi % 2);
1607 if stem || tip {
1608 Some(0x80FF_8000 | (fi & 0xF)) } else {
1610 None
1611 }
1612 })
1613 })
1614 .collect();
1615 let clip = VoxelClip::from_frames(
1616 dims,
1617 [
1618 dims[0] as f32 * 0.5,
1619 dims[1] as f32 * 0.5,
1620 dims[2] as f32 * 0.5,
1621 ],
1622 1.0,
1623 LoopMode::Loop,
1624 &frames,
1625 &[],
1626 33,
1627 keyframe_interval,
1628 );
1629 (clip, frames)
1630 }
1631
1632 #[test]
1633 fn occ_words_per_col_matches_sprite_model() {
1634 assert_eq!(occ_words_per_col([8, 8, 1]), 1);
1635 assert_eq!(occ_words_per_col([8, 8, 32]), 1);
1636 assert_eq!(occ_words_per_col([8, 8, 33]), 2);
1637 assert_eq!(occ_words_per_col([8, 8, 256]), 8);
1638 }
1639
1640 #[test]
1641 fn frame_validate_catches_mismatch() {
1642 let dims = [4, 4, 8];
1643 let mut f = frame_from_fn(dims, |x, y, z| {
1644 (x == 0 && y == 0 && z < 3).then_some(0x8000_00FF)
1645 });
1646 assert!(f.validate(dims).is_ok());
1647 f.occupancy[0] &= !1u32;
1650 assert!(matches!(
1651 f.validate(dims),
1652 Err(FrameError::OccupancyColorMismatch(0))
1653 ));
1654 }
1655
1656 #[test]
1657 fn decode_reconstructs_every_frame() {
1658 let dims = [9, 9, 40];
1659 let (clip, original) = flame_clip(dims, 8, 4);
1660 let decoded = clip.decode().expect("decode");
1661 assert_eq!(decoded.frame_count(), original.len());
1662 for (i, (got, want)) in decoded.frames.iter().zip(&original).enumerate() {
1663 assert_eq!(got, want, "frame {i} mismatch");
1664 assert_eq!(
1666 decoded.dirs[i].len(),
1667 got.colors.len(),
1668 "frame {i} dirs len"
1669 );
1670 }
1671 }
1672
1673 #[test]
1674 fn diff_frames_are_smaller_than_keyframes() {
1675 let dims = [9, 9, 40];
1676 let (clip, _) = flame_clip(dims, 8, 0); let keys = clip
1678 .frames
1679 .iter()
1680 .filter(|f| matches!(f, EncodedFrame::Key(_)))
1681 .count();
1682 assert_eq!(keys, 1, "keyframe_interval=0 ⇒ exactly one keyframe");
1683 for f in &clip.frames {
1686 if let EncodedFrame::Delta(changed) = f {
1687 assert!(
1688 changed.len() < (dims[0] * dims[1]) as usize,
1689 "delta should be sparse, got {} columns",
1690 changed.len()
1691 );
1692 }
1693 }
1694 }
1695
1696 #[test]
1697 fn serialize_parse_round_trips() {
1698 let dims = [9, 9, 40];
1699 let (clip, _) = flame_clip(dims, 8, 4);
1700 let bytes = clip.serialize();
1701 let parsed = VoxelClip::parse(&bytes).expect("parse");
1702 assert_eq!(parsed, clip);
1703 assert_eq!(parsed.serialize(), bytes);
1705 let a = clip.decode().expect("decode a");
1707 let b = parsed.decode().expect("decode b");
1708 assert_eq!(a.frames, b.frames);
1709 }
1710
1711 #[test]
1712 fn durations_default_when_time_chunk_absent() {
1713 let dims = [4, 4, 8];
1714 let (clip, _) = flame_clip(dims, 4, 2);
1715 assert!(clip.durations.is_empty());
1716 let decoded = clip.decode().expect("decode");
1717 assert_eq!(decoded.durations, vec![33; 4]);
1718 assert_eq!(decoded.total_ms(), 33 * 4);
1719 }
1720
1721 #[test]
1722 fn explicit_durations_round_trip() {
1723 let dims = [4, 4, 8];
1724 let frames: Vec<VoxelFrame> = (0..3)
1725 .map(|fi| {
1726 frame_from_fn(dims, move |x, y, z| {
1727 (x == 0 && y == 0 && z == fi).then_some(0x8011_2233)
1728 })
1729 })
1730 .collect();
1731 let clip = VoxelClip::from_frames(
1732 dims,
1733 [0.0; 3],
1734 1.0,
1735 LoopMode::Once,
1736 &frames,
1737 &[10, 20, 30],
1738 33,
1739 0,
1740 );
1741 let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1742 assert_eq!(parsed.durations, vec![10, 20, 30]);
1743 assert_eq!(parsed.decode().unwrap().durations, vec![10, 20, 30]);
1744 assert_eq!(parsed.loop_mode, LoopMode::Once);
1745 }
1746
1747 #[test]
1748 fn unknown_chunks_preserved() {
1749 let dims = [4, 4, 8];
1750 let (mut clip, _) = flame_clip(dims, 2, 0);
1751 clip.extra_chunks.push((*b"XTRA", vec![1, 2, 3, 4, 5]));
1752 let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1753 assert_eq!(parsed.extra_chunks, vec![(*b"XTRA", vec![1, 2, 3, 4, 5])]);
1754 }
1755
1756 #[test]
1757 fn bad_magic_and_version_rejected() {
1758 let dims = [4, 4, 8];
1759 let (clip, _) = flame_clip(dims, 2, 0);
1760 let mut bytes = clip.serialize();
1761 let good = bytes.clone();
1762 bytes[0] = b'X';
1763 assert!(matches!(
1764 VoxelClip::parse(&bytes),
1765 Err(ParseError::BadMagic { .. })
1766 ));
1767 let mut v = good.clone();
1768 v[4] = 9; assert!(matches!(
1770 VoxelClip::parse(&v),
1771 Err(ParseError::UnsupportedVersion(_))
1772 ));
1773 }
1774
1775 #[test]
1776 fn frame_at_honours_loop_modes() {
1777 let dims = [4, 4, 8];
1779 let frames: Vec<VoxelFrame> = (0..3)
1780 .map(|fi| {
1781 frame_from_fn(dims, move |x, y, z| {
1782 (x == 0 && y == 0 && z == fi).then_some(0x8011_2233)
1783 })
1784 })
1785 .collect();
1786 let mk = |mode| {
1787 VoxelClip::from_frames(dims, [0.0; 3], 1.0, mode, &frames, &[10, 10, 10], 33, 0)
1788 .decode()
1789 .unwrap()
1790 };
1791
1792 let loop_c = mk(LoopMode::Loop);
1793 assert_eq!(loop_c.frame_at(0), 0);
1794 assert_eq!(loop_c.frame_at(9), 0);
1795 assert_eq!(loop_c.frame_at(10), 1);
1796 assert_eq!(loop_c.frame_at(25), 2);
1797 assert_eq!(loop_c.frame_at(30), 0, "wraps at total");
1798 assert_eq!(loop_c.frame_at(45), 1);
1799
1800 let once = mk(LoopMode::Once);
1801 assert_eq!(once.frame_at(25), 2);
1802 assert_eq!(once.frame_at(1000), 2, "holds the last frame");
1803
1804 let ping = mk(LoopMode::PingPong);
1805 assert_eq!(ping.frame_at(5), 0);
1806 assert_eq!(ping.frame_at(25), 2);
1807 assert_eq!(ping.frame_at(35), 2, "mirror: 35→ frame 2");
1808 assert_eq!(ping.frame_at(55), 0, "mirror back to 0 near 2·total");
1809 }
1810
1811 #[test]
1812 fn delta_before_key_rejected() {
1813 let dims = [4, 4, 8];
1814 let clip = VoxelClip {
1815 dims,
1816 pivot: [0.0; 3],
1817 voxel_world_size: 1.0,
1818 loop_mode: LoopMode::Loop,
1819 default_frame_ms: 33,
1820 frames: vec![EncodedFrame::Delta(Vec::new())],
1821 durations: Vec::new(),
1822 extra_chunks: Vec::new(),
1823 };
1824 assert!(matches!(clip.decode(), Err(DecodeError::DeltaBeforeKey)));
1825 }
1826
1827 fn isolated_fill(x: u32, y: u32, z: u32) -> Option<u32> {
1834 (x % 2 == 0 && y % 2 == 0 && z % 2 == 0).then_some(0x8000_0000 | (x << 16) | (y << 8) | z)
1835 }
1836
1837 #[test]
1838 fn from_kv6_matches_dense_reference() {
1839 let dims = [3u32, 2, 41];
1842 let kv6 = Kv6::from_fn(dims[0], dims[1], dims[2], isolated_fill);
1843 let imported = VoxelFrame::from_kv6(&kv6);
1844 let expected = frame_from_fn(dims, isolated_fill);
1845 assert_eq!(imported, expected);
1846 imported.validate(dims).expect("imported frame is valid");
1847 }
1848
1849 #[test]
1850 fn from_kv6_packs_z_across_word_boundary() {
1851 let kv6 = Kv6::from_fn(1, 1, 41, |_, _, z| match z {
1853 0 => Some(0x80FF_0000),
1854 5 => Some(0x8000_FF00),
1855 33 => Some(0x8000_00FF),
1856 40 => Some(0x80FF_FF00),
1857 _ => None,
1858 });
1859 let f = VoxelFrame::from_kv6(&kv6);
1860 assert_eq!(f.occupancy, vec![(1 << 0) | (1 << 5), (1 << 1) | (1 << 8)]);
1862 assert_eq!(
1864 f.colors,
1865 vec![0x80FF_0000, 0x8000_FF00, 0x8000_00FF, 0x80FF_FF00]
1866 );
1867 assert_eq!(f.color_offsets, vec![0, 4]);
1868 f.validate([1, 1, 41]).expect("valid");
1869 }
1870
1871 #[test]
1872 fn from_kv6_frames_round_trips_through_clip() {
1873 let dims = [2u32, 2, 3];
1874 let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1876 (z == 0).then_some(0x80FF_0000)
1877 });
1878 let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1879 (z == 2).then_some(0x8000_FF00)
1880 });
1881 let clip = VoxelClip::from_kv6_frames(
1882 &[ka.clone(), kb.clone()],
1883 2.0,
1884 LoopMode::Loop,
1885 &[100, 200],
1886 0,
1887 0,
1888 )
1889 .expect("import");
1890 assert_eq!(clip.dims, dims);
1891 assert_eq!(clip.voxel_world_size, 2.0);
1892 assert_eq!(clip.pivot, [ka.xpiv, ka.ypiv, ka.zpiv]);
1893 assert_eq!(clip.durations, vec![100, 200]);
1894
1895 let decoded = clip.decode().expect("decode");
1896 assert_eq!(decoded.frames.len(), 2);
1897 assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
1898 assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
1899 }
1900
1901 #[test]
1902 fn from_kv6_frames_rejects_empty() {
1903 let err = VoxelClip::from_kv6_frames(&[], 1.0, LoopMode::Loop, &[], 50, 0)
1904 .expect_err("empty must fail");
1905 assert_eq!(err, Kv6ImportError::Empty);
1906 }
1907
1908 #[test]
1909 fn from_kv6_frames_rejects_dims_mismatch() {
1910 let ka = Kv6::from_fn(2, 2, 2, |_, _, z| (z == 0).then_some(0x80FF_FFFF));
1911 let kb = Kv6::from_fn(3, 2, 2, |_, _, z| (z == 0).then_some(0x80FF_FFFF));
1912 let err = VoxelClip::from_kv6_frames(&[ka, kb], 1.0, LoopMode::Loop, &[], 50, 0)
1913 .expect_err("mismatch must fail");
1914 assert_eq!(
1915 err,
1916 Kv6ImportError::DimsMismatch {
1917 frame: 1,
1918 dims: [3, 2, 2],
1919 expected: [2, 2, 2],
1920 }
1921 );
1922 }
1923
1924 #[test]
1925 fn to_kv6_inverts_from_kv6() {
1926 let dims = [3u32, 2, 40];
1929 let frame = frame_from_fn(dims, |x, y, z| {
1930 (z <= (x + y) * 6 + 3).then_some(0x8000_0000 | (z << 8) | (x * 16 + y))
1931 });
1932 let kv6 = frame.to_kv6(dims, [1.0, 0.5, 20.0]);
1933 assert_eq!([kv6.xsiz, kv6.ysiz, kv6.zsiz], dims);
1934 assert_eq!([kv6.xpiv, kv6.ypiv, kv6.zpiv], [1.0, 0.5, 20.0]);
1935 assert_eq!(VoxelFrame::from_kv6(&kv6), frame);
1937 }
1938
1939 #[test]
1940 fn voxel_frame_dirs_match_decoded() {
1941 let dims = [4u32, 3, 8];
1945 let frame = frame_from_fn(dims, |x, y, z| (z <= x + y).then_some(0x80FF_0000));
1946 let clip = VoxelClip::from_frames(
1947 dims,
1948 [0.0; 3],
1949 1.0,
1950 LoopMode::Loop,
1951 std::slice::from_ref(&frame),
1952 &[],
1953 33,
1954 0,
1955 );
1956 let decoded = clip.decode().unwrap();
1957 assert_eq!(frame.dirs(dims), decoded.dirs[0]);
1958 }
1959
1960 #[test]
1963 fn compressed_clip_round_trips_and_shrinks() {
1964 let dims = [16u32, 16, 32];
1967 let frame = frame_from_fn(dims, |_, _, _| Some(0x80AB_CDEF));
1968 let clip = VoxelClip::from_frames(
1969 dims,
1970 [8.0, 8.0, 16.0],
1971 1.0,
1972 LoopMode::Loop,
1973 &[frame],
1974 &[],
1975 33,
1976 0,
1977 );
1978 let bytes = clip.serialize();
1979 let raw_colors_bytes = (dims[0] * dims[1] * dims[2]) as usize * 4;
1982 assert!(
1983 bytes.len() < raw_colors_bytes / 4,
1984 "expected compression: {} serialized bytes vs {raw_colors_bytes} raw colour bytes",
1985 bytes.len(),
1986 );
1987 assert_eq!(&bytes[4..6], &VERSION.to_le_bytes());
1990 let parsed = VoxelClip::parse(&bytes).expect("parse");
1991 assert_eq!(parsed, clip);
1992 assert_eq!(parsed.serialize(), bytes);
1993 }
1994
1995 fn serialize_v1(clip: &VoxelClip) -> Vec<u8> {
1998 fn chunk(out: &mut Vec<u8>, tag: [u8; 4], payload: &[u8]) {
1999 out.extend_from_slice(&tag);
2000 out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2001 out.extend_from_slice(payload);
2002 }
2003 fn u32_vec(out: &mut Vec<u8>, v: &[u32]) {
2004 out.extend_from_slice(&(v.len() as u32).to_le_bytes());
2005 for w in v {
2006 out.extend_from_slice(&w.to_le_bytes());
2007 }
2008 }
2009 let mut out = Vec::new();
2010 out.extend_from_slice(b"RVCL");
2011 out.extend_from_slice(&1u16.to_le_bytes());
2012
2013 let mut meta = Vec::new();
2014 for v in clip.dims {
2015 meta.extend_from_slice(&v.to_le_bytes());
2016 }
2017 for v in clip.pivot {
2018 meta.extend_from_slice(&v.to_le_bytes());
2019 }
2020 meta.extend_from_slice(&clip.voxel_world_size.to_le_bytes());
2021 meta.push(clip.loop_mode.to_u8());
2022 meta.extend_from_slice(&clip.default_frame_ms.to_le_bytes());
2023 meta.extend_from_slice(&(clip.frames.len() as u32).to_le_bytes());
2024 chunk(&mut out, *b"META", &meta);
2025
2026 let mut frms = Vec::new();
2027 for ef in &clip.frames {
2028 let EncodedFrame::Key(f) = ef else {
2029 panic!("serialize_v1 test helper handles keyframes only");
2030 };
2031 frms.push(FRAME_KIND_KEY);
2032 u32_vec(&mut frms, &f.occupancy);
2033 u32_vec(&mut frms, &f.color_offsets);
2034 u32_vec(&mut frms, &f.colors);
2035 }
2036 chunk(&mut out, *b"FRMS", &frms);
2037 out
2038 }
2039
2040 #[test]
2041 fn legacy_v1_file_still_parses() {
2042 let dims = [2u32, 2, 3];
2043 let frame = frame_from_fn(dims, |_, _, z| (z == 0).then_some(0x80FF_0000));
2044 let clip =
2045 VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Once, &[frame], &[], 50, 0);
2046 let v1 = serialize_v1(&clip);
2047 assert_eq!(&v1[4..6], &1u16.to_le_bytes(), "helper writes version 1");
2048 let parsed = VoxelClip::parse(&v1).expect("v1 must still parse");
2049 assert_eq!(parsed, clip);
2050 }
2051
2052 #[test]
2053 fn bad_deflate_payload_is_rejected() {
2054 let mut bytes = Vec::new();
2056 bytes.extend_from_slice(b"RVCL");
2057 bytes.extend_from_slice(&VERSION.to_le_bytes());
2058 bytes.extend_from_slice(b"META");
2059 bytes.push(CHUNK_FLAG_DEFLATED);
2060 let payload = [99u8, 0, 0, 0, 0xDE, 0xAD, 0xBE, 0xEF]; bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2062 bytes.extend_from_slice(&payload);
2063 assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2064 }
2065
2066 fn build_varied_clip() -> VoxelClip {
2073 let dims = [4u32, 3, 40];
2074 let frames: Vec<VoxelFrame> = (0..7u32)
2075 .map(|i| {
2076 let h = 5 + i * 5;
2077 frame_from_fn(dims, move |_x, _y, z| {
2078 (z < h).then_some(0x8000_0000 | (i * 0x10))
2079 })
2080 })
2081 .collect();
2082 VoxelClip::from_frames(
2083 dims,
2084 [2.0, 1.5, 20.0],
2085 1.0,
2086 LoopMode::Loop,
2087 &frames,
2088 &[],
2089 33,
2090 3,
2091 )
2092 }
2093
2094 #[test]
2095 fn streaming_matches_decoded_forward_and_random() {
2096 let clip = build_varied_clip();
2097 let decoded = clip.decode().expect("decode");
2098 let mut stream = StreamingClip::new(&clip).expect("stream");
2099 assert_eq!(stream.frame_count(), decoded.frames.len());
2100 assert_eq!(stream.dims(), decoded.dims);
2101 assert_eq!(stream.pivot(), decoded.pivot);
2102
2103 for (i, want) in decoded.frames.iter().enumerate() {
2105 let got = stream.seek(i).expect("seek").clone();
2106 assert_eq!(&got, want, "frame {i} (forward)");
2107 assert_eq!(
2108 stream.current_dirs(),
2109 decoded.dirs[i].as_slice(),
2110 "dirs {i}"
2111 );
2112 assert_eq!(stream.current_index(), i);
2113 }
2114 for &i in &[6usize, 0, 4, 1, 5, 2, 3, 0, 6] {
2116 let got = stream.seek(i).expect("seek").clone();
2117 assert_eq!(&got, &decoded.frames[i], "frame {i} (random)");
2118 assert_eq!(stream.current_dirs(), decoded.dirs[i].as_slice());
2119 }
2120 }
2121
2122 #[test]
2123 fn streaming_seek_clamps_past_end() {
2124 let clip = build_varied_clip();
2125 let decoded = clip.decode().unwrap();
2126 let mut stream = StreamingClip::new(&clip).unwrap();
2127 let last = decoded.frames.len() - 1;
2128 let got = stream.seek(999).unwrap().clone();
2129 assert_eq!(got, decoded.frames[last]);
2130 assert_eq!(stream.current_index(), last);
2131 }
2132
2133 #[test]
2134 fn streaming_rejects_empty_and_delta_first() {
2135 let dims = [1u32, 1, 1];
2136 let mk = |frames: Vec<EncodedFrame>| VoxelClip {
2137 dims,
2138 pivot: [0.0; 3],
2139 voxel_world_size: 1.0,
2140 loop_mode: LoopMode::Loop,
2141 default_frame_ms: 1,
2142 frames,
2143 durations: Vec::new(),
2144 extra_chunks: Vec::new(),
2145 };
2146 assert_eq!(
2147 StreamingClip::new(&mk(Vec::new())).map(|_| ()),
2148 Err(DecodeError::DeltaBeforeKey),
2149 );
2150 assert_eq!(
2151 StreamingClip::new(&mk(vec![EncodedFrame::Delta(Vec::new())])).map(|_| ()),
2152 Err(DecodeError::DeltaBeforeKey),
2153 );
2154 }
2155
2156 #[test]
2159 fn pad_stats_tight_clip_is_not_wasteful() {
2160 let dims = [8u32, 8, 8];
2162 let frame = frame_from_fn(dims, |x, y, z| {
2163 ((x == 0 && y == 0 && z == 0) || (x == 7 && y == 7 && z == 7)).then_some(0x80FF_FFFF)
2164 });
2165 let s = pad_stats(dims, std::slice::from_ref(&frame));
2166 assert_eq!(s.content_dims, dims);
2167 assert_eq!(s.solid_voxels, 2);
2168 assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2169 assert!(!s.is_wasteful());
2170 }
2171
2172 #[test]
2173 fn pad_stats_padded_clip_is_wasteful() {
2174 let dims = [40u32, 40, 40];
2176 let frames: Vec<VoxelFrame> = (0..3)
2177 .map(|_| {
2178 frame_from_fn(dims, |x, y, z| {
2179 (x < 10 && y < 10 && z < 10).then_some(0x80FF_0000)
2180 })
2181 })
2182 .collect();
2183 let s = pad_stats(dims, &frames);
2184 assert_eq!(s.content_dims, [10, 10, 10]);
2185 assert_eq!(s.solid_voxels, 3 * 1000);
2186 assert!((s.pad_ratio() - 64.0).abs() < 1e-3);
2188 assert!(s.is_wasteful());
2189 }
2190
2191 #[test]
2192 fn pad_stats_empty_clip_is_not_wasteful() {
2193 let dims = [4u32, 4, 4];
2194 let empty = frame_from_fn(dims, |_, _, _| None);
2195 let s = pad_stats(dims, std::slice::from_ref(&empty));
2196 assert_eq!(s.content_dims, [0, 0, 0]);
2197 assert_eq!(s.solid_voxels, 0);
2198 assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2199 assert!(!s.is_wasteful());
2200 }
2201
2202 #[test]
2203 fn decoded_clip_pad_stats_delegates() {
2204 let dims = [20u32, 4, 4];
2205 let frame = frame_from_fn(dims, |x, _, _| (x < 4).then_some(0x80FF_FFFF));
2206 let clip =
2207 VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Loop, &[frame], &[], 33, 0);
2208 let s = clip.decode().unwrap().pad_stats();
2209 assert_eq!(s.content_dims, [4, 4, 4]);
2210 assert!((s.pad_ratio() - 5.0).abs() < 1e-3);
2212 assert!(s.is_wasteful());
2213 }
2214
2215 fn is_key(e: &EncodedFrame) -> bool {
2218 matches!(e, EncodedFrame::Key(_))
2219 }
2220 fn key_positions(clip: &VoxelClip) -> Vec<usize> {
2221 clip.frames
2222 .iter()
2223 .enumerate()
2224 .filter_map(|(i, e)| is_key(e).then_some(i))
2225 .collect()
2226 }
2227
2228 #[test]
2229 fn from_frames_auto_round_trips() {
2230 let dims = [4u32, 3, 40];
2231 let frames: Vec<VoxelFrame> = (0..7u32)
2232 .map(|i| {
2233 let h = 5 + i * 5;
2234 frame_from_fn(dims, move |_, _, z| {
2235 (z < h).then_some(0x8000_0000 | (i * 0x10))
2236 })
2237 })
2238 .collect();
2239 let clip =
2240 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2241 assert_eq!(clip.decode().unwrap().frames, frames);
2243 assert!(is_key(&clip.frames[0]), "frame 0 is always a keyframe");
2244 }
2245
2246 #[test]
2247 fn from_frames_auto_keyframes_scene_change_but_deltas_small_change() {
2248 let dims = [4u32, 4, 8];
2249 let a = frame_from_fn(dims, |_, _, z| (z < 4).then_some(0x80FF_0000));
2250 let scene_cut = frame_from_fn(dims, |_, _, z| (z >= 4).then_some(0x8000_FF00));
2252 let small = frame_from_fn(dims, |x, y, z| {
2254 ((z < 4) || (x == 0 && y == 0 && z == 4)).then_some(0x80FF_0000)
2255 });
2256
2257 let cut = VoxelClip::from_frames_auto(
2258 dims,
2259 [0.0; 3],
2260 1.0,
2261 LoopMode::Loop,
2262 &[a.clone(), scene_cut],
2263 &[],
2264 33,
2265 0,
2266 );
2267 assert!(is_key(&cut.frames[1]), "scene change → keyframe");
2268
2269 let tweak = VoxelClip::from_frames_auto(
2270 dims,
2271 [0.0; 3],
2272 1.0,
2273 LoopMode::Loop,
2274 &[a, small],
2275 &[],
2276 33,
2277 0,
2278 );
2279 assert!(!is_key(&tweak.frames[1]), "small change → delta");
2280 }
2281
2282 #[test]
2283 fn from_frames_auto_gap_caps_keyframe_spacing() {
2284 let dims = [2u32, 2, 4];
2285 let f = frame_from_fn(dims, |_, _, z| (z < 2).then_some(0x80FF_FFFF));
2286 let frames = vec![f; 7]; let none =
2290 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2291 assert_eq!(key_positions(&none), vec![0]);
2292
2293 let capped =
2295 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 3);
2296 assert_eq!(key_positions(&capped), vec![0, 3, 6]);
2297 for (i, e) in capped.frames.iter().enumerate() {
2298 if let EncodedFrame::Delta(d) = e {
2299 assert!(d.is_empty(), "identical frame {i} → empty delta");
2300 }
2301 }
2302 }
2303
2304 #[test]
2305 fn from_kv6_frames_auto_round_trips() {
2306 let dims = [3u32, 3, 6];
2307 let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2308 (z == 0).then_some(0x80FF_0000)
2309 });
2310 let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2311 (z >= 3).then_some(0x8000_FF00)
2312 });
2313 let clip = VoxelClip::from_kv6_frames_auto(
2314 &[ka.clone(), kb.clone()],
2315 1.0,
2316 LoopMode::Loop,
2317 &[],
2318 33,
2319 0,
2320 )
2321 .expect("import");
2322 let decoded = clip.decode().unwrap();
2323 assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
2324 assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
2325 }
2326
2327 #[test]
2330 fn from_kv6_does_not_panic_on_malformed_kv6() {
2331 let bad = Kv6 {
2334 xsiz: 2,
2335 ysiz: 2,
2336 zsiz: 4,
2337 xpiv: 0.0,
2338 ypiv: 0.0,
2339 zpiv: 0.0,
2340 voxels: vec![Voxel {
2341 col: 0x80FF_FFFF,
2342 z: 0,
2343 vis: 63,
2344 dir: 0,
2345 }],
2346 xlen: vec![5, 5], ylen: vec![vec![3, 3], vec![3, 3, 99]], palette: None,
2349 };
2350 let f = VoxelFrame::from_kv6(&bad); f.validate([2, 2, 4]).expect("frame is well-formed");
2354 assert_eq!(f.colors, vec![0x80FF_FFFF]);
2355 }
2356
2357 #[test]
2358 fn inflate_rejects_oversized_raw_len() {
2359 let mut bytes = Vec::new();
2362 bytes.extend_from_slice(b"RVCL");
2363 bytes.extend_from_slice(&VERSION.to_le_bytes());
2364 bytes.extend_from_slice(b"META");
2365 bytes.push(CHUNK_FLAG_DEFLATED);
2366 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());
2369 bytes.extend_from_slice(&payload);
2370 assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2371 }
2372
2373 #[test]
2374 fn frame_at_no_overflow_for_huge_durations() {
2375 let durations = vec![u32::MAX / 2, u32::MAX / 2];
2378 let f = frame_at(&durations, LoopMode::PingPong, u32::MAX);
2379 assert!(f < durations.len());
2380 assert!(frame_at(&durations, LoopMode::Loop, u32::MAX) < durations.len());
2382 assert!(frame_at(&durations, LoopMode::Once, u32::MAX) < durations.len());
2383 }
2384
2385 #[test]
2390 fn frame_at_prefix_matches_linear_walk() {
2391 let timelines: [&[u32]; 7] = [
2392 &[],
2393 &[100],
2394 &[100, 100, 100],
2395 &[50, 0, 50, 200],
2396 &[0, 0, 0],
2397 &[1, 1, 1, 1, 1],
2398 &[u32::MAX / 2, u32::MAX / 2],
2399 ];
2400 let modes = [LoopMode::Loop, LoopMode::Once, LoopMode::PingPong];
2401 for durations in timelines {
2402 let prefix = duration_prefix_sums(durations);
2403 for mode in modes {
2404 for t in (0u32..700).chain([u32::MAX - 1, u32::MAX]) {
2405 assert_eq!(
2406 frame_at_prefix(&prefix, mode, t),
2407 frame_at(durations, mode, t),
2408 "durations={durations:?} mode={mode:?} t={t}"
2409 );
2410 }
2411 }
2412 }
2413 }
2414}