Skip to main content

oxihuman_export/
point_cache.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Binary point cache export for vertex animation sequences.
5//!
6//! Format: `.opc` (OxiHuman Point Cache)
7//! Header: 32 bytes, frame data follows as packed f32 LE triples.
8
9#![allow(dead_code)]
10
11use std::io::{Read, Write};
12use std::path::Path;
13
14use anyhow::{anyhow, bail, Context};
15use oxihuman_mesh::MeshBuffers;
16
17/// Magic bytes identifying an OPC file.
18pub const OPC_MAGIC: &[u8; 4] = b"OPC1";
19
20/// Header for a point cache file.
21#[derive(Debug, Clone, PartialEq)]
22pub struct PointCacheHeader {
23    pub vertex_count: u32,
24    pub frame_count: u32,
25    pub fps: f32,
26}
27
28/// In-memory point cache: per-frame vertex positions.
29#[derive(Debug, Clone)]
30pub struct PointCache {
31    pub header: PointCacheHeader,
32    /// All frames: outer = frame index, inner = per-vertex [x, y, z].
33    pub frames: Vec<Vec<[f32; 3]>>,
34}
35
36impl PointCache {
37    /// Create a new empty cache for the given vertex count and frame rate.
38    pub fn new(vertex_count: usize, fps: f32) -> Self {
39        Self {
40            header: PointCacheHeader {
41                vertex_count: vertex_count as u32,
42                frame_count: 0,
43                fps,
44            },
45            frames: Vec::new(),
46        }
47    }
48
49    /// Append a frame. Returns an error if the position count does not match.
50    pub fn add_frame(&mut self, positions: Vec<[f32; 3]>) -> anyhow::Result<()> {
51        if positions.len() != self.header.vertex_count as usize {
52            bail!(
53                "frame vertex count {} does not match cache vertex count {}",
54                positions.len(),
55                self.header.vertex_count
56            );
57        }
58        self.frames.push(positions);
59        self.header.frame_count = self.frames.len() as u32;
60        Ok(())
61    }
62
63    /// Number of frames currently stored.
64    pub fn frame_count(&self) -> usize {
65        self.frames.len()
66    }
67
68    /// Number of vertices per frame.
69    pub fn vertex_count(&self) -> usize {
70        self.header.vertex_count as usize
71    }
72
73    /// Total duration in seconds.
74    pub fn duration(&self) -> f32 {
75        if self.header.fps == 0.0 {
76            0.0
77        } else {
78            self.header.frame_count as f32 / self.header.fps
79        }
80    }
81
82    /// Get a reference to a frame by index.
83    pub fn get_frame(&self, index: usize) -> Option<&Vec<[f32; 3]>> {
84        self.frames.get(index)
85    }
86
87    /// Interpolate vertex positions at fractional frame time `t` (range: 0 .. frame_count-1).
88    ///
89    /// Returns `None` if the cache has fewer than two frames or `t` is out of range.
90    pub fn sample(&self, t: f32) -> Option<Vec<[f32; 3]>> {
91        let n = self.frames.len();
92        if n < 2 {
93            return None;
94        }
95        if t < 0.0 || t > (n - 1) as f32 {
96            return None;
97        }
98        let f = t.floor() as usize;
99        let frac = t - f as f32;
100        let f_next = (f + 1).min(n - 1);
101
102        let a = &self.frames[f];
103        let b = &self.frames[f_next];
104
105        let result = a
106            .iter()
107            .zip(b.iter())
108            .map(|(pa, pb)| {
109                [
110                    pa[0] + frac * (pb[0] - pa[0]),
111                    pa[1] + frac * (pb[1] - pa[1]),
112                    pa[2] + frac * (pb[2] - pa[2]),
113                ]
114            })
115            .collect();
116        Some(result)
117    }
118}
119
120/// Export a `PointCache` to a `.opc` binary file.
121pub fn export_point_cache(cache: &PointCache, path: &Path) -> anyhow::Result<()> {
122    let mut file =
123        std::fs::File::create(path).with_context(|| format!("cannot create {}", path.display()))?;
124
125    // --- Header (32 bytes) ---
126    file.write_all(OPC_MAGIC)?;
127    file.write_all(&cache.header.vertex_count.to_le_bytes())?;
128    file.write_all(&cache.header.frame_count.to_le_bytes())?;
129    file.write_all(&cache.header.fps.to_le_bytes())?;
130    file.write_all(&[0u8; 16])?; // reserved
131
132    // --- Frame data ---
133    for frame in &cache.frames {
134        for &[x, y, z] in frame {
135            file.write_all(&x.to_le_bytes())?;
136            file.write_all(&y.to_le_bytes())?;
137            file.write_all(&z.to_le_bytes())?;
138        }
139    }
140
141    Ok(())
142}
143
144/// Load a `PointCache` from a `.opc` binary file.
145pub fn load_point_cache(path: &Path) -> anyhow::Result<PointCache> {
146    let mut file =
147        std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
148
149    let header = read_header(&mut file)?;
150
151    let vertex_count = header.vertex_count as usize;
152    let frame_count = header.frame_count as usize;
153
154    let mut frames = Vec::with_capacity(frame_count);
155    let mut buf4 = [0u8; 4];
156
157    for _ in 0..frame_count {
158        let mut verts = Vec::with_capacity(vertex_count);
159        for _ in 0..vertex_count {
160            file.read_exact(&mut buf4)?;
161            let x = f32::from_le_bytes(buf4);
162            file.read_exact(&mut buf4)?;
163            let y = f32::from_le_bytes(buf4);
164            file.read_exact(&mut buf4)?;
165            let z = f32::from_le_bytes(buf4);
166            verts.push([x, y, z]);
167        }
168        frames.push(verts);
169    }
170
171    Ok(PointCache { header, frames })
172}
173
174/// Build a `PointCache` from a slice of `MeshBuffers`. All meshes must share the same vertex count.
175pub fn mesh_sequence_to_cache(frames: &[MeshBuffers], fps: f32) -> anyhow::Result<PointCache> {
176    if frames.is_empty() {
177        bail!("frame sequence is empty");
178    }
179    let vertex_count = frames[0].positions.len();
180    let mut cache = PointCache::new(vertex_count, fps);
181    for (i, mesh) in frames.iter().enumerate() {
182        if mesh.positions.len() != vertex_count {
183            bail!(
184                "frame {} has {} vertices; expected {}",
185                i,
186                mesh.positions.len(),
187                vertex_count
188            );
189        }
190        cache.add_frame(mesh.positions.clone())?;
191    }
192    Ok(cache)
193}
194
195/// Extract the vertex positions for a single frame from a cache.
196pub fn cache_frame_to_positions(cache: &PointCache, frame: usize) -> anyhow::Result<Vec<[f32; 3]>> {
197    cache.get_frame(frame).cloned().ok_or_else(|| {
198        anyhow!(
199            "frame index {} out of range (cache has {} frames)",
200            frame,
201            cache.frame_count()
202        )
203    })
204}
205
206/// Validate a `.opc` file and return its header without loading frame data.
207pub fn validate_point_cache_file(path: &Path) -> anyhow::Result<PointCacheHeader> {
208    let mut file =
209        std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
210    read_header(&mut file)
211}
212
213// ---------------------------------------------------------------------------
214// Internal helpers
215// ---------------------------------------------------------------------------
216
217fn read_header<R: Read>(reader: &mut R) -> anyhow::Result<PointCacheHeader> {
218    let mut magic = [0u8; 4];
219    reader.read_exact(&mut magic)?;
220    if &magic != OPC_MAGIC {
221        bail!("invalid magic bytes: expected OPC1, got {:?}", magic);
222    }
223
224    let mut buf4 = [0u8; 4];
225
226    reader.read_exact(&mut buf4)?;
227    let vertex_count = u32::from_le_bytes(buf4);
228
229    reader.read_exact(&mut buf4)?;
230    let frame_count = u32::from_le_bytes(buf4);
231
232    reader.read_exact(&mut buf4)?;
233    let fps = f32::from_le_bytes(buf4);
234
235    // Skip 16 reserved bytes
236    let mut reserved = [0u8; 16];
237    reader.read_exact(&mut reserved)?;
238
239    Ok(PointCacheHeader {
240        vertex_count,
241        frame_count,
242        fps,
243    })
244}
245
246// ---------------------------------------------------------------------------
247// Tests
248// ---------------------------------------------------------------------------
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use oxihuman_mesh::MeshBuffers;
254
255    fn make_mesh(positions: Vec<[f32; 3]>) -> MeshBuffers {
256        let n = positions.len();
257        MeshBuffers {
258            positions,
259            normals: vec![[0.0, 1.0, 0.0]; n],
260            tangents: vec![[1.0, 0.0, 0.0, 1.0]; n],
261            uvs: vec![[0.0, 0.0]; n],
262            indices: vec![],
263            colors: None,
264            has_suit: false,
265        }
266    }
267
268    #[test]
269    fn test_point_cache_new() {
270        let cache = PointCache::new(4, 24.0);
271        assert_eq!(cache.vertex_count(), 4);
272        assert_eq!(cache.frame_count(), 0);
273        assert_eq!(cache.header.fps, 24.0);
274    }
275
276    #[test]
277    fn test_add_frame() {
278        let mut cache = PointCache::new(2, 30.0);
279        let frame = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
280        cache.add_frame(frame.clone()).expect("should succeed");
281        assert_eq!(cache.frame_count(), 1);
282        assert_eq!(cache.header.frame_count, 1);
283        assert_eq!(cache.get_frame(0).expect("should succeed"), &frame);
284    }
285
286    #[test]
287    fn test_add_frame_wrong_vertex_count() {
288        let mut cache = PointCache::new(2, 30.0);
289        let bad = vec![[1.0, 2.0, 3.0]]; // only 1 vertex
290        let result = cache.add_frame(bad);
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn test_duration() {
296        let mut cache = PointCache::new(1, 25.0);
297        cache.add_frame(vec![[0.0, 0.0, 0.0]]).expect("should succeed");
298        cache.add_frame(vec![[1.0, 1.0, 1.0]]).expect("should succeed");
299        // 2 frames / 25 fps = 0.08 s
300        let expected = 2.0 / 25.0;
301        assert!((cache.duration() - expected).abs() < 1e-6);
302    }
303
304    #[test]
305    fn test_get_frame() {
306        let mut cache = PointCache::new(2, 24.0);
307        let f0 = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
308        let f1 = vec![[0.0, 1.0, 0.0], [1.0, 1.0, 0.0]];
309        cache.add_frame(f0.clone()).expect("should succeed");
310        cache.add_frame(f1.clone()).expect("should succeed");
311        assert_eq!(cache.get_frame(0).expect("should succeed"), &f0);
312        assert_eq!(cache.get_frame(1).expect("should succeed"), &f1);
313        assert!(cache.get_frame(2).is_none());
314    }
315
316    #[test]
317    fn test_sample_exact_frame() {
318        let mut cache = PointCache::new(1, 24.0);
319        cache.add_frame(vec![[0.0, 0.0, 0.0]]).expect("should succeed");
320        cache.add_frame(vec![[2.0, 4.0, 6.0]]).expect("should succeed");
321        let s0 = cache.sample(0.0).expect("should succeed");
322        assert_eq!(s0[0], [0.0, 0.0, 0.0]);
323        let s1 = cache.sample(1.0).expect("should succeed");
324        assert_eq!(s1[0], [2.0, 4.0, 6.0]);
325    }
326
327    #[test]
328    fn test_sample_between_frames() {
329        let mut cache = PointCache::new(1, 24.0);
330        cache.add_frame(vec![[0.0, 0.0, 0.0]]).expect("should succeed");
331        cache.add_frame(vec![[2.0, 4.0, 6.0]]).expect("should succeed");
332        let s = cache.sample(0.5).expect("should succeed");
333        let eps = 1e-5;
334        assert!((s[0][0] - 1.0).abs() < eps);
335        assert!((s[0][1] - 2.0).abs() < eps);
336        assert!((s[0][2] - 3.0).abs() < eps);
337    }
338
339    #[test]
340    fn test_sample_out_of_range() {
341        let mut cache = PointCache::new(1, 24.0);
342        cache.add_frame(vec![[0.0, 0.0, 0.0]]).expect("should succeed");
343        cache.add_frame(vec![[1.0, 1.0, 1.0]]).expect("should succeed");
344        assert!(cache.sample(-0.1).is_none());
345        assert!(cache.sample(1.1).is_none());
346    }
347
348    #[test]
349    fn test_export_and_load() {
350        let path = std::path::Path::new("/tmp/test_export_and_load.opc");
351        let mut cache = PointCache::new(2, 30.0);
352        cache
353            .add_frame(vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
354            .expect("should succeed");
355        cache
356            .add_frame(vec![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]])
357            .expect("should succeed");
358
359        export_point_cache(&cache, path).expect("should succeed");
360        let loaded = load_point_cache(path).expect("should succeed");
361
362        assert_eq!(loaded.header.vertex_count, 2);
363        assert_eq!(loaded.header.frame_count, 2);
364        assert_eq!(loaded.header.fps, 30.0);
365        assert_eq!(loaded.frames[0], vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);
366        assert_eq!(loaded.frames[1], vec![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]]);
367    }
368
369    #[test]
370    fn test_validate_header() {
371        let path = std::path::Path::new("/tmp/test_validate_header.opc");
372        let mut cache = PointCache::new(3, 60.0);
373        cache
374            .add_frame(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
375            .expect("should succeed");
376        export_point_cache(&cache, path).expect("should succeed");
377
378        let hdr = validate_point_cache_file(path).expect("should succeed");
379        assert_eq!(hdr.vertex_count, 3);
380        assert_eq!(hdr.frame_count, 1);
381        assert_eq!(hdr.fps, 60.0);
382    }
383
384    #[test]
385    fn test_validate_bad_magic() {
386        let path = std::path::Path::new("/tmp/test_validate_bad_magic.opc");
387        // Write a file with wrong magic
388        std::fs::write(path, b"BAAD\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00").expect("should succeed");
389        let result = validate_point_cache_file(path);
390        assert!(result.is_err());
391        let msg = format!("{}", result.unwrap_err());
392        assert!(msg.contains("invalid magic bytes") || msg.contains("OPC1"));
393    }
394
395    #[test]
396    fn test_mesh_sequence_to_cache() {
397        let m0 = make_mesh(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
398        let m1 = make_mesh(vec![[0.0, 1.0, 0.0], [1.0, 1.0, 0.0]]);
399        let cache = mesh_sequence_to_cache(&[m0, m1], 24.0).expect("should succeed");
400        assert_eq!(cache.vertex_count(), 2);
401        assert_eq!(cache.frame_count(), 2);
402        assert_eq!(cache.header.fps, 24.0);
403    }
404
405    #[test]
406    fn test_mesh_sequence_mismatched_vertex_count() {
407        let m0 = make_mesh(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
408        let m1 = make_mesh(vec![[0.0, 1.0, 0.0]]); // 1 vertex
409        let result = mesh_sequence_to_cache(&[m0, m1], 24.0);
410        assert!(result.is_err());
411    }
412
413    #[test]
414    fn test_cache_frame_to_positions() {
415        let mut cache = PointCache::new(2, 24.0);
416        let f0 = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
417        cache.add_frame(f0.clone()).expect("should succeed");
418        let positions = cache_frame_to_positions(&cache, 0).expect("should succeed");
419        assert_eq!(positions, f0);
420        assert!(cache_frame_to_positions(&cache, 99).is_err());
421    }
422}