Skip to main content

ogeom_io/
obj.rs

1//! Wavefront OBJ, read with its materials.
2//!
3//! The geometry is the file's `v`, `vn` and `f` records. The colour is in
4//! other files: the material libraries `mtllib` names, whose `Kd` is a
5//! material's diffuse colour and whose `map_Kd` names a texture image the
6//! faces' `vt` coordinates sample. [`read_obj_with_materials`] is handed a
7//! way to open those files by the names the OBJ gives them;
8//! [`read_obj_file`] opens them beside the OBJ on disk.
9
10use 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/// What [`read_obj_with_materials`] found.
18#[derive(Debug, Clone)]
19pub struct ObjImport {
20    /// The mesh, as [`read_obj`] reads it.
21    pub mesh: Triangulation,
22    /// The colours, where every triangle has one: from its material's
23    /// texture at each corner's texture coordinate, or else from its
24    /// material's `Kd`; or, where no material colours every triangle, the
25    /// per-vertex colours some writers append to `v` records.
26    pub colours: Option<MeshColours>,
27    /// What was read with a caveat or passed over: a library or texture
28    /// that could not be opened or decoded, a material the libraries do
29    /// not define, triangles left without a colour.
30    pub warnings: Vec<String>,
31}
32
33/// One face corner's indices: position, texture coordinate, normal.
34type Corner = (usize, Option<usize>, Option<usize>);
35
36/// The records of an OBJ, faces fanned into triangles.
37struct Parsed {
38    positions: Vec<Point>,
39    /// The colour a `v x y z r g b` record appends.
40    vertex_colours: Vec<Option<[f64; 4]>>,
41    normals: Vec<Vector>,
42    /// The `vt` records, `None` for one that does not parse.
43    uvs: Vec<Option<(f64, f64)>>,
44    triangles: Vec<[u32; 3]>,
45    /// Each triangle's corners' texture coordinates.
46    corner_uvs: Vec<[Option<usize>; 3]>,
47    /// Each triangle's material, an index into `materials`.
48    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                // `v x y z r g b`: a colour in [0, 1] after the position.
79                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                // `u`, then `v` (zero where only `u` is given). A record
96                // that does not parse is kept as a gap, so the indices of
97                // the rest still line up and the corners naming it read as
98                // having no coordinate.
99                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                // Names separated by spaces; a name ending in `.mtl` closes
119                // one, so a name holding spaces still reads.
120                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        // A vertex whose normal the file did not give takes the average of
180        // the triangles it belongs to, the same answer the writer would
181        // have had.
182        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
215/// Read an OBJ into a triangulation.
216///
217/// Vertices, faces and vertex normals; everything else (materials, groups,
218/// texture coordinates, smoothing) is a statement about *rendering* a mesh
219/// rather than about the mesh, and is skipped rather than half-honoured;
220/// [`read_obj_with_materials`] reads the colours. A face of more than
221/// three vertices is fanned from its first, which is what a convex polygon
222/// means and what OBJ writers emit.
223///
224/// Indices may be negative, which in OBJ counts back from the end, and may
225/// carry the `v/vt/vn` triple, of which the first field is the position.
226///
227/// # Errors
228///
229/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if an
230/// index names a vertex that is not there, or a coordinate does not parse.
231pub fn read_obj(text: &str) -> OgeomResult<Triangulation> {
232    Ok(parse(text)?.mesh())
233}
234
235/// Three floats off an iterator.
236fn 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
253/// One OBJ face corner: `v`, `v/vt`, `v//vn` or `v/vt/vn`, one-based, and
254/// negative counting back from the end.
255fn 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/// A material as a library defines it.
280#[derive(Default)]
281struct Material {
282    diffuse: Option<[f64; 3]>,
283    opacity: f64,
284    texture: Option<Texture>,
285}
286
287/// A `map_Kd`: the image, and the options that place it.
288struct Texture {
289    file: String,
290    offset: (f64, f64),
291    scale: (f64, f64),
292    clamp: bool,
293    image: Option<Image>,
294}
295
296/// The file a `map_Kd` names after its options, and the options it honours:
297/// `-o` and `-s` (offset and scale of the coordinates) and `-clamp`.
298fn 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        // How many arguments the option takes: the three-number options
311        // take up to three, as many as are numbers.
312        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
352/// The materials a library defines, by name.
353fn 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
404/// Read an OBJ with the colours its materials give it.
405///
406/// The mesh is the one [`read_obj`] reads. Each library `mtllib` names is
407/// asked of `open` by the name the OBJ gives it, and each texture a
408/// library's `map_Kd` names by the name the library gives it; `open`
409/// answers with the file's bytes, or `None` where there is no such file.
410///
411/// A triangle takes the colour of the material `usemtl` last named before
412/// it: with a texture and texture coordinates at its corners, the texel at
413/// each corner's coordinate (after the `-o` offset and `-s` scale the
414/// statement gives; repeating outside `[0, 1]`, or clamped under
415/// `-clamp on`); otherwise the material's `Kd`. A texture stands for the
416/// diffuse colour rather than scaling `Kd`, as scan textures are written
417/// to be shown. Alpha is the material's `d` (or one less its `Tr`), times
418/// the texel's. Textures are PNG or JPEG.
419///
420/// What cannot be opened or decoded (a library, a texture, a material the
421/// libraries do not define) is a warning, and the triangles it would have
422/// coloured fall back as above or go without. Colours are returned only
423/// where every triangle has one; see [`ObjImport::colours`].
424///
425/// # Errors
426///
427/// As [`read_obj`].
428pub 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
562/// Read an OBJ file from disk with its materials, as
563/// [`read_obj_with_materials`] reads them.
564///
565/// A library or texture is looked for at the relative path its statement
566/// gives, under the OBJ's own directory; a path that is absolute, or that
567/// climbs out with `..`, or that is not found there, is looked for by its
568/// last component in that directory, and nowhere else. Backslashes
569/// separate components as slashes do.
570///
571/// # Errors
572///
573/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
574/// `path` cannot be read, and as [`read_obj_with_materials`].
575pub 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    /// A unit cube, each face its own quad with its own texture
612    /// coordinates, laid out as six cells of a 3 × 2 texture.
613    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        // Cell (i, j) of the 3 × 2 grid, sampled at its centre.
623        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        // Six faces, outward, one-based; each corner the face's cell.
631        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    /// The 3 × 2 texture: row 0 is the top, so cells 3..6 (v in the upper
659    /// half) are the first row.
660    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        // The texture missing, the painted material falls back to its Kd.
710        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        // A library that is not there leaves the mesh uncoloured, and says so.
726        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}