Skip to main content

oxihuman_export/
pc2.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! PC2 (Point Cache 2) binary point cache format — used by Blender.
5//!
6//! Binary layout (little-endian):
7//!   - Magic: b"POINTCACHE2\0"  (12 bytes)
8//!   - Version: i32 = 1
9//!   - Point count: i32
10//!   - Start time: f32
11//!   - Sample rate: f32
12//!   - Sample count: i32
13//!   - Data: for each frame, (point_count × 3) f32 values (xyz)
14
15use std::path::Path;
16
17/// Magic bytes for PC2 format (12 bytes, null-terminated).
18pub const PC2_MAGIC: &[u8; 12] = b"POINTCACHE2\0";
19
20/// Header metadata for a PC2 point cache file.
21#[allow(dead_code)]
22pub struct Pc2Header {
23    pub point_count: u32,
24    pub start_time: f32,
25    pub sample_rate: f32,
26    pub sample_count: u32,
27}
28
29/// Complete PC2 point cache (header + all frame data).
30#[allow(dead_code)]
31pub struct Pc2Cache {
32    pub header: Pc2Header,
33    /// One entry per frame; each entry contains `point_count` XYZ positions.
34    pub frames: Vec<Vec<[f32; 3]>>,
35}
36
37impl Pc2Cache {
38    /// Create a new empty cache with the given metadata.
39    #[allow(dead_code)]
40    pub fn new(point_count: u32, start_time: f32, sample_rate: f32) -> Self {
41        Self {
42            header: Pc2Header {
43                point_count,
44                start_time,
45                sample_rate,
46                sample_count: 0,
47            },
48            frames: Vec::new(),
49        }
50    }
51
52    /// Append a frame of positions.  Panics if `positions.len() != point_count`.
53    #[allow(dead_code)]
54    pub fn add_frame(&mut self, positions: Vec<[f32; 3]>) {
55        assert_eq!(
56            positions.len(),
57            self.header.point_count as usize,
58            "add_frame: expected {} points, got {}",
59            self.header.point_count,
60            positions.len()
61        );
62        self.frames.push(positions);
63        self.header.sample_count += 1;
64    }
65}
66
67// ── serialisation ─────────────────────────────────────────────────────────────
68
69/// Serialise a [`Pc2Cache`] to a `Vec<u8>` (little-endian binary).
70#[allow(dead_code)]
71pub fn write_pc2(cache: &Pc2Cache) -> Vec<u8> {
72    let h = &cache.header;
73    let data_bytes = (h.point_count as usize) * 3 * 4 * cache.frames.len();
74    let mut out = Vec::with_capacity(12 + 4 + 4 + 4 + 4 + 4 + data_bytes);
75
76    // Magic
77    out.extend_from_slice(PC2_MAGIC);
78    // Version = 1 (i32 LE)
79    out.extend_from_slice(&1_i32.to_le_bytes());
80    // Header fields
81    out.extend_from_slice(&(h.point_count as i32).to_le_bytes());
82    out.extend_from_slice(&h.start_time.to_le_bytes());
83    out.extend_from_slice(&h.sample_rate.to_le_bytes());
84    out.extend_from_slice(&(h.sample_count as i32).to_le_bytes());
85
86    // Frame data
87    for frame in &cache.frames {
88        for pos in frame {
89            out.extend_from_slice(&pos[0].to_le_bytes());
90            out.extend_from_slice(&pos[1].to_le_bytes());
91            out.extend_from_slice(&pos[2].to_le_bytes());
92        }
93    }
94    out
95}
96
97/// Deserialise a PC2 binary blob into a [`Pc2Cache`].
98#[allow(dead_code)]
99pub fn read_pc2(data: &[u8]) -> anyhow::Result<Pc2Cache> {
100    use anyhow::bail;
101
102    if data.len() < 28 {
103        bail!("PC2 data too short: {} bytes", data.len());
104    }
105
106    // Magic
107    if &data[..12] != PC2_MAGIC {
108        bail!("PC2 magic mismatch");
109    }
110
111    let version = i32::from_le_bytes(
112        data[12..16]
113            .try_into()
114            .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
115    );
116    if version != 1 {
117        bail!("unsupported PC2 version: {}", version);
118    }
119
120    let point_count = i32::from_le_bytes(
121        data[16..20]
122            .try_into()
123            .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
124    ) as u32;
125    let start_time = f32::from_le_bytes(
126        data[20..24]
127            .try_into()
128            .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
129    );
130    let sample_rate = f32::from_le_bytes(
131        data[24..28]
132            .try_into()
133            .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
134    );
135    let sample_count = i32::from_le_bytes(
136        data[28..32]
137            .try_into()
138            .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
139    ) as u32;
140
141    let frame_stride = (point_count as usize) * 3 * 4;
142    let expected_total = 32 + frame_stride * (sample_count as usize);
143    if data.len() < expected_total {
144        bail!(
145            "PC2 data truncated: need {} bytes, have {}",
146            expected_total,
147            data.len()
148        );
149    }
150
151    let mut frames = Vec::with_capacity(sample_count as usize);
152    let mut offset = 32_usize;
153    for _ in 0..sample_count {
154        let mut frame = Vec::with_capacity(point_count as usize);
155        for _ in 0..point_count {
156            let x = f32::from_le_bytes(
157                data[offset..offset + 4]
158                    .try_into()
159                    .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
160            );
161            let y = f32::from_le_bytes(
162                data[offset + 4..offset + 8]
163                    .try_into()
164                    .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
165            );
166            let z = f32::from_le_bytes(
167                data[offset + 8..offset + 12]
168                    .try_into()
169                    .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
170            );
171            frame.push([x, y, z]);
172            offset += 12;
173        }
174        frames.push(frame);
175    }
176
177    Ok(Pc2Cache {
178        header: Pc2Header {
179            point_count,
180            start_time,
181            sample_rate,
182            sample_count,
183        },
184        frames,
185    })
186}
187
188/// Write a [`Pc2Cache`] to a file on disk.
189#[allow(dead_code)]
190pub fn export_pc2(cache: &Pc2Cache, path: &Path) -> anyhow::Result<()> {
191    let bytes = write_pc2(cache);
192    std::fs::write(path, &bytes)
193        .map_err(|e| anyhow::anyhow!("writing PC2 to {}: {}", path.display(), e))
194}
195
196// ── helpers ───────────────────────────────────────────────────────────────────
197
198/// Convert a slice of position frames into a [`Pc2Cache`].
199///
200/// All frames must have the same number of points; the first frame's length
201/// is used as `point_count`.  Panics if `frames` is empty.
202#[allow(dead_code)]
203pub fn mesh_sequence_to_pc2(
204    frames: &[Vec<[f32; 3]>],
205    start_time: f32,
206    sample_rate: f32,
207) -> Pc2Cache {
208    assert!(
209        !frames.is_empty(),
210        "mesh_sequence_to_pc2: frames must not be empty"
211    );
212    let point_count = frames[0].len() as u32;
213    let mut cache = Pc2Cache::new(point_count, start_time, sample_rate);
214    for frame in frames {
215        cache.add_frame(frame.clone());
216    }
217    cache
218}
219
220/// Return a human-readable summary string for a [`Pc2Cache`].
221#[allow(dead_code)]
222pub fn pc2_stats(cache: &Pc2Cache) -> String {
223    let h = &cache.header;
224    format!(
225        "PC2 | points={} | frames={} | start={:.3} | rate={:.2} fps",
226        h.point_count, h.sample_count, h.start_time, h.sample_rate
227    )
228}
229
230// ── tests ─────────────────────────────────────────────────────────────────────
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn two_point_cache() -> Pc2Cache {
237        let mut c = Pc2Cache::new(2, 0.0, 24.0);
238        c.add_frame(vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);
239        c.add_frame(vec![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]]);
240        c
241    }
242
243    // 1. round-trip: write then read gives back identical data
244    #[test]
245    fn roundtrip_basic() {
246        let cache = two_point_cache();
247        let bytes = write_pc2(&cache);
248        let back = read_pc2(&bytes).expect("should succeed");
249        assert_eq!(back.header.point_count, 2);
250        assert_eq!(back.header.sample_count, 2);
251        assert_eq!(back.frames[0], vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);
252        assert_eq!(back.frames[1], vec![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]]);
253    }
254
255    // 2. round-trip preserves start_time and sample_rate
256    #[test]
257    fn roundtrip_metadata() {
258        let mut c = Pc2Cache::new(1, 1.5, 30.0);
259        c.add_frame(vec![[0.0, 0.0, 0.0]]);
260        let back = read_pc2(&write_pc2(&c)).expect("should succeed");
261        assert!((back.header.start_time - 1.5).abs() < 1e-6);
262        assert!((back.header.sample_rate - 30.0).abs() < 1e-6);
263    }
264
265    // 3. magic bytes are correct
266    #[test]
267    fn magic_bytes() {
268        let cache = two_point_cache();
269        let bytes = write_pc2(&cache);
270        assert_eq!(&bytes[..12], PC2_MAGIC);
271    }
272
273    // 4. version field is 1
274    #[test]
275    fn version_field_is_one() {
276        let cache = two_point_cache();
277        let bytes = write_pc2(&cache);
278        let ver = i32::from_le_bytes(bytes[12..16].try_into().expect("should succeed"));
279        assert_eq!(ver, 1);
280    }
281
282    // 5. wrong point count panics
283    #[test]
284    #[should_panic]
285    fn add_frame_wrong_count_panics() {
286        let mut c = Pc2Cache::new(3, 0.0, 24.0);
287        c.add_frame(vec![[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]); // only 2 points
288    }
289
290    // 6. empty frames (zero frames added)
291    #[test]
292    fn empty_frames() {
293        let c = Pc2Cache::new(5, 0.0, 24.0);
294        let bytes = write_pc2(&c);
295        let back = read_pc2(&bytes).expect("should succeed");
296        assert_eq!(back.header.sample_count, 0);
297        assert!(back.frames.is_empty());
298    }
299
300    // 7. pc2_stats contains key fields
301    #[test]
302    fn pc2_stats_contains_points_and_frames() {
303        let cache = two_point_cache();
304        let s = pc2_stats(&cache);
305        assert!(s.contains("points=2"));
306        assert!(s.contains("frames=2"));
307    }
308
309    // 8. pc2_stats contains fps info
310    #[test]
311    fn pc2_stats_contains_rate() {
312        let cache = two_point_cache();
313        let s = pc2_stats(&cache);
314        assert!(s.contains("24"));
315    }
316
317    // 9. mesh_sequence_to_pc2 produces correct frame count
318    #[test]
319    fn mesh_sequence_frame_count() {
320        let frames: Vec<Vec<[f32; 3]>> = (0..5)
321            .map(|i| vec![[i as f32, 0.0, 0.0], [0.0, i as f32, 0.0]])
322            .collect();
323        let cache = mesh_sequence_to_pc2(&frames, 0.0, 24.0);
324        assert_eq!(cache.header.sample_count, 5);
325        assert_eq!(cache.header.point_count, 2);
326    }
327
328    // 10. mesh_sequence_to_pc2 first frame positions are preserved
329    #[test]
330    fn mesh_sequence_positions_preserved() {
331        let frames = vec![vec![[1.0_f32, 2.0, 3.0]], vec![[4.0_f32, 5.0, 6.0]]];
332        let cache = mesh_sequence_to_pc2(&frames, 0.0, 24.0);
333        let back = read_pc2(&write_pc2(&cache)).expect("should succeed");
334        assert_eq!(back.frames[0][0], [1.0, 2.0, 3.0]);
335        assert_eq!(back.frames[1][0], [4.0, 5.0, 6.0]);
336    }
337
338    // 11. read_pc2 errors on truncated data
339    #[test]
340    fn read_pc2_truncated_error() {
341        let cache = two_point_cache();
342        let bytes = write_pc2(&cache);
343        let result = read_pc2(&bytes[..20]);
344        assert!(result.is_err());
345    }
346
347    // 12. read_pc2 errors on bad magic
348    #[test]
349    fn read_pc2_bad_magic() {
350        let cache = two_point_cache();
351        let mut bytes = write_pc2(&cache);
352        bytes[0] = 0xFF;
353        assert!(read_pc2(&bytes).is_err());
354    }
355}