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,
316 pub occ: Vec<u32>,
319 pub colors: Vec<u32>,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
327pub enum EncodedFrame {
328 Key(VoxelFrame),
330 Delta(Vec<ColumnDelta>),
332}
333
334#[derive(Debug, Clone, PartialEq)]
338pub struct VoxelClip {
339 pub dims: [u32; 3],
342 pub pivot: [f32; 3],
345 pub voxel_world_size: f32,
348 pub loop_mode: LoopMode,
350 pub default_frame_ms: u32,
352 pub frames: Vec<EncodedFrame>,
354 pub durations: Vec<u32>,
356 pub extra_chunks: Vec<([u8; 4], Vec<u8>)>,
358}
359
360#[derive(Debug, Clone)]
364pub struct DecodedClip {
365 pub dims: [u32; 3],
368 pub pivot: [f32; 3],
371 pub voxel_world_size: f32,
374 pub occ_words_per_col: u32,
376 pub loop_mode: LoopMode,
378 pub frames: Vec<VoxelFrame>,
381 pub dirs: Vec<Vec<u32>>,
384 pub durations: Vec<u32>,
387}
388
389impl DecodedClip {
390 #[must_use]
392 pub fn frame_count(&self) -> usize {
393 self.frames.len()
394 }
395
396 #[must_use]
399 pub fn total_ms(&self) -> u32 {
400 self.durations
401 .iter()
402 .fold(0u32, |acc, &d| acc.saturating_add(d))
403 }
404
405 #[must_use]
408 pub fn pad_stats(&self) -> PadStats {
409 pad_stats(self.dims, &self.frames)
410 }
411
412 #[must_use]
421 pub fn frame_at(&self, elapsed_ms: u32) -> usize {
422 frame_at(&self.durations, self.loop_mode, elapsed_ms)
423 }
424}
425
426#[must_use]
436pub fn frame_at(durations: &[u32], loop_mode: LoopMode, elapsed_ms: u32) -> usize {
437 let n = durations.len();
438 if n <= 1 {
439 return 0;
440 }
441 let total: u64 = durations.iter().map(|&d| u64::from(d)).sum();
444 if total == 0 {
445 return 0;
446 }
447 let elapsed = u64::from(elapsed_ms);
448 let t = match loop_mode {
450 LoopMode::Loop => elapsed % total,
451 LoopMode::Once => elapsed.min(total - 1),
452 LoopMode::PingPong => {
453 let p = elapsed % (2 * total);
454 if p < total {
455 p
456 } else {
457 2 * total - 1 - p
459 }
460 }
461 };
462 let mut acc = 0u64;
464 for (i, &d) in durations.iter().enumerate() {
465 acc += u64::from(d);
466 if t < acc {
467 return i;
468 }
469 }
470 n - 1
471}
472
473#[must_use]
479pub fn duration_prefix_sums(durations: &[u32]) -> Vec<u64> {
480 let mut acc = 0u64;
481 durations
482 .iter()
483 .map(|&d| {
484 acc += u64::from(d);
485 acc
486 })
487 .collect()
488}
489
490#[must_use]
495pub fn frame_at_prefix(prefix: &[u64], loop_mode: LoopMode, elapsed_ms: u32) -> usize {
496 let n = prefix.len();
497 if n <= 1 {
498 return 0;
499 }
500 let total = prefix[n - 1];
501 if total == 0 {
502 return 0;
503 }
504 let elapsed = u64::from(elapsed_ms);
505 let t = match loop_mode {
506 LoopMode::Loop => elapsed % total,
507 LoopMode::Once => elapsed.min(total - 1),
508 LoopMode::PingPong => {
509 let p = elapsed % (2 * total);
510 if p < total {
511 p
512 } else {
513 2 * total - 1 - p
514 }
515 }
516 };
517 prefix.partition_point(|&acc| acc <= t).min(n - 1)
521}
522
523#[must_use]
525pub fn occ_words_per_col(dims: [u32; 3]) -> u32 {
526 dims[2].div_ceil(32).max(1)
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546pub struct PadStats {
547 pub dims: [u32; 3],
549 pub content_dims: [u32; 3],
553 pub solid_voxels: u64,
555}
556
557impl PadStats {
558 #[must_use]
563 pub fn pad_ratio(&self) -> f32 {
564 let content = vol(self.content_dims);
565 if content == 0 {
566 1.0
567 } else {
568 vol(self.dims) as f32 / content as f32
569 }
570 }
571
572 #[must_use]
576 pub fn is_wasteful(&self) -> bool {
577 self.pad_ratio() >= 2.0
578 }
579}
580
581fn vol(d: [u32; 3]) -> u64 {
582 u64::from(d[0]) * u64::from(d[1]) * u64::from(d[2])
583}
584
585#[must_use]
590pub fn pad_stats(dims: [u32; 3], frames: &[VoxelFrame]) -> PadStats {
591 let owpc = occ_words_per_col(dims) as usize;
592 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
593 let mut min = [u32::MAX; 3];
594 let mut max = [0u32; 3];
595 let mut solid_voxels = 0u64;
596 let mut any = false;
597
598 for f in frames {
599 for y in 0..my {
600 for x in 0..mx {
601 let base = (x + y * mx) as usize * owpc;
602 let occ = match f.occupancy.get(base..base + owpc) {
603 Some(o) if o.iter().any(|&w| w != 0) => o,
604 _ => continue, };
606 for z in 0..mz {
607 if (occ[(z >> 5) as usize] >> (z & 31)) & 1 != 0 {
608 any = true;
609 solid_voxels += 1;
610 min[0] = min[0].min(x);
611 min[1] = min[1].min(y);
612 min[2] = min[2].min(z);
613 max[0] = max[0].max(x);
614 max[1] = max[1].max(y);
615 max[2] = max[2].max(z);
616 }
617 }
618 }
619 }
620 }
621
622 let content_dims = if any {
623 [
624 max[0] - min[0] + 1,
625 max[1] - min[1] + 1,
626 max[2] - min[2] + 1,
627 ]
628 } else {
629 [0; 3]
630 };
631 PadStats {
632 dims,
633 content_dims,
634 solid_voxels,
635 }
636}
637
638#[derive(Debug, Clone)]
652pub struct StreamingClip {
653 dims: [u32; 3],
654 pivot: [f32; 3],
655 voxel_world_size: f32,
656 loop_mode: LoopMode,
657 owpc: usize,
658 cols: usize,
659 frames: Vec<EncodedFrame>,
661 durations: Vec<u32>,
662 keyframes: Vec<usize>,
664 work_occ: Vec<u32>,
666 work_cols: Vec<Vec<u32>>,
667 current: usize,
669 cur_frame: VoxelFrame,
670 cur_dirs: Vec<u32>,
671}
672
673impl StreamingClip {
674 pub fn new(clip: &VoxelClip) -> Result<Self, DecodeError> {
681 if !matches!(clip.frames.first(), Some(EncodedFrame::Key(_))) {
682 return Err(DecodeError::DeltaBeforeKey);
683 }
684 let owpc = occ_words_per_col(clip.dims) as usize;
685 let cols = (clip.dims[0] as usize) * (clip.dims[1] as usize);
686 let keyframes = clip
687 .frames
688 .iter()
689 .enumerate()
690 .filter_map(|(i, f)| matches!(f, EncodedFrame::Key(_)).then_some(i))
691 .collect();
692 let durations = if clip.durations.is_empty() {
693 vec![clip.default_frame_ms; clip.frames.len()]
694 } else {
695 clip.durations.clone()
696 };
697 let mut s = Self {
698 dims: clip.dims,
699 pivot: clip.pivot,
700 voxel_world_size: clip.voxel_world_size,
701 loop_mode: clip.loop_mode,
702 owpc,
703 cols,
704 frames: clip.frames.clone(),
705 durations,
706 keyframes,
707 work_occ: vec![0u32; cols * owpc],
708 work_cols: vec![Vec::new(); cols],
709 current: 0,
710 cur_frame: VoxelFrame {
711 occupancy: Vec::new(),
712 colors: Vec::new(),
713 color_offsets: Vec::new(),
714 },
715 cur_dirs: Vec::new(),
716 };
717 s.reconstruct(0)?;
718 Ok(s)
719 }
720
721 #[must_use]
723 pub fn frame_count(&self) -> usize {
724 self.frames.len()
725 }
726 #[must_use]
728 pub fn dims(&self) -> [u32; 3] {
729 self.dims
730 }
731 #[must_use]
734 pub fn pivot(&self) -> [f32; 3] {
735 self.pivot
736 }
737 #[must_use]
739 pub fn voxel_world_size(&self) -> f32 {
740 self.voxel_world_size
741 }
742 #[must_use]
744 pub fn loop_mode(&self) -> LoopMode {
745 self.loop_mode
746 }
747 #[must_use]
749 pub fn durations(&self) -> &[u32] {
750 &self.durations
751 }
752 #[must_use]
754 pub fn current_index(&self) -> usize {
755 self.current
756 }
757 #[must_use]
759 pub fn current_frame(&self) -> &VoxelFrame {
760 &self.cur_frame
761 }
762 #[must_use]
765 pub fn current_dirs(&self) -> &[u32] {
766 &self.cur_dirs
767 }
768
769 pub fn seek(&mut self, frame: usize) -> Result<&VoxelFrame, DecodeError> {
777 let target = frame.min(self.frame_count() - 1);
778 if target != self.current || self.cur_frame.occupancy.is_empty() {
779 self.reconstruct(target)?;
780 }
781 Ok(&self.cur_frame)
782 }
783
784 fn keyframe_at_or_before(&self, target: usize) -> usize {
786 let pp = self.keyframes.partition_point(|&k| k <= target);
787 self.keyframes[pp - 1]
788 }
789
790 fn reconstruct(&mut self, target: usize) -> Result<(), DecodeError> {
792 let start = if target > self.current && !self.cur_frame.occupancy.is_empty() {
795 self.current + 1
796 } else {
797 let kf = self.keyframe_at_or_before(target);
798 let mut started = false;
799 apply_frame(
800 &self.frames[kf],
801 &mut self.work_occ,
802 &mut self.work_cols,
803 self.dims,
804 self.owpc,
805 self.cols,
806 &mut started,
807 )?;
808 kf + 1
809 };
810 let mut started = true;
811 for i in start..=target {
812 let ef = &self.frames[i];
814 apply_frame(
815 ef,
816 &mut self.work_occ,
817 &mut self.work_cols,
818 self.dims,
819 self.owpc,
820 self.cols,
821 &mut started,
822 )?;
823 }
824 self.current = target;
825 self.cur_frame = flatten(&self.work_occ, &self.work_cols, self.cols);
826 self.cur_frame
827 .validate(self.dims)
828 .map_err(DecodeError::Frame)?;
829 self.cur_dirs = frame_dirs(&self.cur_frame, self.dims, self.owpc);
830 Ok(())
831 }
832}
833
834const KEYFRAME_COST_PCT: usize = 60;
839
840fn column_diff(
844 prev: &VoxelFrame,
845 frame: &VoxelFrame,
846 cols: usize,
847 owpc: usize,
848) -> Vec<ColumnDelta> {
849 let mut changed = Vec::new();
850 for col in 0..cols {
851 if prev.column_occ(col, owpc) != frame.column_occ(col, owpc)
852 || prev.column_colors(col) != frame.column_colors(col)
853 {
854 changed.push(ColumnDelta {
855 col: col as u32,
856 occ: frame.column_occ(col, owpc).to_vec(),
857 colors: frame.column_colors(col).to_vec(),
858 });
859 }
860 }
861 changed
862}
863
864fn key_words(frame: &VoxelFrame) -> usize {
866 frame.occupancy.len() + frame.color_offsets.len() + frame.colors.len() + 3
868}
869
870fn delta_words(changed: &[ColumnDelta], owpc: usize) -> usize {
872 1 + changed
874 .iter()
875 .map(|d| 1 + owpc + 1 + d.colors.len())
876 .sum::<usize>()
877}
878
879impl VoxelClip {
880 #[must_use]
882 pub fn occ_words_per_col(&self) -> u32 {
883 occ_words_per_col(self.dims)
884 }
885
886 #[must_use]
888 pub fn frame_count(&self) -> usize {
889 self.frames.len()
890 }
891
892 pub fn from_kv6_frames(
907 frames: &[Kv6],
908 voxel_world_size: f32,
909 loop_mode: LoopMode,
910 durations: &[u32],
911 default_frame_ms: u32,
912 keyframe_interval: u32,
913 ) -> Result<Self, Kv6ImportError> {
914 let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
915 Ok(Self::from_frames(
916 dims,
917 pivot,
918 voxel_world_size,
919 loop_mode,
920 &vframes,
921 durations,
922 default_frame_ms,
923 keyframe_interval,
924 ))
925 }
926
927 pub fn from_kv6_frames_auto(
936 frames: &[Kv6],
937 voxel_world_size: f32,
938 loop_mode: LoopMode,
939 durations: &[u32],
940 default_frame_ms: u32,
941 max_keyframe_gap: u32,
942 ) -> Result<Self, Kv6ImportError> {
943 let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
944 Ok(Self::from_frames_auto(
945 dims,
946 pivot,
947 voxel_world_size,
948 loop_mode,
949 &vframes,
950 durations,
951 default_frame_ms,
952 max_keyframe_gap,
953 ))
954 }
955
956 #[allow(clippy::type_complexity)]
959 fn kv6_frames_prepare(
960 frames: &[Kv6],
961 ) -> Result<([u32; 3], [f32; 3], Vec<VoxelFrame>), Kv6ImportError> {
962 let Some(first) = frames.first() else {
963 return Err(Kv6ImportError::Empty);
964 };
965 let dims = [first.xsiz, first.ysiz, first.zsiz];
966 for (i, k) in frames.iter().enumerate() {
967 let d = [k.xsiz, k.ysiz, k.zsiz];
968 if d != dims {
969 return Err(Kv6ImportError::DimsMismatch {
970 frame: i,
971 dims: d,
972 expected: dims,
973 });
974 }
975 }
976 let pivot = [first.xpiv, first.ypiv, first.zpiv];
977 let vframes = frames.iter().map(VoxelFrame::from_kv6).collect();
978 Ok((dims, pivot, vframes))
979 }
980
981 #[must_use]
993 pub fn from_frames(
994 dims: [u32; 3],
995 pivot: [f32; 3],
996 voxel_world_size: f32,
997 loop_mode: LoopMode,
998 frames: &[VoxelFrame],
999 durations: &[u32],
1000 default_frame_ms: u32,
1001 keyframe_interval: u32,
1002 ) -> Self {
1003 for (i, f) in frames.iter().enumerate() {
1004 f.validate(dims)
1005 .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
1006 }
1007 assert!(
1008 durations.is_empty() || durations.len() == frames.len(),
1009 "durations must be empty or one per frame",
1010 );
1011 let owpc = occ_words_per_col(dims) as usize;
1012 let cols = (dims[0] as usize) * (dims[1] as usize);
1013
1014 let mut encoded = Vec::with_capacity(frames.len());
1015 for (i, frame) in frames.iter().enumerate() {
1016 let is_key =
1017 i == 0 || (keyframe_interval != 0 && (i as u32).is_multiple_of(keyframe_interval));
1018 if is_key {
1019 encoded.push(EncodedFrame::Key(frame.clone()));
1020 } else {
1021 encoded.push(EncodedFrame::Delta(column_diff(
1022 &frames[i - 1],
1023 frame,
1024 cols,
1025 owpc,
1026 )));
1027 }
1028 }
1029
1030 Self {
1031 dims,
1032 pivot,
1033 voxel_world_size,
1034 loop_mode,
1035 default_frame_ms,
1036 frames: encoded,
1037 durations: durations.to_vec(),
1038 extra_chunks: Vec::new(),
1039 }
1040 }
1041
1042 #[must_use]
1055 pub fn from_frames_auto(
1056 dims: [u32; 3],
1057 pivot: [f32; 3],
1058 voxel_world_size: f32,
1059 loop_mode: LoopMode,
1060 frames: &[VoxelFrame],
1061 durations: &[u32],
1062 default_frame_ms: u32,
1063 max_keyframe_gap: u32,
1064 ) -> Self {
1065 for (i, f) in frames.iter().enumerate() {
1066 f.validate(dims)
1067 .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
1068 }
1069 assert!(
1070 durations.is_empty() || durations.len() == frames.len(),
1071 "durations must be empty or one per frame",
1072 );
1073 let owpc = occ_words_per_col(dims) as usize;
1074 let cols = (dims[0] as usize) * (dims[1] as usize);
1075
1076 let mut encoded = Vec::with_capacity(frames.len());
1077 let mut since_key = 0u32;
1078 for (i, frame) in frames.iter().enumerate() {
1079 if i == 0 {
1080 encoded.push(EncodedFrame::Key(frame.clone()));
1081 since_key = 0;
1082 continue;
1083 }
1084 let changed = column_diff(&frames[i - 1], frame, cols, owpc);
1085 let gap_forces_key = max_keyframe_gap != 0 && since_key + 1 >= max_keyframe_gap;
1086 let delta_too_big =
1087 delta_words(&changed, owpc) * 100 >= key_words(frame) * KEYFRAME_COST_PCT;
1088 if gap_forces_key || delta_too_big {
1089 encoded.push(EncodedFrame::Key(frame.clone()));
1090 since_key = 0;
1091 } else {
1092 encoded.push(EncodedFrame::Delta(changed));
1093 since_key += 1;
1094 }
1095 }
1096
1097 Self {
1098 dims,
1099 pivot,
1100 voxel_world_size,
1101 loop_mode,
1102 default_frame_ms,
1103 frames: encoded,
1104 durations: durations.to_vec(),
1105 extra_chunks: Vec::new(),
1106 }
1107 }
1108
1109 pub fn decode(&self) -> Result<DecodedClip, DecodeError> {
1118 let owpc = occ_words_per_col(self.dims) as usize;
1119 let cols = (self.dims[0] as usize) * (self.dims[1] as usize);
1120
1121 let mut work_occ = vec![0u32; cols * owpc];
1124 let mut work_cols: Vec<Vec<u32>> = vec![Vec::new(); cols];
1125 let mut frames: Vec<VoxelFrame> = Vec::with_capacity(self.frames.len());
1126 let mut started = false;
1127
1128 for ef in &self.frames {
1129 apply_frame(
1130 ef,
1131 &mut work_occ,
1132 &mut work_cols,
1133 self.dims,
1134 owpc,
1135 cols,
1136 &mut started,
1137 )?;
1138 frames.push(flatten(&work_occ, &work_cols, cols));
1139 }
1140
1141 let mut dirs = Vec::with_capacity(frames.len());
1143 for f in &frames {
1144 f.validate(self.dims).map_err(DecodeError::Frame)?;
1145 dirs.push(frame_dirs(f, self.dims, owpc));
1146 }
1147
1148 let durations = if self.durations.is_empty() {
1149 vec![self.default_frame_ms; frames.len()]
1150 } else {
1151 self.durations.clone()
1152 };
1153
1154 Ok(DecodedClip {
1155 dims: self.dims,
1156 pivot: self.pivot,
1157 voxel_world_size: self.voxel_world_size,
1158 occ_words_per_col: owpc as u32,
1159 loop_mode: self.loop_mode,
1160 frames,
1161 dirs,
1162 durations,
1163 })
1164 }
1165
1166 #[must_use]
1169 pub fn serialize(&self) -> Vec<u8> {
1170 let mut out = Vec::new();
1171 out.extend_from_slice(&MAGIC);
1172 out.extend_from_slice(&VERSION.to_le_bytes());
1173
1174 write_chunk(&mut out, TAG_META, |b| {
1175 for v in self.dims {
1176 b.extend_from_slice(&v.to_le_bytes());
1177 }
1178 for v in self.pivot {
1179 b.extend_from_slice(&v.to_le_bytes());
1180 }
1181 b.extend_from_slice(&self.voxel_world_size.to_le_bytes());
1182 b.push(self.loop_mode.to_u8());
1183 b.extend_from_slice(&self.default_frame_ms.to_le_bytes());
1184 let fc = u32::try_from(self.frames.len()).expect("frame count fits u32");
1185 b.extend_from_slice(&fc.to_le_bytes());
1186 });
1187
1188 write_chunk(&mut out, TAG_FRMS, |b| {
1189 for ef in &self.frames {
1190 match ef {
1191 EncodedFrame::Key(frame) => {
1192 b.push(FRAME_KIND_KEY);
1193 write_u32_vec(b, &frame.occupancy);
1194 write_u32_vec(b, &frame.color_offsets);
1195 write_u32_vec(b, &frame.colors);
1196 }
1197 EncodedFrame::Delta(changed) => {
1198 b.push(FRAME_KIND_DELTA);
1199 let n = u32::try_from(changed.len()).expect("changed count fits u32");
1200 b.extend_from_slice(&n.to_le_bytes());
1201 for d in changed {
1202 b.extend_from_slice(&d.col.to_le_bytes());
1203 for w in &d.occ {
1205 b.extend_from_slice(&w.to_le_bytes());
1206 }
1207 write_u32_vec(b, &d.colors);
1208 }
1209 }
1210 }
1211 }
1212 });
1213
1214 if !self.durations.is_empty() {
1215 write_chunk(&mut out, TAG_TIME, |b| {
1216 for d in &self.durations {
1217 b.extend_from_slice(&d.to_le_bytes());
1218 }
1219 });
1220 }
1221
1222 for (tag, payload) in &self.extra_chunks {
1223 write_chunk(&mut out, *tag, |b| b.extend_from_slice(payload));
1224 }
1225
1226 out
1227 }
1228
1229 pub fn parse(bytes: &[u8]) -> Result<VoxelClip, ParseError> {
1235 let mut cur = Cursor::new(bytes);
1236 let magic = cur.read_bytes(4)?;
1237 if magic != MAGIC {
1238 return Err(ParseError::BadMagic {
1239 got: [magic[0], magic[1], magic[2], magic[3]],
1240 });
1241 }
1242 let version = cur.read_u16()?;
1243 if version != VERSION && version != VERSION_LEGACY {
1244 return Err(ParseError::UnsupportedVersion(version));
1245 }
1246 let has_flags = version >= VERSION;
1248
1249 let mut meta: Option<Vec<u8>> = None;
1250 let mut frms: Option<Vec<u8>> = None;
1251 let mut time: Option<Vec<u8>> = None;
1252 let mut extra_chunks = Vec::new();
1253 while cur.remaining() > 0 {
1254 let tag_buf = cur.read_bytes(4)?;
1255 let tag = [tag_buf[0], tag_buf[1], tag_buf[2], tag_buf[3]];
1256 let flags = if has_flags { cur.read_u8()? } else { 0 };
1257 let len = cur.read_u32()? as usize;
1258 let stored = cur.read_bytes(len)?;
1259 let payload = if flags & CHUNK_FLAG_DEFLATED != 0 {
1260 inflate_chunk(stored)?
1261 } else {
1262 stored.to_vec()
1263 };
1264 match tag {
1265 TAG_META => meta = Some(payload),
1266 TAG_FRMS => frms = Some(payload),
1267 TAG_TIME => time = Some(payload),
1268 _ => extra_chunks.push((tag, payload)),
1269 }
1270 }
1271
1272 let meta = meta.ok_or(ParseError::MissingChunk(TAG_META))?;
1273 let frms = frms.ok_or(ParseError::MissingChunk(TAG_FRMS))?;
1274
1275 let (dims, pivot, voxel_world_size, loop_mode, default_frame_ms, frame_count) =
1276 parse_meta(&meta)?;
1277 let frames = parse_frms(&frms, dims, frame_count)?;
1278 let durations = match time {
1279 Some(p) => parse_time(&p, frame_count)?,
1280 None => Vec::new(),
1281 };
1282
1283 Ok(VoxelClip {
1284 dims,
1285 pivot,
1286 voxel_world_size,
1287 loop_mode,
1288 default_frame_ms,
1289 frames,
1290 durations,
1291 extra_chunks,
1292 })
1293 }
1294}
1295
1296fn apply_frame(
1303 ef: &EncodedFrame,
1304 work_occ: &mut [u32],
1305 work_cols: &mut [Vec<u32>],
1306 dims: [u32; 3],
1307 owpc: usize,
1308 cols: usize,
1309 started: &mut bool,
1310) -> Result<(), DecodeError> {
1311 match ef {
1312 EncodedFrame::Key(frame) => {
1313 frame.validate(dims).map_err(DecodeError::Frame)?;
1314 work_occ.copy_from_slice(&frame.occupancy);
1315 for (col, wc) in work_cols.iter_mut().enumerate() {
1316 wc.clear();
1317 wc.extend_from_slice(frame.column_colors(col));
1318 }
1319 *started = true;
1320 }
1321 EncodedFrame::Delta(changed) => {
1322 if !*started {
1323 return Err(DecodeError::DeltaBeforeKey);
1324 }
1325 for d in changed {
1326 let col = d.col as usize;
1327 if col >= cols || d.occ.len() != owpc {
1328 return Err(DecodeError::ColumnOutOfRange(d.col));
1329 }
1330 work_occ[col * owpc..(col + 1) * owpc].copy_from_slice(&d.occ);
1331 work_cols[col].clear();
1332 work_cols[col].extend_from_slice(&d.colors);
1333 }
1334 }
1335 }
1336 Ok(())
1337}
1338
1339fn flatten(occ: &[u32], cols_colors: &[Vec<u32>], cols: usize) -> VoxelFrame {
1341 let mut color_offsets = Vec::with_capacity(cols + 1);
1342 let mut colors = Vec::new();
1343 for run in cols_colors {
1344 color_offsets.push(colors.len() as u32);
1345 colors.extend_from_slice(run);
1346 }
1347 color_offsets.push(colors.len() as u32);
1348 VoxelFrame {
1349 occupancy: occ.to_vec(),
1350 colors,
1351 color_offsets,
1352 }
1353}
1354
1355fn frame_dirs(frame: &VoxelFrame, dims: [u32; 3], owpc: usize) -> Vec<u32> {
1358 let (mx, my, mz) = (dims[0] as i64, dims[1] as i64, dims[2] as i64);
1359 let solid = |x: i64, y: i64, z: i64| -> bool {
1360 if x < 0 || y < 0 || z < 0 || x >= mx || y >= my || z >= mz {
1361 return false;
1362 }
1363 let col = (x + y * mx) as usize;
1364 let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1365 (word >> (z & 31)) & 1 != 0
1366 };
1367 let mut dirs = Vec::with_capacity(frame.colors.len());
1368 for y in 0..my {
1369 for x in 0..mx {
1370 let col = (x + y * mx) as usize;
1371 for z in 0..mz {
1373 let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1374 if (word >> (z & 31)) & 1 != 0 {
1375 let (_vis, dir) = compute_vis_dir(&solid, x, y, z);
1376 dirs.push(u32::from(dir));
1377 }
1378 }
1379 }
1380 }
1381 dirs
1382}
1383
1384fn write_chunk(out: &mut Vec<u8>, tag: [u8; 4], body: impl FnOnce(&mut Vec<u8>)) {
1391 let mut raw = Vec::new();
1392 body(&mut raw);
1393 out.extend_from_slice(&tag);
1394
1395 let deflated = miniz_oxide::deflate::compress_to_vec(&raw, DEFLATE_LEVEL);
1396 if deflated.len() + 4 < raw.len() {
1398 out.push(CHUNK_FLAG_DEFLATED);
1399 let len = u32::try_from(deflated.len() + 4).expect("chunk length fits u32");
1400 out.extend_from_slice(&len.to_le_bytes());
1401 let raw_len = u32::try_from(raw.len()).expect("raw length fits u32");
1402 out.extend_from_slice(&raw_len.to_le_bytes());
1403 out.extend_from_slice(&deflated);
1404 } else {
1405 out.push(0);
1406 let len = u32::try_from(raw.len()).expect("chunk length fits u32");
1407 out.extend_from_slice(&len.to_le_bytes());
1408 out.extend_from_slice(&raw);
1409 }
1410}
1411
1412fn inflate_chunk(payload: &[u8]) -> Result<Vec<u8>, ParseError> {
1416 if payload.len() < 4 {
1417 return Err(ParseError::BadDeflate);
1418 }
1419 let raw_len = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
1420 if raw_len > MAX_CHUNK_INFLATE {
1424 return Err(ParseError::BadDeflate);
1425 }
1426 let out = miniz_oxide::inflate::decompress_to_vec_with_limit(&payload[4..], raw_len)
1427 .map_err(|_| ParseError::BadDeflate)?;
1428 if out.len() != raw_len {
1429 return Err(ParseError::BadDeflate);
1430 }
1431 Ok(out)
1432}
1433
1434fn write_u32_vec(out: &mut Vec<u8>, v: &[u32]) {
1436 let n = u32::try_from(v.len()).expect("u32 array length fits u32");
1437 out.extend_from_slice(&n.to_le_bytes());
1438 for w in v {
1439 out.extend_from_slice(&w.to_le_bytes());
1440 }
1441}
1442
1443fn read_u32_vec(cur: &mut Cursor) -> Result<Vec<u32>, ParseError> {
1444 let n = cur.read_u32()? as usize;
1445 let mut v = Vec::with_capacity(cur.clamped_capacity(n, 4));
1449 for _ in 0..n {
1450 v.push(cur.read_u32()?);
1451 }
1452 Ok(v)
1453}
1454
1455#[allow(clippy::type_complexity)]
1456fn parse_meta(payload: &[u8]) -> Result<([u32; 3], [f32; 3], f32, LoopMode, u32, u32), ParseError> {
1457 let mut cur = Cursor::new(payload);
1458 let dims = [cur.read_u32()?, cur.read_u32()?, cur.read_u32()?];
1459 if dims.iter().any(|&d| d == 0 || d > MAX_CLIP_DIM) {
1462 return Err(ParseError::BadDims { dims });
1463 }
1464 let pivot = [cur.read_f32()?, cur.read_f32()?, cur.read_f32()?];
1465 let voxel_world_size = cur.read_f32()?;
1466 let loop_mode = LoopMode::from_u8(cur.read_u8()?).ok_or(ParseError::BadLoopMode)?;
1467 let default_frame_ms = cur.read_u32()?;
1468 let frame_count = cur.read_u32()?;
1469 Ok((
1470 dims,
1471 pivot,
1472 voxel_world_size,
1473 loop_mode,
1474 default_frame_ms,
1475 frame_count,
1476 ))
1477}
1478
1479fn parse_frms(
1480 payload: &[u8],
1481 dims: [u32; 3],
1482 frame_count: u32,
1483) -> Result<Vec<EncodedFrame>, ParseError> {
1484 let owpc = occ_words_per_col(dims) as usize;
1485 let mut cur = Cursor::new(payload);
1486 let mut frames = Vec::with_capacity(frame_count as usize);
1487 for _ in 0..frame_count {
1488 let kind = cur.read_u8()?;
1489 match kind {
1490 FRAME_KIND_KEY => {
1491 let occupancy = read_u32_vec(&mut cur)?;
1492 let color_offsets = read_u32_vec(&mut cur)?;
1493 let colors = read_u32_vec(&mut cur)?;
1494 frames.push(EncodedFrame::Key(VoxelFrame {
1495 occupancy,
1496 colors,
1497 color_offsets,
1498 }));
1499 }
1500 FRAME_KIND_DELTA => {
1501 let n = cur.read_u32()? as usize;
1502 let mut changed = Vec::with_capacity(n);
1503 for _ in 0..n {
1504 let col = cur.read_u32()?;
1505 let mut occ = Vec::with_capacity(owpc);
1506 for _ in 0..owpc {
1507 occ.push(cur.read_u32()?);
1508 }
1509 let colors = read_u32_vec(&mut cur)?;
1510 changed.push(ColumnDelta { col, occ, colors });
1511 }
1512 frames.push(EncodedFrame::Delta(changed));
1513 }
1514 other => return Err(ParseError::BadFrameKind(other)),
1515 }
1516 }
1517 Ok(frames)
1518}
1519
1520fn parse_time(payload: &[u8], frame_count: u32) -> Result<Vec<u32>, ParseError> {
1521 let mut cur = Cursor::new(payload);
1522 let mut durations = Vec::with_capacity(frame_count as usize);
1523 for _ in 0..frame_count {
1524 durations.push(cur.read_u32()?);
1525 }
1526 Ok(durations)
1527}
1528
1529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1533pub enum Kv6ImportError {
1534 Empty,
1536 DimsMismatch {
1538 frame: usize,
1540 dims: [u32; 3],
1542 expected: [u32; 3],
1544 },
1545}
1546
1547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1549pub enum FrameError {
1550 OccupancyLen,
1552 OffsetsLen,
1554 OffsetsBounds,
1557 OffsetsMonotonic,
1560 OccupancyColorMismatch(usize),
1562}
1563
1564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1566pub enum ParseError {
1567 BadMagic {
1569 got: [u8; 4],
1571 },
1572 UnsupportedVersion(u16),
1575 Truncated,
1577 MissingChunk([u8; 4]),
1579 BadLoopMode,
1581 BadFrameKind(u8),
1583 BadDeflate,
1586 BadDims {
1589 dims: [u32; 3],
1591 },
1592}
1593
1594impl From<OutOfBounds> for ParseError {
1595 fn from(_: OutOfBounds) -> Self {
1596 ParseError::Truncated
1597 }
1598}
1599
1600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1602pub enum DecodeError {
1603 DeltaBeforeKey,
1605 ColumnOutOfRange(u32),
1607 Frame(FrameError),
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613 use super::*;
1614
1615 use crate::color::VoxColor;
1616
1617 fn frame_from_fn(
1620 dims: [u32; 3],
1621 fill: impl Fn(u32, u32, u32) -> Option<VoxColor>,
1622 ) -> VoxelFrame {
1623 let owpc = occ_words_per_col(dims) as usize;
1624 let cols = (dims[0] as usize) * (dims[1] as usize);
1625 let mut occupancy = vec![0u32; cols * owpc];
1626 let mut color_offsets = vec![0u32; cols + 1];
1627 let mut colors = Vec::new();
1628 for y in 0..dims[1] {
1629 for x in 0..dims[0] {
1630 let col = (x + y * dims[0]) as usize;
1631 color_offsets[col] = colors.len() as u32;
1632 for z in 0..dims[2] {
1633 if let Some(c) = fill(x, y, z) {
1634 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1635 colors.push(c.0);
1636 }
1637 }
1638 }
1639 }
1640 color_offsets[cols] = colors.len() as u32;
1641 VoxelFrame {
1642 occupancy,
1643 colors,
1644 color_offsets,
1645 }
1646 }
1647
1648 fn flame_clip(
1652 dims: [u32; 3],
1653 n_frames: u32,
1654 keyframe_interval: u32,
1655 ) -> (VoxelClip, Vec<VoxelFrame>) {
1656 let frames: Vec<VoxelFrame> = (0..n_frames)
1657 .map(|fi| {
1658 frame_from_fn(dims, |x, y, z| {
1659 let cx = dims[0] / 2;
1660 let cy = dims[1] / 2;
1661 let stem = x == cx && y == cy && z < dims[2] - 2;
1663 let tip = x == cx && y == cy && z == dims[2] - 2 - (fi % 2);
1665 if stem || tip {
1666 Some(VoxColor(0x80FF_8000 | (fi & 0xF))) } else {
1668 None
1669 }
1670 })
1671 })
1672 .collect();
1673 let clip = VoxelClip::from_frames(
1674 dims,
1675 [
1676 dims[0] as f32 * 0.5,
1677 dims[1] as f32 * 0.5,
1678 dims[2] as f32 * 0.5,
1679 ],
1680 1.0,
1681 LoopMode::Loop,
1682 &frames,
1683 &[],
1684 33,
1685 keyframe_interval,
1686 );
1687 (clip, frames)
1688 }
1689
1690 #[test]
1691 fn occ_words_per_col_matches_sprite_model() {
1692 assert_eq!(occ_words_per_col([8, 8, 1]), 1);
1693 assert_eq!(occ_words_per_col([8, 8, 32]), 1);
1694 assert_eq!(occ_words_per_col([8, 8, 33]), 2);
1695 assert_eq!(occ_words_per_col([8, 8, 256]), 8);
1696 }
1697
1698 #[test]
1699 fn frame_validate_catches_mismatch() {
1700 let dims = [4, 4, 8];
1701 let mut f = frame_from_fn(dims, |x, y, z| {
1702 (x == 0 && y == 0 && z < 3).then_some(VoxColor(0x8000_00FF))
1703 });
1704 assert!(f.validate(dims).is_ok());
1705 f.occupancy[0] &= !1u32;
1708 assert!(matches!(
1709 f.validate(dims),
1710 Err(FrameError::OccupancyColorMismatch(0))
1711 ));
1712 }
1713
1714 #[test]
1715 fn decode_reconstructs_every_frame() {
1716 let dims = [9, 9, 40];
1717 let (clip, original) = flame_clip(dims, 8, 4);
1718 let decoded = clip.decode().expect("decode");
1719 assert_eq!(decoded.frame_count(), original.len());
1720 for (i, (got, want)) in decoded.frames.iter().zip(&original).enumerate() {
1721 assert_eq!(got, want, "frame {i} mismatch");
1722 assert_eq!(
1724 decoded.dirs[i].len(),
1725 got.colors.len(),
1726 "frame {i} dirs len"
1727 );
1728 }
1729 }
1730
1731 #[test]
1732 fn diff_frames_are_smaller_than_keyframes() {
1733 let dims = [9, 9, 40];
1734 let (clip, _) = flame_clip(dims, 8, 0); let keys = clip
1736 .frames
1737 .iter()
1738 .filter(|f| matches!(f, EncodedFrame::Key(_)))
1739 .count();
1740 assert_eq!(keys, 1, "keyframe_interval=0 ⇒ exactly one keyframe");
1741 for f in &clip.frames {
1744 if let EncodedFrame::Delta(changed) = f {
1745 assert!(
1746 changed.len() < (dims[0] * dims[1]) as usize,
1747 "delta should be sparse, got {} columns",
1748 changed.len()
1749 );
1750 }
1751 }
1752 }
1753
1754 #[test]
1755 fn serialize_parse_round_trips() {
1756 let dims = [9, 9, 40];
1757 let (clip, _) = flame_clip(dims, 8, 4);
1758 let bytes = clip.serialize();
1759 let parsed = VoxelClip::parse(&bytes).expect("parse");
1760 assert_eq!(parsed, clip);
1761 assert_eq!(parsed.serialize(), bytes);
1763 let a = clip.decode().expect("decode a");
1765 let b = parsed.decode().expect("decode b");
1766 assert_eq!(a.frames, b.frames);
1767 }
1768
1769 #[test]
1770 fn durations_default_when_time_chunk_absent() {
1771 let dims = [4, 4, 8];
1772 let (clip, _) = flame_clip(dims, 4, 2);
1773 assert!(clip.durations.is_empty());
1774 let decoded = clip.decode().expect("decode");
1775 assert_eq!(decoded.durations, vec![33; 4]);
1776 assert_eq!(decoded.total_ms(), 33 * 4);
1777 }
1778
1779 #[test]
1780 fn explicit_durations_round_trip() {
1781 let dims = [4, 4, 8];
1782 let frames: Vec<VoxelFrame> = (0..3)
1783 .map(|fi| {
1784 frame_from_fn(dims, move |x, y, z| {
1785 (x == 0 && y == 0 && z == fi).then_some(VoxColor(0x8011_2233))
1786 })
1787 })
1788 .collect();
1789 let clip = VoxelClip::from_frames(
1790 dims,
1791 [0.0; 3],
1792 1.0,
1793 LoopMode::Once,
1794 &frames,
1795 &[10, 20, 30],
1796 33,
1797 0,
1798 );
1799 let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1800 assert_eq!(parsed.durations, vec![10, 20, 30]);
1801 assert_eq!(parsed.decode().unwrap().durations, vec![10, 20, 30]);
1802 assert_eq!(parsed.loop_mode, LoopMode::Once);
1803 }
1804
1805 #[test]
1806 fn unknown_chunks_preserved() {
1807 let dims = [4, 4, 8];
1808 let (mut clip, _) = flame_clip(dims, 2, 0);
1809 clip.extra_chunks.push((*b"XTRA", vec![1, 2, 3, 4, 5]));
1810 let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1811 assert_eq!(parsed.extra_chunks, vec![(*b"XTRA", vec![1, 2, 3, 4, 5])]);
1812 }
1813
1814 #[test]
1815 fn bad_magic_and_version_rejected() {
1816 let dims = [4, 4, 8];
1817 let (clip, _) = flame_clip(dims, 2, 0);
1818 let mut bytes = clip.serialize();
1819 let good = bytes.clone();
1820 bytes[0] = b'X';
1821 assert!(matches!(
1822 VoxelClip::parse(&bytes),
1823 Err(ParseError::BadMagic { .. })
1824 ));
1825 let mut v = good.clone();
1826 v[4] = 9; assert!(matches!(
1828 VoxelClip::parse(&v),
1829 Err(ParseError::UnsupportedVersion(_))
1830 ));
1831 }
1832
1833 #[test]
1834 fn frame_at_honours_loop_modes() {
1835 let dims = [4, 4, 8];
1837 let frames: Vec<VoxelFrame> = (0..3)
1838 .map(|fi| {
1839 frame_from_fn(dims, move |x, y, z| {
1840 (x == 0 && y == 0 && z == fi).then_some(VoxColor(0x8011_2233))
1841 })
1842 })
1843 .collect();
1844 let mk = |mode| {
1845 VoxelClip::from_frames(dims, [0.0; 3], 1.0, mode, &frames, &[10, 10, 10], 33, 0)
1846 .decode()
1847 .unwrap()
1848 };
1849
1850 let loop_c = mk(LoopMode::Loop);
1851 assert_eq!(loop_c.frame_at(0), 0);
1852 assert_eq!(loop_c.frame_at(9), 0);
1853 assert_eq!(loop_c.frame_at(10), 1);
1854 assert_eq!(loop_c.frame_at(25), 2);
1855 assert_eq!(loop_c.frame_at(30), 0, "wraps at total");
1856 assert_eq!(loop_c.frame_at(45), 1);
1857
1858 let once = mk(LoopMode::Once);
1859 assert_eq!(once.frame_at(25), 2);
1860 assert_eq!(once.frame_at(1000), 2, "holds the last frame");
1861
1862 let ping = mk(LoopMode::PingPong);
1863 assert_eq!(ping.frame_at(5), 0);
1864 assert_eq!(ping.frame_at(25), 2);
1865 assert_eq!(ping.frame_at(35), 2, "mirror: 35→ frame 2");
1866 assert_eq!(ping.frame_at(55), 0, "mirror back to 0 near 2·total");
1867 }
1868
1869 #[test]
1870 fn delta_before_key_rejected() {
1871 let dims = [4, 4, 8];
1872 let clip = VoxelClip {
1873 dims,
1874 pivot: [0.0; 3],
1875 voxel_world_size: 1.0,
1876 loop_mode: LoopMode::Loop,
1877 default_frame_ms: 33,
1878 frames: vec![EncodedFrame::Delta(Vec::new())],
1879 durations: Vec::new(),
1880 extra_chunks: Vec::new(),
1881 };
1882 assert!(matches!(clip.decode(), Err(DecodeError::DeltaBeforeKey)));
1883 }
1884
1885 fn isolated_fill(x: u32, y: u32, z: u32) -> Option<crate::color::VoxColor> {
1892 (x.is_multiple_of(2) && y.is_multiple_of(2) && z.is_multiple_of(2)).then_some(
1893 crate::color::VoxColor(0x8000_0000 | (x << 16) | (y << 8) | z),
1894 )
1895 }
1896
1897 #[test]
1898 fn from_kv6_matches_dense_reference() {
1899 let dims = [3u32, 2, 41];
1902 let kv6 = Kv6::from_fn(dims[0], dims[1], dims[2], isolated_fill);
1903 let imported = VoxelFrame::from_kv6(&kv6);
1904 let expected = frame_from_fn(dims, isolated_fill);
1905 assert_eq!(imported, expected);
1906 imported.validate(dims).expect("imported frame is valid");
1907 }
1908
1909 #[test]
1910 fn from_kv6_packs_z_across_word_boundary() {
1911 let kv6 = Kv6::from_fn(1, 1, 41, |_, _, z| match z {
1913 0 => Some(VoxColor(0x80FF_0000)),
1914 5 => Some(VoxColor(0x8000_FF00)),
1915 33 => Some(VoxColor(0x8000_00FF)),
1916 40 => Some(VoxColor(0x80FF_FF00)),
1917 _ => None,
1918 });
1919 let f = VoxelFrame::from_kv6(&kv6);
1920 assert_eq!(f.occupancy, vec![(1 << 0) | (1 << 5), (1 << 1) | (1 << 8)]);
1922 assert_eq!(
1924 f.colors,
1925 vec![0x80FF_0000, 0x8000_FF00, 0x8000_00FF, 0x80FF_FF00]
1926 );
1927 assert_eq!(f.color_offsets, vec![0, 4]);
1928 f.validate([1, 1, 41]).expect("valid");
1929 }
1930
1931 #[test]
1932 fn from_kv6_frames_round_trips_through_clip() {
1933 let dims = [2u32, 2, 3];
1934 let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1936 (z == 0).then_some(VoxColor(0x80FF_0000))
1937 });
1938 let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1939 (z == 2).then_some(VoxColor(0x8000_FF00))
1940 });
1941 let clip = VoxelClip::from_kv6_frames(
1942 &[ka.clone(), kb.clone()],
1943 2.0,
1944 LoopMode::Loop,
1945 &[100, 200],
1946 0,
1947 0,
1948 )
1949 .expect("import");
1950 assert_eq!(clip.dims, dims);
1951 assert_eq!(clip.voxel_world_size, 2.0);
1952 assert_eq!(clip.pivot, [ka.xpiv, ka.ypiv, ka.zpiv]);
1953 assert_eq!(clip.durations, vec![100, 200]);
1954
1955 let decoded = clip.decode().expect("decode");
1956 assert_eq!(decoded.frames.len(), 2);
1957 assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
1958 assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
1959 }
1960
1961 #[test]
1962 fn from_kv6_frames_rejects_empty() {
1963 let err = VoxelClip::from_kv6_frames(&[], 1.0, LoopMode::Loop, &[], 50, 0)
1964 .expect_err("empty must fail");
1965 assert_eq!(err, Kv6ImportError::Empty);
1966 }
1967
1968 #[test]
1969 fn from_kv6_frames_rejects_dims_mismatch() {
1970 let ka = Kv6::from_fn(2, 2, 2, |_, _, z| (z == 0).then_some(VoxColor(0x80FF_FFFF)));
1971 let kb = Kv6::from_fn(3, 2, 2, |_, _, z| (z == 0).then_some(VoxColor(0x80FF_FFFF)));
1972 let err = VoxelClip::from_kv6_frames(&[ka, kb], 1.0, LoopMode::Loop, &[], 50, 0)
1973 .expect_err("mismatch must fail");
1974 assert_eq!(
1975 err,
1976 Kv6ImportError::DimsMismatch {
1977 frame: 1,
1978 dims: [3, 2, 2],
1979 expected: [2, 2, 2],
1980 }
1981 );
1982 }
1983
1984 #[test]
1985 fn to_kv6_inverts_from_kv6() {
1986 let dims = [3u32, 2, 40];
1989 let frame = frame_from_fn(dims, |x, y, z| {
1990 (z <= (x + y) * 6 + 3).then_some(VoxColor(0x8000_0000 | (z << 8) | (x * 16 + y)))
1991 });
1992 let kv6 = frame.to_kv6(dims, [1.0, 0.5, 20.0]);
1993 assert_eq!([kv6.xsiz, kv6.ysiz, kv6.zsiz], dims);
1994 assert_eq!([kv6.xpiv, kv6.ypiv, kv6.zpiv], [1.0, 0.5, 20.0]);
1995 assert_eq!(VoxelFrame::from_kv6(&kv6), frame);
1997 }
1998
1999 #[test]
2000 fn voxel_frame_dirs_match_decoded() {
2001 let dims = [4u32, 3, 8];
2005 let frame = frame_from_fn(dims, |x, y, z| {
2006 (z <= x + y).then_some(VoxColor(0x80FF_0000))
2007 });
2008 let clip = VoxelClip::from_frames(
2009 dims,
2010 [0.0; 3],
2011 1.0,
2012 LoopMode::Loop,
2013 std::slice::from_ref(&frame),
2014 &[],
2015 33,
2016 0,
2017 );
2018 let decoded = clip.decode().unwrap();
2019 assert_eq!(frame.dirs(dims), decoded.dirs[0]);
2020 }
2021
2022 #[test]
2025 fn compressed_clip_round_trips_and_shrinks() {
2026 let dims = [16u32, 16, 32];
2029 let frame = frame_from_fn(dims, |_, _, _| Some(VoxColor(0x80AB_CDEF)));
2030 let clip = VoxelClip::from_frames(
2031 dims,
2032 [8.0, 8.0, 16.0],
2033 1.0,
2034 LoopMode::Loop,
2035 &[frame],
2036 &[],
2037 33,
2038 0,
2039 );
2040 let bytes = clip.serialize();
2041 let raw_colors_bytes = (dims[0] * dims[1] * dims[2]) as usize * 4;
2044 assert!(
2045 bytes.len() < raw_colors_bytes / 4,
2046 "expected compression: {} serialized bytes vs {raw_colors_bytes} raw colour bytes",
2047 bytes.len(),
2048 );
2049 assert_eq!(&bytes[4..6], &VERSION.to_le_bytes());
2052 let parsed = VoxelClip::parse(&bytes).expect("parse");
2053 assert_eq!(parsed, clip);
2054 assert_eq!(parsed.serialize(), bytes);
2055 }
2056
2057 fn serialize_v1(clip: &VoxelClip) -> Vec<u8> {
2060 fn chunk(out: &mut Vec<u8>, tag: [u8; 4], payload: &[u8]) {
2061 out.extend_from_slice(&tag);
2062 out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2063 out.extend_from_slice(payload);
2064 }
2065 fn u32_vec(out: &mut Vec<u8>, v: &[u32]) {
2066 out.extend_from_slice(&(v.len() as u32).to_le_bytes());
2067 for w in v {
2068 out.extend_from_slice(&w.to_le_bytes());
2069 }
2070 }
2071 let mut out = Vec::new();
2072 out.extend_from_slice(b"RVCL");
2073 out.extend_from_slice(&1u16.to_le_bytes());
2074
2075 let mut meta = Vec::new();
2076 for v in clip.dims {
2077 meta.extend_from_slice(&v.to_le_bytes());
2078 }
2079 for v in clip.pivot {
2080 meta.extend_from_slice(&v.to_le_bytes());
2081 }
2082 meta.extend_from_slice(&clip.voxel_world_size.to_le_bytes());
2083 meta.push(clip.loop_mode.to_u8());
2084 meta.extend_from_slice(&clip.default_frame_ms.to_le_bytes());
2085 meta.extend_from_slice(&(clip.frames.len() as u32).to_le_bytes());
2086 chunk(&mut out, *b"META", &meta);
2087
2088 let mut frms = Vec::new();
2089 for ef in &clip.frames {
2090 let EncodedFrame::Key(f) = ef else {
2091 panic!("serialize_v1 test helper handles keyframes only");
2092 };
2093 frms.push(FRAME_KIND_KEY);
2094 u32_vec(&mut frms, &f.occupancy);
2095 u32_vec(&mut frms, &f.color_offsets);
2096 u32_vec(&mut frms, &f.colors);
2097 }
2098 chunk(&mut out, *b"FRMS", &frms);
2099 out
2100 }
2101
2102 #[test]
2103 fn legacy_v1_file_still_parses() {
2104 let dims = [2u32, 2, 3];
2105 let frame = frame_from_fn(dims, |_, _, z| (z == 0).then_some(VoxColor(0x80FF_0000)));
2106 let clip =
2107 VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Once, &[frame], &[], 50, 0);
2108 let v1 = serialize_v1(&clip);
2109 assert_eq!(&v1[4..6], &1u16.to_le_bytes(), "helper writes version 1");
2110 let parsed = VoxelClip::parse(&v1).expect("v1 must still parse");
2111 assert_eq!(parsed, clip);
2112 }
2113
2114 #[test]
2115 fn bad_deflate_payload_is_rejected() {
2116 let mut bytes = Vec::new();
2118 bytes.extend_from_slice(b"RVCL");
2119 bytes.extend_from_slice(&VERSION.to_le_bytes());
2120 bytes.extend_from_slice(b"META");
2121 bytes.push(CHUNK_FLAG_DEFLATED);
2122 let payload = [99u8, 0, 0, 0, 0xDE, 0xAD, 0xBE, 0xEF]; bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2124 bytes.extend_from_slice(&payload);
2125 assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2126 }
2127
2128 fn build_varied_clip() -> VoxelClip {
2135 let dims = [4u32, 3, 40];
2136 let frames: Vec<VoxelFrame> = (0..7u32)
2137 .map(|i| {
2138 let h = 5 + i * 5;
2139 frame_from_fn(dims, move |_x, _y, z| {
2140 (z < h).then_some(VoxColor(0x8000_0000 | (i * 0x10)))
2141 })
2142 })
2143 .collect();
2144 VoxelClip::from_frames(
2145 dims,
2146 [2.0, 1.5, 20.0],
2147 1.0,
2148 LoopMode::Loop,
2149 &frames,
2150 &[],
2151 33,
2152 3,
2153 )
2154 }
2155
2156 #[test]
2157 fn streaming_matches_decoded_forward_and_random() {
2158 let clip = build_varied_clip();
2159 let decoded = clip.decode().expect("decode");
2160 let mut stream = StreamingClip::new(&clip).expect("stream");
2161 assert_eq!(stream.frame_count(), decoded.frames.len());
2162 assert_eq!(stream.dims(), decoded.dims);
2163 assert_eq!(stream.pivot(), decoded.pivot);
2164
2165 for (i, want) in decoded.frames.iter().enumerate() {
2167 let got = stream.seek(i).expect("seek").clone();
2168 assert_eq!(&got, want, "frame {i} (forward)");
2169 assert_eq!(
2170 stream.current_dirs(),
2171 decoded.dirs[i].as_slice(),
2172 "dirs {i}"
2173 );
2174 assert_eq!(stream.current_index(), i);
2175 }
2176 for &i in &[6usize, 0, 4, 1, 5, 2, 3, 0, 6] {
2178 let got = stream.seek(i).expect("seek").clone();
2179 assert_eq!(&got, &decoded.frames[i], "frame {i} (random)");
2180 assert_eq!(stream.current_dirs(), decoded.dirs[i].as_slice());
2181 }
2182 }
2183
2184 #[test]
2185 fn streaming_seek_clamps_past_end() {
2186 let clip = build_varied_clip();
2187 let decoded = clip.decode().unwrap();
2188 let mut stream = StreamingClip::new(&clip).unwrap();
2189 let last = decoded.frames.len() - 1;
2190 let got = stream.seek(999).unwrap().clone();
2191 assert_eq!(got, decoded.frames[last]);
2192 assert_eq!(stream.current_index(), last);
2193 }
2194
2195 #[test]
2196 fn streaming_rejects_empty_and_delta_first() {
2197 let dims = [1u32, 1, 1];
2198 let mk = |frames: Vec<EncodedFrame>| VoxelClip {
2199 dims,
2200 pivot: [0.0; 3],
2201 voxel_world_size: 1.0,
2202 loop_mode: LoopMode::Loop,
2203 default_frame_ms: 1,
2204 frames,
2205 durations: Vec::new(),
2206 extra_chunks: Vec::new(),
2207 };
2208 assert_eq!(
2209 StreamingClip::new(&mk(Vec::new())).map(|_| ()),
2210 Err(DecodeError::DeltaBeforeKey),
2211 );
2212 assert_eq!(
2213 StreamingClip::new(&mk(vec![EncodedFrame::Delta(Vec::new())])).map(|_| ()),
2214 Err(DecodeError::DeltaBeforeKey),
2215 );
2216 }
2217
2218 #[test]
2221 fn pad_stats_tight_clip_is_not_wasteful() {
2222 let dims = [8u32, 8, 8];
2224 let frame = frame_from_fn(dims, |x, y, z| {
2225 ((x == 0 && y == 0 && z == 0) || (x == 7 && y == 7 && z == 7))
2226 .then_some(VoxColor(0x80FF_FFFF))
2227 });
2228 let s = pad_stats(dims, std::slice::from_ref(&frame));
2229 assert_eq!(s.content_dims, dims);
2230 assert_eq!(s.solid_voxels, 2);
2231 assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2232 assert!(!s.is_wasteful());
2233 }
2234
2235 #[test]
2236 fn pad_stats_padded_clip_is_wasteful() {
2237 let dims = [40u32, 40, 40];
2239 let frames: Vec<VoxelFrame> = (0..3)
2240 .map(|_| {
2241 frame_from_fn(dims, |x, y, z| {
2242 (x < 10 && y < 10 && z < 10).then_some(VoxColor(0x80FF_0000))
2243 })
2244 })
2245 .collect();
2246 let s = pad_stats(dims, &frames);
2247 assert_eq!(s.content_dims, [10, 10, 10]);
2248 assert_eq!(s.solid_voxels, 3 * 1000);
2249 assert!((s.pad_ratio() - 64.0).abs() < 1e-3);
2251 assert!(s.is_wasteful());
2252 }
2253
2254 #[test]
2255 fn pad_stats_empty_clip_is_not_wasteful() {
2256 let dims = [4u32, 4, 4];
2257 let empty = frame_from_fn(dims, |_, _, _| None);
2258 let s = pad_stats(dims, std::slice::from_ref(&empty));
2259 assert_eq!(s.content_dims, [0, 0, 0]);
2260 assert_eq!(s.solid_voxels, 0);
2261 assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2262 assert!(!s.is_wasteful());
2263 }
2264
2265 #[test]
2266 fn decoded_clip_pad_stats_delegates() {
2267 let dims = [20u32, 4, 4];
2268 let frame = frame_from_fn(dims, |x, _, _| (x < 4).then_some(VoxColor(0x80FF_FFFF)));
2269 let clip =
2270 VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Loop, &[frame], &[], 33, 0);
2271 let s = clip.decode().unwrap().pad_stats();
2272 assert_eq!(s.content_dims, [4, 4, 4]);
2273 assert!((s.pad_ratio() - 5.0).abs() < 1e-3);
2275 assert!(s.is_wasteful());
2276 }
2277
2278 fn is_key(e: &EncodedFrame) -> bool {
2281 matches!(e, EncodedFrame::Key(_))
2282 }
2283 fn key_positions(clip: &VoxelClip) -> Vec<usize> {
2284 clip.frames
2285 .iter()
2286 .enumerate()
2287 .filter_map(|(i, e)| is_key(e).then_some(i))
2288 .collect()
2289 }
2290
2291 #[test]
2292 fn from_frames_auto_round_trips() {
2293 let dims = [4u32, 3, 40];
2294 let frames: Vec<VoxelFrame> = (0..7u32)
2295 .map(|i| {
2296 let h = 5 + i * 5;
2297 frame_from_fn(dims, move |_, _, z| {
2298 (z < h).then_some(VoxColor(0x8000_0000 | (i * 0x10)))
2299 })
2300 })
2301 .collect();
2302 let clip =
2303 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2304 assert_eq!(clip.decode().unwrap().frames, frames);
2306 assert!(is_key(&clip.frames[0]), "frame 0 is always a keyframe");
2307 }
2308
2309 #[test]
2310 fn from_frames_auto_keyframes_scene_change_but_deltas_small_change() {
2311 let dims = [4u32, 4, 8];
2312 let a = frame_from_fn(dims, |_, _, z| (z < 4).then_some(VoxColor(0x80FF_0000)));
2313 let scene_cut = frame_from_fn(dims, |_, _, z| (z >= 4).then_some(VoxColor(0x8000_FF00)));
2315 let small = frame_from_fn(dims, |x, y, z| {
2317 ((z < 4) || (x == 0 && y == 0 && z == 4)).then_some(VoxColor(0x80FF_0000))
2318 });
2319
2320 let cut = VoxelClip::from_frames_auto(
2321 dims,
2322 [0.0; 3],
2323 1.0,
2324 LoopMode::Loop,
2325 &[a.clone(), scene_cut],
2326 &[],
2327 33,
2328 0,
2329 );
2330 assert!(is_key(&cut.frames[1]), "scene change → keyframe");
2331
2332 let tweak = VoxelClip::from_frames_auto(
2333 dims,
2334 [0.0; 3],
2335 1.0,
2336 LoopMode::Loop,
2337 &[a, small],
2338 &[],
2339 33,
2340 0,
2341 );
2342 assert!(!is_key(&tweak.frames[1]), "small change → delta");
2343 }
2344
2345 #[test]
2346 fn from_frames_auto_gap_caps_keyframe_spacing() {
2347 let dims = [2u32, 2, 4];
2348 let f = frame_from_fn(dims, |_, _, z| (z < 2).then_some(VoxColor(0x80FF_FFFF)));
2349 let frames = vec![f; 7]; let none =
2353 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2354 assert_eq!(key_positions(&none), vec![0]);
2355
2356 let capped =
2358 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 3);
2359 assert_eq!(key_positions(&capped), vec![0, 3, 6]);
2360 for (i, e) in capped.frames.iter().enumerate() {
2361 if let EncodedFrame::Delta(d) = e {
2362 assert!(d.is_empty(), "identical frame {i} → empty delta");
2363 }
2364 }
2365 }
2366
2367 #[test]
2368 fn from_kv6_frames_auto_round_trips() {
2369 let dims = [3u32, 3, 6];
2370 let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2371 (z == 0).then_some(VoxColor(0x80FF_0000))
2372 });
2373 let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2374 (z >= 3).then_some(VoxColor(0x8000_FF00))
2375 });
2376 let clip = VoxelClip::from_kv6_frames_auto(
2377 &[ka.clone(), kb.clone()],
2378 1.0,
2379 LoopMode::Loop,
2380 &[],
2381 33,
2382 0,
2383 )
2384 .expect("import");
2385 let decoded = clip.decode().unwrap();
2386 assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
2387 assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
2388 }
2389
2390 #[test]
2393 fn from_kv6_does_not_panic_on_malformed_kv6() {
2394 let bad = Kv6 {
2397 xsiz: 2,
2398 ysiz: 2,
2399 zsiz: 4,
2400 xpiv: 0.0,
2401 ypiv: 0.0,
2402 zpiv: 0.0,
2403 voxels: vec![Voxel {
2404 col: 0x80FF_FFFF,
2405 z: 0,
2406 vis: 63,
2407 dir: 0,
2408 }],
2409 xlen: vec![5, 5], ylen: vec![vec![3, 3], vec![3, 3, 99]], palette: None,
2412 };
2413 let f = VoxelFrame::from_kv6(&bad); f.validate([2, 2, 4]).expect("frame is well-formed");
2417 assert_eq!(f.colors, vec![0x80FF_FFFF]);
2418 }
2419
2420 #[test]
2421 fn inflate_rejects_oversized_raw_len() {
2422 let mut bytes = Vec::new();
2425 bytes.extend_from_slice(b"RVCL");
2426 bytes.extend_from_slice(&VERSION.to_le_bytes());
2427 bytes.extend_from_slice(b"META");
2428 bytes.push(CHUNK_FLAG_DEFLATED);
2429 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());
2432 bytes.extend_from_slice(&payload);
2433 assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2434 }
2435
2436 #[test]
2437 fn frame_at_no_overflow_for_huge_durations() {
2438 let durations = vec![u32::MAX / 2, u32::MAX / 2];
2441 let f = frame_at(&durations, LoopMode::PingPong, u32::MAX);
2442 assert!(f < durations.len());
2443 assert!(frame_at(&durations, LoopMode::Loop, u32::MAX) < durations.len());
2445 assert!(frame_at(&durations, LoopMode::Once, u32::MAX) < durations.len());
2446 }
2447
2448 #[test]
2453 fn frame_at_prefix_matches_linear_walk() {
2454 let timelines: [&[u32]; 7] = [
2455 &[],
2456 &[100],
2457 &[100, 100, 100],
2458 &[50, 0, 50, 200],
2459 &[0, 0, 0],
2460 &[1, 1, 1, 1, 1],
2461 &[u32::MAX / 2, u32::MAX / 2],
2462 ];
2463 let modes = [LoopMode::Loop, LoopMode::Once, LoopMode::PingPong];
2464 for durations in timelines {
2465 let prefix = duration_prefix_sums(durations);
2466 for mode in modes {
2467 for t in (0u32..700).chain([u32::MAX - 1, u32::MAX]) {
2468 assert_eq!(
2469 frame_at_prefix(&prefix, mode, t),
2470 frame_at(durations, mode, t),
2471 "durations={durations:?} mode={mode:?} t={t}"
2472 );
2473 }
2474 }
2475 }
2476 }
2477}