Skip to main content

oxihuman_export/
geometry_cache.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Binary animated geometry cache format (Alembic-inspired, custom binary format).
5//!
6//! Format: `.oxgc` (OXiHuman Geometry Cache)
7//!
8//! # File Layout
9//!
10//! ```text
11//! [GeoCacheHeader – fixed 96 bytes]
12//!   magic:        [u8; 4]   = b"OXGC"
13//!   version:      u16 LE
14//!   _pad:         u16       (alignment padding)
15//!   vertex_count: u32 LE
16//!   frame_count:  u32 LE
17//!   fps:          f32 LE
18//!   has_normals:  u8  (0 or 1)
19//!   _pad:         [u8; 3]
20//!   name:         [u8; 64]  (null-padded ASCII)
21//!   reserved:     [u8; 4]
22//!
23//! [Per-frame blocks, repeated frame_count times]
24//!   frame_index:  u32 LE
25//!   time_seconds: f32 LE
26//!   positions:    vertex_count × [f32 LE; 3]
27//!   normals:      vertex_count × [f32 LE; 3]  (only when has_normals = 1)
28//! ```
29
30#![allow(dead_code)]
31
32use std::io::{Read, Write};
33use std::path::Path;
34
35use anyhow::{bail, Context};
36use oxihuman_mesh::MeshBuffers;
37
38// ---------------------------------------------------------------------------
39// Constants
40// ---------------------------------------------------------------------------
41
42/// Magic bytes identifying an OXGC file.
43pub const OXGC_MAGIC: [u8; 4] = *b"OXGC";
44
45/// Current format version.
46pub const OXGC_VERSION: u16 = 1;
47
48/// Total size of the fixed binary header in bytes.
49const HEADER_SIZE: usize = 96;
50
51// ---------------------------------------------------------------------------
52// Structs
53// ---------------------------------------------------------------------------
54
55/// Per-frame data stored in a geometry cache.
56#[derive(Debug, Clone, PartialEq)]
57pub struct GeoCacheFrame {
58    /// Zero-based frame index.
59    pub frame_index: u32,
60    /// Time stamp of this frame in seconds.
61    pub time_seconds: f32,
62    /// Per-vertex positions (`[x, y, z]`).
63    pub positions: Vec<[f32; 3]>,
64    /// Optional per-vertex normals (`[nx, ny, nz]`).
65    pub normals: Option<Vec<[f32; 3]>>,
66}
67
68/// Fixed-size binary header written at the start of every OXGC file.
69#[derive(Debug, Clone, PartialEq)]
70pub struct GeoCacheHeader {
71    pub magic: [u8; 4],
72    pub version: u16,
73    pub vertex_count: u32,
74    pub frame_count: u32,
75    pub fps: f32,
76    pub has_normals: bool,
77    /// Null-padded ASCII cache name (64 bytes).
78    pub name: [u8; 64],
79}
80
81/// In-memory geometry cache containing all frames.
82#[derive(Debug, Clone)]
83pub struct GeoCache {
84    /// Human-readable name (stored in the file header).
85    pub name: String,
86    /// Frames per second.
87    pub fps: f32,
88    /// Number of vertices per frame (must be constant across all frames).
89    pub vertex_count: usize,
90    /// All frames in chronological order.
91    pub frames: Vec<GeoCacheFrame>,
92}
93
94// ---------------------------------------------------------------------------
95// GeoCache implementation
96// ---------------------------------------------------------------------------
97
98impl GeoCache {
99    /// Create a new empty cache.
100    pub fn new(name: &str, fps: f32, vertex_count: usize) -> Self {
101        Self {
102            name: name.to_owned(),
103            fps,
104            vertex_count,
105            frames: Vec::new(),
106        }
107    }
108
109    /// Append a frame. Returns an error when vertex counts mismatch, or when
110    /// the frame has normals but the existing frames do not (or vice-versa).
111    pub fn add_frame(&mut self, frame: GeoCacheFrame) -> Result<(), String> {
112        if frame.positions.len() != self.vertex_count {
113            return Err(format!(
114                "frame {} has {} positions; cache expects {}",
115                frame.frame_index,
116                frame.positions.len(),
117                self.vertex_count
118            ));
119        }
120        if let Some(ref n) = frame.normals {
121            if n.len() != self.vertex_count {
122                return Err(format!(
123                    "frame {} has {} normals; cache expects {}",
124                    frame.frame_index,
125                    n.len(),
126                    self.vertex_count
127                ));
128            }
129        }
130        // Consistency check: all frames must agree on has_normals
131        if !self.frames.is_empty() {
132            let first_has = self.frames[0].normals.is_some();
133            let this_has = frame.normals.is_some();
134            if first_has != this_has {
135                return Err(format!(
136                    "frame {} normals presence ({}) differs from earlier frames ({})",
137                    frame.frame_index, this_has, first_has
138                ));
139            }
140        }
141        self.frames.push(frame);
142        Ok(())
143    }
144
145    /// Number of frames stored.
146    pub fn frame_count(&self) -> usize {
147        self.frames.len()
148    }
149
150    /// Total animation duration in seconds (last frame time, or 0 if empty).
151    pub fn duration_seconds(&self) -> f32 {
152        self.frames.last().map(|f| f.time_seconds).unwrap_or(0.0)
153    }
154
155    /// Access a frame by index.
156    pub fn get_frame(&self, index: usize) -> Option<&GeoCacheFrame> {
157        self.frames.get(index)
158    }
159
160    /// Linearly interpolate vertex positions at `time_seconds`.
161    ///
162    /// Clamps to the first or last frame when `time_seconds` is out of the
163    /// recorded range.  Returns `None` only when the cache is empty.
164    pub fn sample(&self, time_seconds: f32) -> Option<Vec<[f32; 3]>> {
165        let n = self.frames.len();
166        if n == 0 {
167            return None;
168        }
169        if n == 1 {
170            return Some(self.frames[0].positions.clone());
171        }
172
173        // Clamp to bounds
174        let t = time_seconds.clamp(self.frames[0].time_seconds, self.frames[n - 1].time_seconds);
175
176        // Find the two surrounding frames
177        let idx = self
178            .frames
179            .partition_point(|f| f.time_seconds <= t)
180            .saturating_sub(1)
181            .min(n - 2);
182
183        let fa = &self.frames[idx];
184        let fb = &self.frames[idx + 1];
185
186        let dt = fb.time_seconds - fa.time_seconds;
187        let alpha = if dt.abs() < f32::EPSILON {
188            0.0
189        } else {
190            ((t - fa.time_seconds) / dt).clamp(0.0, 1.0)
191        };
192
193        let result = fa
194            .positions
195            .iter()
196            .zip(fb.positions.iter())
197            .map(|(a, b)| {
198                [
199                    a[0] + alpha * (b[0] - a[0]),
200                    a[1] + alpha * (b[1] - a[1]),
201                    a[2] + alpha * (b[2] - a[2]),
202                ]
203            })
204            .collect();
205        Some(result)
206    }
207
208    /// Write the cache to a binary file.
209    pub fn write(&self, path: &Path) -> anyhow::Result<()> {
210        export_geo_cache(self, path)
211    }
212
213    /// Read a cache from a binary file.
214    pub fn read(path: &Path) -> anyhow::Result<Self> {
215        load_geo_cache(path)
216    }
217
218    /// Validate a cache file (check magic, version, sizes).
219    pub fn validate(path: &Path) -> anyhow::Result<()> {
220        validate_geo_cache_file(path)
221    }
222}
223
224// ---------------------------------------------------------------------------
225// Conversion helpers
226// ---------------------------------------------------------------------------
227
228/// Convert a slice of [`MeshBuffers`] frames into a [`GeoCache`].
229///
230/// Normals from each mesh are included automatically.
231pub fn mesh_sequence_to_geo_cache(name: &str, fps: f32, frames: &[MeshBuffers]) -> GeoCache {
232    let vertex_count = frames.first().map(|m| m.positions.len()).unwrap_or(0);
233    let mut cache = GeoCache::new(name, fps, vertex_count);
234
235    for (i, mesh) in frames.iter().enumerate() {
236        let time_seconds = i as f32 / fps.max(f32::EPSILON);
237        let has_normals = !mesh.normals.is_empty() && mesh.normals.len() == mesh.positions.len();
238        let normals = if has_normals {
239            Some(mesh.normals.clone())
240        } else {
241            None
242        };
243        let frame = GeoCacheFrame {
244            frame_index: i as u32,
245            time_seconds,
246            positions: mesh.positions.clone(),
247            normals,
248        };
249        // Ignore vertex-count mismatches gracefully (skip bad frames)
250        let _ = cache.add_frame(frame);
251    }
252
253    cache
254}
255
256// ---------------------------------------------------------------------------
257// Public convenience wrappers
258// ---------------------------------------------------------------------------
259
260/// Write a [`GeoCache`] to the given path.
261pub fn export_geo_cache(cache: &GeoCache, path: &Path) -> anyhow::Result<()> {
262    let mut file =
263        std::fs::File::create(path).with_context(|| format!("cannot create {}", path.display()))?;
264
265    let has_normals = cache
266        .frames
267        .first()
268        .map(|f| f.normals.is_some())
269        .unwrap_or(false);
270
271    write_header(&mut file, cache, has_normals)?;
272
273    for frame in &cache.frames {
274        write_frame(&mut file, frame, has_normals, cache.vertex_count)?;
275    }
276
277    Ok(())
278}
279
280/// Load a [`GeoCache`] from a binary file.
281pub fn load_geo_cache(path: &Path) -> anyhow::Result<GeoCache> {
282    let mut file =
283        std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
284
285    let header = read_header(&mut file)?;
286
287    let name = bytes_to_name(&header.name);
288    let vertex_count = header.vertex_count as usize;
289    let frame_count = header.frame_count as usize;
290    let has_normals = header.has_normals;
291
292    let mut cache = GeoCache::new(&name, header.fps, vertex_count);
293
294    for _ in 0..frame_count {
295        let frame = read_frame(&mut file, vertex_count, has_normals)?;
296        cache.frames.push(frame);
297    }
298
299    Ok(cache)
300}
301
302/// Validate an OXGC file without loading all frame data.
303pub fn validate_geo_cache_file(path: &Path) -> anyhow::Result<()> {
304    let mut file =
305        std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
306
307    let header = read_header(&mut file)?;
308
309    // Validate version
310    if header.version != OXGC_VERSION {
311        bail!(
312            "unsupported OXGC version {} (expected {})",
313            header.version,
314            OXGC_VERSION
315        );
316    }
317
318    // Validate name is null-terminated ASCII
319    let name = bytes_to_name(&header.name);
320    if name.len() > 64 {
321        bail!("name field exceeds 64 bytes");
322    }
323
324    // Spot-check: verify we can read at least the first frame header
325    if header.frame_count > 0 {
326        let mut buf4 = [0u8; 4];
327        file.read_exact(&mut buf4)
328            .with_context(|| "could not read first frame_index")?;
329        let _frame_index = u32::from_le_bytes(buf4);
330        file.read_exact(&mut buf4)
331            .with_context(|| "could not read first frame time")?;
332        let _time = f32::from_le_bytes(buf4);
333    }
334
335    Ok(())
336}
337
338// ---------------------------------------------------------------------------
339// Internal I/O helpers
340// ---------------------------------------------------------------------------
341
342fn name_to_bytes(name: &str) -> [u8; 64] {
343    let mut buf = [0u8; 64];
344    let bytes = name.as_bytes();
345    let len = bytes.len().min(63);
346    buf[..len].copy_from_slice(&bytes[..len]);
347    buf
348}
349
350fn bytes_to_name(buf: &[u8; 64]) -> String {
351    let end = buf.iter().position(|&b| b == 0).unwrap_or(64);
352    String::from_utf8_lossy(&buf[..end]).into_owned()
353}
354
355fn write_header<W: Write>(
356    writer: &mut W,
357    cache: &GeoCache,
358    has_normals: bool,
359) -> anyhow::Result<()> {
360    // magic (4 bytes)
361    writer.write_all(&OXGC_MAGIC)?;
362    // version (2 bytes) + alignment pad (2 bytes)
363    writer.write_all(&OXGC_VERSION.to_le_bytes())?;
364    writer.write_all(&[0u8; 2])?;
365    // vertex_count (4 bytes)
366    writer.write_all(&(cache.vertex_count as u32).to_le_bytes())?;
367    // frame_count (4 bytes)
368    writer.write_all(&(cache.frames.len() as u32).to_le_bytes())?;
369    // fps (4 bytes)
370    writer.write_all(&cache.fps.to_le_bytes())?;
371    // has_normals (1 byte) + pad (3 bytes)
372    writer.write_all(&[u8::from(has_normals)])?;
373    writer.write_all(&[0u8; 3])?;
374    // name (64 bytes)
375    writer.write_all(&name_to_bytes(&cache.name))?;
376    // reserved (4 bytes)
377    writer.write_all(&[0u8; 4])?;
378
379    // Total so far: 4+2+2+4+4+4+1+3+64+4 = 92 bytes — need 96
380    writer.write_all(&[0u8; 4])?; // extra reserved
381
382    Ok(())
383}
384
385fn read_header<R: Read>(reader: &mut R) -> anyhow::Result<GeoCacheHeader> {
386    let mut magic = [0u8; 4];
387    reader.read_exact(&mut magic)?;
388    if magic != OXGC_MAGIC {
389        bail!(
390            "invalid magic bytes: expected OXGC, got {:?}",
391            std::str::from_utf8(&magic).unwrap_or("???")
392        );
393    }
394
395    let mut buf2 = [0u8; 2];
396    let mut buf4 = [0u8; 4];
397
398    // version
399    reader.read_exact(&mut buf2)?;
400    let version = u16::from_le_bytes(buf2);
401    // alignment pad
402    reader.read_exact(&mut buf2)?;
403
404    // vertex_count
405    reader.read_exact(&mut buf4)?;
406    let vertex_count = u32::from_le_bytes(buf4);
407
408    // frame_count
409    reader.read_exact(&mut buf4)?;
410    let frame_count = u32::from_le_bytes(buf4);
411
412    // fps
413    reader.read_exact(&mut buf4)?;
414    let fps = f32::from_le_bytes(buf4);
415
416    // has_normals (1 byte) + pad (3 bytes)
417    let mut buf1 = [0u8; 1];
418    reader.read_exact(&mut buf1)?;
419    let has_normals = buf1[0] != 0;
420    let mut pad3 = [0u8; 3];
421    reader.read_exact(&mut pad3)?;
422
423    // name (64 bytes)
424    let mut name = [0u8; 64];
425    reader.read_exact(&mut name)?;
426
427    // reserved (4 + 4 = 8 bytes)
428    let mut reserved = [0u8; 8];
429    reader.read_exact(&mut reserved)?;
430
431    Ok(GeoCacheHeader {
432        magic,
433        version,
434        vertex_count,
435        frame_count,
436        fps,
437        has_normals,
438        name,
439    })
440}
441
442fn write_frame<W: Write>(
443    writer: &mut W,
444    frame: &GeoCacheFrame,
445    has_normals: bool,
446    vertex_count: usize,
447) -> anyhow::Result<()> {
448    writer.write_all(&frame.frame_index.to_le_bytes())?;
449    writer.write_all(&frame.time_seconds.to_le_bytes())?;
450
451    for &[x, y, z] in &frame.positions {
452        writer.write_all(&x.to_le_bytes())?;
453        writer.write_all(&y.to_le_bytes())?;
454        writer.write_all(&z.to_le_bytes())?;
455    }
456
457    if has_normals {
458        if let Some(ref normals) = frame.normals {
459            for &[nx, ny, nz] in normals {
460                writer.write_all(&nx.to_le_bytes())?;
461                writer.write_all(&ny.to_le_bytes())?;
462                writer.write_all(&nz.to_le_bytes())?;
463            }
464        } else {
465            // Write zero normals as placeholder
466            for _ in 0..vertex_count {
467                writer.write_all(&0f32.to_le_bytes())?;
468                writer.write_all(&1f32.to_le_bytes())?;
469                writer.write_all(&0f32.to_le_bytes())?;
470            }
471        }
472    }
473
474    Ok(())
475}
476
477fn read_frame<R: Read>(
478    reader: &mut R,
479    vertex_count: usize,
480    has_normals: bool,
481) -> anyhow::Result<GeoCacheFrame> {
482    let mut buf4 = [0u8; 4];
483
484    reader.read_exact(&mut buf4)?;
485    let frame_index = u32::from_le_bytes(buf4);
486
487    reader.read_exact(&mut buf4)?;
488    let time_seconds = f32::from_le_bytes(buf4);
489
490    let mut positions = Vec::with_capacity(vertex_count);
491    for _ in 0..vertex_count {
492        reader.read_exact(&mut buf4)?;
493        let x = f32::from_le_bytes(buf4);
494        reader.read_exact(&mut buf4)?;
495        let y = f32::from_le_bytes(buf4);
496        reader.read_exact(&mut buf4)?;
497        let z = f32::from_le_bytes(buf4);
498        positions.push([x, y, z]);
499    }
500
501    let normals = if has_normals {
502        let mut nrm = Vec::with_capacity(vertex_count);
503        for _ in 0..vertex_count {
504            reader.read_exact(&mut buf4)?;
505            let nx = f32::from_le_bytes(buf4);
506            reader.read_exact(&mut buf4)?;
507            let ny = f32::from_le_bytes(buf4);
508            reader.read_exact(&mut buf4)?;
509            let nz = f32::from_le_bytes(buf4);
510            nrm.push([nx, ny, nz]);
511        }
512        Some(nrm)
513    } else {
514        None
515    };
516
517    Ok(GeoCacheFrame {
518        frame_index,
519        time_seconds,
520        positions,
521        normals,
522    })
523}
524
525// ---------------------------------------------------------------------------
526// Tests
527// ---------------------------------------------------------------------------
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    // -----------------------------------------------------------------------
534    // Helpers
535    // -----------------------------------------------------------------------
536
537    fn make_frame(
538        index: u32,
539        time: f32,
540        positions: Vec<[f32; 3]>,
541        normals: Option<Vec<[f32; 3]>>,
542    ) -> GeoCacheFrame {
543        GeoCacheFrame {
544            frame_index: index,
545            time_seconds: time,
546            positions,
547            normals,
548        }
549    }
550
551    fn make_mesh_buffers(positions: Vec<[f32; 3]>) -> MeshBuffers {
552        let n = positions.len();
553        MeshBuffers {
554            positions,
555            normals: vec![[0.0, 1.0, 0.0]; n],
556            tangents: vec![[1.0, 0.0, 0.0, 1.0]; n],
557            uvs: vec![[0.0, 0.0]; n],
558            indices: vec![],
559            colors: None,
560            has_suit: false,
561        }
562    }
563
564    // -----------------------------------------------------------------------
565    // 1. Constants
566    // -----------------------------------------------------------------------
567
568    #[test]
569    fn test_constants() {
570        assert_eq!(&OXGC_MAGIC, b"OXGC");
571        assert_eq!(OXGC_VERSION, 1);
572    }
573
574    // -----------------------------------------------------------------------
575    // 2. GeoCache::new
576    // -----------------------------------------------------------------------
577
578    #[test]
579    fn test_new_cache() {
580        let cache = GeoCache::new("TestCache", 30.0, 8);
581        assert_eq!(cache.name, "TestCache");
582        assert_eq!(cache.fps, 30.0);
583        assert_eq!(cache.vertex_count, 8);
584        assert_eq!(cache.frame_count(), 0);
585    }
586
587    // -----------------------------------------------------------------------
588    // 3. add_frame – success
589    // -----------------------------------------------------------------------
590
591    #[test]
592    fn test_add_frame_success() {
593        let mut cache = GeoCache::new("A", 24.0, 2);
594        let f = make_frame(0, 0.0, vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], None);
595        cache.add_frame(f).expect("should succeed");
596        assert_eq!(cache.frame_count(), 1);
597    }
598
599    // -----------------------------------------------------------------------
600    // 4. add_frame – wrong vertex count
601    // -----------------------------------------------------------------------
602
603    #[test]
604    fn test_add_frame_wrong_vertex_count() {
605        let mut cache = GeoCache::new("A", 24.0, 3);
606        let f = make_frame(0, 0.0, vec![[0.0, 0.0, 0.0]], None); // only 1 vertex
607        assert!(cache.add_frame(f).is_err());
608    }
609
610    // -----------------------------------------------------------------------
611    // 5. add_frame – normals mismatch consistency
612    // -----------------------------------------------------------------------
613
614    #[test]
615    fn test_add_frame_normals_consistency() {
616        let mut cache = GeoCache::new("B", 24.0, 2);
617        let f0 = make_frame(0, 0.0, vec![[0.0; 3], [1.0; 3]], None);
618        let f1 = make_frame(
619            1,
620            1.0 / 24.0,
621            vec![[0.0; 3], [1.0; 3]],
622            Some(vec![[0.0, 1.0, 0.0], [0.0, 1.0, 0.0]]),
623        );
624        cache.add_frame(f0).expect("should succeed");
625        // Adding a frame with normals when the first had none should fail
626        assert!(cache.add_frame(f1).is_err());
627    }
628
629    // -----------------------------------------------------------------------
630    // 6. duration_seconds
631    // -----------------------------------------------------------------------
632
633    #[test]
634    fn test_duration_seconds() {
635        let mut cache = GeoCache::new("D", 25.0, 1);
636        assert_eq!(cache.duration_seconds(), 0.0);
637        cache
638            .add_frame(make_frame(0, 0.0, vec![[0.0; 3]], None))
639            .expect("should succeed");
640        cache
641            .add_frame(make_frame(1, 1.0 / 25.0, vec![[1.0; 3]], None))
642            .expect("should succeed");
643        let expected = 1.0f32 / 25.0;
644        assert!((cache.duration_seconds() - expected).abs() < 1e-6);
645    }
646
647    // -----------------------------------------------------------------------
648    // 7. get_frame
649    // -----------------------------------------------------------------------
650
651    #[test]
652    fn test_get_frame() {
653        let mut cache = GeoCache::new("G", 24.0, 1);
654        cache
655            .add_frame(make_frame(0, 0.0, vec![[1.0, 2.0, 3.0]], None))
656            .expect("should succeed");
657        let f = cache.get_frame(0).expect("should succeed");
658        assert_eq!(f.positions[0], [1.0, 2.0, 3.0]);
659        assert!(cache.get_frame(1).is_none());
660    }
661
662    // -----------------------------------------------------------------------
663    // 8. sample – interpolation
664    // -----------------------------------------------------------------------
665
666    #[test]
667    fn test_sample_interpolation() {
668        let mut cache = GeoCache::new("S", 24.0, 1);
669        cache
670            .add_frame(make_frame(0, 0.0, vec![[0.0, 0.0, 0.0]], None))
671            .expect("should succeed");
672        cache
673            .add_frame(make_frame(1, 1.0, vec![[10.0, 20.0, 30.0]], None))
674            .expect("should succeed");
675
676        let mid = cache.sample(0.5).expect("should succeed");
677        let eps = 1e-4;
678        assert!((mid[0][0] - 5.0).abs() < eps);
679        assert!((mid[0][1] - 10.0).abs() < eps);
680        assert!((mid[0][2] - 15.0).abs() < eps);
681    }
682
683    // -----------------------------------------------------------------------
684    // 9. sample – clamping
685    // -----------------------------------------------------------------------
686
687    #[test]
688    fn test_sample_clamping() {
689        let mut cache = GeoCache::new("S", 24.0, 1);
690        cache
691            .add_frame(make_frame(0, 0.0, vec![[1.0, 2.0, 3.0]], None))
692            .expect("should succeed");
693        cache
694            .add_frame(make_frame(1, 1.0, vec![[4.0, 5.0, 6.0]], None))
695            .expect("should succeed");
696
697        // Before start -> clamp to first frame
698        let before = cache.sample(-5.0).expect("should succeed");
699        assert_eq!(before[0], [1.0, 2.0, 3.0]);
700
701        // After end -> clamp to last frame
702        let after = cache.sample(100.0).expect("should succeed");
703        assert_eq!(after[0], [4.0, 5.0, 6.0]);
704    }
705
706    // -----------------------------------------------------------------------
707    // 10. sample – empty cache returns None
708    // -----------------------------------------------------------------------
709
710    #[test]
711    fn test_sample_empty() {
712        let cache = GeoCache::new("E", 24.0, 4);
713        assert!(cache.sample(0.0).is_none());
714    }
715
716    // -----------------------------------------------------------------------
717    // 11. export / load round-trip (no normals)
718    // -----------------------------------------------------------------------
719
720    #[test]
721    fn test_export_load_no_normals() {
722        let path = std::path::Path::new("/tmp/test_oxgc_no_normals.oxgc");
723        let mut cache = GeoCache::new("RoundTrip", 30.0, 2);
724        cache
725            .add_frame(make_frame(
726                0,
727                0.0,
728                vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
729                None,
730            ))
731            .expect("should succeed");
732        cache
733            .add_frame(make_frame(
734                1,
735                1.0 / 30.0,
736                vec![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]],
737                None,
738            ))
739            .expect("should succeed");
740
741        export_geo_cache(&cache, path).expect("should succeed");
742        let loaded = load_geo_cache(path).expect("should succeed");
743
744        assert_eq!(loaded.name, "RoundTrip");
745        assert_eq!(loaded.fps, 30.0);
746        assert_eq!(loaded.vertex_count, 2);
747        assert_eq!(loaded.frame_count(), 2);
748        assert_eq!(
749            loaded.frames[0].positions,
750            vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]
751        );
752        assert_eq!(
753            loaded.frames[1].positions,
754            vec![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]]
755        );
756        assert!(loaded.frames[0].normals.is_none());
757    }
758
759    // -----------------------------------------------------------------------
760    // 12. export / load round-trip (with normals)
761    // -----------------------------------------------------------------------
762
763    #[test]
764    fn test_export_load_with_normals() {
765        let path = std::path::Path::new("/tmp/test_oxgc_with_normals.oxgc");
766        let mut cache = GeoCache::new("WithNormals", 24.0, 2);
767        let nrm0 = vec![[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
768        let nrm1 = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
769        cache
770            .add_frame(make_frame(
771                0,
772                0.0,
773                vec![[0.0; 3], [1.0; 3]],
774                Some(nrm0.clone()),
775            ))
776            .expect("should succeed");
777        cache
778            .add_frame(make_frame(
779                1,
780                1.0 / 24.0,
781                vec![[2.0; 3], [3.0; 3]],
782                Some(nrm1.clone()),
783            ))
784            .expect("should succeed");
785
786        export_geo_cache(&cache, path).expect("should succeed");
787        let loaded = load_geo_cache(path).expect("should succeed");
788
789        assert_eq!(loaded.frame_count(), 2);
790        assert_eq!(loaded.frames[0].normals.as_ref().expect("should succeed"), &nrm0);
791        assert_eq!(loaded.frames[1].normals.as_ref().expect("should succeed"), &nrm1);
792    }
793
794    // -----------------------------------------------------------------------
795    // 13. validate – good file
796    // -----------------------------------------------------------------------
797
798    #[test]
799    fn test_validate_good_file() {
800        let path = std::path::Path::new("/tmp/test_oxgc_validate_ok.oxgc");
801        let mut cache = GeoCache::new("Valid", 25.0, 3);
802        cache
803            .add_frame(make_frame(0, 0.0, vec![[0.0; 3], [1.0; 3], [2.0; 3]], None))
804            .expect("should succeed");
805        export_geo_cache(&cache, path).expect("should succeed");
806        assert!(GeoCache::validate(path).is_ok());
807    }
808
809    // -----------------------------------------------------------------------
810    // 14. validate – bad magic
811    // -----------------------------------------------------------------------
812
813    #[test]
814    fn test_validate_bad_magic() {
815        let path = std::path::Path::new("/tmp/test_oxgc_bad_magic.oxgc");
816        let mut data = vec![0u8; HEADER_SIZE];
817        data[..4].copy_from_slice(b"BAAD");
818        std::fs::write(path, &data).expect("should succeed");
819        let result = GeoCache::validate(path);
820        assert!(result.is_err());
821        let msg = format!("{}", result.unwrap_err());
822        assert!(msg.contains("invalid magic") || msg.contains("OXGC"));
823    }
824
825    // -----------------------------------------------------------------------
826    // 15. mesh_sequence_to_geo_cache
827    // -----------------------------------------------------------------------
828
829    #[test]
830    fn test_mesh_sequence_to_geo_cache() {
831        let m0 = make_mesh_buffers(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
832        let m1 = make_mesh_buffers(vec![[0.0, 1.0, 0.0], [1.0, 1.0, 0.0]]);
833        let cache = mesh_sequence_to_geo_cache("Seq", 24.0, &[m0, m1]);
834        assert_eq!(cache.vertex_count, 2);
835        assert_eq!(cache.frame_count(), 2);
836        assert_eq!(cache.fps, 24.0);
837        assert_eq!(cache.name, "Seq");
838        // Normals included from MeshBuffers
839        assert!(cache.frames[0].normals.is_some());
840    }
841
842    // -----------------------------------------------------------------------
843    // 16. GeoCache::write / read method aliases
844    // -----------------------------------------------------------------------
845
846    #[test]
847    fn test_write_read_methods() {
848        let path = std::path::Path::new("/tmp/test_oxgc_methods.oxgc");
849        let mut cache = GeoCache::new("Methods", 60.0, 1);
850        cache
851            .add_frame(make_frame(0, 0.0, vec![[9.0, 8.0, 7.0]], None))
852            .expect("should succeed");
853
854        cache.write(path).expect("should succeed");
855        let loaded = GeoCache::read(path).expect("should succeed");
856        assert_eq!(loaded.name, "Methods");
857        assert_eq!(loaded.frames[0].positions[0], [9.0, 8.0, 7.0]);
858    }
859
860    // -----------------------------------------------------------------------
861    // 17. load_geo_cache convenience wrapper
862    // -----------------------------------------------------------------------
863
864    #[test]
865    fn test_load_geo_cache_wrapper() {
866        let path = std::path::Path::new("/tmp/test_oxgc_load_wrapper.oxgc");
867        let mut cache = GeoCache::new("Wrap", 12.0, 1);
868        cache
869            .add_frame(make_frame(0, 0.0, vec![[3.0, 2.71, 1.41]], None))
870            .expect("should succeed");
871        export_geo_cache(&cache, path).expect("should succeed");
872
873        let loaded = load_geo_cache(path).expect("should succeed");
874        let eps = 1e-5;
875        assert!((loaded.frames[0].positions[0][0] - 3.0).abs() < eps);
876    }
877
878    // -----------------------------------------------------------------------
879    // 18. Name padding / truncation
880    // -----------------------------------------------------------------------
881
882    #[test]
883    fn test_name_padding() {
884        let path = std::path::Path::new("/tmp/test_oxgc_name.oxgc");
885        let cache = GeoCache::new("Short", 1.0, 0);
886        export_geo_cache(&cache, path).expect("should succeed");
887        let loaded = load_geo_cache(path).expect("should succeed");
888        assert_eq!(loaded.name, "Short");
889    }
890
891    // -----------------------------------------------------------------------
892    // 19. Long name truncated to 63 chars
893    // -----------------------------------------------------------------------
894
895    #[test]
896    fn test_name_truncation() {
897        let long_name = "A".repeat(200);
898        let name_bytes = name_to_bytes(&long_name);
899        // Must fit in 64 bytes with null terminator
900        assert_eq!(name_bytes.len(), 64);
901        assert_eq!(name_bytes[63], 0); // last byte must be null
902        let recovered = bytes_to_name(&name_bytes);
903        assert_eq!(recovered.len(), 63);
904    }
905
906    // -----------------------------------------------------------------------
907    // 20. frame_index stored and recovered correctly
908    // -----------------------------------------------------------------------
909
910    #[test]
911    fn test_frame_index_round_trip() {
912        let path = std::path::Path::new("/tmp/test_oxgc_frame_index.oxgc");
913        let mut cache = GeoCache::new("Idx", 24.0, 1);
914        cache
915            .add_frame(make_frame(42, 0.0, vec![[0.0; 3]], None))
916            .expect("should succeed");
917        export_geo_cache(&cache, path).expect("should succeed");
918        let loaded = load_geo_cache(path).expect("should succeed");
919        assert_eq!(loaded.frames[0].frame_index, 42);
920    }
921
922    // -----------------------------------------------------------------------
923    // 21. Empty cache writes and loads cleanly
924    // -----------------------------------------------------------------------
925
926    #[test]
927    fn test_empty_cache_round_trip() {
928        let path = std::path::Path::new("/tmp/test_oxgc_empty.oxgc");
929        let cache = GeoCache::new("Empty", 24.0, 100);
930        export_geo_cache(&cache, path).expect("should succeed");
931        let loaded = load_geo_cache(path).expect("should succeed");
932        assert_eq!(loaded.frame_count(), 0);
933        assert_eq!(loaded.vertex_count, 100);
934    }
935
936    // -----------------------------------------------------------------------
937    // 22. Sample on single-frame cache returns that frame
938    // -----------------------------------------------------------------------
939
940    #[test]
941    fn test_sample_single_frame() {
942        let mut cache = GeoCache::new("One", 24.0, 2);
943        cache
944            .add_frame(make_frame(
945                0,
946                0.0,
947                vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
948                None,
949            ))
950            .expect("should succeed");
951        let result = cache.sample(99.0).expect("should succeed");
952        assert_eq!(result[0], [1.0, 2.0, 3.0]);
953        assert_eq!(result[1], [4.0, 5.0, 6.0]);
954    }
955
956    // -----------------------------------------------------------------------
957    // 23. Header constants match HEADER_SIZE
958    // -----------------------------------------------------------------------
959
960    #[test]
961    fn test_header_binary_size() {
962        // Write a minimal cache and check offset of first frame data
963        let path = std::path::Path::new("/tmp/test_oxgc_header_size.oxgc");
964        let mut cache = GeoCache::new("Sz", 1.0, 1);
965        cache
966            .add_frame(make_frame(0, 0.0, vec![[1.0, 2.0, 3.0]], None))
967            .expect("should succeed");
968        export_geo_cache(&cache, path).expect("should succeed");
969
970        let data = std::fs::read(path).expect("should succeed");
971        // Header = 96 bytes, frame = 4+4+12 = 20 bytes
972        assert_eq!(data.len(), HEADER_SIZE + 20);
973    }
974
975    // -----------------------------------------------------------------------
976    // 24. GeoCacheHeader magic field
977    // -----------------------------------------------------------------------
978
979    #[test]
980    fn test_header_struct_fields() {
981        let path = std::path::Path::new("/tmp/test_oxgc_hdr_fields.oxgc");
982        let cache = GeoCache::new("Hdr", 48.0, 5);
983        export_geo_cache(&cache, path).expect("should succeed");
984        let mut file = std::fs::File::open(path).expect("should succeed");
985        let hdr = read_header(&mut file).expect("should succeed");
986        assert_eq!(hdr.magic, OXGC_MAGIC);
987        assert_eq!(hdr.version, OXGC_VERSION);
988        assert_eq!(hdr.vertex_count, 5);
989        assert_eq!(hdr.frame_count, 0);
990        assert_eq!(hdr.fps, 48.0);
991        assert!(!hdr.has_normals);
992    }
993}