Skip to main content

oxihuman_export/
tex_embed.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GLB texture embedding: embed a texture into a GLB file alongside the mesh,
5//! wired to the material's `baseColorTexture`.
6
7use std::io::Write;
8use std::path::Path;
9
10use bytemuck::cast_slice;
11use oxihuman_mesh::MeshBuffers;
12use serde_json::json;
13
14use crate::material::PbrMaterial;
15use crate::texture::PixelBuffer;
16
17// GLB magic constants (same as glb.rs)
18const GLB_MAGIC: u32 = 0x46546C67; // "glTF"
19const GLB_VERSION: u32 = 2;
20const CHUNK_JSON: u32 = 0x4E4F534A; // "JSON"
21const CHUNK_BIN: u32 = 0x004E4942; // "BIN\0"
22
23/// An embedded texture.
24pub struct EmbeddedTexture {
25    pub name: String,
26    /// Raw or encoded bytes (row-major RGBA, TGA, etc.)
27    pub pixels: Vec<u8>,
28    pub width: u32,
29    pub height: u32,
30    /// MIME type hint, e.g. "image/x-raw-rgba" or "image/x-tga".
31    pub mime_type: String,
32}
33
34impl EmbeddedTexture {
35    /// Create from a raw RGBA `PixelBuffer` (no encoding).
36    pub fn from_pixel_buffer(name: impl Into<String>, buf: &PixelBuffer) -> Self {
37        Self {
38            name: name.into(),
39            pixels: buf.pixels.clone(),
40            width: buf.width,
41            height: buf.height,
42            mime_type: "image/x-raw-rgba".into(),
43        }
44    }
45
46    /// Create from a `PixelBuffer`, encoding the pixels to TGA bytes.
47    pub fn from_tga(name: impl Into<String>, buf: &PixelBuffer) -> Self {
48        Self {
49            name: name.into(),
50            pixels: buf.to_tga_bytes(),
51            width: buf.width,
52            height: buf.height,
53            mime_type: "image/x-tga".into(),
54        }
55    }
56
57    /// Total byte length of the texture payload.
58    pub fn byte_len(&self) -> usize {
59        self.pixels.len()
60    }
61}
62
63/// Export a GLB 2.0 file with an embedded texture wired to `baseColorTexture`.
64///
65/// BIN layout: `[POSITION][NORMAL][TEXCOORD_0][indices][texture_bytes]`
66///
67/// The GLTF JSON includes:
68/// - A material with `pbrMetallicRoughness.baseColorTexture` pointing at the texture.
69/// - A texture, image, and sampler entry.
70/// - A bufferView for the texture bytes appended after the mesh bufferViews.
71pub fn export_glb_with_texture(
72    mesh: &MeshBuffers,
73    material: &PbrMaterial,
74    texture: &EmbeddedTexture,
75    path: &Path,
76) -> anyhow::Result<()> {
77    // ── 1. Build BIN chunk ────────────────────────────────────────────────────
78    let n_verts = mesh.positions.len();
79    let n_idx = mesh.indices.len();
80
81    let pos_bytes: &[u8] = cast_slice(&mesh.positions);
82    let norm_bytes: &[u8] = cast_slice(&mesh.normals);
83    let uv_bytes: &[u8] = cast_slice(&mesh.uvs);
84    let idx_bytes: &[u8] = cast_slice(&mesh.indices);
85
86    let pos_offset = 0usize;
87    let norm_offset = pos_offset + pos_bytes.len();
88    let uv_offset = norm_offset + norm_bytes.len();
89    let idx_offset = uv_offset + uv_bytes.len();
90    let mesh_bin_len = idx_offset + idx_bytes.len();
91
92    // Texture bytes appended right after the mesh binary data.
93    let tex_offset = mesh_bin_len;
94
95    let mut bin_data: Vec<u8> = Vec::with_capacity(mesh_bin_len + texture.byte_len() + 3);
96    bin_data.extend_from_slice(pos_bytes);
97    bin_data.extend_from_slice(norm_bytes);
98    bin_data.extend_from_slice(uv_bytes);
99    bin_data.extend_from_slice(idx_bytes);
100    bin_data.extend_from_slice(&texture.pixels);
101
102    // Pad BIN to 4-byte boundary.
103    while !bin_data.len().is_multiple_of(4) {
104        bin_data.push(0x00);
105    }
106    let total_bin = bin_data.len() as u32;
107
108    // ── 2. Build GLTF JSON ────────────────────────────────────────────────────
109    // bufferViews: 0=POSITION, 1=NORMAL, 2=TEXCOORD_0, 3=indices, 4=texture
110    // accessors:   0=POSITION, 1=NORMAL, 2=TEXCOORD_0, 3=indices
111    let accessors: Vec<serde_json::Value> = vec![
112        json!({ "bufferView": 0, "componentType": 5126, "count": n_verts, "type": "VEC3" }),
113        json!({ "bufferView": 1, "componentType": 5126, "count": n_verts, "type": "VEC3" }),
114        json!({ "bufferView": 2, "componentType": 5126, "count": n_verts, "type": "VEC2" }),
115        json!({ "bufferView": 3, "componentType": 5125, "count": n_idx,   "type": "SCALAR" }),
116    ];
117
118    let buffer_views: Vec<serde_json::Value> = vec![
119        json!({ "buffer": 0, "byteOffset": pos_offset,  "byteLength": pos_bytes.len()  }),
120        json!({ "buffer": 0, "byteOffset": norm_offset, "byteLength": norm_bytes.len() }),
121        json!({ "buffer": 0, "byteOffset": uv_offset,   "byteLength": uv_bytes.len()   }),
122        json!({ "buffer": 0, "byteOffset": idx_offset,  "byteLength": idx_bytes.len()  }),
123        // bufferView index 4: texture data
124        json!({ "buffer": 0, "byteOffset": tex_offset,  "byteLength": texture.byte_len() }),
125    ];
126
127    let gltf = json!({
128        "asset": { "version": "2.0", "generator": "OxiHuman 0.1.0" },
129        "scene": 0,
130        "scenes": [{ "nodes": [0] }],
131        "nodes": [{ "mesh": 0 }],
132        "meshes": [{
133            "name": "human",
134            "primitives": [{
135                "attributes": {
136                    "POSITION":   0,
137                    "NORMAL":     1,
138                    "TEXCOORD_0": 2
139                },
140                "indices": 3,
141                "mode": 4,
142                "material": 0
143            }]
144        }],
145        "materials": [{
146            "name": material.name,
147            "pbrMetallicRoughness": {
148                "baseColorFactor": [
149                    material.base_color[0],
150                    material.base_color[1],
151                    material.base_color[2],
152                    material.base_color[3]
153                ],
154                "baseColorTexture": { "index": 0 },
155                "metallicFactor": material.metallic,
156                "roughnessFactor": material.roughness
157            },
158            "emissiveFactor": [
159                material.emissive[0],
160                material.emissive[1],
161                material.emissive[2]
162            ],
163            "doubleSided": material.double_sided,
164            "alphaMode": material.alpha_mode
165        }],
166        "textures": [{ "source": 0, "sampler": 0 }],
167        "images": [{
168            "name": texture.name,
169            "bufferView": 4,
170            "mimeType": texture.mime_type
171        }],
172        "samplers": [{
173            "magFilter": 9729,
174            "minFilter": 9987,
175            "wrapS": 10497,
176            "wrapT": 10497
177        }],
178        "accessors":   accessors,
179        "bufferViews": buffer_views,
180        "buffers": [{ "byteLength": total_bin }]
181    });
182
183    let mut json_bytes = serde_json::to_vec(&gltf)?;
184    // Pad JSON to 4-byte boundary with spaces.
185    while json_bytes.len() % 4 != 0 {
186        json_bytes.push(b' ');
187    }
188
189    // ── 3. Write GLB ──────────────────────────────────────────────────────────
190    let json_chunk_len = json_bytes.len() as u32;
191    let bin_chunk_len = bin_data.len() as u32;
192    let total_len = 12 + 8 + json_chunk_len + 8 + bin_chunk_len;
193
194    let mut file = std::fs::File::create(path)?;
195
196    // GLB header (12 bytes)
197    file.write_all(&GLB_MAGIC.to_le_bytes())?;
198    file.write_all(&GLB_VERSION.to_le_bytes())?;
199    file.write_all(&total_len.to_le_bytes())?;
200
201    // JSON chunk
202    file.write_all(&json_chunk_len.to_le_bytes())?;
203    file.write_all(&CHUNK_JSON.to_le_bytes())?;
204    file.write_all(&json_bytes)?;
205
206    // BIN chunk
207    file.write_all(&bin_chunk_len.to_le_bytes())?;
208    file.write_all(&CHUNK_BIN.to_le_bytes())?;
209    file.write_all(&bin_data)?;
210
211    Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::glb::export_glb;
218    use crate::material::PbrMaterial;
219    use crate::texture::generate_skin_texture;
220
221    fn tri_mesh() -> MeshBuffers {
222        MeshBuffers {
223            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
224            normals: vec![[0.0, 0.0, 1.0]; 3],
225            uvs: vec![[0.0, 0.0]; 3],
226            tangents: vec![],
227            colors: None,
228            indices: vec![0, 1, 2],
229            has_suit: true,
230        }
231    }
232
233    fn small_tex() -> EmbeddedTexture {
234        let buf = generate_skin_texture(4, 4, 200, 160, 140);
235        EmbeddedTexture::from_tga("skin", &buf)
236    }
237
238    #[test]
239    fn embedded_texture_byte_len_positive() {
240        assert!(small_tex().byte_len() > 0);
241    }
242
243    #[test]
244    fn from_pixel_buffer_dimensions() {
245        let buf = generate_skin_texture(8, 6, 180, 140, 120);
246        let tex = EmbeddedTexture::from_pixel_buffer("t", &buf);
247        assert_eq!(tex.width, buf.width);
248        assert_eq!(tex.height, buf.height);
249    }
250
251    #[test]
252    fn export_glb_with_texture_creates_file() {
253        let mesh = tri_mesh();
254        let mat = PbrMaterial::skin();
255        let tex = small_tex();
256        let path = std::path::PathBuf::from("/tmp/test_tex_embed_create.glb");
257        export_glb_with_texture(&mesh, &mat, &tex, &path).expect("export failed");
258        assert!(path.exists(), "GLB file was not created");
259        std::fs::remove_file(&path).ok();
260    }
261
262    #[test]
263    fn glb_with_texture_valid_header() {
264        let mesh = tri_mesh();
265        let mat = PbrMaterial::skin();
266        let tex = small_tex();
267        let path = std::path::PathBuf::from("/tmp/test_tex_embed_header.glb");
268        export_glb_with_texture(&mesh, &mat, &tex, &path).expect("export failed");
269        let bytes = std::fs::read(&path).expect("should succeed");
270        assert!(bytes.len() >= 4, "file too short to contain GLB magic");
271        let magic = u32::from_le_bytes(bytes[0..4].try_into().expect("should succeed"));
272        assert_eq!(magic, 0x46546C67u32, "first 4 bytes are not GLB magic");
273        std::fs::remove_file(&path).ok();
274    }
275
276    #[test]
277    fn glb_with_texture_larger_than_without() {
278        let mesh = tri_mesh();
279        let mat = PbrMaterial::skin();
280        let tex = small_tex();
281
282        let path_plain = std::path::PathBuf::from("/tmp/test_tex_embed_plain.glb");
283        let path_tex = std::path::PathBuf::from("/tmp/test_tex_embed_with_tex.glb");
284
285        // Export plain GLB (no texture) using the existing export_glb function.
286        export_glb(&mesh, &path_plain).expect("plain export failed");
287        export_glb_with_texture(&mesh, &mat, &tex, &path_tex).expect("texture export failed");
288
289        let size_plain = std::fs::metadata(&path_plain).expect("should succeed").len();
290        let size_tex = std::fs::metadata(&path_tex).expect("should succeed").len();
291
292        assert!(
293            size_tex > size_plain,
294            "GLB with texture ({} bytes) should be larger than plain ({} bytes)",
295            size_tex,
296            size_plain
297        );
298
299        std::fs::remove_file(&path_plain).ok();
300        std::fs::remove_file(&path_tex).ok();
301    }
302}