1use 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
26fn 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
50fn 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
63struct Builder {
65 mesh: Mesh,
66 colors: Vec<V3>,
67 uvs: Vec<[f32; 2]>,
68 all_normals: bool,
69 any_uv: bool,
70 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#[derive(Default)]
86struct MapSet {
87 kd: Option<MapOpts>,
88 bump: Option<MapOpts>,
89}
90
91#[derive(Clone, Debug, PartialEq)]
94struct MapOpts {
95 dir: String,
96 path: String,
97 offset: [f32; 2],
98 scale: [f32; 2],
99}
100
101fn 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 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
202fn dir_of(path: &str) -> &str {
204 path.rfind(['/', '\\']).map_or("", |i| &path[..i])
205}
206
207fn 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 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 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 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 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 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}