Skip to main content

oxihuman_export/
mesh_quantize.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use std::io::{Read, Write};
7use std::path::Path;
8
9use oxihuman_mesh::MeshBuffers;
10
11// ─── Magic / version ────────────────────────────────────────────────────────
12
13const QMSH_MAGIC: &[u8; 4] = b"QMSH";
14const QMSH_VERSION: u32 = 1;
15
16// ─── QuantizeRange ───────────────────────────────────────────────────────────
17
18/// Maps a float range `[min, max]` to the unsigned 16-bit integer range
19/// `[0, 65535]`.  Used to encode per-axis position and UV channels.
20#[derive(Debug, Clone)]
21pub struct QuantizeRange {
22    pub min: f32,
23    pub max: f32,
24}
25
26impl QuantizeRange {
27    /// Construct a `QuantizeRange` by scanning `values` to find the extremes.
28    /// When the slice is empty, or all values are identical, a trivial range
29    /// of `[0, 1]` is returned so that division by zero is avoided downstream.
30    pub fn from_data(values: &[f32]) -> Self {
31        if values.is_empty() {
32            return Self { min: 0.0, max: 1.0 };
33        }
34        let mut lo = values[0];
35        let mut hi = values[0];
36        for &v in values.iter().skip(1) {
37            if v < lo {
38                lo = v;
39            }
40            if v > hi {
41                hi = v;
42            }
43        }
44        // Guard against degenerate (zero-width) ranges.
45        if (hi - lo).abs() < 1e-12 {
46            hi = lo + 1.0;
47        }
48        Self { min: lo, max: hi }
49    }
50
51    /// Clamp `v` to `[min, max]` and quantize to `[0, 65535]`.
52    #[inline]
53    pub fn encode(&self, v: f32) -> u16 {
54        let clamped = v.max(self.min).min(self.max);
55        let t = (clamped - self.min) / (self.max - self.min);
56        (t * 65535.0).round() as u16
57    }
58
59    /// Reconstruct a float from a quantized `u16`.
60    #[inline]
61    pub fn decode(&self, q: u16) -> f32 {
62        let t = q as f32 / 65535.0;
63        self.min + t * (self.max - self.min)
64    }
65}
66
67// ─── QuantizedMesh ───────────────────────────────────────────────────────────
68
69/// A mesh whose attributes have been quantized to compact integer types.
70///
71/// * `positions`  — XYZ per vertex, each axis stored as `u16`.
72/// * `normals`    — XYZ per vertex in `i8` (direct; range `[-127, 127]`).
73/// * `uvs`        — UV per vertex as `u16`.
74/// * `pos_range`  — The per-axis decode parameters for positions.
75#[derive(Debug, Clone)]
76pub struct QuantizedMesh {
77    pub positions: Vec<[u16; 3]>,
78    pub normals: Vec<[i8; 3]>,
79    pub uvs: Vec<[u16; 2]>,
80    pub indices: Vec<u32>,
81    pub pos_range: [QuantizeRange; 3],
82    pub has_suit: bool,
83}
84
85// ─── QuantizeStats ───────────────────────────────────────────────────────────
86
87/// Error metrics comparing the original float mesh with the quantized version.
88#[derive(Debug, Clone)]
89pub struct QuantizeStats {
90    pub position_error_rms: f32,
91    pub normal_error_rms: f32,
92    pub uv_error_rms: f32,
93    /// `original_bytes / quantized_bytes` — values > 1 mean compression.
94    pub compression_ratio: f32,
95}
96
97// ─── quantize_mesh ───────────────────────────────────────────────────────────
98
99/// Quantize all attributes of `mesh` into a compact [`QuantizedMesh`].
100pub fn quantize_mesh(mesh: &MeshBuffers) -> QuantizedMesh {
101    let n = mesh.positions.len();
102
103    // Build per-axis ranges for positions.
104    let xs: Vec<f32> = mesh.positions.iter().map(|p| p[0]).collect();
105    let ys: Vec<f32> = mesh.positions.iter().map(|p| p[1]).collect();
106    let zs: Vec<f32> = mesh.positions.iter().map(|p| p[2]).collect();
107    let rx = QuantizeRange::from_data(&xs);
108    let ry = QuantizeRange::from_data(&ys);
109    let rz = QuantizeRange::from_data(&zs);
110
111    // UV ranges.
112    let us: Vec<f32> = mesh.uvs.iter().map(|uv| uv[0]).collect();
113    let vs: Vec<f32> = mesh.uvs.iter().map(|uv| uv[1]).collect();
114    let ru = QuantizeRange::from_data(&us);
115    let rv = QuantizeRange::from_data(&vs);
116
117    let mut positions = Vec::with_capacity(n);
118    let mut normals = Vec::with_capacity(n);
119    let mut uvs = Vec::with_capacity(n);
120
121    for i in 0..n {
122        let [px, py, pz] = mesh.positions[i];
123        positions.push([rx.encode(px), ry.encode(py), rz.encode(pz)]);
124
125        // Normals: scale float [-1,1] to i8 [-127,127].
126        let [nx, ny, nz] = if i < mesh.normals.len() {
127            mesh.normals[i]
128        } else {
129            [0.0, 0.0, 1.0]
130        };
131        normals.push([
132            encode_normal_component(nx),
133            encode_normal_component(ny),
134            encode_normal_component(nz),
135        ]);
136
137        let [pu, pv] = if i < mesh.uvs.len() {
138            mesh.uvs[i]
139        } else {
140            [0.0, 0.0]
141        };
142        uvs.push([ru.encode(pu), rv.encode(pv)]);
143    }
144
145    QuantizedMesh {
146        positions,
147        normals,
148        uvs,
149        indices: mesh.indices.clone(),
150        pos_range: [rx, ry, rz],
151        has_suit: mesh.has_suit,
152    }
153}
154
155// ─── dequantize_mesh ─────────────────────────────────────────────────────────
156
157/// Reconstruct a [`MeshBuffers`] from a [`QuantizedMesh`].  The result will
158/// differ from the original by the quantization error (≈ 1/65535 of the range
159/// per axis).
160pub fn dequantize_mesh(q: &QuantizedMesh) -> MeshBuffers {
161    let n = q.positions.len();
162
163    // Recover UV ranges — we store only pos_range in the struct, so we have
164    // to re-derive UV ranges from the quantized data.  We use a [0,1] default
165    // since the canonical UV domain is [0,1].
166    let ru = QuantizeRange { min: 0.0, max: 1.0 };
167    let rv = QuantizeRange { min: 0.0, max: 1.0 };
168
169    let mut positions = Vec::with_capacity(n);
170    let mut normals = Vec::with_capacity(n);
171    let mut uvs = Vec::with_capacity(n);
172
173    let [ref rx, ref ry, ref rz] = q.pos_range;
174
175    for i in 0..n {
176        let [qx, qy, qz] = q.positions[i];
177        positions.push([rx.decode(qx), ry.decode(qy), rz.decode(qz)]);
178
179        let [enx, eny, enz] = q.normals[i];
180        let nx = decode_normal_component(enx);
181        let ny = decode_normal_component(eny);
182        let nz = decode_normal_component(enz);
183        // Re-normalize to compensate for quantization.
184        let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-9);
185        normals.push([nx / len, ny / len, nz / len]);
186
187        let [qu, qv] = q.uvs[i];
188        uvs.push([ru.decode(qu), rv.decode(qv)]);
189    }
190
191    let tangents = vec![[1.0f32, 0.0, 0.0, 1.0]; n];
192
193    MeshBuffers {
194        positions,
195        normals,
196        tangents,
197        uvs,
198        indices: q.indices.clone(),
199        colors: None,
200        has_suit: q.has_suit,
201    }
202}
203
204// ─── quantize_stats ──────────────────────────────────────────────────────────
205
206/// Compute error metrics between `original` and the round-tripped mesh derived
207/// from `q`.
208pub fn quantize_stats(original: &MeshBuffers, q: &QuantizedMesh) -> QuantizeStats {
209    let reconstructed = dequantize_mesh(q);
210    let n = original.positions.len().min(reconstructed.positions.len());
211
212    // Position RMS.
213    let pos_rms = if n == 0 {
214        0.0
215    } else {
216        let sum_sq: f32 = (0..n)
217            .map(|i| {
218                let [ox, oy, oz] = original.positions[i];
219                let [rx, ry, rz] = reconstructed.positions[i];
220                let d = [(ox - rx), (oy - ry), (oz - rz)];
221                d[0] * d[0] + d[1] * d[1] + d[2] * d[2]
222            })
223            .sum();
224        (sum_sq / n as f32).sqrt()
225    };
226
227    // Normal RMS (angle-based: dot product distance).
228    let nor_rms = if n == 0 {
229        0.0
230    } else {
231        let mn = original
232            .normals
233            .len()
234            .min(reconstructed.normals.len())
235            .min(n);
236        let sum_sq: f32 = (0..mn)
237            .map(|i| {
238                let [ox, oy, oz] = original.normals[i];
239                let [rx, ry, rz] = reconstructed.normals[i];
240                let d = [(ox - rx), (oy - ry), (oz - rz)];
241                d[0] * d[0] + d[1] * d[1] + d[2] * d[2]
242            })
243            .sum();
244        (sum_sq / mn as f32).sqrt()
245    };
246
247    // UV RMS.
248    let uv_rms = if n == 0 {
249        0.0
250    } else {
251        let mu = original.uvs.len().min(reconstructed.uvs.len()).min(n);
252        let sum_sq: f32 = (0..mu)
253            .map(|i| {
254                let [ou, ov] = original.uvs[i];
255                let [ru, rv] = reconstructed.uvs[i];
256                let du = ou - ru;
257                let dv = ov - rv;
258                du * du + dv * dv
259            })
260            .sum();
261        (sum_sq / mu as f32).sqrt()
262    };
263
264    // Byte sizes.
265    let orig_bytes = original.positions.len() * 12  // f32x3
266        + original.normals.len() * 12   // f32x3
267        + original.uvs.len() * 8        // f32x2
268        + original.indices.len() * 4; // u32
269
270    let quant_bytes = q.positions.len() * 6  // u16x3
271        + q.normals.len() * 3           // i8x3
272        + q.uvs.len() * 4               // u16x2
273        + q.indices.len() * 4; // u32
274
275    let compression_ratio = if quant_bytes == 0 {
276        1.0
277    } else {
278        orig_bytes as f32 / quant_bytes as f32
279    };
280
281    QuantizeStats {
282        position_error_rms: pos_rms,
283        normal_error_rms: nor_rms,
284        uv_error_rms: uv_rms,
285        compression_ratio,
286    }
287}
288
289// ─── Oct-map normal encoding ─────────────────────────────────────────────────
290
291/// Encode a unit normal vector using octahedral projection to 2 `i8` bytes.
292///
293/// Reference: Cigolle et al., "Survey of Efficient Representations for
294/// Independent Unit Vectors", JCGT 2014.
295pub fn encode_normal_oct(n: [f32; 3]) -> [i8; 2] {
296    // Project onto the L1 unit sphere.
297    let l1 = n[0].abs() + n[1].abs() + n[2].abs();
298    let (mut ox, mut oy) = if l1 < 1e-9 {
299        (0.0f32, 0.0f32)
300    } else {
301        (n[0] / l1, n[1] / l1)
302    };
303
304    // Fold for negative hemisphere.
305    if n[2] < 0.0 {
306        let ox2 = (1.0 - oy.abs()) * ox.signum();
307        let oy2 = (1.0 - ox.abs()) * oy.signum();
308        ox = ox2;
309        oy = oy2;
310    }
311
312    // Scale to [-127, 127].
313    let ex = (ox * 127.0).round().clamp(-127.0, 127.0) as i8;
314    let ey = (oy * 127.0).round().clamp(-127.0, 127.0) as i8;
315    [ex, ey]
316}
317
318/// Decode an oct-encoded 2-byte normal back to a unit `[f32; 3]`.
319pub fn decode_normal_oct(enc: [i8; 2]) -> [f32; 3] {
320    let ox = enc[0] as f32 / 127.0;
321    let oy = enc[1] as f32 / 127.0;
322    let oz = 1.0 - ox.abs() - oy.abs();
323
324    let (fx, fy) = if oz < 0.0 {
325        (
326            (1.0 - oy.abs()) * ox.signum(),
327            (1.0 - ox.abs()) * oy.signum(),
328        )
329    } else {
330        (ox, oy)
331    };
332
333    let len = (fx * fx + fy * fy + oz * oz).sqrt().max(1e-9);
334    [fx / len, fy / len, oz / len]
335}
336
337// ─── Binary I/O ──────────────────────────────────────────────────────────────
338
339/// Write a [`QuantizedMesh`] to a compact binary file.
340///
341/// File layout:
342/// ```text
343/// Bytes  0..4   : magic  b"QMSH"
344/// Bytes  4..8   : version u32 LE
345/// Bytes  8..12  : vertex_count u32 LE
346/// Bytes 12..16  : index_count  u32 LE
347/// Then: 6 f32s (3 × min/max for pos_range) LE
348/// Then: vertex_count × 6 bytes  (u16x3 positions, LE)
349/// Then: vertex_count × 3 bytes  (i8x3 normals)
350/// Then: vertex_count × 4 bytes  (u16x2 uvs, LE)
351/// Then: index_count  × 4 bytes  (u32 indices, LE)
352/// ```
353///
354/// Returns the total number of bytes written.
355pub fn write_quantized_bin(q: &QuantizedMesh, path: &Path) -> anyhow::Result<usize> {
356    let vc = q.positions.len() as u32;
357    let ic = q.indices.len() as u32;
358
359    let mut buf: Vec<u8> = Vec::new();
360
361    // Header: 16 bytes.
362    buf.extend_from_slice(QMSH_MAGIC);
363    buf.extend_from_slice(&QMSH_VERSION.to_le_bytes());
364    buf.extend_from_slice(&vc.to_le_bytes());
365    buf.extend_from_slice(&ic.to_le_bytes());
366
367    // Range data: 3 ranges × 2 floats = 6 f32 = 24 bytes.
368    for r in &q.pos_range {
369        buf.extend_from_slice(&r.min.to_le_bytes());
370        buf.extend_from_slice(&r.max.to_le_bytes());
371    }
372
373    // Positions.
374    for p in &q.positions {
375        for &v in p {
376            buf.extend_from_slice(&v.to_le_bytes());
377        }
378    }
379
380    // Normals (i8 — single byte each).
381    for n in &q.normals {
382        for &b in n {
383            buf.push(b as u8);
384        }
385    }
386
387    // UVs.
388    for uv in &q.uvs {
389        for &v in uv {
390            buf.extend_from_slice(&v.to_le_bytes());
391        }
392    }
393
394    // Indices.
395    for &idx in &q.indices {
396        buf.extend_from_slice(&idx.to_le_bytes());
397    }
398
399    // has_suit flag (1 byte).
400    buf.push(q.has_suit as u8);
401
402    let total = buf.len();
403    let mut file = std::fs::File::create(path)?;
404    file.write_all(&buf)?;
405
406    Ok(total)
407}
408
409/// Read a [`QuantizedMesh`] from a binary file written by [`write_quantized_bin`].
410pub fn read_quantized_bin(path: &Path) -> anyhow::Result<QuantizedMesh> {
411    let mut file = std::fs::File::open(path)?;
412    let mut buf = Vec::new();
413    file.read_to_end(&mut buf)?;
414
415    anyhow::ensure!(buf.len() >= 16, "file too short for QMSH header");
416
417    // Check magic.
418    anyhow::ensure!(&buf[0..4] == QMSH_MAGIC, "invalid QMSH magic");
419
420    let version = u32::from_le_bytes(buf[4..8].try_into()?);
421    anyhow::ensure!(
422        version == QMSH_VERSION,
423        "unsupported QMSH version {version}"
424    );
425
426    let vertex_count = u32::from_le_bytes(buf[8..12].try_into()?) as usize;
427    let index_count = u32::from_le_bytes(buf[12..16].try_into()?) as usize;
428
429    let mut offset = 16usize;
430
431    // Ranges: 3 × 2 f32 = 24 bytes.
432    anyhow::ensure!(buf.len() >= offset + 24, "file truncated at range data");
433    let mut pos_range_arr = std::array::from_fn(|_| QuantizeRange { min: 0.0, max: 1.0 });
434    for r in &mut pos_range_arr {
435        r.min = f32::from_le_bytes(buf[offset..offset + 4].try_into()?);
436        offset += 4;
437        r.max = f32::from_le_bytes(buf[offset..offset + 4].try_into()?);
438        offset += 4;
439    }
440
441    // Positions: vertex_count × 6.
442    let pos_bytes = vertex_count * 6;
443    anyhow::ensure!(
444        buf.len() >= offset + pos_bytes,
445        "file truncated at positions"
446    );
447    let mut positions = Vec::with_capacity(vertex_count);
448    for _ in 0..vertex_count {
449        let x = u16::from_le_bytes(buf[offset..offset + 2].try_into()?);
450        let y = u16::from_le_bytes(buf[offset + 2..offset + 4].try_into()?);
451        let z = u16::from_le_bytes(buf[offset + 4..offset + 6].try_into()?);
452        positions.push([x, y, z]);
453        offset += 6;
454    }
455
456    // Normals: vertex_count × 3.
457    let nor_bytes = vertex_count * 3;
458    anyhow::ensure!(buf.len() >= offset + nor_bytes, "file truncated at normals");
459    let mut normals = Vec::with_capacity(vertex_count);
460    for _ in 0..vertex_count {
461        let nx = buf[offset] as i8;
462        let ny = buf[offset + 1] as i8;
463        let nz = buf[offset + 2] as i8;
464        normals.push([nx, ny, nz]);
465        offset += 3;
466    }
467
468    // UVs: vertex_count × 4.
469    let uv_bytes = vertex_count * 4;
470    anyhow::ensure!(buf.len() >= offset + uv_bytes, "file truncated at UVs");
471    let mut uvs = Vec::with_capacity(vertex_count);
472    for _ in 0..vertex_count {
473        let u = u16::from_le_bytes(buf[offset..offset + 2].try_into()?);
474        let v = u16::from_le_bytes(buf[offset + 2..offset + 4].try_into()?);
475        uvs.push([u, v]);
476        offset += 4;
477    }
478
479    // Indices: index_count × 4.
480    let idx_bytes = index_count * 4;
481    anyhow::ensure!(buf.len() >= offset + idx_bytes, "file truncated at indices");
482    let mut indices = Vec::with_capacity(index_count);
483    for _ in 0..index_count {
484        let idx = u32::from_le_bytes(buf[offset..offset + 4].try_into()?);
485        indices.push(idx);
486        offset += 4;
487    }
488
489    // has_suit flag.
490    let has_suit = if offset < buf.len() {
491        buf[offset] != 0
492    } else {
493        false
494    };
495
496    Ok(QuantizedMesh {
497        positions,
498        normals,
499        uvs,
500        indices,
501        pos_range: pos_range_arr,
502        has_suit,
503    })
504}
505
506// ─── Internal helpers ────────────────────────────────────────────────────────
507
508/// Encode one float normal component in `[-1, 1]` to `i8` in `[-127, 127]`.
509#[inline]
510fn encode_normal_component(v: f32) -> i8 {
511    (v.clamp(-1.0, 1.0) * 127.0).round() as i8
512}
513
514/// Decode one `i8` normal component back to `f32`.
515#[inline]
516fn decode_normal_component(b: i8) -> f32 {
517    b as f32 / 127.0
518}
519
520// ─── Tests ───────────────────────────────────────────────────────────────────
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use oxihuman_mesh::MeshBuffers;
526    use oxihuman_morph::engine::MeshBuffers as MB;
527
528    // ── Helpers ──────────────────────────────────────────────────────────────
529
530    fn make_mesh(
531        positions: Vec<[f32; 3]>,
532        normals: Vec<[f32; 3]>,
533        uvs: Vec<[f32; 2]>,
534        indices: Vec<u32>,
535    ) -> MeshBuffers {
536        MeshBuffers::from_morph(MB {
537            positions,
538            normals,
539            uvs,
540            indices,
541            has_suit: false,
542        })
543    }
544
545    fn simple_triangle() -> MeshBuffers {
546        make_mesh(
547            vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]],
548            vec![[0.0, 0.0, 1.0]; 3],
549            vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]],
550            vec![0, 1, 2],
551        )
552    }
553
554    fn empty_mesh() -> MeshBuffers {
555        make_mesh(vec![], vec![], vec![], vec![])
556    }
557
558    // ── QuantizeRange ────────────────────────────────────────────────────────
559
560    #[test]
561    fn qrange_encode_min_gives_zero() {
562        let r = QuantizeRange {
563            min: -1.0,
564            max: 1.0,
565        };
566        assert_eq!(r.encode(-1.0), 0);
567    }
568
569    #[test]
570    fn qrange_encode_max_gives_65535() {
571        let r = QuantizeRange {
572            min: -1.0,
573            max: 1.0,
574        };
575        assert_eq!(r.encode(1.0), 65535);
576    }
577
578    #[test]
579    fn qrange_decode_zero_gives_min() {
580        let r = QuantizeRange { min: 2.0, max: 5.0 };
581        assert!((r.decode(0) - 2.0).abs() < 1e-5);
582    }
583
584    #[test]
585    fn qrange_decode_max_gives_max() {
586        let r = QuantizeRange { min: 2.0, max: 5.0 };
587        assert!((r.decode(65535) - 5.0).abs() < 1e-4);
588    }
589
590    #[test]
591    fn qrange_roundtrip_midpoint() {
592        let r = QuantizeRange { min: 0.0, max: 1.0 };
593        let original = 0.5f32;
594        let decoded = r.decode(r.encode(original));
595        assert!(
596            (decoded - original).abs() < 1.0 / 65535.0 * 2.0,
597            "roundtrip error {} too large",
598            (decoded - original).abs()
599        );
600    }
601
602    #[test]
603    fn qrange_from_data_detects_extremes() {
604        let data = vec![-3.0f32, 0.0, 7.5, 2.1];
605        let r = QuantizeRange::from_data(&data);
606        assert!((r.min - (-3.0)).abs() < 1e-6);
607        assert!((r.max - 7.5).abs() < 1e-6);
608    }
609
610    #[test]
611    fn qrange_from_empty_is_valid() {
612        let r = QuantizeRange::from_data(&[]);
613        // Must not produce min == max to avoid div-by-zero.
614        assert!((r.max - r.min).abs() > 1e-9);
615    }
616
617    #[test]
618    fn qrange_clamps_out_of_range_value() {
619        let r = QuantizeRange { min: 0.0, max: 1.0 };
620        assert_eq!(r.encode(2.0), 65535);
621        assert_eq!(r.encode(-1.0), 0);
622    }
623
624    // ── quantize_mesh / dequantize_mesh ──────────────────────────────────────
625
626    #[test]
627    fn quantize_vertex_count_preserved() {
628        let mesh = simple_triangle();
629        let q = quantize_mesh(&mesh);
630        assert_eq!(q.positions.len(), 3);
631        assert_eq!(q.normals.len(), 3);
632        assert_eq!(q.uvs.len(), 3);
633    }
634
635    #[test]
636    fn quantize_index_count_preserved() {
637        let mesh = simple_triangle();
638        let q = quantize_mesh(&mesh);
639        assert_eq!(q.indices, vec![0, 1, 2]);
640    }
641
642    #[test]
643    fn dequantize_roundtrip_position_error_small() {
644        let mesh = simple_triangle();
645        let q = quantize_mesh(&mesh);
646        let rec = dequantize_mesh(&q);
647        for (orig, recon) in mesh.positions.iter().zip(rec.positions.iter()) {
648            let err = (0..3)
649                .map(|i| (orig[i] - recon[i]).abs())
650                .fold(0.0f32, f32::max);
651            assert!(err < 1e-3, "position error {err} too large");
652        }
653    }
654
655    #[test]
656    fn dequantize_normal_unit_length() {
657        let mesh = simple_triangle();
658        let q = quantize_mesh(&mesh);
659        let rec = dequantize_mesh(&q);
660        for n in &rec.normals {
661            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
662            assert!((len - 1.0).abs() < 0.01, "normal not unit-length: {len}");
663        }
664    }
665
666    #[test]
667    fn quantize_empty_mesh_no_panic() {
668        let mesh = empty_mesh();
669        let q = quantize_mesh(&mesh);
670        assert!(q.positions.is_empty());
671        assert!(q.indices.is_empty());
672    }
673
674    #[test]
675    fn dequantize_empty_mesh_no_panic() {
676        let mesh = empty_mesh();
677        let q = quantize_mesh(&mesh);
678        let rec = dequantize_mesh(&q);
679        assert!(rec.positions.is_empty());
680    }
681
682    // ── quantize_stats ───────────────────────────────────────────────────────
683
684    #[test]
685    fn stats_compression_ratio_above_one() {
686        // A mesh with many vertices gets a compression ratio > 1.
687        let positions: Vec<[f32; 3]> = (0..100).map(|i| [i as f32 * 0.01, 0.0, 0.0]).collect();
688        let normals = vec![[0.0f32, 0.0, 1.0]; 100];
689        let uvs: Vec<[f32; 2]> = (0..100).map(|i| [i as f32 * 0.01, 0.0]).collect();
690        let indices: Vec<u32> = (0..99).flat_map(|i| [i, i + 1, i]).collect();
691        let mesh = make_mesh(positions, normals, uvs, indices);
692        let q = quantize_mesh(&mesh);
693        let stats = quantize_stats(&mesh, &q);
694        assert!(
695            stats.compression_ratio > 1.0,
696            "expected compression_ratio > 1, got {}",
697            stats.compression_ratio
698        );
699    }
700
701    #[test]
702    fn stats_position_error_rms_nonnegative() {
703        let mesh = simple_triangle();
704        let q = quantize_mesh(&mesh);
705        let stats = quantize_stats(&mesh, &q);
706        assert!(stats.position_error_rms >= 0.0);
707        assert!(stats.position_error_rms < 0.01);
708    }
709
710    #[test]
711    fn stats_empty_mesh_no_panic() {
712        let mesh = empty_mesh();
713        let q = quantize_mesh(&mesh);
714        let stats = quantize_stats(&mesh, &q);
715        assert_eq!(stats.position_error_rms, 0.0);
716        assert_eq!(stats.compression_ratio, 1.0);
717    }
718
719    // ── encode/decode_normal_oct ─────────────────────────────────────────────
720
721    #[test]
722    fn oct_encode_decode_z_up() {
723        let n = [0.0f32, 0.0, 1.0];
724        let enc = encode_normal_oct(n);
725        let dec = decode_normal_oct(enc);
726        let dot = n[0] * dec[0] + n[1] * dec[1] + n[2] * dec[2];
727        assert!(dot > 0.99, "z-up oct roundtrip dot={dot}");
728    }
729
730    #[test]
731    fn oct_encode_decode_z_down() {
732        let n = [0.0f32, 0.0, -1.0];
733        let enc = encode_normal_oct(n);
734        let dec = decode_normal_oct(enc);
735        let dot = n[0] * dec[0] + n[1] * dec[1] + n[2] * dec[2];
736        assert!(dot > 0.99, "z-down oct roundtrip dot={dot}");
737    }
738
739    #[test]
740    fn oct_encode_decode_diagonal() {
741        let s = 1.0f32 / 3.0f32.sqrt();
742        let n = [s, s, s];
743        let enc = encode_normal_oct(n);
744        let dec = decode_normal_oct(enc);
745        let dot = n[0] * dec[0] + n[1] * dec[1] + n[2] * dec[2];
746        assert!(dot > 0.99, "diagonal oct roundtrip dot={dot}");
747    }
748
749    #[test]
750    fn oct_decoded_is_unit_length() {
751        for enc in [[10i8, 20], [-10, 50], [127, 0], [0, -127]] {
752            let dec = decode_normal_oct(enc);
753            let len = (dec[0] * dec[0] + dec[1] * dec[1] + dec[2] * dec[2]).sqrt();
754            assert!(
755                (len - 1.0).abs() < 0.01,
756                "oct decoded not unit-length: {len}"
757            );
758        }
759    }
760
761    // ── write / read binary ──────────────────────────────────────────────────
762
763    #[test]
764    fn write_read_roundtrip() {
765        let mesh = simple_triangle();
766        let q = quantize_mesh(&mesh);
767        let path = std::path::Path::new("/tmp/test_qmsh_roundtrip.bin");
768        let written = write_quantized_bin(&q, path).expect("write failed");
769        assert!(
770            written > 16,
771            "expected more than header bytes, got {written}"
772        );
773
774        let q2 = read_quantized_bin(path).expect("read failed");
775        assert_eq!(q2.positions.len(), q.positions.len());
776        assert_eq!(q2.normals.len(), q.normals.len());
777        assert_eq!(q2.uvs.len(), q.uvs.len());
778        assert_eq!(q2.indices, q.indices);
779        // Verify first position.
780        assert_eq!(q2.positions[0], q.positions[0]);
781    }
782
783    #[test]
784    fn write_read_preserves_ranges() {
785        let mesh = simple_triangle();
786        let q = quantize_mesh(&mesh);
787        let path = std::path::Path::new("/tmp/test_qmsh_ranges.bin");
788        write_quantized_bin(&q, path).expect("write failed");
789        let q2 = read_quantized_bin(path).expect("read failed");
790        for i in 0..3 {
791            assert!((q2.pos_range[i].min - q.pos_range[i].min).abs() < 1e-5);
792            assert!((q2.pos_range[i].max - q.pos_range[i].max).abs() < 1e-5);
793        }
794    }
795
796    #[test]
797    fn write_read_empty_mesh() {
798        let mesh = empty_mesh();
799        let q = quantize_mesh(&mesh);
800        let path = std::path::Path::new("/tmp/test_qmsh_empty.bin");
801        write_quantized_bin(&q, path).expect("write failed");
802        let q2 = read_quantized_bin(path).expect("read failed");
803        assert!(q2.positions.is_empty());
804        assert!(q2.indices.is_empty());
805    }
806
807    #[test]
808    fn read_bad_magic_returns_error() {
809        let path = std::path::Path::new("/tmp/test_qmsh_badmagic.bin");
810        std::fs::write(
811            path,
812            b"BAAD\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
813        )
814        .expect("should succeed");
815        assert!(read_quantized_bin(path).is_err());
816    }
817}