mesh files in: STL, OBJ, glTF and PLY
git clone https://git.lucas.co/cce-mesh-io.git
src/gltf.rs (20K)
1 //! glTF 2.0, both forms: `.gltf` (JSON, buffers beside it or inline as
2 //! base64 data URIs) and `.glb` (one binary file).
3 //!
4 //! The scene's node tree is walked and every mesh placed where its node's
5 //! transforms put it, one [`Part`] per node with a mesh, named by the node
6 //! (else the mesh). Triangle lists, strips and fans are read; points and
7 //! lines are skipped. Each primitive's material becomes a [`Material`]: its
8 //! base colour factor, metallic and roughness factors, and its base-colour
9 //! texture (decoded once per image, the first time a material uses it; the
10 //! texture-coordinate set it names supplies the corners' uvs). A `COLOR_0`
11 //! attribute becomes corner colours; the spec multiplies them by the factor,
12 //! and the material carries the factor, so they are stored as they are.
13 //! NORMAL is kept, carried through the node's inverse-transpose; a part where
14 //! any primitive lacks it has no file normals. Metallic-roughness and normal
15 //! TEXTURES are not read. A node whose transform mirrors (negative
16 //! determinant) has its winding reversed, so its faces still point out. glTF
17 //! is Y-up by its spec, in metres.
18 //!
19 //! Buffers and images are loaded here rather than by the crate's `import`
20 //! feature, which would decode every image whether a material uses it or
21 //! not, and drags in every image format.
22
23 use std::path::Path;
24
25 use base64::Engine as _;
26 use glam::{Mat3, Mat4, Vec3};
27 use gltf::buffer::Source;
28 use gltf::mesh::Mode;
29
30 use crate::mesh::{Material, Mesh};
31 use crate::{Part, Scene, Texture, Unit, UpAxis};
32
33 /// Deeper than any real node tree; a guard against a cycle the parser let by.
34 const MAX_DEPTH: usize = 128;
35
36 pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
37 let file = gltf::Gltf::from_slice(bytes).map_err(|e| format!("not a readable glTF: {e}"))?;
38 let buffers = load_buffers(&file, dir)?;
39 let mut parts = Vec::new();
40 let mut skipped_modes = 0usize;
41 let mut textures: Vec<Texture> = Vec::new();
42 // glTF image index -> index into `textures`, or None for one that would
43 // not load (said once, then drawn without).
44 let mut image_slot: std::collections::HashMap<usize, Option<usize>> = std::collections::HashMap::new();
45
46 let roots: Vec<gltf::Node> = match file.default_scene().or_else(|| file.scenes().next()) {
47 Some(scene) => scene.nodes().collect(),
48 None => {
49 // No scene: every node that is nobody's child is a root.
50 let children: std::collections::HashSet<usize> =
51 file.nodes().flat_map(|n| n.children().map(|c| c.index())).collect();
52 file.nodes().filter(|n| !children.contains(&n.index())).collect()
53 }
54 };
55 // Pushed in reverse, so the stack pops in pre-order: parts come out in
56 // the order the file lists them, parents before children.
57 let mut stack: Vec<(gltf::Node, Mat4, usize)> = roots.into_iter().rev().map(|n| (n, Mat4::IDENTITY, 0)).collect();
58 while let Some((node, parent, depth)) = stack.pop() {
59 if depth > MAX_DEPTH {
60 return Err("the node tree is too deep (a cycle?)".into());
61 }
62 let world = parent * Mat4::from_cols_array_2d(&node.transform().matrix());
63 let children: Vec<gltf::Node> = node.children().collect();
64 for child in children.into_iter().rev() {
65 stack.push((child, world, depth + 1));
66 }
67 let Some(gmesh) = node.mesh() else { continue };
68 let mirrored = world.determinant() < 0.0;
69 let normal_matrix = Mat3::from_mat4(world).inverse().transpose();
70 let mut mesh = Mesh::default();
71 let mut corner_colors: Vec<[f32; 3]> = Vec::new();
72 let mut any_colors = false;
73 let mut corner_uvs: Vec<[f32; 2]> = Vec::new();
74 let mut any_uvs = false;
75 let mut corner_normals: Vec<Vec3> = Vec::new();
76 let mut all_normals = true;
77 for prim in gmesh.primitives() {
78 let reader = prim.reader(|b| buffers.get(b.index()).map(Vec::as_slice));
79 let Some(positions) = reader.read_positions() else { continue };
80 let base = mesh.positions.len() as u32;
81 mesh.positions.extend(positions.map(|p| world.transform_point3(Vec3::from(p))));
82 let count = mesh.positions.len() as u32 - base;
83 let indices: Vec<u32> = match reader.read_indices() {
84 Some(i) => i.into_u32().collect(),
85 None => (0..count).collect(),
86 };
87 if let Some(&bad) = indices.iter().find(|&&i| i >= count) {
88 return Err(format!("mesh '{}' indexes point {bad} of {count}", gmesh.name().unwrap_or("?")));
89 }
90 let tris = triangles(prim.mode(), &indices);
91 let Some(tris) = tris else {
92 skipped_modes += 1;
93 continue;
94 };
95 let material = prim.material();
96 let pbr = material.pbr_metallic_roughness();
97 let [r, g, b, _] = pbr.base_color_factor();
98 let base_texture = pbr.base_color_texture();
99 let texture = base_texture.as_ref().and_then(|info| {
100 let image = info.texture().source();
101 *image_slot.entry(image.index()).or_insert_with(|| match load_image(&image, &buffers, dir) {
102 Ok(t) => {
103 textures.push(t);
104 Some(textures.len() - 1)
105 }
106 Err(e) => {
107 log::warn!("[mesh-io] glTF image {}: {e}", image.index());
108 None
109 }
110 })
111 });
112 let slot = mesh.materials.len() as u32;
113 mesh.materials.push(Material {
114 color: [r, g, b],
115 texture,
116 metallic: pbr.metallic_factor(),
117 roughness: pbr.roughness_factor(),
118 });
119 let point_colors: Option<Vec<[f32; 3]>> = reader.read_colors(0).map(|c| c.into_rgb_f32().collect());
120 let set = base_texture.as_ref().map_or(0, |info| info.tex_coord());
121 let point_uvs: Option<Vec<[f32; 2]>> = reader.read_tex_coords(set).map(|t| t.into_f32().collect());
122 let point_normals: Option<Vec<Vec3>> = reader
123 .read_normals()
124 .map(|n| n.map(|n| (normal_matrix * Vec3::from(n)).normalize_or_zero()).collect());
125 all_normals &= point_normals.is_some();
126 for t in tris {
127 let t = if mirrored { [t[0], t[2], t[1]] } else { t };
128 for k in t {
129 let k = k as usize;
130 corner_colors.push(match &point_colors {
131 Some(pc) => {
132 any_colors = true;
133 pc.get(k).copied().unwrap_or([1.0; 3])
134 }
135 None => [1.0; 3],
136 });
137 corner_uvs.push(match &point_uvs {
138 Some(pu) => {
139 any_uvs = true;
140 pu.get(k).copied().unwrap_or([0.0; 2])
141 }
142 None => [0.0; 2],
143 });
144 if let Some(pn) = &point_normals {
145 corner_normals.push(pn.get(k).copied().unwrap_or(Vec3::Y));
146 }
147 }
148 mesh.triangles.push(t.map(|i| i + base));
149 mesh.tri_material.push(slot);
150 }
151 }
152 if any_colors {
153 // Corner colours replace the material's colour where present
154 // (`Mesh::corner_color`), so a material with a factor carries
155 // it into them: the spec multiplies the two.
156 let tinted = corner_colors
157 .iter()
158 .enumerate()
159 .map(|(i, c)| {
160 let f = mesh.materials[mesh.tri_material[i / 3] as usize].color;
161 [c[0] * f[0], c[1] * f[1], c[2] * f[2]]
162 })
163 .collect();
164 mesh.corner_colors = Some(tinted);
165 }
166 if any_uvs {
167 mesh.corner_uvs = Some(corner_uvs);
168 }
169 if all_normals && !mesh.triangles.is_empty() {
170 mesh.file_normals = Some(corner_normals);
171 }
172 let name = node.name().or(gmesh.name()).map(str::to_string).unwrap_or_else(|| format!("node {}", node.index()));
173 parts.push(Part { name, mesh });
174 }
175
176 if skipped_modes > 0 {
177 log::info!("[mesh-io] glTF: skipped {skipped_modes} primitives of points or lines");
178 }
179 Ok(Scene { parts, up: UpAxis::Y, unit: Unit::Metre, textures })
180 }
181
182 /// A primitive's index list as triangles, or `None` for points and lines.
183 fn triangles(mode: Mode, i: &[u32]) -> Option<Vec<[u32; 3]>> {
184 Some(match mode {
185 Mode::Triangles => i.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect(),
186 // Every other triangle of a strip is reversed to keep the winding.
187 Mode::TriangleStrip => (0..i.len().saturating_sub(2))
188 .map(|k| if k % 2 == 0 { [i[k], i[k + 1], i[k + 2]] } else { [i[k + 1], i[k], i[k + 2]] })
189 .collect(),
190 Mode::TriangleFan => (1..i.len().saturating_sub(1)).map(|k| [i[0], i[k], i[k + 1]]).collect(),
191 Mode::Points | Mode::Lines | Mode::LineLoop | Mode::LineStrip => return None,
192 })
193 }
194
195 fn load_buffers(file: &gltf::Gltf, dir: Option<&Path>) -> Result<Vec<Vec<u8>>, String> {
196 file.buffers()
197 .map(|b| {
198 let data = match b.source() {
199 Source::Bin => file.blob.clone().ok_or("the file names a binary chunk it does not have")?,
200 Source::Uri(uri) if uri.starts_with("data:") => {
201 let (_, payload) = uri.split_once(";base64,").ok_or("a data URI that is not base64")?;
202 base64::engine::general_purpose::STANDARD
203 .decode(payload)
204 .map_err(|e| format!("a bad base64 buffer: {e}"))?
205 }
206 Source::Uri(uri) => {
207 let path = dir.unwrap_or(Path::new(".")).join(percent_decode(uri));
208 std::fs::read(&path).map_err(|e| format!("buffer {}: {e}", path.display()))?
209 }
210 };
211 if data.len() < b.length() {
212 return Err(format!("buffer {} is {} bytes, shorter than the {} it declares", b.index(), data.len(), b.length()));
213 }
214 Ok(data)
215 })
216 .collect()
217 }
218
219 /// Decode an image a texture names: from a buffer view (a GLB's usual way),
220 /// a data URI, or a file beside the glTF.
221 fn load_image(image: &gltf::Image, buffers: &[Vec<u8>], dir: Option<&Path>) -> Result<Texture, String> {
222 match image.source() {
223 gltf::image::Source::View { view, .. } => {
224 let buffer = buffers.get(view.buffer().index()).ok_or("an image in a buffer that is not there")?;
225 let bytes = buffer
226 .get(view.offset()..view.offset() + view.length())
227 .ok_or("an image past the end of its buffer")?;
228 Texture::decode(bytes)
229 }
230 gltf::image::Source::Uri { uri, .. } if uri.starts_with("data:") => {
231 let (_, payload) = uri.split_once(";base64,").ok_or("an image data URI that is not base64")?;
232 let bytes = base64::engine::general_purpose::STANDARD.decode(payload).map_err(|e| format!("bad base64: {e}"))?;
233 Texture::decode(&bytes)
234 }
235 gltf::image::Source::Uri { uri, .. } => {
236 let path = dir.unwrap_or(Path::new(".")).join(percent_decode(uri));
237 let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
238 Texture::decode(&bytes)
239 }
240 }
241 }
242
243 /// `my%20model.bin` → `my model.bin`: URIs in a glTF are percent-encoded.
244 fn percent_decode(s: &str) -> String {
245 let b = s.as_bytes();
246 let mut out = Vec::with_capacity(b.len());
247 let mut i = 0;
248 while i < b.len() {
249 if b[i] == b'%' && i + 2 < b.len() {
250 if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
251 out.push(v);
252 i += 3;
253 continue;
254 }
255 }
256 out.push(b[i]);
257 i += 1;
258 }
259 String::from_utf8_lossy(&out).into_owned()
260 }
261
262 #[cfg(test)]
263 mod tests {
264 use super::*;
265
266 /// One triangle's points and indices as a base64 buffer, and the JSON
267 /// that describes it, with `nodes` and `extra` spliced in.
268 fn gltf_json(nodes: &str, mode: u32, extra: &str) -> (String, Vec<u8>) {
269 let mut bin = Vec::new();
270 for p in [[0f32, 0., 0.], [1., 0., 0.], [0., 1., 0.], [1., 1., 0.]] {
271 for c in p {
272 bin.extend(c.to_le_bytes());
273 }
274 }
275 for i in [0u16, 1, 2, 3] {
276 bin.extend(i.to_le_bytes());
277 }
278 let json = format!(
279 r#"{{"asset":{{"version":"2.0"}},"scene":0,"scenes":[{{"nodes":[0]}}],"nodes":{nodes},
280 "meshes":[{{"name":"tri","primitives":[{{"attributes":{{"POSITION":0}},"indices":1,"mode":{mode},"material":0}}]}}],
281 "materials":[{{"pbrMetallicRoughness":{{"baseColorFactor":[0.8,0.2,0.1,1.0]}}}}],
282 "accessors":[{{"bufferView":0,"componentType":5126,"count":4,"type":"VEC3","min":[0,0,0],"max":[1,1,0]}},
283 {{"bufferView":1,"componentType":5123,"count":4,"type":"SCALAR"}}],
284 "bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":48}},{{"buffer":0,"byteOffset":48,"byteLength":8}}],
285 "buffers":[{{"byteLength":56{extra}}}]}}"#
286 );
287 (json, bin)
288 }
289
290 fn inline(nodes: &str, mode: u32) -> Vec<u8> {
291 let (_, bin) = gltf_json(nodes, mode, "");
292 let uri = format!(r#","uri":"data:application/octet-stream;base64,{}""#, base64::engine::general_purpose::STANDARD.encode(&bin));
293 gltf_json(nodes, mode, &uri).0.into_bytes()
294 }
295
296 #[test]
297 fn a_node_transform_places_the_mesh_and_the_material_colours_it() {
298 let s = read(&inline(r#"[{"name":"moved","mesh":0,"translation":[10,0,0],"scale":[2,2,2]}]"#, 5), None).unwrap();
299 assert_eq!(s.up, UpAxis::Y);
300 assert_eq!(s.unit, Unit::Metre);
301 assert_eq!(Unit::Metre.millimetres(), Some(1000.0));
302 let part = &s.parts[0];
303 assert_eq!(part.name, "moved");
304 assert_eq!(part.mesh.positions[1], Vec3::new(12.0, 0.0, 0.0));
305 assert_eq!(part.mesh.triangles.len(), 2, "a strip of four points is two triangles");
306 assert_eq!(part.mesh.corner_color(0, 0), [0.8, 0.2, 0.1]);
307 assert_eq!(part.mesh.material(0).roughness, 1.0, "the spec's default factor");
308 }
309
310 #[test]
311 fn children_inherit_their_parents_transform() {
312 let nodes = r#"[{"translation":[0,5,0],"children":[1]},{"mesh":0,"translation":[1,0,0]}]"#;
313 let s = read(&inline(nodes, 4), None).unwrap();
314 assert_eq!(s.parts.len(), 1);
315 assert_eq!(s.parts[0].name, "tri", "an unnamed node takes its mesh's name");
316 assert_eq!(s.parts[0].mesh.positions[0], Vec3::new(1.0, 5.0, 0.0));
317 }
318
319 #[test]
320 fn a_mirroring_node_keeps_faces_pointing_out() {
321 let plain = read(&inline(r#"[{"mesh":0}]"#, 4), None).unwrap();
322 let mirrored = read(&inline(r#"[{"mesh":0,"scale":[-1,1,1]}]"#, 4), None).unwrap();
323 let normal = |m: &Mesh| {
324 let [a, b, c] = m.triangles[0].map(|i| m.positions[i as usize]);
325 (b - a).cross(c - a).normalize()
326 };
327 assert_eq!(normal(&plain.parts[0].mesh), Vec3::Z);
328 assert_eq!(normal(&mirrored.parts[0].mesh), Vec3::Z, "the mirror flipped the winding back");
329 }
330
331 #[test]
332 fn a_glb_is_read_from_its_binary_chunk() {
333 let (json, bin) = gltf_json(r#"[{"mesh":0}]"#, 4, "");
334 let mut json = json.into_bytes();
335 while json.len() % 4 != 0 {
336 json.push(b' ');
337 }
338 let mut glb = Vec::new();
339 glb.extend(b"glTF");
340 glb.extend(2u32.to_le_bytes());
341 glb.extend(((12 + 8 + json.len() + 8 + bin.len()) as u32).to_le_bytes());
342 glb.extend((json.len() as u32).to_le_bytes());
343 glb.extend(b"JSON");
344 glb.extend(&json);
345 glb.extend((bin.len() as u32).to_le_bytes());
346 glb.extend(b"BIN\0");
347 glb.extend(&bin);
348 let s = read(&glb, None).unwrap();
349 assert_eq!(s.parts[0].mesh.triangles, vec![[0, 1, 2]]);
350 }
351
352 #[test]
353 fn lines_are_skipped_and_a_missing_buffer_file_is_an_error() {
354 let s = read(&inline(r#"[{"mesh":0}]"#, 1), None).unwrap();
355 assert!(s.parts[0].mesh.triangles.is_empty());
356 let (json, _) = gltf_json(r#"[{"mesh":0}]"#, 4, r#","uri":"nowhere.bin""#);
357 let e = read(json.as_bytes(), Some(Path::new("/nonexistent"))).unwrap_err();
358 assert!(e.contains("nowhere.bin"), "{e}");
359 }
360
361 #[test]
362 fn a_textured_glb_brings_uvs_normals_and_its_image() {
363 // One triangle with NORMAL and TEXCOORD_0, a 2x2 PNG in a buffer
364 // view, and a node turned a quarter about Y (normals must turn too).
365 let mut png = Vec::new();
366 image::RgbaImage::from_fn(2, 2, |x, _| image::Rgba([if x == 0 { 255 } else { 0 }, 0, 0, 255]))
367 .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
368 .unwrap();
369 let mut bin = Vec::new();
370 for f in [0f32, 0., 0., 1., 0., 0., 0., 1., 0.] {
371 bin.extend(f.to_le_bytes()); // POSITION, 36 bytes
372 }
373 for _ in 0..3 {
374 for f in [0f32, 0., 1.] {
375 bin.extend(f.to_le_bytes()); // NORMAL +Z, 36 bytes
376 }
377 }
378 for f in [0f32, 0., 1., 0., 0., 1.] {
379 bin.extend(f.to_le_bytes()); // TEXCOORD_0, 24 bytes
380 }
381 let png_at = bin.len();
382 bin.extend(&png);
383 while bin.len() % 4 != 0 {
384 bin.push(0);
385 }
386 let json = format!(
387 r#"{{"asset":{{"version":"2.0"}},"scene":0,"scenes":[{{"nodes":[0]}}],
388 "nodes":[{{"mesh":0,"rotation":[0,0.7071068,0,0.7071068]}}],
389 "meshes":[{{"primitives":[{{"attributes":{{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2}},"material":0}}]}}],
390 "materials":[{{"pbrMetallicRoughness":{{"baseColorTexture":{{"index":0}},"metallicFactor":0.25,"roughnessFactor":0.5}}}}],
391 "textures":[{{"source":0}}],"images":[{{"bufferView":3,"mimeType":"image/png"}}],
392 "accessors":[{{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3","min":[0,0,0],"max":[1,1,0]}},
393 {{"bufferView":1,"componentType":5126,"count":3,"type":"VEC3"}},
394 {{"bufferView":2,"componentType":5126,"count":3,"type":"VEC2"}}],
395 "bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":36}},{{"buffer":0,"byteOffset":36,"byteLength":36}},
396 {{"buffer":0,"byteOffset":72,"byteLength":24}},{{"buffer":0,"byteOffset":{png_at},"byteLength":{}}}],
397 "buffers":[{{"byteLength":{}}}]}}"#,
398 png.len(),
399 bin.len()
400 );
401 let mut json = json.into_bytes();
402 while json.len() % 4 != 0 {
403 json.push(b' ');
404 }
405 let mut glb = Vec::new();
406 glb.extend(b"glTF");
407 glb.extend(2u32.to_le_bytes());
408 glb.extend(((12 + 8 + json.len() + 8 + bin.len()) as u32).to_le_bytes());
409 glb.extend((json.len() as u32).to_le_bytes());
410 glb.extend(b"JSON");
411 glb.extend(&json);
412 glb.extend((bin.len() as u32).to_le_bytes());
413 glb.extend(b"BIN\0");
414 glb.extend(&bin);
415
416 let s = read(&glb, None).unwrap();
417 assert_eq!(s.textures.len(), 1);
418 assert_eq!(s.textures[0].sample([0.1, 0.5])[0], 255, "the left column is red");
419 assert_eq!(s.textures[0].sample([0.9, 0.5])[0], 0);
420 let m = &s.parts[0].mesh;
421 let mat = m.material(0);
422 assert_eq!((mat.texture, mat.metallic, mat.roughness), (Some(0), 0.25, 0.5));
423 assert_eq!(mat.color, [1.0; 3], "the factor defaults to white");
424 assert_eq!(m.corner_uvs.as_ref().unwrap()[1], [1.0, 0.0]);
425 let n = m.file_normals.as_ref().unwrap()[0];
426 assert!((n - Vec3::X).length() < 1e-5, "+Z turned a quarter about Y is +X, got {n}");
427 }
428
429 #[test]
430 fn percent_escapes_are_decoded() {
431 assert_eq!(percent_decode("my%20model.bin"), "my model.bin");
432 assert_eq!(percent_decode("100%"), "100%");
433 }
434 }