git.lucas.co / cce-mesh-io
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 }