Skip to main content

falcon_library/model3d/
obj.rs

1//! Wavefront OBJ, with colours and diffuse textures from its MTL library.
2//!
3//! A material's `map_Kd` picture is looked up next to its MTL file, then
4//! next to the model (never outside the model's folder); its `-o` and `-s`
5//! options are baked into the texture coordinates (`-clamp` is ignored,
6//! textures always repeat). The picture replaces the `Kd` colour where it is drawn, as in
7//! Eagle's viewer.
8//!
9//! `map_bump` (or `bump`) is read the same way into the surface's bump map;
10//! its `-bm` strength is ignored, as Eagle's viewer ignores it. The
11//! coordinates take their `-o` and `-s` from `map_Kd`, else from the bump
12//! map. A material whose `d` or `Tr` is not 1 draws see-through. `map_Ke`,
13//! `Ke`, `norm` and `map_d` are not read, as in Eagle's viewer. Statement
14//! names are compared without regard to case.
15
16use std::borrow::Cow;
17use std::collections::HashMap;
18
19use super::math::V3;
20use super::texture::{Loader, Texture};
21use super::{DEFAULT_COLOR, Material, Mesh, Model, Result, Surface};
22
23const NO_NORMAL: u32 = u32::MAX;
24const NO_UV: u32 = u32::MAX;
25
26/// The lines of `text` with `\` continuations joined; a line is only
27/// copied when it is continued.
28fn logical_lines(text: &str) -> impl Iterator<Item = Cow<'_, str>> {
29    let mut lines = text.lines();
30    std::iter::from_fn(move || {
31        let first = lines.next()?;
32        let Some(head) = first.trim_end().strip_suffix('\\') else {
33            return Some(Cow::Borrowed(first));
34        };
35        let mut joined = String::from(head);
36        for next in lines.by_ref() {
37            joined.push(' ');
38            match next.trim_end().strip_suffix('\\') {
39                Some(h) => joined.push_str(h),
40                None => {
41                    joined.push_str(next);
42                    break;
43                }
44            }
45        }
46        Some(Cow::Owned(joined))
47    })
48}
49
50/// A line without its trailing `#` comment.
51fn strip_comment(line: &str) -> &str {
52    line.find('#').map_or(line, |i| &line[..i])
53}
54
55fn floats<'a, const N: usize>(it: &mut impl Iterator<Item = &'a str>) -> Option<[f32; N]> {
56    let mut out = [0.0f32; N];
57    for slot in &mut out {
58        *slot = it.next()?.parse().ok()?;
59    }
60    Some(out)
61}
62
63/// One part under construction: its own compact vertex list.
64struct Builder {
65    mesh: Mesh,
66    colors: Vec<V3>,
67    uvs: Vec<[f32; 2]>,
68    all_normals: bool,
69    any_uv: bool,
70    /// The material's texture map: placement and wrap.
71    map: Option<MapOpts>,
72    lookup: HashMap<(u32, u32, u32), u32>,
73}
74
75#[derive(Default)]
76struct MtlEntry {
77    color: Option<V3>,
78    opacity: Option<f32>,
79    tr: Option<f32>,
80    map: Option<String>,
81    bump: Option<String>,
82}
83
84/// A material's `map_Kd` and bump statements.
85#[derive(Default)]
86struct MapSet {
87    kd: Option<MapOpts>,
88    bump: Option<MapOpts>,
89}
90
91/// A `map_Kd` statement: the picture's path (relative to the MTL file's
92/// folder `dir`) and its options.
93#[derive(Clone, Debug, PartialEq)]
94struct MapOpts {
95    dir: String,
96    path: String,
97    offset: [f32; 2],
98    scale: [f32; 2],
99}
100
101/// Reads a `map_Kd` line's options and file name. Options take a fixed
102/// number of words, `-o`, `-s` and `-t` one to three numbers; the rest is
103/// the file name (which may hold spaces).
104fn map_opts(rest: &str, dir: &str) -> Option<MapOpts> {
105    let mut m = MapOpts { dir: dir.to_string(), path: String::new(), offset: [0.0; 2], scale: [1.0; 2] };
106    let words: Vec<&str> = rest.split_ascii_whitespace().collect();
107    let mut i = 0;
108    while i < words.len() && words[i].starts_with('-') {
109        let opt = words[i].to_ascii_lowercase();
110        i += 1;
111        match opt.as_str() {
112            "-o" | "-s" | "-t" => {
113                let mut v = Vec::new();
114                while v.len() < 3 && i < words.len() && let Ok(f) = words[i].parse::<f32>() {
115                    v.push(f);
116                    i += 1;
117                }
118                let at = |k: usize, d: f32| v.get(k).copied().filter(|f| f.is_finite()).unwrap_or(d);
119                match opt.as_str() {
120                    "-o" => m.offset = [at(0, 0.0), at(1, 0.0)],
121                    "-s" => m.scale = [at(0, 1.0), at(1, 1.0)],
122                    _ => {}
123                }
124            }
125            "-clamp" => i += 1,
126            "-mm" => i += 2,
127            _ => i += 1,
128        }
129    }
130    // The file name keeps its own spacing.
131    let name_start = words.get(i).and_then(|w| rest.find(w))?;
132    m.path = rest[name_start..].trim().to_string();
133    (!m.path.is_empty()).then_some(m)
134}
135
136fn parse_mtl(bytes: &[u8], dir: &str, out: &mut HashMap<String, Material>, maps: &mut HashMap<String, MapSet>) {
137    let text = String::from_utf8_lossy(bytes);
138    let mut cur: Option<(String, MtlEntry)> = None;
139    let flush = |cur: &mut Option<(String, MtlEntry)>, out: &mut HashMap<String, Material>, maps: &mut HashMap<String, MapSet>| {
140        if let Some((name, e)) = cur.take() {
141            let opacity = e.opacity.or(e.tr.map(|t| 1.0 - t)).unwrap_or(1.0).clamp(0.0, 1.0);
142            let color = e.color.unwrap_or(DEFAULT_COLOR);
143            if !out.contains_key(&name) {
144                let set = MapSet {
145                    kd: e.map.as_deref().and_then(|r| map_opts(r, dir)),
146                    bump: e.bump.as_deref().and_then(|r| map_opts(r, dir)),
147                };
148                if set.kd.is_some() || set.bump.is_some() {
149                    maps.insert(name.clone(), set);
150                }
151            }
152            out.entry(name.clone()).or_insert(Material { name, color, opacity });
153        }
154    };
155    for line in logical_lines(&text) {
156        let line = strip_comment(&line).trim();
157        let (key, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
158        let rest = rest.trim();
159        match key {
160            _ if key.eq_ignore_ascii_case("newmtl") => {
161                flush(&mut cur, out, maps);
162                cur = Some((rest.to_string(), MtlEntry::default()));
163            }
164            _ if key.eq_ignore_ascii_case("Kd") => {
165                if let Some((_, e)) = cur.as_mut() {
166                    let mut it = rest.split_ascii_whitespace();
167                    if let Some([r, g, b]) = floats::<3>(&mut it) {
168                        e.color = Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]);
169                    }
170                }
171            }
172            _ if key.eq_ignore_ascii_case("d") => {
173                if let (Some((_, e)), Ok(v)) = (cur.as_mut(), rest.split_ascii_whitespace().next().unwrap_or("").parse::<f32>())
174                    && v.is_finite()
175                {
176                    e.opacity = Some(v);
177                }
178            }
179            _ if key.eq_ignore_ascii_case("Tr") => {
180                if let (Some((_, e)), Ok(v)) = (cur.as_mut(), rest.split_ascii_whitespace().next().unwrap_or("").parse::<f32>())
181                    && v.is_finite()
182                {
183                    e.tr = Some(v);
184                }
185            }
186            k if k.eq_ignore_ascii_case("map_Kd") => {
187                if let Some((_, e)) = cur.as_mut() {
188                    e.map = Some(rest.to_string());
189                }
190            }
191            k if k.eq_ignore_ascii_case("map_bump") || k.eq_ignore_ascii_case("bump") => {
192                if let Some((_, e)) = cur.as_mut() {
193                    e.bump = Some(rest.to_string());
194                }
195            }
196            _ => {}
197        }
198    }
199    flush(&mut cur, out, maps);
200}
201
202/// The folder part of a relative path (`""` for a bare name).
203fn dir_of(path: &str) -> &str {
204    path.rfind(['/', '\\']).map_or("", |i| &path[..i])
205}
206
207/// Resolves an OBJ index (1-based, or negative from the end) to 0-based.
208fn resolve(tok: &str, len: usize) -> Option<u32> {
209    let i: i64 = tok.parse().ok()?;
210    let r = if i > 0 { i - 1 } else if i < 0 { len as i64 + i } else { return None };
211    (r >= 0 && (r as usize) < len).then_some(r as u32)
212}
213
214pub fn parse(bytes: &[u8], side: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
215    let text = String::from_utf8_lossy(bytes);
216    let mut positions: Vec<V3> = Vec::new();
217    let mut vcolors: Vec<V3> = Vec::new();
218    let mut has_color = false;
219    let mut normals: Vec<V3> = Vec::new();
220    let mut texcoords: Vec<[f32; 2]> = Vec::new();
221    let mut materials: HashMap<String, Material> = HashMap::new();
222    let mut maps: HashMap<String, MapSet> = HashMap::new();
223    let mut loader = Loader::new(side);
224    let mut textures: HashMap<String, Option<Texture>> = HashMap::new();
225    let mut bumps: HashMap<String, Option<Texture>> = HashMap::new();
226
227    let mut obj_name = String::new();
228    let mut mat_name: Option<String> = None;
229    let mut builders: Vec<Builder> = Vec::new();
230    let mut by_key: HashMap<(String, Option<String>), usize> = HashMap::new();
231    // Index into `builders` of the current run; None until a face needs one.
232    let mut current: Option<usize> = None;
233    let mut corners: Vec<(u32, u32, u32)> = Vec::new();
234
235    for line in logical_lines(&text) {
236        let line = strip_comment(&line);
237        let mut it = line.split_ascii_whitespace();
238        let Some(key) = it.next() else { continue };
239        match key {
240            "v" => {
241                let Some([x, y, z]) = floats::<3>(&mut it) else { continue };
242                positions.push([x, y, z]);
243                if let Some([r, g, b]) = floats::<3>(&mut it) {
244                    has_color = true;
245                    vcolors.push([r, g, b]);
246                } else {
247                    vcolors.push(DEFAULT_COLOR);
248                }
249            }
250            "vn" => {
251                let Some(n) = floats::<3>(&mut it) else { continue };
252                normals.push(n);
253            }
254            "vt" => {
255                let Some(uv) = floats::<2>(&mut it).or_else(|| floats::<1>(&mut line.split_ascii_whitespace().skip(1)).map(|[u]| [u, 0.0])) else { continue };
256                texcoords.push(uv);
257            }
258            "o" | "g" => {
259                let name = line.trim().get(key.len()..).unwrap_or("").trim();
260                if name != obj_name {
261                    obj_name = name.to_string();
262                    current = None;
263                }
264            }
265            "usemtl" => {
266                let name = line.trim().get(key.len()..).unwrap_or("").trim();
267                if mat_name.as_deref() != Some(name) {
268                    mat_name = Some(name.to_string());
269                    current = None;
270                }
271            }
272            "mtllib" => {
273                let rest = line.trim().get(key.len()..).unwrap_or("").trim();
274                if rest.is_empty() {
275                    continue;
276                }
277                if let Some(b) = side(rest) {
278                    parse_mtl(&b, dir_of(rest), &mut materials, &mut maps);
279                } else {
280                    for name in rest.split_ascii_whitespace() {
281                        if let Some(b) = side(name) {
282                            parse_mtl(&b, dir_of(name), &mut materials, &mut maps);
283                        }
284                    }
285                }
286            }
287            "f" => {
288                corners.clear();
289                for tok in it {
290                    let mut parts = tok.split('/');
291                    let Some(p) = parts.next().and_then(|t| resolve(t, positions.len())) else {
292                        corners.clear();
293                        break;
294                    };
295                    let t = parts.next().filter(|t| !t.is_empty()).and_then(|t| resolve(t, texcoords.len())).unwrap_or(NO_UV);
296                    let n = match parts.next() {
297                        Some(t) if !t.is_empty() => match resolve(t, normals.len()) {
298                            Some(n) => n,
299                            None => {
300                                corners.clear();
301                                break;
302                            }
303                        },
304                        _ => NO_NORMAL,
305                    };
306                    corners.push((p, t, n));
307                }
308                if corners.len() < 3 {
309                    continue;
310                }
311                let bi = match current {
312                    Some(b) => b,
313                    None => {
314                        let key = (obj_name.clone(), mat_name.clone());
315                        let b = *by_key.entry(key).or_insert_with(|| {
316                            let material = mat_name.as_ref().map(|n| {
317                                materials.get(n).cloned().unwrap_or_else(|| Material { name: n.clone(), color: DEFAULT_COLOR, opacity: 1.0 })
318                            });
319                            let set = mat_name.as_ref().and_then(|n| maps.get(n));
320                            let kd = set.and_then(|s| s.kd.clone());
321                            let bump = set.and_then(|s| s.bump.clone());
322                            let map = kd.clone().or_else(|| bump.clone());
323                            let blend = material.as_ref().is_some_and(|m| (m.opacity - 1.0).abs() > 1e-6);
324                            let texture = match (&mat_name, &kd) {
325                                (Some(n), Some(m)) => textures
326                                    .entry(n.clone())
327                                    .or_insert_with(|| {
328                                        let image = loader.file_in(&m.dir, &m.path)?;
329                                        Some(Texture::new(image))
330                                    })
331                                    .clone(),
332                                _ => None,
333                            };
334                            let bump_map = match (&mat_name, &bump) {
335                                (Some(n), Some(m)) => bumps
336                                    .entry(n.clone())
337                                    .or_insert_with(|| {
338                                        let image = loader.for_map(|l| l.file_in(&m.dir, &m.path))?;
339                                        Some(Texture::new(image))
340                                    })
341                                    .clone(),
342                                _ => None,
343                            };
344                            builders.push(Builder {
345                                mesh: Mesh {
346                                    name: obj_name.clone(),
347                                    material,
348                                    texture,
349                                    surface: Surface { bump_map, blend, ..Surface::default() },
350                                    ..Mesh::default()
351                                },
352                                colors: Vec::new(),
353                                uvs: Vec::new(),
354                                all_normals: true,
355                                any_uv: false,
356                                map,
357                                lookup: HashMap::new(),
358                            });
359                            builders.len() - 1
360                        });
361                        current = Some(b);
362                        b
363                    }
364                };
365                let b = &mut builders[bi];
366                let mut idx = |(p, t, n): (u32, u32, u32)| -> u32 {
367                    *b.lookup.entry((p, t, n)).or_insert_with(|| {
368                        let i = b.mesh.positions.len() as u32;
369                        b.mesh.positions.push(positions[p as usize]);
370                        b.colors.push(vcolors[p as usize]);
371                        if t == NO_UV {
372                            b.uvs.push([0.0; 2]);
373                        } else {
374                            b.any_uv = true;
375                            b.uvs.push(texcoords[t as usize]);
376                        }
377                        let nv = if n == NO_NORMAL {
378                            b.all_normals = false;
379                            [0.0, 0.0, 1.0]
380                        } else {
381                            normals[n as usize]
382                        };
383                        b.mesh.normals.get_or_insert_with(Vec::new).push(nv);
384                        i
385                    })
386                };
387                let first = idx(corners[0]);
388                let mut prev = idx(corners[1]);
389                for &c in &corners[2..] {
390                    let cur = idx(c);
391                    b.mesh.indices.extend_from_slice(&[first, prev, cur]);
392                    prev = cur;
393                }
394            }
395            _ => {}
396        }
397    }
398
399    let meshes = builders
400        .into_iter()
401        .map(|mut b| {
402            if !b.all_normals {
403                b.mesh.normals = None;
404            }
405            if has_color {
406                b.mesh.colors = Some(b.colors);
407            }
408            if b.any_uv {
409                // Through the map's placement, then v flipped to run down
410                // the picture.
411                let (o, s) = b.map.as_ref().map_or(([0.0; 2], [1.0; 2]), |m| (m.offset, m.scale));
412                b.mesh.uvs = Some(b.uvs.iter().map(|uv| [uv[0] * s[0] + o[0], 1.0 - (uv[1] * s[1] + o[1])]).collect());
413            }
414            b.mesh
415        })
416        .collect();
417    Model { meshes }.finish()
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn none(_: &str) -> Option<Vec<u8>> {
425        None
426    }
427
428    #[test]
429    fn quad_is_fan_triangulated() {
430        let m = parse(b"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n", &none).unwrap();
431        assert_eq!(m.meshes.len(), 1);
432        assert_eq!(m.meshes[0].indices, vec![0, 1, 2, 0, 2, 3]);
433        assert_eq!(m.meshes[0].positions.len(), 4);
434        assert!(m.meshes[0].material.is_none());
435        assert!(m.meshes[0].normals.is_none());
436    }
437
438    #[test]
439    fn double_slash_normals() {
440        let src = b"v 0 0 0\r\nv 1 0 0\r\nv 0 1 0\r\nvn 0 0 1\r\nf 1//1 2//1 3//1\r\n";
441        let m = parse(src, &none).unwrap();
442        assert_eq!(m.meshes[0].normals.as_ref().unwrap(), &vec![[0.0, 0.0, 1.0]; 3]);
443        let m = parse(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvn 0 0 1\nf 1/1/1 2/1/1 3/1/1\n", &none).unwrap();
444        assert!(m.meshes[0].normals.is_some());
445    }
446
447    #[test]
448    fn negative_indices() {
449        let m = parse(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf -3 -2 -1\n", &none).unwrap();
450        assert_eq!(m.meshes[0].indices, vec![0, 1, 2]);
451        assert_eq!(m.meshes[0].positions[2], [0.0, 1.0, 0.0]);
452    }
453
454    #[test]
455    fn usemtl_runs_make_meshes_with_mtl_colours() {
456        let src = b"mtllib a.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nv 1 1 0\nusemtl red\nf 1 2 3\nusemtl blue\nf 2 4 3\n";
457        let side = |n: &str| -> Option<Vec<u8>> {
458            (n == "a.mtl").then(|| b"newmtl red\nKd 1 0 0\nnewmtl blue\r\nKd 0 0 1\nTr 0.25\n".to_vec())
459        };
460        let m = parse(src, &side).unwrap();
461        assert_eq!(m.meshes.len(), 2);
462        let a = m.meshes[0].material.as_ref().unwrap();
463        assert_eq!((a.name.as_str(), a.color, a.opacity), ("red", [1.0, 0.0, 0.0], 1.0));
464        let b = m.meshes[1].material.as_ref().unwrap();
465        assert_eq!(b.color, [0.0, 0.0, 1.0]);
466        assert!((b.opacity - 0.75).abs() < 1e-6);
467        assert_eq!(m.meshes[1].positions.len(), 3);
468    }
469
470    #[test]
471    fn missing_mtl_falls_back_to_default() {
472        let m = parse(b"mtllib gone.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl wood\nf 1 2 3\n", &none).unwrap();
473        let mat = m.meshes[0].material.as_ref().unwrap();
474        assert_eq!(mat.name, "wood");
475        assert_eq!(mat.color, DEFAULT_COLOR);
476        assert_eq!(mat.opacity, 1.0);
477    }
478
479    #[test]
480    fn vertex_colours() {
481        let m = parse(b"v 0 0 0 1 0 0\nv 1 0 0\nv 0 1 0 0 1 0\nf 1 2 3\n", &none).unwrap();
482        let c = m.meshes[0].colors.as_ref().unwrap();
483        assert_eq!(c[0], [1.0, 0.0, 0.0]);
484        assert_eq!(c[1], DEFAULT_COLOR);
485        assert_eq!(c[2], [0.0, 1.0, 0.0]);
486    }
487
488    #[test]
489    fn garbage_and_empty_fail() {
490        assert!(parse(b"", &none).is_err());
491        assert!(parse(b"\x00\xff not an obj \x01\n# c\nhello world\n", &none).is_err());
492    }
493
494    #[test]
495    fn out_of_range_face_is_skipped() {
496        let m = parse(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 9\nf 1 2 3\n# c\n", &none).unwrap();
497        assert_eq!(m.meshes[0].indices, vec![0, 1, 2]);
498        assert!(parse(b"v 0 0 0\nv 1 0 0\nf 1 2 3\n", &none).is_err());
499    }
500
501    const TEXTURED: &[u8] = b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nusemtl wood\nf 1/1 2/2 3/3\n";
502
503    #[test]
504    fn map_kd_textures_the_mesh() {
505        let png = crate::model3d::texture::tests::png_2x2();
506        let side = |n: &str| match n {
507            "m.mtl" => Some(b"newmtl wood\nKd 0.5 0.25 0\nmap_Kd -clamp on -s 2 2 1 -o 0.5 0 tex/wood grain.png\n".to_vec()),
508            "tex/wood grain.png" => Some(png.clone()),
509            _ => None,
510        };
511        let m = parse(TEXTURED, &side).unwrap();
512        let mesh = &m.meshes[0];
513        let t = mesh.texture.as_ref().expect("texture decoded");
514        assert_eq!((t.image.width, t.image.height), (2, 2));
515        assert_eq!(t.tint, [1.0; 3], "the picture replaces Kd");
516        // Scaled by 2, offset by 0.5 in u, then v flipped.
517        assert_eq!(mesh.uvs.as_deref(), Some(&[[0.5, 1.0], [2.5, 1.0], [0.5, -1.0]][..]));
518        assert_eq!(mesh.material.as_ref().unwrap().color, [0.5, 0.25, 0.0]);
519    }
520
521    #[test]
522    fn map_kd_options_and_names() {
523        let m = map_opts("-blendu on -bm 0.5 -mm 0 1 -o 0.1 0.2 -imfchan l my file.png", "").unwrap();
524        assert_eq!((m.path.as_str(), m.offset, m.scale), ("my file.png", [0.1, 0.2], [1.0, 1.0]));
525        assert_eq!(map_opts("wood.png", "sub").unwrap().dir, "sub");
526        assert!(map_opts("-clamp on", "").is_none());
527    }
528
529    #[test]
530    fn missing_or_outside_textures_keep_the_colour() {
531        let asked = std::cell::RefCell::new(Vec::new());
532        let png = crate::model3d::texture::tests::png_2x2();
533        for map in ["gone.png", "../up/x.png", "/abs/x.png"] {
534            let side = |n: &str| {
535                asked.borrow_mut().push(n.to_string());
536                match n {
537                    "m.mtl" => Some(format!("newmtl wood\nKd 0.5 0.25 0\nmap_Kd {map}\n").into_bytes()),
538                    "up/x.png" => Some(png.clone()),
539                    _ => None,
540                }
541            };
542            let m = parse(TEXTURED, &side).unwrap();
543            assert!(m.meshes[0].texture.is_none(), "{map}");
544            assert_eq!(m.meshes[0].material.as_ref().unwrap().color, [0.5, 0.25, 0.0]);
545            assert!(m.meshes[0].uvs.is_some());
546        }
547        assert!(asked.borrow().iter().all(|n| !n.contains("..") && !n.starts_with('/')), "{:?}", asked.borrow());
548    }
549
550    #[test]
551    fn maps_resolve_next_to_their_mtl_first() {
552        let png = crate::model3d::texture::tests::png_2x2();
553        let src = String::from_utf8_lossy(TEXTURED).replace("mtllib m.mtl", "mtllib mats/m.mtl");
554        let side = |n: &str| match n {
555            "mats/m.mtl" => Some(b"newmtl wood\nmap_Kd w.png\n".to_vec()),
556            "mats/w.png" => Some(png.clone()),
557            _ => None,
558        };
559        assert!(parse(src.as_bytes(), &side).unwrap().meshes[0].texture.is_some());
560        // Faces without vt have no coordinates.
561        let m = parse(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n", &none).unwrap();
562        assert!(m.meshes[0].uvs.is_none());
563    }
564
565    fn bump_model(mtl: &str, with_vt: bool) -> Model {
566        let png = crate::model3d::texture::tests::png_2x2();
567        let mtl = mtl.to_string();
568        let side = move |n: &str| match n {
569            "m.mtl" => Some(mtl.clone().into_bytes()),
570            "b.png" => Some(png.clone()),
571            _ => None,
572        };
573        let src: &[u8] = if with_vt { TEXTURED } else { b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl wood\nf 1 2 3\n" };
574        parse(src, &side).unwrap()
575    }
576
577    #[test]
578    fn map_bump_with_options_and_aliases() {
579        for stmt in ["map_bump -bm 0.5 -s 2 2 b.png", "bump b.png", "MAP_BUMP b.png", "Bump -bm 3 b.png"] {
580            let m = bump_model(&format!("newmtl wood\n{stmt}\n"), true);
581            let mesh = &m.meshes[0];
582            assert!(mesh.surface.bump_map.is_some(), "{stmt}");
583            assert!(mesh.texture.is_none());
584            assert!(mesh.uvs.is_some());
585            assert!(!mesh.surface.blend);
586        }
587        let m = bump_model("newmtl wood\nmap_bump -clamp on b.png\n", true);
588        assert!(m.meshes[0].surface.bump_map.is_some());
589    }
590
591    #[test]
592    fn missing_bump_keeps_the_mesh() {
593        let m = bump_model("newmtl wood\nKd 1 0 0\nmap_bump gone.png\n", true);
594        assert_eq!(m.meshes.len(), 1);
595        assert!(m.meshes[0].surface.bump_map.is_none());
596        assert_eq!(m.meshes[0].material.as_ref().unwrap().color, [1.0, 0.0, 0.0]);
597        // Without vt coordinates no map survives.
598        let m = bump_model("newmtl wood\nmap_bump b.png\n", false);
599        assert!(m.meshes[0].surface.bump_map.is_none());
600    }
601
602    #[test]
603    fn bump_placement_is_used_without_map_kd() {
604        let m = bump_model("newmtl wood\nmap_bump -s 2 2 b.png\n", true);
605        assert_eq!(m.meshes[0].uvs.as_deref(), Some(&[[0.0, 1.0], [2.0, 1.0], [0.0, -1.0]][..]));
606        let m = bump_model("newmtl wood\nmap_bump -s 2 2 b.png\nmap_Kd -s 3 3 b.png\n", true);
607        assert_eq!(m.meshes[0].uvs.as_deref(), Some(&[[0.0, 1.0], [3.0, 1.0], [0.0, -2.0]][..]));
608    }
609
610    #[test]
611    fn opacity_sets_blend() {
612        for (stmt, blend) in [("d 0.5", true), ("Tr 0.25", true), ("d 1", false), ("d garbage", false), ("d nan", false), ("Tr inf", false), ("Tr 0", false)] {
613            let m = bump_model(&format!("newmtl wood\n{stmt}\n"), true);
614            assert_eq!(m.meshes[0].surface.blend, blend, "{stmt}");
615        }
616    }
617
618    #[test]
619    fn names_and_continuations() {
620        let m = parse(b"o part\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 \\\n 3 # tail\n", &none).unwrap();
621        assert_eq!(m.meshes[0].name, "part");
622        assert_eq!(m.meshes[0].triangle_count(), 1);
623    }
624}