1use ogeom_core::{OgeomResult, ogeom_bail};
11use ogeom_math::{Point, Vector};
12use ogeom_topo::Triangulation;
13
14use crate::image::{Image, Wrap};
15use crate::mesh_formats::{MeshColours, held_is_unset};
16
17#[derive(Debug, Clone)]
19pub struct ObjImport {
20 pub mesh: Triangulation,
22 pub colours: Option<MeshColours>,
27 pub warnings: Vec<String>,
31}
32
33type Corner = (usize, Option<usize>, Option<usize>);
35
36struct Parsed {
38 positions: Vec<Point>,
39 vertex_colours: Vec<Option<[f64; 4]>>,
41 normals: Vec<Vector>,
42 uvs: Vec<Option<(f64, f64)>>,
44 triangles: Vec<[u32; 3]>,
45 corner_uvs: Vec<[Option<usize>; 3]>,
47 material_of: Vec<Option<usize>>,
49 materials: Vec<String>,
50 libraries: Vec<String>,
51 normal_of: Vec<Option<usize>>,
52}
53
54fn parse(text: &str) -> OgeomResult<Parsed> {
55 let mut p = Parsed {
56 positions: Vec::new(),
57 vertex_colours: Vec::new(),
58 normals: Vec::new(),
59 uvs: Vec::new(),
60 triangles: Vec::new(),
61 corner_uvs: Vec::new(),
62 material_of: Vec::new(),
63 materials: Vec::new(),
64 libraries: Vec::new(),
65 normal_of: Vec::new(),
66 };
67 let mut material: Option<usize> = None;
68 for line in text.lines() {
69 let line = line.split('#').next().unwrap_or("").trim();
70 let mut fields = line.split_whitespace();
71 match fields.next() {
72 Some("v") => {
73 let rest: Vec<&str> = fields.collect();
74 let coords = triple(&mut rest.iter().copied(), "a vertex")?;
75 p.positions
76 .push(Point::new(coords[0], coords[1], coords[2]));
77 p.normal_of.push(None);
78 let colour = if rest.len() == 6 {
80 let rgb = rest[3..]
81 .iter()
82 .map(|f| f.parse::<f64>().ok().filter(|c| (0.0..=1.0).contains(c)))
83 .collect::<Option<Vec<f64>>>();
84 rgb.map(|c| [c[0], c[1], c[2], 1.0])
85 } else {
86 None
87 };
88 p.vertex_colours.push(colour);
89 }
90 Some("vn") => {
91 let coords = triple(&mut fields, "a normal")?;
92 p.normals.push(Vector::new(coords[0], coords[1], coords[2]));
93 }
94 Some("vt") => {
95 let mut numbers = fields.map(|f| f.parse::<f64>().ok().filter(|x| x.is_finite()));
100 let uv = match (numbers.next().flatten(), numbers.next()) {
101 (Some(u), None) => Some((u, 0.0)),
102 (Some(u), Some(Some(v))) => Some((u, v)),
103 _ => None,
104 };
105 p.uvs.push(uv);
106 }
107 Some("usemtl") => {
108 let name = fields.collect::<Vec<_>>().join(" ");
109 material = Some(match p.materials.iter().position(|m| *m == name) {
110 Some(i) => i,
111 None => {
112 p.materials.push(name);
113 p.materials.len() - 1
114 }
115 });
116 }
117 Some("mtllib") => {
118 let mut name = String::new();
121 for field in fields {
122 if !name.is_empty() {
123 name.push(' ');
124 }
125 name.push_str(field);
126 if field.to_ascii_lowercase().ends_with(".mtl") {
127 p.libraries.push(std::mem::take(&mut name));
128 }
129 }
130 if !name.is_empty() {
131 p.libraries.push(name);
132 }
133 }
134 Some("f") => {
135 let mut corners: Vec<Corner> = Vec::new();
136 for field in fields {
137 corners.push(corner(
138 field,
139 p.positions.len(),
140 p.uvs.len(),
141 p.normals.len(),
142 )?);
143 }
144 if corners.len() < 3 {
145 ogeom_bail!(
146 Construction,
147 "a face of {} corners is not a face",
148 corners.len()
149 );
150 }
151 for i in 1..corners.len() - 1 {
152 let fan = [corners[0], corners[i], corners[i + 1]];
153 let mut indices = [0_u32; 3];
154 let mut uvs = [None; 3];
155 for (k, (vertex, uv, normal)) in fan.iter().enumerate() {
156 if let Some(n) = normal {
157 p.normal_of[*vertex] = Some(*n);
158 }
159 indices[k] = u32::try_from(*vertex).unwrap_or(u32::MAX);
160 uvs[k] = *uv;
161 }
162 p.triangles.push(indices);
163 p.corner_uvs.push(uvs);
164 p.material_of.push(material);
165 }
166 }
167 _ => {}
168 }
169 }
170 if p.positions.is_empty() {
171 ogeom_bail!(Construction, "the file carries no vertices");
172 }
173 Ok(p)
174}
175
176impl Parsed {
177 fn mesh(&self) -> Triangulation {
178 let positions = &self.positions;
179 let mut resolved = vec![Vector::ZERO; positions.len()];
183 for (i, held) in self.normal_of.iter().enumerate() {
184 if let Some(n) = held.and_then(|n| self.normals.get(n)) {
185 resolved[i] = *n;
186 }
187 }
188 for triangle in &self.triangles {
189 let [a, b, c] = triangle.map(|i| positions[i as usize]);
190 let face = (b - a).cross(c - a);
191 for index in triangle {
192 let slot = &mut resolved[*index as usize];
193 if held_is_unset(slot) {
194 *slot += face;
195 }
196 }
197 }
198 let normals = resolved
199 .into_iter()
200 .map(|n| {
201 let m = n.magnitude();
202 if m > 0.0 { n / m } else { Vector::Z }
203 })
204 .collect::<Vec<_>>();
205 Triangulation {
206 positions: positions.clone(),
207 normals,
208 parameters: vec![(0.0, 0.0); positions.len()],
209 triangles: self.triangles.clone(),
210 deflection_met: true,
211 }
212 }
213}
214
215pub fn read_obj(text: &str) -> OgeomResult<Triangulation> {
232 Ok(parse(text)?.mesh())
233}
234
235fn triple<'a>(fields: &mut impl Iterator<Item = &'a str>, what: &str) -> OgeomResult<[f64; 3]> {
237 let mut out = [0.0; 3];
238 for slot in &mut out {
239 let Some(field) = fields.next() else {
240 ogeom_bail!(Construction, "{what} needs three coordinates");
241 };
242 let Ok(value) = field.parse::<f64>() else {
243 ogeom_bail!(
244 Construction,
245 "{what} carries {field}, which is not a number"
246 );
247 };
248 *slot = value;
249 }
250 Ok(out)
251}
252
253fn corner(field: &str, vertices: usize, uvs: usize, normals: usize) -> OgeomResult<Corner> {
256 let mut parts = field.split('/');
257 let resolve = |text: &str, count: usize| -> Option<usize> {
258 let index = text.parse::<isize>().ok()?;
259 let zero = if index > 0 {
260 usize::try_from(index).ok()?.checked_sub(1)?
261 } else if index < 0 {
262 count.checked_sub(usize::try_from(-index).ok()?)?
263 } else {
264 return None;
265 };
266 (zero < count).then_some(zero)
267 };
268 let Some(vertex) = parts.next().and_then(|t| resolve(t, vertices)) else {
269 ogeom_bail!(
270 Construction,
271 "a face names vertex {field}, which is not there"
272 );
273 };
274 let uv = parts.next().and_then(|t| resolve(t, uvs));
275 let normal = parts.next().and_then(|t| resolve(t, normals));
276 Ok((vertex, uv, normal))
277}
278
279#[derive(Default)]
281struct Material {
282 diffuse: Option<[f64; 3]>,
283 opacity: f64,
284 texture: Option<Texture>,
285}
286
287struct Texture {
289 file: String,
290 offset: (f64, f64),
291 scale: (f64, f64),
292 clamp: bool,
293 image: Option<Image>,
294}
295
296fn texture_statement(fields: &[&str]) -> Texture {
299 let mut texture = Texture {
300 file: String::new(),
301 offset: (0.0, 0.0),
302 scale: (1.0, 1.0),
303 clamp: false,
304 image: None,
305 };
306 let mut i = 0;
307 while i < fields.len() && fields[i].starts_with('-') {
308 let option = fields[i];
309 i += 1;
310 let numbers = |i: &mut usize| -> Vec<f64> {
313 let mut out = Vec::new();
314 while out.len() < 3
315 && let Some(v) = fields.get(*i).and_then(|f| f.parse::<f64>().ok())
316 {
317 out.push(v);
318 *i += 1;
319 }
320 out
321 };
322 match option {
323 "-o" => {
324 let v = numbers(&mut i);
325 texture.offset = (
326 v.first().copied().unwrap_or(0.0),
327 v.get(1).copied().unwrap_or(0.0),
328 );
329 }
330 "-s" => {
331 let v = numbers(&mut i);
332 texture.scale = (
333 v.first().copied().unwrap_or(1.0),
334 v.get(1).copied().unwrap_or(1.0),
335 );
336 }
337 "-t" => {
338 numbers(&mut i);
339 }
340 "-clamp" => {
341 texture.clamp = fields.get(i) == Some(&"on");
342 i += 1;
343 }
344 "-mm" => i += 2,
345 _ => i += 1,
346 }
347 }
348 texture.file = fields.get(i..).unwrap_or_default().join(" ");
349 texture
350}
351
352fn library(text: &str, into: &mut Vec<(String, Material)>, warnings: &mut Vec<String>) {
354 let mut current: Option<usize> = None;
355 for line in text.lines() {
356 let line = line.split('#').next().unwrap_or("").trim();
357 let fields: Vec<&str> = line.split_whitespace().collect();
358 let Some((&keyword, rest)) = fields.split_first() else {
359 continue;
360 };
361 if keyword == "newmtl" {
362 into.push((
363 rest.join(" "),
364 Material {
365 opacity: 1.0,
366 ..Material::default()
367 },
368 ));
369 current = Some(into.len() - 1);
370 continue;
371 }
372 let Some(material) = current.map(|i| &mut into[i]) else {
373 continue;
374 };
375 let name = &material.0;
376 match keyword {
377 "Kd" => {
378 let rgb: Option<Vec<f64>> = rest.iter().map(|f| f.parse::<f64>().ok()).collect();
379 match rgb.as_deref() {
380 Some([r, g, b]) => material.1.diffuse = Some([*r, *g, *b]),
381 Some([v]) => material.1.diffuse = Some([*v; 3]),
382 _ => warnings.push(format!(
383 "the material {name} gives Kd as {}, which is not read",
384 rest.join(" ")
385 )),
386 }
387 }
388 "d" => {
389 if let Some(d) = rest.last().and_then(|f| f.parse::<f64>().ok()) {
390 material.1.opacity = d.clamp(0.0, 1.0);
391 }
392 }
393 "Tr" => {
394 if let Some(t) = rest.first().and_then(|f| f.parse::<f64>().ok()) {
395 material.1.opacity = (1.0 - t).clamp(0.0, 1.0);
396 }
397 }
398 "map_Kd" => material.1.texture = Some(texture_statement(rest)),
399 _ => {}
400 }
401 }
402}
403
404pub fn read_obj_with_materials(
429 text: &str,
430 open: &mut dyn FnMut(&str) -> Option<Vec<u8>>,
431) -> OgeomResult<ObjImport> {
432 let parsed = parse(text)?;
433 let mesh = parsed.mesh();
434 let mut warnings = Vec::new();
435 let mut defined: Vec<(String, Material)> = Vec::new();
436 if !parsed.materials.is_empty() {
437 for name in &parsed.libraries {
438 match open(name) {
439 Some(bytes) => library(
440 &String::from_utf8_lossy(&bytes),
441 &mut defined,
442 &mut warnings,
443 ),
444 None => warnings.push(format!("the material library {name} could not be opened")),
445 }
446 }
447 }
448 let mut budget = crate::image::Budget::new();
449 for (name, material) in &mut defined {
450 let Some(texture) = material.texture.as_mut() else {
451 continue;
452 };
453 if !parsed
454 .material_of
455 .contains(&parsed.materials.iter().position(|m| m == name))
456 {
457 continue;
458 }
459 match open(&texture.file) {
460 None => warnings.push(format!(
461 "the texture {} of {name} could not be opened",
462 texture.file
463 )),
464 Some(bytes) => match budget.decode(&bytes) {
465 Ok(image) => texture.image = Some(image),
466 Err(e) => warnings.push(format!(
467 "the texture {} of {name} could not be decoded: {e}",
468 texture.file
469 )),
470 },
471 }
472 }
473 let used: Vec<Option<&Material>> = parsed
474 .materials
475 .iter()
476 .map(|name| {
477 let found = defined
478 .iter()
479 .rev()
480 .find(|(n, _)| n == name)
481 .map(|(_, m)| m);
482 if found.is_none() {
483 warnings.push(format!("the material {name} is not defined"));
484 }
485 found
486 })
487 .collect();
488
489 let mut corners: Vec<[[f64; 4]; 3]> = Vec::with_capacity(parsed.triangles.len());
490 let mut uncoloured = 0_usize;
491 let mut untextured = 0_usize;
492 for (t, material) in parsed.material_of.iter().enumerate() {
493 let Some(material) = material.and_then(|m| used[m]) else {
494 uncoloured += 1;
495 continue;
496 };
497 let alpha = material.opacity;
498 let sampled = material
499 .texture
500 .as_ref()
501 .and_then(|tex| Some((tex, tex.image.as_ref()?)))
502 .and_then(|(tex, image)| {
503 let uvs = parsed.corner_uvs[t];
504 let wrap = if tex.clamp { Wrap::Clamp } else { Wrap::Repeat };
505 let mut out = [[0.0; 4]; 3];
506 for (slot, uv) in out.iter_mut().zip(uvs) {
507 let (u, v) = parsed.uvs[uv?]?;
508 let (u, v) = (
509 u * tex.scale.0 + tex.offset.0,
510 v * tex.scale.1 + tex.offset.1,
511 );
512 let [r, g, b, a] = image.sample(u, v, [wrap; 2]);
513 *slot = [r, g, b, a * alpha];
514 }
515 Some(out)
516 });
517 if let Some(colours) = sampled {
518 corners.push(colours);
519 continue;
520 }
521 if material.texture.as_ref().is_some_and(|t| t.image.is_some()) {
522 untextured += 1;
523 }
524 match material.diffuse {
525 Some([r, g, b]) => corners.push([[r, g, b, alpha]; 3]),
526 None => uncoloured += 1,
527 }
528 }
529 if untextured > 0 {
530 warnings.push(format!(
531 "{untextured} textured triangles have no texture coordinates and take their material's Kd"
532 ));
533 }
534 let colours = if uncoloured == 0 && !corners.is_empty() {
535 Some(MeshColours::from_corners(
536 &parsed.triangles,
537 parsed.positions.len(),
538 corners,
539 ))
540 } else if let Some(vertex) = parsed
541 .vertex_colours
542 .iter()
543 .copied()
544 .collect::<Option<Vec<[f64; 4]>>>()
545 {
546 Some(MeshColours::PerVertex(vertex))
547 } else {
548 if !corners.is_empty() {
549 warnings.push(format!(
550 "{uncoloured} triangles have no colour, so the colours of the rest are not returned"
551 ));
552 }
553 None
554 };
555 Ok(ObjImport {
556 mesh,
557 colours,
558 warnings,
559 })
560}
561
562pub fn read_obj_file(path: impl AsRef<std::path::Path>) -> OgeomResult<ObjImport> {
576 let path = path.as_ref();
577 let bytes = match std::fs::read(path) {
578 Ok(b) => b,
579 Err(e) => ogeom_bail!(Construction, "{} could not be read: {e}", path.display()),
580 };
581 let directory = path
582 .parent()
583 .map(std::path::Path::to_path_buf)
584 .unwrap_or_default();
585 let mut open = |name: &str| -> Option<Vec<u8>> {
586 let name = name.replace('\\', "/");
587 let parts: Vec<&str> = name
588 .split('/')
589 .filter(|p| !p.is_empty() && *p != ".")
590 .collect();
591 let contained =
592 !name.starts_with('/') && !parts.iter().any(|p| *p == ".." || p.contains(':'));
593 if contained {
594 let mut nested = directory.clone();
595 nested.extend(&parts);
596 if let Ok(bytes) = std::fs::read(&nested) {
597 return Some(bytes);
598 }
599 }
600 std::fs::read(directory.join(parts.last()?)).ok()
601 };
602 read_obj_with_materials(&String::from_utf8_lossy(&bytes), &mut open)
603}
604
605#[cfg(test)]
606mod tests {
607 #![allow(clippy::unwrap_used, reason = "test code")]
608 use super::*;
609 use ogeom_core::FastMap;
610
611 fn textured_cube(material: &str) -> String {
614 let mut text = format!("mtllib cube.mtl\nusemtl {material}\n");
615 for z in [0, 1] {
616 for y in [0, 1] {
617 for x in [0, 1] {
618 text.push_str(&format!("v {x} {y} {z}\n"));
619 }
620 }
621 }
622 for i in 0..6 {
624 let (u, v) = (
625 (f64::from(i % 3) + 0.5) / 3.0,
626 (f64::from(i / 3) + 0.5) / 2.0,
627 );
628 text.push_str(&format!("vt {u} {v}\n"));
629 }
630 let faces = [
632 [1, 3, 4, 2],
633 [5, 6, 8, 7],
634 [1, 2, 6, 5],
635 [3, 7, 8, 4],
636 [1, 5, 7, 3],
637 [2, 4, 8, 6],
638 ];
639 for (k, f) in faces.iter().enumerate() {
640 let t = k + 1;
641 text.push_str(&format!(
642 "f {}/{t} {}/{t} {}/{t} {}/{t}\n",
643 f[0], f[1], f[2], f[3]
644 ));
645 }
646 text
647 }
648
649 const CELLS: [[u8; 4]; 6] = [
650 [255, 0, 0, 255],
651 [0, 255, 0, 255],
652 [0, 0, 255, 255],
653 [255, 255, 0, 255],
654 [0, 255, 255, 255],
655 [255, 0, 255, 255],
656 ];
657
658 fn texture() -> Vec<u8> {
661 let mut texels = Vec::new();
662 texels.extend_from_slice(&CELLS[3..6]);
663 texels.extend_from_slice(&CELLS[0..3]);
664 crate::image::encode_png(3, 2, &texels)
665 }
666
667 fn files() -> FastMap<String, Vec<u8>> {
668 let mut files = FastMap::default();
669 files.insert(
670 "cube.mtl".to_owned(),
671 b"newmtl painted\nKd 0.8 0.8 0.8\nmap_Kd -s 1 1 1 textures/cube.png\n\
672newmtl plain\nKd 0.25 0.5 1\nd 0.5\n"
673 .to_vec(),
674 );
675 files.insert("textures/cube.png".to_owned(), texture());
676 files
677 }
678
679 #[test]
680 fn a_textured_cube_reads_its_face_colours() {
681 let files = files();
682 let import =
683 read_obj_with_materials(&textured_cube("painted"), &mut |n| files.get(n).cloned())
684 .unwrap();
685 assert!(import.warnings.is_empty(), "{:?}", import.warnings);
686 assert_eq!(import.mesh.triangles.len(), 12);
687 let Some(MeshColours::PerTriangle(colours)) = import.colours else {
688 panic!("a colour per triangle: {:?}", import.colours);
689 };
690 for (t, colour) in colours.iter().enumerate() {
691 assert_eq!(
692 *colour,
693 CELLS[t / 2].map(|c| f64::from(c) / 255.0),
694 "triangle {t}"
695 );
696 }
697 }
698
699 #[test]
700 fn kd_colours_faces_without_a_texture() {
701 let files = files();
702 let import =
703 read_obj_with_materials(&textured_cube("plain"), &mut |n| files.get(n).cloned())
704 .unwrap();
705 assert_eq!(
706 import.colours,
707 Some(MeshColours::PerVertex(vec![[0.25, 0.5, 1.0, 0.5]; 8]))
708 );
709 let mut missing = files.clone();
711 missing.remove("textures/cube.png");
712 let import =
713 read_obj_with_materials(&textured_cube("painted"), &mut |n| missing.get(n).cloned())
714 .unwrap();
715 assert_eq!(
716 import.colours,
717 Some(MeshColours::PerVertex(vec![[0.8, 0.8, 0.8, 1.0]; 8]))
718 );
719 assert!(
720 import
721 .warnings
722 .iter()
723 .any(|w| w.contains("could not be opened"))
724 );
725 let import = read_obj_with_materials(&textured_cube("painted"), &mut |_| None).unwrap();
727 assert!(import.colours.is_none());
728 assert_eq!(import.mesh, read_obj(&textured_cube("painted")).unwrap());
729 assert!(!import.warnings.is_empty());
730 }
731
732 #[test]
733 fn vertex_colours_read_where_no_material_colours() {
734 let text = "v 0 0 0 1 0 0\nv 1 0 0 0 1 0\nv 0 1 0 0 0 1\nf 1 2 3\n";
735 let import = read_obj_with_materials(text, &mut |_| None).unwrap();
736 assert_eq!(
737 import.colours,
738 Some(MeshColours::PerVertex(vec![
739 [1.0, 0.0, 0.0, 1.0],
740 [0.0, 1.0, 0.0, 1.0],
741 [0.0, 0.0, 1.0, 1.0]
742 ]))
743 );
744 }
745
746 #[test]
747 fn hostile_materials_and_textures_never_panic() {
748 let text = textured_cube("painted");
749 let png = texture();
750 for cut in 0..png.len() {
751 let mut files = files();
752 files.insert("textures/cube.png".to_owned(), png[..cut].to_vec());
753 let import = read_obj_with_materials(&text, &mut |n| files.get(n).cloned()).unwrap();
754 assert!(!import.warnings.is_empty(), "cut at {cut}");
755 }
756 let mut files = files();
757 files.insert(
758 "cube.mtl".to_owned(),
759 b"Kd 1 1 1\nnewmtl painted\nKd spectral x.rfl\nmap_Kd -o\nmap_Kd -clamp\nd\nTr x\n"
760 .to_vec(),
761 );
762 let _ = read_obj_with_materials(&text, &mut |n| files.get(n).cloned()).unwrap();
763 let gap = text.replace("vt 0.5 0.75", "vt nan 0.75");
764 let fresh = super::tests::files();
765 let import = read_obj_with_materials(&gap, &mut |n| fresh.get(n).cloned()).unwrap();
766 assert!(
767 import
768 .warnings
769 .iter()
770 .any(|w| w.contains("no texture coordinates"))
771 );
772 assert!(read_obj_with_materials("v 0 0 0\nf 1 2 3\n", &mut |_| None).is_err());
773 }
774}