mesh files in: STL, OBJ, glTF and PLY
git clone https://git.lucas.co/cce-mesh-io.git
src/obj.rs (12.8K)
1 //! Wavefront OBJ, with its MTL materials.
2 //!
3 //! What is read: `v` points, `vt` texture coordinates, `vn` normals, `f`
4 //! faces (any polygon, fanned into triangles; `i`, `i/t`, `i//n` and
5 //! `i/t/n` corners; negative indices counting back from the newest), `usemtl`,
6 //! and `mtllib` for each material's `Kd` colour, `map_Kd` texture (PNG or
7 //! JPEG, relative to the MTL file), `Ns` shininess (as a roughness) and the
8 //! PBR extension's `Pm` / `Pr` when present. A `vt` is flipped in v on the
9 //! way in: OBJ's origin is the image's bottom-left, the crate's its top-left.
10 //! The file's normals are kept only if every corner has one. A missing MTL
11 //! file or texture is not an error; the material falls back to clay, or to
12 //! its colour. One part for the whole file, named by its first `o`; the
13 //! scene is [`UpAxis::Y`].
14
15 use std::collections::HashMap;
16 use std::path::{Path, PathBuf};
17
18 use glam::Vec3;
19
20 use crate::mesh::{Material, Mesh};
21 use crate::{Part, Scene, Texture, Unit, UpAxis};
22
23 /// A material as an MTL file states it, before its texture is loaded.
24 #[derive(Debug, Clone, Default)]
25 struct MtlEntry {
26 kd: Option<[f32; 3]>,
27 map_kd: Option<PathBuf>,
28 ns: Option<f32>,
29 pm: Option<f32>,
30 pr: Option<f32>,
31 }
32
33 pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
34 let text = String::from_utf8_lossy(bytes);
35 let mut mesh = Mesh { materials: vec![Material::default()], ..Mesh::default() };
36 let mut textures: Vec<Texture> = Vec::new();
37 let mut texture_slot: HashMap<PathBuf, Option<usize>> = HashMap::new();
38 let mut library: HashMap<String, MtlEntry> = HashMap::new();
39 let mut slot: HashMap<String, u32> = HashMap::new();
40 let mut current = 0u32;
41 let mut uvs: Vec<[f32; 2]> = Vec::new();
42 let mut normals: Vec<Vec3> = Vec::new();
43 // Per corner, as triangles are emitted: its uv and normal, if named.
44 let mut corner_uv: Vec<Option<[f32; 2]>> = Vec::new();
45 let mut corner_n: Vec<Option<Vec3>> = Vec::new();
46 let mut corners: Vec<(u32, Option<[f32; 2]>, Option<Vec3>)> = Vec::new();
47 let mut name = String::new();
48
49 for (line_no, line) in text.lines().enumerate() {
50 let line = line.split('#').next().unwrap_or("");
51 let mut words = line.split_whitespace();
52 let err = |what: &str| format!("line {}: {what}", line_no + 1);
53 let numbers = |words: &mut std::str::SplitWhitespace, n: usize, what: &str| -> Result<Vec<f32>, String> {
54 let v: Vec<f32> = words.take(n).map(|w| w.parse::<f32>()).collect::<Result<_, _>>().map_err(|_| err(what))?;
55 if v.len() < n {
56 return Err(err(what));
57 }
58 Ok(v)
59 };
60 match words.next() {
61 Some("v") => {
62 let p = numbers(&mut words, 3, "a point needs three numbers")?;
63 mesh.positions.push(Vec3::new(p[0], p[1], p[2]));
64 }
65 Some("vt") => {
66 let t = numbers(&mut words, 1, "a texture coordinate needs a number")?;
67 let v = words.next().and_then(|w| w.parse::<f32>().ok()).unwrap_or(0.0);
68 uvs.push([t[0], 1.0 - v]);
69 }
70 Some("vn") => {
71 let n = numbers(&mut words, 3, "a normal needs three numbers")?;
72 normals.push(Vec3::new(n[0], n[1], n[2]).normalize_or_zero());
73 }
74 Some("f") => {
75 corners.clear();
76 for w in words {
77 let mut parts = w.split('/');
78 let resolve = |s: Option<&str>, n: usize, what: &str| -> Result<Option<usize>, String> {
79 let Some(s) = s.filter(|s| !s.is_empty()) else { return Ok(None) };
80 let i: i64 = s.parse().map_err(|_| err("a bad face corner"))?;
81 let index = if i > 0 { i - 1 } else { n as i64 + i };
82 if i == 0 || !(0..n as i64).contains(&index) {
83 return Err(err(&format!("face corner {i} names no {what} (there are {n} so far)")));
84 }
85 Ok(Some(index as usize))
86 };
87 let p = resolve(parts.next(), mesh.positions.len(), "point")?.ok_or_else(|| err("a bad face corner"))?;
88 let t = resolve(parts.next(), uvs.len(), "texture coordinate")?;
89 let n = resolve(parts.next(), normals.len(), "normal")?;
90 corners.push((p as u32, t.map(|t| uvs[t]), n.map(|n| normals[n])));
91 }
92 if corners.len() < 3 {
93 return Err(err("a face needs three corners"));
94 }
95 for k in 1..corners.len() - 1 {
96 let tri = [corners[0], corners[k], corners[k + 1]];
97 mesh.triangles.push(tri.map(|c| c.0));
98 mesh.tri_material.push(current);
99 corner_uv.extend(tri.map(|c| c.1));
100 corner_n.extend(tri.map(|c| c.2));
101 }
102 }
103 Some("o") if name.is_empty() => name = words.collect::<Vec<_>>().join(" "),
104 Some("mtllib") => {
105 // A library name may hold spaces, so take the rest of the line.
106 let lib = line.trim_start().strip_prefix("mtllib").unwrap_or("").trim().replace('\\', "/");
107 if let Some(dir) = dir {
108 let path = dir.join(&lib);
109 match std::fs::read_to_string(&path) {
110 Ok(mtl) => library.extend(read_mtl(&mtl, path.parent().unwrap_or(dir))),
111 Err(e) => log::warn!("[mesh-io] material library {lib}: {e}"),
112 }
113 }
114 }
115 Some("usemtl") => {
116 let key = words.next().unwrap_or("").to_string();
117 current = *slot.entry(key.clone()).or_insert_with(|| {
118 let entry = library.get(&key).cloned().unwrap_or_default();
119 let texture = entry.map_kd.as_ref().and_then(|path| {
120 *texture_slot.entry(path.clone()).or_insert_with(|| {
121 match std::fs::read(path).map_err(|e| e.to_string()).and_then(|b| Texture::decode(&b)) {
122 Ok(t) => {
123 textures.push(t);
124 Some(textures.len() - 1)
125 }
126 Err(e) => {
127 log::warn!("[mesh-io] texture {}: {e}", path.display());
128 None
129 }
130 }
131 })
132 });
133 let mut m = Material::default();
134 if let Some(kd) = entry.kd {
135 m.color = kd;
136 } else if texture.is_some() {
137 // A texture with no Kd: the texture alone, untinted.
138 m.color = [1.0; 3];
139 }
140 m.texture = texture;
141 // Phong shininess to a roughness: sqrt(2 / (Ns + 2)).
142 if let Some(ns) = entry.ns {
143 m.roughness = (2.0 / (ns.max(0.0) + 2.0)).sqrt().clamp(0.04, 1.0);
144 }
145 m.roughness = entry.pr.unwrap_or(m.roughness);
146 m.metallic = entry.pm.unwrap_or(0.0);
147 mesh.materials.push(m);
148 (mesh.materials.len() - 1) as u32
149 });
150 }
151 _ => {}
152 }
153 }
154 if corner_uv.iter().any(Option::is_some) {
155 mesh.corner_uvs = Some(corner_uv.into_iter().map(|t| t.unwrap_or([0.0; 2])).collect());
156 }
157 if !corner_n.is_empty() && corner_n.iter().all(|n| n.is_some_and(|n| n != Vec3::ZERO)) {
158 mesh.file_normals = Some(corner_n.into_iter().map(Option::unwrap).collect());
159 }
160 Ok(Scene { parts: vec![Part { name, mesh }], up: UpAxis::Y, unit: Unit::Unspecified, textures })
161 }
162
163 /// Each `newmtl` in an MTL file and what it says; texture paths are
164 /// resolved against the MTL file's folder.
165 fn read_mtl(text: &str, dir: &Path) -> HashMap<String, MtlEntry> {
166 let mut out: HashMap<String, MtlEntry> = HashMap::new();
167 let mut name: Option<String> = None;
168 for line in text.lines() {
169 let line = line.split('#').next().unwrap_or("");
170 let mut words = line.split_whitespace();
171 let Some(key) = words.next() else { continue };
172 if key == "newmtl" {
173 name = words.next().map(str::to_string);
174 if let Some(n) = &name {
175 out.entry(n.clone()).or_default();
176 }
177 continue;
178 }
179 let Some(entry) = name.as_ref().and_then(|n| out.get_mut(n)) else { continue };
180 let rest: Vec<&str> = words.collect();
181 let first = rest.first().and_then(|w| w.parse::<f32>().ok());
182 match key {
183 "Kd" => {
184 let rgb: Vec<f32> = rest.iter().take(3).filter_map(|w| w.parse().ok()).collect();
185 if let [r, g, b] = rgb.as_slice() {
186 entry.kd = Some([*r, *g, *b]);
187 }
188 }
189 // Options (`-s 1 1 1`, `-bm 0.5`) come before the file name, so
190 // the name is the last word (file names with spaces are rare in
191 // MTL, and ambiguous there anyway).
192 "map_Kd" => entry.map_kd = rest.last().map(|f| dir.join(f.replace('\\', "/"))),
193 "Ns" => entry.ns = first,
194 "Pm" => entry.pm = first,
195 "Pr" => entry.pr = first,
196 _ => {}
197 }
198 }
199 out
200 }
201
202 #[cfg(test)]
203 mod tests {
204 use super::*;
205 use crate::CLAY;
206
207 fn temp_dir(tag: &str) -> PathBuf {
208 let dir = std::env::temp_dir().join(format!("cce-mesh-io-obj-{tag}-{}", std::process::id()));
209 std::fs::create_dir_all(&dir).unwrap();
210 dir
211 }
212
213 #[test]
214 fn a_quad_is_two_triangles_and_negative_indices_count_back() {
215 let s = read(b"o quad\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\nf -4/-4 -3/-3 -2/-2 -1/-1\n", None).unwrap();
216 assert_eq!(s.parts[0].name, "quad");
217 let m = &s.parts[0].mesh;
218 assert_eq!(m.triangles, vec![[0, 1, 2], [0, 2, 3]]);
219 let uv = m.corner_uvs.as_ref().unwrap();
220 assert_eq!(uv[0], [0.0, 1.0], "vt (0, 0) is the image's bottom-left");
221 assert_eq!(uv[2], [1.0, 0.0]);
222 assert!(m.file_normals.is_none());
223 }
224
225 #[test]
226 fn normals_are_kept_when_every_corner_has_one() {
227 let s = read(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 2\nf 1//1 2//1 3//1\n", None).unwrap();
228 assert_eq!(s.parts[0].mesh.file_normals.as_ref().unwrap()[1], Vec3::Z, "normalized");
229 let s = read(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nv 1 1 0\nvn 0 0 1\nf 1//1 2//1 3//1\nf 2 4 3\n", None).unwrap();
230 assert!(s.parts[0].mesh.file_normals.is_none(), "one face without normals");
231 }
232
233 #[test]
234 fn materials_colour_their_faces() {
235 let dir = temp_dir("colour");
236 std::fs::write(dir.join("m.mtl"), "newmtl red\nKd 0.8 0.1 0.1\nNs 0\n").unwrap();
237 let obj = b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\nusemtl red\nf 1 3 2\nusemtl missing\nf 2 1 3\n";
238 let m = read(obj, Some(&dir)).unwrap().parts.remove(0).mesh;
239 std::fs::remove_dir_all(&dir).ok();
240 assert_eq!(m.material(0).color, CLAY, "before any usemtl");
241 assert_eq!(m.material(1).color, [0.8, 0.1, 0.1]);
242 assert_eq!(m.material(1).roughness, 1.0, "Ns 0 is fully rough");
243 assert_eq!(m.material(2).color, CLAY, "a material the library lacks");
244 }
245
246 #[test]
247 fn map_kd_loads_a_texture_once() {
248 let dir = temp_dir("texture");
249 let mut png = Vec::new();
250 image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255]))
251 .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
252 .unwrap();
253 std::fs::create_dir_all(dir.join("tex")).unwrap();
254 std::fs::write(dir.join("tex/red.png"), &png).unwrap();
255 std::fs::write(dir.join("m.mtl"), "newmtl a\nmap_Kd -s 1 1 1 tex\\red.png\nnewmtl b\nKd 0.5 0.5 0.5\nmap_Kd tex/red.png\n").unwrap();
256 let obj = b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl a\nf 1 2 3\nusemtl b\nf 1 3 2\n";
257 let s = read(obj, Some(&dir)).unwrap();
258 std::fs::remove_dir_all(&dir).ok();
259 assert_eq!(s.textures.len(), 1, "one image, named twice");
260 assert_eq!((s.textures[0].width, s.textures[0].height), (2, 1));
261 let m = &s.parts[0].mesh;
262 assert_eq!(m.material(0).texture, Some(0));
263 assert_eq!(m.material(0).color, [1.0; 3], "no Kd: the texture untinted");
264 assert_eq!(m.material(1).color, [0.5; 3]);
265 }
266
267 #[test]
268 fn a_face_past_the_points_is_an_error() {
269 let e = read(b"v 0 0 0\nv 1 0 0\nf 1 2 3\n", None).unwrap_err();
270 assert!(e.contains("line 3") && e.contains("names no point"), "{e}");
271 }
272 }