Skip to main content

oxihuman_export/
scene.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4use std::io::Write;
5use std::path::Path;
6
7use anyhow::Result;
8use bytemuck::cast_slice;
9use oxihuman_mesh::MeshBuffers;
10use serde_json::json;
11
12use crate::material::PbrMaterial;
13
14// GLB magic constants (same as glb.rs)
15const GLB_MAGIC: u32 = 0x46546C67; // "glTF"
16const GLB_VERSION: u32 = 2;
17const CHUNK_JSON: u32 = 0x4E4F534A; // "JSON"
18const CHUNK_BIN: u32 = 0x004E4942; // "BIN\0"
19
20/// A named mesh entry in a multi-mesh scene.
21pub struct SceneMesh {
22    /// Display name for this mesh in the scene hierarchy.
23    pub name: String,
24    pub mesh: MeshBuffers,
25    pub material: Option<PbrMaterial>,
26    /// Translation offset [x, y, z] from scene origin.
27    pub translation: [f32; 3],
28}
29
30impl SceneMesh {
31    pub fn new(name: impl Into<String>, mesh: MeshBuffers) -> Self {
32        Self {
33            name: name.into(),
34            mesh,
35            material: None,
36            translation: [0.0, 0.0, 0.0],
37        }
38    }
39
40    pub fn with_material(mut self, m: PbrMaterial) -> Self {
41        self.material = Some(m);
42        self
43    }
44
45    pub fn with_translation(mut self, t: [f32; 3]) -> Self {
46        self.translation = t;
47        self
48    }
49}
50
51/// A scene containing multiple meshes.
52pub struct Scene {
53    pub name: String,
54    pub meshes: Vec<SceneMesh>,
55}
56
57impl Scene {
58    pub fn new(name: impl Into<String>) -> Self {
59        Self {
60            name: name.into(),
61            meshes: Vec::new(),
62        }
63    }
64
65    pub fn add_mesh(mut self, mesh: SceneMesh) -> Self {
66        self.meshes.push(mesh);
67        self
68    }
69
70    pub fn mesh_count(&self) -> usize {
71        self.meshes.len()
72    }
73
74    /// Convenience method: export scene as GLB.
75    pub fn export(&self, path: &Path) -> Result<()> {
76        export_scene_glb(self, path)
77    }
78}
79
80/// Per-mesh BIN layout info computed during BIN construction.
81struct MeshBinLayout {
82    pos_offset: usize,
83    norm_offset: usize,
84    uv_offset: usize,
85    idx_offset: usize,
86    n_verts: usize,
87    n_idx: usize,
88    pos_bytes_len: usize,
89    norm_bytes_len: usize,
90    uv_bytes_len: usize,
91    idx_bytes_len: usize,
92}
93
94/// Export a multi-mesh scene as a single GLB file.
95/// Each mesh becomes a separate GLTF mesh node under the scene root.
96/// Materials are per-mesh (if provided).
97pub fn export_scene_glb(scene: &Scene, path: &Path) -> Result<()> {
98    // ── 1. Build BIN chunk by concatenating all mesh data ───────────────────
99    // For each mesh: POSITION, NORMAL, TEXCOORD_0, indices (u32)
100    let mut bin_data: Vec<u8> = Vec::new();
101    let mut layouts: Vec<MeshBinLayout> = Vec::new();
102
103    for sm in &scene.meshes {
104        let mesh = &sm.mesh;
105        let pos_bytes: &[u8] = cast_slice(&mesh.positions);
106        let norm_bytes: &[u8] = cast_slice(&mesh.normals);
107        let uv_bytes: &[u8] = cast_slice(&mesh.uvs);
108        let idx_bytes: &[u8] = cast_slice(&mesh.indices);
109
110        let pos_offset = bin_data.len();
111        bin_data.extend_from_slice(pos_bytes);
112        let norm_offset = bin_data.len();
113        bin_data.extend_from_slice(norm_bytes);
114        let uv_offset = bin_data.len();
115        bin_data.extend_from_slice(uv_bytes);
116        let idx_offset = bin_data.len();
117        bin_data.extend_from_slice(idx_bytes);
118
119        layouts.push(MeshBinLayout {
120            pos_offset,
121            norm_offset,
122            uv_offset,
123            idx_offset,
124            n_verts: mesh.positions.len(),
125            n_idx: mesh.indices.len(),
126            pos_bytes_len: pos_bytes.len(),
127            norm_bytes_len: norm_bytes.len(),
128            uv_bytes_len: uv_bytes.len(),
129            idx_bytes_len: idx_bytes.len(),
130        });
131    }
132
133    // Pad BIN to 4-byte boundary
134    while !bin_data.len().is_multiple_of(4) {
135        bin_data.push(0x00);
136    }
137
138    // ── 2. Build materials array + mesh→material index mapping ──────────────
139    let mut materials_json: Vec<serde_json::Value> = Vec::new();
140    // mesh_material_idx[i] = Some(mat_idx) if scene.meshes[i] has a material
141    let mut mesh_material_idx: Vec<Option<usize>> = Vec::new();
142
143    for sm in &scene.meshes {
144        if let Some(ref mat) = sm.material {
145            let mat_idx = materials_json.len();
146            materials_json.push(mat.to_gltf_json());
147            mesh_material_idx.push(Some(mat_idx));
148        } else {
149            mesh_material_idx.push(None);
150        }
151    }
152
153    // ── 3. Build accessors + bufferViews + meshes + nodes ───────────────────
154    let mut accessors: Vec<serde_json::Value> = Vec::new();
155    let mut buffer_views: Vec<serde_json::Value> = Vec::new();
156    let mut meshes_json: Vec<serde_json::Value> = Vec::new();
157    let mut nodes_json: Vec<serde_json::Value> = Vec::new();
158
159    for (mesh_idx, (sm, layout)) in scene.meshes.iter().zip(layouts.iter()).enumerate() {
160        // Each mesh gets 4 accessors: POSITION(VEC3), NORMAL(VEC3), TEXCOORD_0(VEC2), indices(SCALAR)
161        // and 4 bufferViews corresponding to them.
162
163        let pos_bv_idx = buffer_views.len();
164        buffer_views.push(json!({
165            "buffer": 0,
166            "byteOffset": layout.pos_offset,
167            "byteLength": layout.pos_bytes_len
168        }));
169
170        let norm_bv_idx = buffer_views.len();
171        buffer_views.push(json!({
172            "buffer": 0,
173            "byteOffset": layout.norm_offset,
174            "byteLength": layout.norm_bytes_len
175        }));
176
177        let uv_bv_idx = buffer_views.len();
178        buffer_views.push(json!({
179            "buffer": 0,
180            "byteOffset": layout.uv_offset,
181            "byteLength": layout.uv_bytes_len
182        }));
183
184        let idx_bv_idx = buffer_views.len();
185        buffer_views.push(json!({
186            "buffer": 0,
187            "byteOffset": layout.idx_offset,
188            "byteLength": layout.idx_bytes_len
189        }));
190
191        let pos_acc_idx = accessors.len();
192        accessors.push(json!({
193            "bufferView": pos_bv_idx,
194            "componentType": 5126,
195            "count": layout.n_verts,
196            "type": "VEC3"
197        }));
198
199        let norm_acc_idx = accessors.len();
200        accessors.push(json!({
201            "bufferView": norm_bv_idx,
202            "componentType": 5126,
203            "count": layout.n_verts,
204            "type": "VEC3"
205        }));
206
207        let uv_acc_idx = accessors.len();
208        accessors.push(json!({
209            "bufferView": uv_bv_idx,
210            "componentType": 5126,
211            "count": layout.n_verts,
212            "type": "VEC2"
213        }));
214
215        let idx_acc_idx = accessors.len();
216        accessors.push(json!({
217            "bufferView": idx_bv_idx,
218            "componentType": 5125,
219            "count": layout.n_idx,
220            "type": "SCALAR"
221        }));
222
223        // Build primitive
224        let attributes = json!({
225            "POSITION":   pos_acc_idx,
226            "NORMAL":     norm_acc_idx,
227            "TEXCOORD_0": uv_acc_idx
228        });
229
230        let primitive = if let Some(mat_idx) = mesh_material_idx[mesh_idx] {
231            json!({
232                "attributes": attributes,
233                "indices": idx_acc_idx,
234                "material": mat_idx
235            })
236        } else {
237            json!({
238                "attributes": attributes,
239                "indices": idx_acc_idx
240            })
241        };
242
243        meshes_json.push(json!({
244            "name": sm.name,
245            "primitives": [primitive]
246        }));
247
248        // Build node
249        let t = sm.translation;
250        nodes_json.push(json!({
251            "name": sm.name,
252            "mesh": mesh_idx,
253            "translation": [t[0], t[1], t[2]]
254        }));
255    }
256
257    // Node indices for the scene: [0, 1, 2, ...]
258    let node_indices: Vec<usize> = (0..scene.meshes.len()).collect();
259
260    // ── 4. Build GLTF JSON ───────────────────────────────────────────────────
261    let total_bin = bin_data.len() as u32;
262
263    let gltf = if materials_json.is_empty() {
264        json!({
265            "asset": { "version": "2.0", "generator": "oxihuman-export" },
266            "scene": 0,
267            "scenes": [{ "name": scene.name, "nodes": node_indices }],
268            "nodes": nodes_json,
269            "meshes": meshes_json,
270            "accessors": accessors,
271            "bufferViews": buffer_views,
272            "buffers": [{ "byteLength": total_bin }]
273        })
274    } else {
275        json!({
276            "asset": { "version": "2.0", "generator": "oxihuman-export" },
277            "scene": 0,
278            "scenes": [{ "name": scene.name, "nodes": node_indices }],
279            "nodes": nodes_json,
280            "meshes": meshes_json,
281            "materials": materials_json,
282            "accessors": accessors,
283            "bufferViews": buffer_views,
284            "buffers": [{ "byteLength": total_bin }]
285        })
286    };
287
288    let mut json_bytes = serde_json::to_vec(&gltf)?;
289    // Pad JSON to 4-byte boundary with spaces
290    while !json_bytes.len().is_multiple_of(4) {
291        json_bytes.push(b' ');
292    }
293
294    // ── 5. Write GLB ─────────────────────────────────────────────────────────
295    let json_chunk_len = json_bytes.len() as u32;
296    let bin_chunk_len = bin_data.len() as u32;
297    let total_len = 12 + 8 + json_chunk_len + 8 + bin_chunk_len;
298
299    let mut file = std::fs::File::create(path)?;
300
301    // GLB header (12 bytes)
302    file.write_all(&GLB_MAGIC.to_le_bytes())?;
303    file.write_all(&GLB_VERSION.to_le_bytes())?;
304    file.write_all(&total_len.to_le_bytes())?;
305
306    // JSON chunk
307    file.write_all(&json_chunk_len.to_le_bytes())?;
308    file.write_all(&CHUNK_JSON.to_le_bytes())?;
309    file.write_all(&json_bytes)?;
310
311    // BIN chunk
312    file.write_all(&bin_chunk_len.to_le_bytes())?;
313    file.write_all(&CHUNK_BIN.to_le_bytes())?;
314    file.write_all(&bin_data)?;
315
316    Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use oxihuman_mesh::MeshBuffers;
323    use oxihuman_morph::engine::MeshBuffers as MB;
324
325    fn tri_mesh(y_offset: f32) -> MeshBuffers {
326        MeshBuffers::from_morph(MB {
327            positions: vec![
328                [0.0, y_offset, 0.0],
329                [1.0, y_offset, 0.0],
330                [0.0, y_offset + 1.0, 0.0],
331            ],
332            normals: vec![[0.0, 0.0, 1.0]; 3],
333            uvs: vec![[0.0, 0.0]; 3],
334            indices: vec![0, 1, 2],
335            has_suit: true,
336        })
337    }
338
339    #[test]
340    fn scene_empty_export() {
341        let path = std::path::PathBuf::from("/tmp/test_scene_empty.glb");
342        let scene = Scene::new("test");
343        assert_eq!(scene.mesh_count(), 0);
344        scene.export(&path).expect("export failed");
345        assert!(path.exists(), "GLB file should be created");
346        let bytes = std::fs::read(&path).expect("should succeed");
347        assert!(bytes.len() >= 12, "GLB must have at least 12 bytes");
348        std::fs::remove_file(&path).ok();
349    }
350
351    #[test]
352    fn scene_single_mesh_export() {
353        let path = std::path::PathBuf::from("/tmp/test_scene_single.glb");
354        let scene = Scene::new("single").add_mesh(SceneMesh::new("body", tri_mesh(0.0)));
355        export_scene_glb(&scene, &path).expect("export failed");
356        assert!(path.exists(), "file should exist");
357        let bytes = std::fs::read(&path).expect("should succeed");
358        assert!(bytes.len() >= 12, "valid GLB header required");
359        std::fs::remove_file(&path).ok();
360    }
361
362    #[test]
363    fn scene_two_meshes_export() {
364        let path = std::path::PathBuf::from("/tmp/test_scene_two.glb");
365        let scene = Scene::new("two_meshes")
366            .add_mesh(SceneMesh::new("body", tri_mesh(0.0)))
367            .add_mesh(SceneMesh::new("clothing", tri_mesh(1.0)));
368        export_scene_glb(&scene, &path).expect("export failed");
369        assert!(path.exists(), "file should exist");
370        std::fs::remove_file(&path).ok();
371    }
372
373    #[test]
374    fn scene_glb_header_valid() {
375        let path = std::path::PathBuf::from("/tmp/test_scene_header.glb");
376        let scene = Scene::new("header_test").add_mesh(SceneMesh::new("mesh0", tri_mesh(0.0)));
377        export_scene_glb(&scene, &path).expect("export failed");
378        let bytes = std::fs::read(&path).expect("should succeed");
379        assert!(bytes.len() >= 4);
380        // Magic: "glTF" = 0x46546C67 in LE = [0x67, 0x6C, 0x54, 0x46]
381        assert_eq!(
382            &bytes[0..4],
383            &[0x67u8, 0x6Cu8, 0x54u8, 0x46u8],
384            "GLB magic must be glTF"
385        );
386        std::fs::remove_file(&path).ok();
387    }
388
389    #[test]
390    fn scene_with_material() {
391        let path = std::path::PathBuf::from("/tmp/test_scene_material.glb");
392        let scene = Scene::new("with_material")
393            .add_mesh(SceneMesh::new("body", tri_mesh(0.0)).with_material(PbrMaterial::skin()));
394        export_scene_glb(&scene, &path).expect("export failed");
395        assert!(path.exists(), "file should exist");
396        std::fs::remove_file(&path).ok();
397    }
398
399    #[test]
400    fn scene_mesh_count() {
401        let scene = Scene::new("x")
402            .add_mesh(SceneMesh::new("a", tri_mesh(0.0)))
403            .add_mesh(SceneMesh::new("b", tri_mesh(1.0)));
404        assert_eq!(scene.mesh_count(), 2);
405    }
406}