Skip to main content

oxihuman_export/
instancing.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::Result;
5use bytemuck::cast_slice;
6use oxihuman_mesh::MeshBuffers;
7use serde_json::json;
8use std::f32::consts::PI;
9use std::io::Write;
10use std::path::Path;
11
12// GLB magic constants (same as glb.rs)
13const GLB_MAGIC: u32 = 0x46546C67; // "glTF"
14const GLB_VERSION: u32 = 2;
15const CHUNK_JSON: u32 = 0x4E4F534A; // "JSON"
16const CHUNK_BIN: u32 = 0x004E4942; // "BIN\0"
17
18/// A transform for a single instance: translation, rotation (quaternion), scale.
19#[derive(Debug, Clone)]
20pub struct InstanceTransform {
21    pub translation: [f32; 3],
22    pub rotation: [f32; 4], // quaternion [x, y, z, w]
23    pub scale: [f32; 3],
24}
25
26impl InstanceTransform {
27    pub fn identity() -> Self {
28        Self {
29            translation: [0.0, 0.0, 0.0],
30            rotation: [0.0, 0.0, 0.0, 1.0],
31            scale: [1.0, 1.0, 1.0],
32        }
33    }
34
35    pub fn at(translation: [f32; 3]) -> Self {
36        Self {
37            translation,
38            ..Self::identity()
39        }
40    }
41
42    pub fn scaled(mut self, s: f32) -> Self {
43        self.scale = [s, s, s];
44        self
45    }
46}
47
48/// Export a mesh with N instances as a GLB.
49/// Each instance is a separate GLTF node sharing mesh index 0.
50/// `instances`: list of transforms for each instance.
51pub fn export_instanced_glb(
52    mesh: &MeshBuffers,
53    instances: &[InstanceTransform],
54    path: &Path,
55) -> Result<()> {
56    // ── 1. Build BIN chunk data ──────────────────────────────────────────────
57    // Layout: [positions f32*3*n] [normals f32*3*n] [uvs f32*2*n] [indices u32*m]
58    let n_verts = mesh.positions.len();
59    let n_idx = mesh.indices.len();
60
61    let pos_bytes: &[u8] = cast_slice(&mesh.positions);
62    let norm_bytes: &[u8] = cast_slice(&mesh.normals);
63    let uv_bytes: &[u8] = cast_slice(&mesh.uvs);
64    let idx_bytes: &[u8] = cast_slice(&mesh.indices);
65
66    let pos_offset = 0usize;
67    let norm_offset = pos_offset + pos_bytes.len();
68    let uv_offset = norm_offset + norm_bytes.len();
69    let idx_offset = uv_offset + uv_bytes.len();
70    let bin_len = idx_offset + idx_bytes.len();
71
72    let mut bin_data: Vec<u8> = Vec::with_capacity(bin_len + 3);
73    bin_data.extend_from_slice(pos_bytes);
74    bin_data.extend_from_slice(norm_bytes);
75    bin_data.extend_from_slice(uv_bytes);
76    bin_data.extend_from_slice(idx_bytes);
77    // Pad BIN to 4-byte boundary
78    while !bin_data.len().is_multiple_of(4) {
79        bin_data.push(0x00);
80    }
81
82    // ── 2. Build GLTF JSON ───────────────────────────────────────────────────
83    let total_bin = bin_data.len() as u32;
84
85    let accessors = vec![
86        json!({ "bufferView": 0, "componentType": 5126, "count": n_verts, "type": "VEC3" }),
87        json!({ "bufferView": 1, "componentType": 5126, "count": n_verts, "type": "VEC3" }),
88        json!({ "bufferView": 2, "componentType": 5126, "count": n_verts, "type": "VEC2" }),
89        json!({ "bufferView": 3, "componentType": 5125, "count": n_idx,   "type": "SCALAR" }),
90    ];
91
92    let buffer_views = vec![
93        json!({ "buffer": 0, "byteOffset": pos_offset,  "byteLength": pos_bytes.len()  }),
94        json!({ "buffer": 0, "byteOffset": norm_offset, "byteLength": norm_bytes.len() }),
95        json!({ "buffer": 0, "byteOffset": uv_offset,   "byteLength": uv_bytes.len()   }),
96        json!({ "buffer": 0, "byteOffset": idx_offset,  "byteLength": idx_bytes.len()  }),
97    ];
98
99    // Build nodes: one per instance, each references mesh 0
100    let nodes: Vec<serde_json::Value> = instances
101        .iter()
102        .map(|inst| {
103            json!({
104                "mesh": 0,
105                "translation": inst.translation,
106                "rotation": inst.rotation,
107                "scale": inst.scale
108            })
109        })
110        .collect();
111
112    // Scene lists all node indices 0..N
113    let node_indices: Vec<usize> = (0..instances.len()).collect();
114
115    let gltf = json!({
116        "asset": { "version": "2.0", "generator": "OxiHuman 0.1.0" },
117        "scene": 0,
118        "scenes": [{ "nodes": node_indices }],
119        "nodes": nodes,
120        "meshes": [{
121            "name": "instanced_mesh",
122            "primitives": [{
123                "attributes": {
124                    "POSITION":   0,
125                    "NORMAL":     1,
126                    "TEXCOORD_0": 2
127                },
128                "indices": 3,
129                "mode": 4
130            }]
131        }],
132        "accessors":   accessors,
133        "bufferViews": buffer_views,
134        "buffers": [{ "byteLength": total_bin }]
135    });
136
137    let mut json_bytes = serde_json::to_vec(&gltf)?;
138    // Pad JSON to 4-byte boundary with spaces
139    while json_bytes.len() % 4 != 0 {
140        json_bytes.push(b' ');
141    }
142
143    // ── 3. Write GLB ─────────────────────────────────────────────────────────
144    let json_chunk_len = json_bytes.len() as u32;
145    let bin_chunk_len = bin_data.len() as u32;
146    let total_len = 12 + 8 + json_chunk_len + 8 + bin_chunk_len;
147
148    let mut file = std::fs::File::create(path)?;
149
150    // GLB header (12 bytes)
151    file.write_all(&GLB_MAGIC.to_le_bytes())?;
152    file.write_all(&GLB_VERSION.to_le_bytes())?;
153    file.write_all(&total_len.to_le_bytes())?;
154
155    // JSON chunk
156    file.write_all(&json_chunk_len.to_le_bytes())?;
157    file.write_all(&CHUNK_JSON.to_le_bytes())?;
158    file.write_all(&json_bytes)?;
159
160    // BIN chunk
161    file.write_all(&bin_chunk_len.to_le_bytes())?;
162    file.write_all(&CHUNK_BIN.to_le_bytes())?;
163    file.write_all(&bin_data)?;
164
165    Ok(())
166}
167
168/// Build a row of N instances spaced `spacing` apart along the X axis.
169pub fn row_instances(count: usize, spacing: f32) -> Vec<InstanceTransform> {
170    (0..count)
171        .map(|i| InstanceTransform::at([i as f32 * spacing, 0.0, 0.0]))
172        .collect()
173}
174
175/// Build a grid of instances: `rows` x `cols`, spaced `spacing` apart in XZ plane.
176pub fn grid_instances(rows: usize, cols: usize, spacing: f32) -> Vec<InstanceTransform> {
177    let mut out = Vec::with_capacity(rows * cols);
178    for r in 0..rows {
179        for c in 0..cols {
180            out.push(InstanceTransform::at([
181                c as f32 * spacing,
182                0.0,
183                r as f32 * spacing,
184            ]));
185        }
186    }
187    out
188}
189
190/// Build instances in a circle of radius `r`, evenly distributed.
191pub fn circle_instances(count: usize, radius: f32) -> Vec<InstanceTransform> {
192    (0..count)
193        .map(|i| {
194            let angle = 2.0 * PI * i as f32 / count as f32;
195            InstanceTransform::at([radius * angle.cos(), 0.0, radius * angle.sin()])
196        })
197        .collect()
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use std::io::Read;
204    use std::path::PathBuf;
205
206    fn tri_mesh() -> MeshBuffers {
207        MeshBuffers {
208            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
209            normals: vec![[0.0, 0.0, 1.0]; 3],
210            uvs: vec![[0.0, 0.0]; 3],
211            tangents: vec![],
212            colors: None,
213            indices: vec![0, 1, 2],
214            has_suit: true,
215        }
216    }
217
218    fn tmp_path(name: &str) -> PathBuf {
219        PathBuf::from(format!("/tmp/{}", name))
220    }
221
222    #[test]
223    fn row_instances_count() {
224        assert_eq!(row_instances(5, 2.0).len(), 5);
225    }
226
227    #[test]
228    fn grid_instances_count() {
229        assert_eq!(grid_instances(3, 4, 1.0).len(), 12);
230    }
231
232    #[test]
233    fn circle_instances_count() {
234        assert_eq!(circle_instances(8, 2.0).len(), 8);
235    }
236
237    #[test]
238    fn export_single_instance() {
239        let mesh = tri_mesh();
240        let path = tmp_path("test_instancing_single.glb");
241        export_instanced_glb(&mesh, &[InstanceTransform::identity()], &path).expect("should succeed");
242        assert!(path.exists());
243    }
244
245    #[test]
246    fn export_five_instances() {
247        let mesh = tri_mesh();
248        let path = tmp_path("test_instancing_five.glb");
249        let instances = row_instances(5, 1.5);
250        export_instanced_glb(&mesh, &instances, &path).expect("should succeed");
251        assert!(path.exists());
252        // Verify file has content (valid GLB has at least 12 byte header)
253        let metadata = std::fs::metadata(&path).expect("should succeed");
254        assert!(metadata.len() > 12);
255    }
256
257    #[test]
258    fn glb_header_valid() {
259        let mesh = tri_mesh();
260        let path = tmp_path("test_instancing_header.glb");
261        export_instanced_glb(&mesh, &[InstanceTransform::identity()], &path).expect("should succeed");
262        let mut f = std::fs::File::open(&path).expect("should succeed");
263        let mut buf = [0u8; 4];
264        f.read_exact(&mut buf).expect("should succeed");
265        // "glTF" magic: 0x46546C67 in little-endian = [0x67, 0x6C, 0x54, 0x46]
266        assert_eq!(buf, [0x67, 0x6C, 0x54, 0x46]);
267    }
268
269    #[test]
270    fn identity_transform_fields() {
271        let t = InstanceTransform::identity();
272        assert_eq!(t.rotation[3], 1.0);
273    }
274}