git.lucas.co / cce-mesh-io
mesh files in: STL, OBJ, glTF and PLY
git clone https://git.lucas.co/cce-mesh-io.git

commit02e0b6fbcc30e92566522bb55c8e2ce18aed65ec
parenta95346cc26
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 13:19
Materials, textures, texture coordinates and normals (milestone 5)

The palette becomes materials: a Material per triangle (tri_material ->
materials) with a linear colour, an optional texture (an index into the new
Scene::textures), metallic and roughness, glTF's model. Textures are
decoded once to RGBA8 (Texture::decode: PNG and JPEG through the image
crate) and kept sRGB-encoded, top row first; Texture::sample gives the
nearest texel, repeating.

Corners gain texture coordinates (image convention, (0, 0) top-left) and
the file's own normals, beside their colours, and weld / append /
z_up_to_y_up keep all three in step; append drops file normals one side
lacks.

- glTF: TEXCOORD_n of the set the base-colour texture names; NORMAL through
  the node's inverse-transpose; base-colour images from a buffer view, a
  data URI or a file, decoded the first time a material uses them;
  metallic and roughness factors. COLOR_0 corner colours are stored already
  multiplied by the factor, which corner_color has always returned.
- OBJ: vt (flipped in v), vn (kept when every corner has one), and the MTL's
  map_Kd (options before the file name skipped, backslashes turned),
  Ns as a roughness, Pm / Pr.

Breaking for callers: colors / tri_color become materials / tri_material,
Scene gains textures. cce-model, the one caller, moves with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md   |  26 +++++--
 Cargo.toml  |   3 +
 src/gltf.rs | 204 +++++++++++++++++++++++++++++++++++++++++++------
 src/lib.rs  |  39 +++++++++-
 src/mesh.rs | 154 +++++++++++++++++++++++++++++--------
 src/obj.rs  | 249 ++++++++++++++++++++++++++++++++++++++++++++++--------------
 src/ply.rs  |  14 +++-
 src/stl.rs  |  10 ++-
 8 files changed, 572 insertions(+), 127 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 34afed3..f42a08a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,18 +20,30 @@ particular to this crate.
   export is not Z-up — a slicer will lay those models on their side).
 - `unit` is what the format conventionally means (STL mm, glTF metres, OBJ
   and PLY unspecified); a caller showing a size says which.
-- Colours are linear RGB. A palette colour per triangle (`tri_color` →
-  `colors`: MTL `Kd`, glTF base-colour factor) and, when the file colours
-  its points, `corner_colors` (three per triangle) which win. They ride on
-  corners so welding never has to pick between two colours at one point.
-  8-bit PLY colours are sRGB and decoded; float colours are taken as linear.
+- **Materials, not a palette** (since milestone 5): each triangle names a
+  `Material` (`tri_material` → `materials`): linear colour, an optional
+  texture (an index into `Scene::textures`), metallic and roughness — glTF's
+  model; an MTL's `Kd` / `map_Kd` / `Ns` (as roughness) / `Pm` / `Pr` map
+  onto it. `Texture`s are decoded RGBA8, sRGB-encoded, top row first
+  (`Texture::decode`, PNG or JPEG via the `image` crate; `sample` for the
+  nearest texel, repeating).
+- **Everything per corner rides on corners** (three per triangle, in
+  triangle order), so welding never chooses between two values at a point:
+  `corner_colors` (PLY, glTF `COLOR_0`, already multiplied by the material
+  colour; `Mesh::corner_color` prefers them), `corner_uvs` (image convention,
+  (0, 0) top-left; an OBJ `vt` is flipped in v; they may exceed 0..1 — textures
+  repeat), and `file_normals` (glTF `NORMAL` through the node's
+  inverse-transpose, OBJ `vn` only when every corner has one). `weld`,
+  `append` and `z_up_to_y_up` keep all three in step; `append` drops file
+  normals one side lacks.
 - Errors are `String`s meant for a person: they name the line, the element
   or the buffer that was wrong.
 
 ## Not yet
 
-glTF base-colour textures (milestone 5: the viewer has no textured
-pipeline), PLY point clouds without faces (an error that says so), 3MF.
+Metallic-roughness, normal and occlusion TEXTURES (the factors are read),
+KHR_texture_transform and other extensions, PLY texture coordinates, PLY
+point clouds without faces (an error that says so), 3MF.
 
 ## Tests
 
diff --git a/Cargo.toml b/Cargo.toml
index 5c21b2b..a7574e1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,3 +15,6 @@ log = "0.4"
 # textures nothing here draws yet. Buffers are loaded by hand (`gltf.rs`).
 gltf = { version = "1.4", default-features = false, features = ["utils", "names"] }
 base64 = "0.22"
+# Texture images: what glTF and MTL files name (PNG and JPEG are the two the
+# glTF spec allows).
+image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
diff --git a/src/gltf.rs b/src/gltf.rs
index b31dbb7..354d81e 100644
--- a/src/gltf.rs
+++ b/src/gltf.rs
@@ -4,26 +4,31 @@
 //! The scene's node tree is walked and every mesh placed where its node's
 //! transforms put it, one [`Part`] per node with a mesh, named by the node
 //! (else the mesh). Triangle lists, strips and fans are read; points and
-//! lines are skipped. Each primitive's material contributes its base colour
-//! factor, and a `COLOR_0` attribute (multiplied by that factor, as the spec
-//! does) becomes corner colours. Base-colour TEXTURES are not read yet — a
-//! textured model draws in its factor alone, often plain white — which is
-//! milestone 5's work. A node whose transform mirrors (negative determinant)
-//! has its winding reversed, so its faces still point out. glTF is Y-up by
-//! its spec, in metres.
+//! lines are skipped. Each primitive's material becomes a [`Material`]: its
+//! base colour factor, metallic and roughness factors, and its base-colour
+//! texture (decoded once per image, the first time a material uses it; the
+//! texture-coordinate set it names supplies the corners' uvs). A `COLOR_0`
+//! attribute becomes corner colours; the spec multiplies them by the factor,
+//! and the material carries the factor, so they are stored as they are.
+//! NORMAL is kept, carried through the node's inverse-transpose; a part where
+//! any primitive lacks it has no file normals. Metallic-roughness and normal
+//! TEXTURES are not read. A node whose transform mirrors (negative
+//! determinant) has its winding reversed, so its faces still point out. glTF
+//! is Y-up by its spec, in metres.
 //!
-//! Buffers are loaded here rather than by the crate's `import` feature,
-//! which would also decode every texture image.
+//! Buffers and images are loaded here rather than by the crate's `import`
+//! feature, which would decode every image whether a material uses it or
+//! not, and drags in every image format.
 
 use std::path::Path;
 
 use base64::Engine as _;
-use glam::{Mat4, Vec3};
+use glam::{Mat3, Mat4, Vec3};
 use gltf::buffer::Source;
 use gltf::mesh::Mode;
 
-use crate::mesh::Mesh;
-use crate::{Part, Scene, Unit, UpAxis};
+use crate::mesh::{Material, Mesh};
+use crate::{Part, Scene, Texture, Unit, UpAxis};
 
 /// Deeper than any real node tree; a guard against a cycle the parser let by.
 const MAX_DEPTH: usize = 128;
@@ -33,7 +38,10 @@ pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
     let buffers = load_buffers(&file, dir)?;
     let mut parts = Vec::new();
     let mut skipped_modes = 0usize;
-    let mut textured = false;
+    let mut textures: Vec<Texture> = Vec::new();
+    // glTF image index -> index into `textures`, or None for one that would
+    // not load (said once, then drawn without).
+    let mut image_slot: std::collections::HashMap<usize, Option<usize>> = std::collections::HashMap::new();
 
     let roots: Vec<gltf::Node> = match file.default_scene().or_else(|| file.scenes().next()) {
         Some(scene) => scene.nodes().collect(),
@@ -58,9 +66,14 @@ pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
         }
         let Some(gmesh) = node.mesh() else { continue };
         let mirrored = world.determinant() < 0.0;
+        let normal_matrix = Mat3::from_mat4(world).inverse().transpose();
         let mut mesh = Mesh::default();
         let mut corner_colors: Vec<[f32; 3]> = Vec::new();
         let mut any_colors = false;
+        let mut corner_uvs: Vec<[f32; 2]> = Vec::new();
+        let mut any_uvs = false;
+        let mut corner_normals: Vec<Vec3> = Vec::new();
+        let mut all_normals = true;
         for prim in gmesh.primitives() {
             let reader = prim.reader(|b| buffers.get(b.index()).map(Vec::as_slice));
             let Some(positions) = reader.read_positions() else { continue };
@@ -81,29 +94,80 @@ pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
             };
             let material = prim.material();
             let pbr = material.pbr_metallic_roughness();
-            textured |= pbr.base_color_texture().is_some();
             let [r, g, b, _] = pbr.base_color_factor();
-            let slot = mesh.colors.len() as u32;
-            mesh.colors.push([r, g, b]);
+            let base_texture = pbr.base_color_texture();
+            let texture = base_texture.as_ref().and_then(|info| {
+                let image = info.texture().source();
+                *image_slot.entry(image.index()).or_insert_with(|| match load_image(&image, &buffers, dir) {
+                    Ok(t) => {
+                        textures.push(t);
+                        Some(textures.len() - 1)
+                    }
+                    Err(e) => {
+                        log::warn!("[mesh-io] glTF image {}: {e}", image.index());
+                        None
+                    }
+                })
+            });
+            let slot = mesh.materials.len() as u32;
+            mesh.materials.push(Material {
+                color: [r, g, b],
+                texture,
+                metallic: pbr.metallic_factor(),
+                roughness: pbr.roughness_factor(),
+            });
             let point_colors: Option<Vec<[f32; 3]>> = reader.read_colors(0).map(|c| c.into_rgb_f32().collect());
+            let set = base_texture.as_ref().map_or(0, |info| info.tex_coord());
+            let point_uvs: Option<Vec<[f32; 2]>> = reader.read_tex_coords(set).map(|t| t.into_f32().collect());
+            let point_normals: Option<Vec<Vec3>> = reader
+                .read_normals()
+                .map(|n| n.map(|n| (normal_matrix * Vec3::from(n)).normalize_or_zero()).collect());
+            all_normals &= point_normals.is_some();
             for t in tris {
                 let t = if mirrored { [t[0], t[2], t[1]] } else { t };
                 for k in t {
+                    let k = k as usize;
                     corner_colors.push(match &point_colors {
                         Some(pc) => {
                             any_colors = true;
-                            let c = pc.get(k as usize).copied().unwrap_or([1.0; 3]);
-                            [c[0] * r, c[1] * g, c[2] * b]
+                            pc.get(k).copied().unwrap_or([1.0; 3])
                         }
-                        None => [r, g, b],
+                        None => [1.0; 3],
                     });
+                    corner_uvs.push(match &point_uvs {
+                        Some(pu) => {
+                            any_uvs = true;
+                            pu.get(k).copied().unwrap_or([0.0; 2])
+                        }
+                        None => [0.0; 2],
+                    });
+                    if let Some(pn) = &point_normals {
+                        corner_normals.push(pn.get(k).copied().unwrap_or(Vec3::Y));
+                    }
                 }
                 mesh.triangles.push(t.map(|i| i + base));
-                mesh.tri_color.push(slot);
+                mesh.tri_material.push(slot);
             }
         }
         if any_colors {
-            mesh.corner_colors = Some(corner_colors);
+            // Corner colours replace the material's colour where present
+            // (`Mesh::corner_color`), so a material with a factor carries
+            // it into them: the spec multiplies the two.
+            let tinted = corner_colors
+                .iter()
+                .enumerate()
+                .map(|(i, c)| {
+                    let f = mesh.materials[mesh.tri_material[i / 3] as usize].color;
+                    [c[0] * f[0], c[1] * f[1], c[2] * f[2]]
+                })
+                .collect();
+            mesh.corner_colors = Some(tinted);
+        }
+        if any_uvs {
+            mesh.corner_uvs = Some(corner_uvs);
+        }
+        if all_normals && !mesh.triangles.is_empty() {
+            mesh.file_normals = Some(corner_normals);
         }
         let name = node.name().or(gmesh.name()).map(str::to_string).unwrap_or_else(|| format!("node {}", node.index()));
         parts.push(Part { name, mesh });
@@ -112,10 +176,7 @@ pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
     if skipped_modes > 0 {
         log::info!("[mesh-io] glTF: skipped {skipped_modes} primitives of points or lines");
     }
-    if textured {
-        log::info!("[mesh-io] glTF: base-colour textures are not drawn yet; textured materials show their factor");
-    }
-    Ok(Scene { parts, up: UpAxis::Y, unit: Unit::Metre })
+    Ok(Scene { parts, up: UpAxis::Y, unit: Unit::Metre, textures })
 }
 
 /// A primitive's index list as triangles, or `None` for points and lines.
@@ -155,6 +216,30 @@ fn load_buffers(file: &gltf::Gltf, dir: Option<&Path>) -> Result<Vec<Vec<u8>>, S
         .collect()
 }
 
+/// Decode an image a texture names: from a buffer view (a GLB's usual way),
+/// a data URI, or a file beside the glTF.
+fn load_image(image: &gltf::Image, buffers: &[Vec<u8>], dir: Option<&Path>) -> Result<Texture, String> {
+    match image.source() {
+        gltf::image::Source::View { view, .. } => {
+            let buffer = buffers.get(view.buffer().index()).ok_or("an image in a buffer that is not there")?;
+            let bytes = buffer
+                .get(view.offset()..view.offset() + view.length())
+                .ok_or("an image past the end of its buffer")?;
+            Texture::decode(bytes)
+        }
+        gltf::image::Source::Uri { uri, .. } if uri.starts_with("data:") => {
+            let (_, payload) = uri.split_once(";base64,").ok_or("an image data URI that is not base64")?;
+            let bytes = base64::engine::general_purpose::STANDARD.decode(payload).map_err(|e| format!("bad base64: {e}"))?;
+            Texture::decode(&bytes)
+        }
+        gltf::image::Source::Uri { uri, .. } => {
+            let path = dir.unwrap_or(Path::new(".")).join(percent_decode(uri));
+            let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
+            Texture::decode(&bytes)
+        }
+    }
+}
+
 /// `my%20model.bin` → `my model.bin`: URIs in a glTF are percent-encoded.
 fn percent_decode(s: &str) -> String {
     let b = s.as_bytes();
@@ -219,6 +304,7 @@ mod tests {
         assert_eq!(part.mesh.positions[1], Vec3::new(12.0, 0.0, 0.0));
         assert_eq!(part.mesh.triangles.len(), 2, "a strip of four points is two triangles");
         assert_eq!(part.mesh.corner_color(0, 0), [0.8, 0.2, 0.1]);
+        assert_eq!(part.mesh.material(0).roughness, 1.0, "the spec's default factor");
     }
 
     #[test]
@@ -272,6 +358,74 @@ mod tests {
         assert!(e.contains("nowhere.bin"), "{e}");
     }
 
+    #[test]
+    fn a_textured_glb_brings_uvs_normals_and_its_image() {
+        // One triangle with NORMAL and TEXCOORD_0, a 2x2 PNG in a buffer
+        // view, and a node turned a quarter about Y (normals must turn too).
+        let mut png = Vec::new();
+        image::RgbaImage::from_fn(2, 2, |x, _| image::Rgba([if x == 0 { 255 } else { 0 }, 0, 0, 255]))
+            .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
+            .unwrap();
+        let mut bin = Vec::new();
+        for f in [0f32, 0., 0., 1., 0., 0., 0., 1., 0.] {
+            bin.extend(f.to_le_bytes()); // POSITION, 36 bytes
+        }
+        for _ in 0..3 {
+            for f in [0f32, 0., 1.] {
+                bin.extend(f.to_le_bytes()); // NORMAL +Z, 36 bytes
+            }
+        }
+        for f in [0f32, 0., 1., 0., 0., 1.] {
+            bin.extend(f.to_le_bytes()); // TEXCOORD_0, 24 bytes
+        }
+        let png_at = bin.len();
+        bin.extend(&png);
+        while bin.len() % 4 != 0 {
+            bin.push(0);
+        }
+        let json = format!(
+            r#"{{"asset":{{"version":"2.0"}},"scene":0,"scenes":[{{"nodes":[0]}}],
+            "nodes":[{{"mesh":0,"rotation":[0,0.7071068,0,0.7071068]}}],
+            "meshes":[{{"primitives":[{{"attributes":{{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2}},"material":0}}]}}],
+            "materials":[{{"pbrMetallicRoughness":{{"baseColorTexture":{{"index":0}},"metallicFactor":0.25,"roughnessFactor":0.5}}}}],
+            "textures":[{{"source":0}}],"images":[{{"bufferView":3,"mimeType":"image/png"}}],
+            "accessors":[{{"bufferView":0,"componentType":5126,"count":3,"type":"VEC3","min":[0,0,0],"max":[1,1,0]}},
+                         {{"bufferView":1,"componentType":5126,"count":3,"type":"VEC3"}},
+                         {{"bufferView":2,"componentType":5126,"count":3,"type":"VEC2"}}],
+            "bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":36}},{{"buffer":0,"byteOffset":36,"byteLength":36}},
+                           {{"buffer":0,"byteOffset":72,"byteLength":24}},{{"buffer":0,"byteOffset":{png_at},"byteLength":{}}}],
+            "buffers":[{{"byteLength":{}}}]}}"#,
+            png.len(),
+            bin.len()
+        );
+        let mut json = json.into_bytes();
+        while json.len() % 4 != 0 {
+            json.push(b' ');
+        }
+        let mut glb = Vec::new();
+        glb.extend(b"glTF");
+        glb.extend(2u32.to_le_bytes());
+        glb.extend(((12 + 8 + json.len() + 8 + bin.len()) as u32).to_le_bytes());
+        glb.extend((json.len() as u32).to_le_bytes());
+        glb.extend(b"JSON");
+        glb.extend(&json);
+        glb.extend((bin.len() as u32).to_le_bytes());
+        glb.extend(b"BIN\0");
+        glb.extend(&bin);
+
+        let s = read(&glb, None).unwrap();
+        assert_eq!(s.textures.len(), 1);
+        assert_eq!(s.textures[0].sample([0.1, 0.5])[0], 255, "the left column is red");
+        assert_eq!(s.textures[0].sample([0.9, 0.5])[0], 0);
+        let m = &s.parts[0].mesh;
+        let mat = m.material(0);
+        assert_eq!((mat.texture, mat.metallic, mat.roughness), (Some(0), 0.25, 0.5));
+        assert_eq!(mat.color, [1.0; 3], "the factor defaults to white");
+        assert_eq!(m.corner_uvs.as_ref().unwrap()[1], [1.0, 0.0]);
+        let n = m.file_normals.as_ref().unwrap()[0];
+        assert!((n - Vec3::X).length() < 1e-5, "+Z turned a quarter about Y is +X, got {n}");
+    }
+
     #[test]
     fn percent_escapes_are_decoded() {
         assert_eq!(percent_decode("my%20model.bin"), "my model.bin");
diff --git a/src/lib.rs b/src/lib.rs
index a1eff65..1c9eb55 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,7 +20,7 @@ mod stl;
 
 use std::path::Path;
 
-pub use mesh::{srgb_to_linear, Mesh, CLAY};
+pub use mesh::{srgb_to_linear, Material, Mesh, CLAY};
 
 /// File extensions [`load`] reads, lowercase.
 pub const EXTENSIONS: &[&str] = &["stl", "obj", "gltf", "glb", "ply"];
@@ -58,6 +58,39 @@ impl Unit {
     }
 }
 
+/// An image a material names: decoded, 8-bit RGBA, rows top to bottom,
+/// sRGB-encoded as the file had it (a renderer that samples it through an
+/// sRGB format gets linear values).
+#[derive(Clone)]
+pub struct Texture {
+    pub width: u32,
+    pub height: u32,
+    pub rgba: Vec<u8>,
+}
+
+impl std::fmt::Debug for Texture {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "Texture({}x{})", self.width, self.height)
+    }
+}
+
+impl Texture {
+    /// Decode a PNG or JPEG.
+    pub fn decode(bytes: &[u8]) -> Result<Texture, String> {
+        let image = image::load_from_memory(bytes).map_err(|e| format!("an image that will not decode: {e}"))?;
+        let rgba = image.to_rgba8();
+        Ok(Texture { width: rgba.width(), height: rgba.height(), rgba: rgba.into_raw() })
+    }
+
+    /// The texel nearest `uv` (image convention, repeating), sRGB-encoded.
+    pub fn sample(&self, uv: [f32; 2]) -> [u8; 4] {
+        let wrap = |v: f32, n: u32| ((v - v.floor()) * n as f32).floor().clamp(0.0, n as f32 - 1.0) as usize;
+        let (x, y) = (wrap(uv[0], self.width), wrap(uv[1], self.height));
+        let i = (y * self.width as usize + x) * 4;
+        [self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
+    }
+}
+
 /// One named piece of a scene: a glTF node's mesh, a whole STL.
 #[derive(Debug, Clone, Default)]
 pub struct Part {
@@ -66,12 +99,14 @@ pub struct Part {
 }
 
 /// What a file holds: its parts, already placed where the file's node tree
-/// puts them, its up axis and its unit.
+/// puts them, its up axis and its unit, and the images its materials name.
 #[derive(Debug, Clone)]
 pub struct Scene {
     pub parts: Vec<Part>,
     pub up: UpAxis,
     pub unit: Unit,
+    /// Indexed by [`Material::texture`].
+    pub textures: Vec<Texture>,
 }
 
 impl Scene {
diff --git a/src/mesh.rs b/src/mesh.rs
index dfc8fec..316cea1 100644
--- a/src/mesh.rs
+++ b/src/mesh.rs
@@ -6,8 +6,9 @@
 //! An STL has no shared points: each triangle carries its own corners. A
 //! smooth normal is the average of the faces meeting at a point, which needs
 //! those faces to name the same point, so [`crate::load`] welds every part by
-//! position. Colours ride on CORNERS, not points, so welding never has to
-//! choose between two colours meeting at one place.
+//! position. Colours, texture coordinates and the file's own normals ride on
+//! CORNERS, not points, so welding never has to choose between two of them
+//! meeting at one place (a texture seam, a hard edge).
 
 use std::collections::HashMap;
 
@@ -17,19 +18,52 @@ use glam::Vec3;
 /// material), linear RGB: a warm clay.
 pub const CLAY: [f32; 3] = [0.42, 0.40, 0.36];
 
+/// How a surface looks: glTF's metallic-roughness model, which the other
+/// formats map onto (an MTL's `Kd` is the colour, its `Ns` a roughness).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Material {
+    /// Linear RGB, multiplying the texture when there is one.
+    pub color: [f32; 3],
+    /// An index into the scene's `textures`.
+    pub texture: Option<usize>,
+    /// 0 a dielectric, 1 a metal.
+    pub metallic: f32,
+    /// 0 a mirror, 1 fully rough.
+    pub roughness: f32,
+}
+
+impl Default for Material {
+    fn default() -> Self {
+        Self { color: CLAY, texture: None, metallic: 0.0, roughness: 0.8 }
+    }
+}
+
+impl Material {
+    /// A plain surface of this colour.
+    pub fn colour(color: [f32; 3]) -> Self {
+        Self { color, ..Self::default() }
+    }
+}
+
 #[derive(Debug, Clone, Default)]
 pub struct Mesh {
     pub positions: Vec<Vec3>,
     /// Counter-clockwise seen from outside.
     pub triangles: Vec<[u32; 3]>,
-    /// One per triangle: an index into `colors`.
-    pub tri_color: Vec<u32>,
-    /// The palette `tri_color` indexes, linear RGB: a material's colour each.
-    pub colors: Vec<[f32; 3]>,
+    /// One per triangle: an index into `materials`.
+    pub tri_material: Vec<u32>,
+    pub materials: Vec<Material>,
     /// Three per triangle, in `triangles` order, linear RGB, when the file
-    /// colours its points (PLY, glTF `COLOR_0`). They replace the palette
-    /// colour where present.
+    /// colours its points (PLY, glTF `COLOR_0`). They multiply the
+    /// material's colour (and replace it in [`Mesh::corner_color`]).
     pub corner_colors: Option<Vec<[f32; 3]>>,
+    /// Three per triangle, when the file has texture coordinates. Image
+    /// convention: (0, 0) the top-left texel (an OBJ `vt` is flipped in v
+    /// on the way in). They may run past 0..1: textures repeat.
+    pub corner_uvs: Option<Vec<[f32; 2]>>,
+    /// Three per triangle, unit length, when the file brings its own
+    /// normals; a caller without them derives them ([`Mesh::corner_normals`]).
+    pub file_normals: Option<Vec<Vec3>>,
 }
 
 impl Mesh {
@@ -39,29 +73,53 @@ impl Mesh {
         Some(self.positions.iter().fold((first, first), |(lo, hi), p| (lo.min(*p), hi.max(*p))))
     }
 
-    /// The colour of corner `k` (0..3) of triangle `t`.
+    /// The material of triangle `t`.
+    pub fn material(&self, t: usize) -> Material {
+        self.materials.get(self.tri_material[t] as usize).copied().unwrap_or_default()
+    }
+
+    /// The colour of corner `k` (0..3) of triangle `t`, without its texture:
+    /// the corner's own colour if the file gives one, else its material's.
     pub fn corner_color(&self, t: usize, k: usize) -> [f32; 3] {
         if let Some(c) = &self.corner_colors {
             return c[t * 3 + k];
         }
-        self.colors.get(self.tri_color[t] as usize).copied().unwrap_or(CLAY)
+        self.material(t).color
     }
 
-    /// Add `other`'s triangles to this mesh, its points and palette after
-    /// this one's. Corner colours survive: if either side has them, the
-    /// other's triangles are given their palette colours as corner colours.
+    /// Add `other`'s triangles to this mesh, its points and materials after
+    /// this one's (texture indices are the scene's, so they are kept).
+    /// Corner colours survive: if either side has them, the other's
+    /// triangles are given their material colours as corner colours. Corner
+    /// texture coordinates survive likewise, (0, 0) where a side had none;
+    /// the file's normals survive only if both sides have them.
     pub fn append(&mut self, other: &Mesh) {
         let base = self.positions.len() as u32;
-        let palette = self.colors.len() as u32;
+        let first = self.materials.len() as u32;
         if self.corner_colors.is_some() || other.corner_colors.is_some() {
             let mine = self.expanded_corner_colors();
             let theirs = other.expanded_corner_colors();
             self.corner_colors = Some(mine.into_iter().chain(theirs).collect());
         }
+        if self.corner_uvs.is_some() || other.corner_uvs.is_some() {
+            let uvs = |m: &Mesh| m.corner_uvs.clone().unwrap_or_else(|| vec![[0.0; 2]; m.triangles.len() * 3]);
+            let mut mine = uvs(self);
+            mine.extend(uvs(other));
+            self.corner_uvs = Some(mine);
+        }
+        let empty = self.triangles.is_empty();
+        self.file_normals = match (self.file_normals.take(), &other.file_normals) {
+            (Some(mut mine), Some(theirs)) => {
+                mine.extend_from_slice(theirs);
+                Some(mine)
+            }
+            (None, Some(theirs)) if empty => Some(theirs.clone()),
+            _ => None,
+        };
         self.positions.extend_from_slice(&other.positions);
         self.triangles.extend(other.triangles.iter().map(|t| t.map(|i| i + base)));
-        self.tri_color.extend(other.tri_color.iter().map(|c| c + palette));
-        self.colors.extend_from_slice(&other.colors);
+        self.tri_material.extend(other.tri_material.iter().map(|c| c + first));
+        self.materials.extend_from_slice(&other.materials);
     }
 
     fn expanded_corner_colors(&self) -> Vec<[f32; 3]> {
@@ -75,8 +133,10 @@ impl Mesh {
     /// mirror, so the winding and with it the outward side of every face are
     /// kept.
     pub fn z_up_to_y_up(&mut self) {
-        for p in &mut self.positions {
-            *p = Vec3::new(p.x, p.z, -p.y);
+        let turn = |p: &mut Vec3| *p = Vec3::new(p.x, p.z, -p.y);
+        self.positions.iter_mut().for_each(turn);
+        if let Some(n) = &mut self.file_normals {
+            n.iter_mut().for_each(turn);
         }
     }
 
@@ -120,23 +180,30 @@ impl Mesh {
             .collect();
         drop(index);
         let mut triangles = Vec::with_capacity(self.triangles.len());
-        let mut tri_color = Vec::with_capacity(self.triangles.len());
-        let mut corner_colors = self.corner_colors.as_ref().map(|c| Vec::with_capacity(c.len()));
+        let mut tri_material = Vec::with_capacity(self.triangles.len());
+        let mut kept = Vec::with_capacity(self.triangles.len());
         for (t, tri) in self.triangles.iter().enumerate() {
             let [a, b, c] = tri.map(|i| remap[i as usize]);
             if a != b && b != c && a != c {
                 triangles.push([a, b, c]);
-                tri_color.push(self.tri_color[t]);
-                if let (Some(out), Some(src)) = (&mut corner_colors, &self.corner_colors) {
-                    out.extend_from_slice(&src[t * 3..t * 3 + 3]);
-                }
+                tri_material.push(self.tri_material[t]);
+                kept.push(t);
             }
         }
+        // The corner attributes of the triangles that stayed.
+        fn keep<T: Copy>(src: &Option<Vec<T>>, kept: &[usize]) -> Option<Vec<T>> {
+            src.as_ref().map(|s| kept.iter().flat_map(|&t| [s[t * 3], s[t * 3 + 1], s[t * 3 + 2]]).collect())
+        }
+        let corner_colors = keep(&self.corner_colors, &kept);
+        let corner_uvs = keep(&self.corner_uvs, &kept);
+        let file_normals = keep(&self.file_normals, &kept);
         positions.shrink_to_fit();
         self.positions = positions;
         self.triangles = triangles;
-        self.tri_color = tri_color;
+        self.tri_material = tri_material;
         self.corner_colors = corner_colors;
+        self.corner_uvs = corner_uvs;
+        self.file_normals = file_normals;
     }
 
     /// A normal for each triangle corner (three per triangle, in order):
@@ -225,8 +292,8 @@ pub(crate) mod tests {
             }
         }
         m.triangles = (0..12).map(|t| [t * 3, t * 3 + 1, t * 3 + 2]).collect();
-        m.tri_color = vec![0; 12];
-        m.colors = vec![CLAY];
+        m.tri_material = vec![0; 12];
+        m.materials = vec![Material::default()];
         m
     }
 
@@ -262,7 +329,7 @@ pub(crate) mod tests {
         let lift = 20f32.to_radians().tan();
         m.positions = vec![Vec3::ZERO, Vec3::Z, Vec3::new(-1.0, 0.0, 0.5), Vec3::new(1.0, lift, 0.5)];
         m.triangles = vec![[0, 1, 2], [0, 3, 1]];
-        m.tri_color = vec![0, 0];
+        m.tri_material = vec![0, 0];
         let n = m.corner_normals(40.0);
         assert!(n[0].dot(n[3]) > 0.9999, "the shared point has two normals");
     }
@@ -272,7 +339,7 @@ pub(crate) mod tests {
         let mut m = Mesh::default();
         m.positions = vec![Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::X, Vec3::X * (1.0 + 1e-9), Vec3::Y];
         m.triangles = vec![[0, 1, 2], [3, 4, 5]];
-        m.tri_color = vec![0, 0];
+        m.tri_material = vec![0, 0];
         m.corner_colors = Some(vec![[1.0, 0.0, 0.0]; 3].into_iter().chain(vec![[0.0, 1.0, 0.0]; 3]).collect());
         m.weld();
         assert_eq!(m.triangles, vec![[0, 1, 2]]);
@@ -310,15 +377,40 @@ pub(crate) mod tests {
     fn appending_keeps_colours_on_both_sides() {
         let mut a = soup_cube();
         let mut b = soup_cube();
-        b.colors = vec![[0.0, 0.0, 1.0]];
+        b.materials = vec![Material::colour([0.0, 0.0, 1.0])];
         b.corner_colors = Some(vec![[0.0, 1.0, 0.0]; 36]);
         a.append(&b);
         assert_eq!(a.triangles.len(), 24);
         assert_eq!(a.triangles[12], [36, 37, 38]);
-        assert_eq!(a.corner_color(0, 0), CLAY, "the first mesh's palette colour, as a corner colour");
+        assert_eq!(a.corner_color(0, 0), CLAY, "the first mesh's material colour, as a corner colour");
         assert_eq!(a.corner_color(12, 0), [0.0, 1.0, 0.0]);
     }
 
+    #[test]
+    fn welding_keeps_each_kept_triangles_uvs_and_normals() {
+        let mut m = Mesh::default();
+        m.positions = vec![Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::X, Vec3::X * (1.0 + 1e-9), Vec3::Y, Vec3::Z, Vec3::X, Vec3::Y];
+        m.triangles = vec![[0, 1, 2], [3, 4, 5], [6, 7, 8]];
+        m.tri_material = vec![0; 3];
+        m.corner_uvs = Some((0..9).map(|i| [i as f32, 0.0]).collect());
+        m.file_normals = Some((0..9).map(|i| Vec3::splat(i as f32)).collect());
+        m.weld();
+        assert_eq!(m.triangles.len(), 2, "the middle one collapsed");
+        assert_eq!(m.corner_uvs.unwrap()[3], [6.0, 0.0], "the third triangle's uvs moved up");
+        assert_eq!(m.file_normals.unwrap()[3], Vec3::splat(6.0));
+    }
+
+    #[test]
+    fn appending_keeps_uvs_and_drops_normals_one_side_lacks() {
+        let mut a = soup_cube();
+        a.corner_uvs = Some(vec![[1.0, 1.0]; 36]);
+        a.file_normals = Some(vec![Vec3::Y; 36]);
+        a.append(&soup_cube());
+        assert_eq!(a.corner_uvs.as_ref().unwrap()[36], [0.0, 0.0]);
+        assert_eq!(a.corner_uvs.unwrap().len(), 72);
+        assert!(a.file_normals.is_none());
+    }
+
     #[test]
     fn srgb_round_numbers() {
         assert_eq!(srgb_to_linear(0), 0.0);
diff --git a/src/obj.rs b/src/obj.rs
index ac3585f..8924cd6 100644
--- a/src/obj.rs
+++ b/src/obj.rs
@@ -1,100 +1,198 @@
-//! Wavefront OBJ, with its MTL colours.
+//! Wavefront OBJ, with its MTL materials.
 //!
-//! What is read: `v` points, `f` faces (any polygon, fanned into triangles;
-//! `i`, `i/t`, `i//n` and `i/t/n` corners; negative indices counting back
-//! from the newest point), `usemtl`, and `mtllib` for each material's `Kd`
-//! diffuse colour. Texture coordinates and the file's normals are skipped:
-//! the viewer derives normals itself and draws no textures yet. A missing
-//! MTL file is not an error; its materials fall back to clay. One part for
-//! the whole file, named by its first `o`; the scene is [`UpAxis::Y`].
+//! What is read: `v` points, `vt` texture coordinates, `vn` normals, `f`
+//! faces (any polygon, fanned into triangles; `i`, `i/t`, `i//n` and
+//! `i/t/n` corners; negative indices counting back from the newest), `usemtl`,
+//! and `mtllib` for each material's `Kd` colour, `map_Kd` texture (PNG or
+//! JPEG, relative to the MTL file), `Ns` shininess (as a roughness) and the
+//! PBR extension's `Pm` / `Pr` when present. A `vt` is flipped in v on the
+//! way in: OBJ's origin is the image's bottom-left, the crate's its top-left.
+//! The file's normals are kept only if every corner has one. A missing MTL
+//! file or texture is not an error; the material falls back to clay, or to
+//! its colour. One part for the whole file, named by its first `o`; the
+//! scene is [`UpAxis::Y`].
 
 use std::collections::HashMap;
-use std::path::Path;
+use std::path::{Path, PathBuf};
 
 use glam::Vec3;
 
-use crate::mesh::{Mesh, CLAY};
-use crate::{Part, Scene, Unit, UpAxis};
+use crate::mesh::{Material, Mesh};
+use crate::{Part, Scene, Texture, Unit, UpAxis};
+
+/// A material as an MTL file states it, before its texture is loaded.
+#[derive(Debug, Clone, Default)]
+struct MtlEntry {
+    kd: Option<[f32; 3]>,
+    map_kd: Option<PathBuf>,
+    ns: Option<f32>,
+    pm: Option<f32>,
+    pr: Option<f32>,
+}
 
 pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
     let text = String::from_utf8_lossy(bytes);
-    let mut mesh = Mesh { colors: vec![CLAY], ..Mesh::default() };
-    // Material name → index into `mesh.colors`, filled as `mtllib`s are read.
-    let mut library: HashMap<String, [f32; 3]> = HashMap::new();
+    let mut mesh = Mesh { materials: vec![Material::default()], ..Mesh::default() };
+    let mut textures: Vec<Texture> = Vec::new();
+    let mut texture_slot: HashMap<PathBuf, Option<usize>> = HashMap::new();
+    let mut library: HashMap<String, MtlEntry> = HashMap::new();
     let mut slot: HashMap<String, u32> = HashMap::new();
     let mut current = 0u32;
-    let mut corners: Vec<u32> = Vec::new();
+    let mut uvs: Vec<[f32; 2]> = Vec::new();
+    let mut normals: Vec<Vec3> = Vec::new();
+    // Per corner, as triangles are emitted: its uv and normal, if named.
+    let mut corner_uv: Vec<Option<[f32; 2]>> = Vec::new();
+    let mut corner_n: Vec<Option<Vec3>> = Vec::new();
+    let mut corners: Vec<(u32, Option<[f32; 2]>, Option<Vec3>)> = Vec::new();
     let mut name = String::new();
 
     for (line_no, line) in text.lines().enumerate() {
         let line = line.split('#').next().unwrap_or("");
         let mut words = line.split_whitespace();
         let err = |what: &str| format!("line {}: {what}", line_no + 1);
+        let numbers = |words: &mut std::str::SplitWhitespace, n: usize, what: &str| -> Result<Vec<f32>, String> {
+            let v: Vec<f32> = words.take(n).map(|w| w.parse::<f32>()).collect::<Result<_, _>>().map_err(|_| err(what))?;
+            if v.len() < n {
+                return Err(err(what));
+            }
+            Ok(v)
+        };
         match words.next() {
             Some("v") => {
-                let mut xyz = [0f32; 3];
-                for v in &mut xyz {
-                    *v = words.next().and_then(|w| w.parse().ok()).ok_or_else(|| err("a point needs three numbers"))?;
-                }
-                mesh.positions.push(Vec3::from(xyz));
+                let p = numbers(&mut words, 3, "a point needs three numbers")?;
+                mesh.positions.push(Vec3::new(p[0], p[1], p[2]));
+            }
+            Some("vt") => {
+                let t = numbers(&mut words, 1, "a texture coordinate needs a number")?;
+                let v = words.next().and_then(|w| w.parse::<f32>().ok()).unwrap_or(0.0);
+                uvs.push([t[0], 1.0 - v]);
+            }
+            Some("vn") => {
+                let n = numbers(&mut words, 3, "a normal needs three numbers")?;
+                normals.push(Vec3::new(n[0], n[1], n[2]).normalize_or_zero());
             }
             Some("f") => {
                 corners.clear();
                 for w in words {
-                    let i: i64 = w.split('/').next().and_then(|s| s.parse().ok()).ok_or_else(|| err("a bad face corner"))?;
-                    let n = mesh.positions.len() as i64;
-                    let index = if i > 0 { i - 1 } else { n + i };
-                    if i == 0 || !(0..n).contains(&index) {
-                        return Err(err(&format!("face corner {i} names no point (there are {n} so far)")));
-                    }
-                    corners.push(index as u32);
+                    let mut parts = w.split('/');
+                    let resolve = |s: Option<&str>, n: usize, what: &str| -> Result<Option<usize>, String> {
+                        let Some(s) = s.filter(|s| !s.is_empty()) else { return Ok(None) };
+                        let i: i64 = s.parse().map_err(|_| err("a bad face corner"))?;
+                        let index = if i > 0 { i - 1 } else { n as i64 + i };
+                        if i == 0 || !(0..n as i64).contains(&index) {
+                            return Err(err(&format!("face corner {i} names no {what} (there are {n} so far)")));
+                        }
+                        Ok(Some(index as usize))
+                    };
+                    let p = resolve(parts.next(), mesh.positions.len(), "point")?.ok_or_else(|| err("a bad face corner"))?;
+                    let t = resolve(parts.next(), uvs.len(), "texture coordinate")?;
+                    let n = resolve(parts.next(), normals.len(), "normal")?;
+                    corners.push((p as u32, t.map(|t| uvs[t]), n.map(|n| normals[n])));
                 }
                 if corners.len() < 3 {
                     return Err(err("a face needs three corners"));
                 }
                 for k in 1..corners.len() - 1 {
-                    mesh.triangles.push([corners[0], corners[k], corners[k + 1]]);
-                    mesh.tri_color.push(current);
+                    let tri = [corners[0], corners[k], corners[k + 1]];
+                    mesh.triangles.push(tri.map(|c| c.0));
+                    mesh.tri_material.push(current);
+                    corner_uv.extend(tri.map(|c| c.1));
+                    corner_n.extend(tri.map(|c| c.2));
                 }
             }
             Some("o") if name.is_empty() => name = words.collect::<Vec<_>>().join(" "),
             Some("mtllib") => {
                 // A library name may hold spaces, so take the rest of the line.
-                let name = line.trim_start().strip_prefix("mtllib").unwrap_or("").trim();
+                let lib = line.trim_start().strip_prefix("mtllib").unwrap_or("").trim().replace('\\', "/");
                 if let Some(dir) = dir {
-                    match std::fs::read_to_string(dir.join(name)) {
-                        Ok(mtl) => library.extend(read_mtl(&mtl)),
-                        Err(e) => log::warn!("[model] material library {name}: {e}"),
+                    let path = dir.join(&lib);
+                    match std::fs::read_to_string(&path) {
+                        Ok(mtl) => library.extend(read_mtl(&mtl, path.parent().unwrap_or(dir))),
+                        Err(e) => log::warn!("[mesh-io] material library {lib}: {e}"),
                     }
                 }
             }
             Some("usemtl") => {
-                let name = words.next().unwrap_or("").to_string();
-                current = *slot.entry(name.clone()).or_insert_with(|| {
-                    mesh.colors.push(library.get(&name).copied().unwrap_or(CLAY));
-                    (mesh.colors.len() - 1) as u32
+                let key = words.next().unwrap_or("").to_string();
+                current = *slot.entry(key.clone()).or_insert_with(|| {
+                    let entry = library.get(&key).cloned().unwrap_or_default();
+                    let texture = entry.map_kd.as_ref().and_then(|path| {
+                        *texture_slot.entry(path.clone()).or_insert_with(|| {
+                            match std::fs::read(path).map_err(|e| e.to_string()).and_then(|b| Texture::decode(&b)) {
+                                Ok(t) => {
+                                    textures.push(t);
+                                    Some(textures.len() - 1)
+                                }
+                                Err(e) => {
+                                    log::warn!("[mesh-io] texture {}: {e}", path.display());
+                                    None
+                                }
+                            }
+                        })
+                    });
+                    let mut m = Material::default();
+                    if let Some(kd) = entry.kd {
+                        m.color = kd;
+                    } else if texture.is_some() {
+                        // A texture with no Kd: the texture alone, untinted.
+                        m.color = [1.0; 3];
+                    }
+                    m.texture = texture;
+                    // Phong shininess to a roughness: sqrt(2 / (Ns + 2)).
+                    if let Some(ns) = entry.ns {
+                        m.roughness = (2.0 / (ns.max(0.0) + 2.0)).sqrt().clamp(0.04, 1.0);
+                    }
+                    m.roughness = entry.pr.unwrap_or(m.roughness);
+                    m.metallic = entry.pm.unwrap_or(0.0);
+                    mesh.materials.push(m);
+                    (mesh.materials.len() - 1) as u32
                 });
             }
             _ => {}
         }
     }
-    Ok(Scene { parts: vec![Part { name, mesh }], up: UpAxis::Y, unit: Unit::Unspecified })
+    if corner_uv.iter().any(Option::is_some) {
+        mesh.corner_uvs = Some(corner_uv.into_iter().map(|t| t.unwrap_or([0.0; 2])).collect());
+    }
+    if !corner_n.is_empty() && corner_n.iter().all(|n| n.is_some_and(|n| n != Vec3::ZERO)) {
+        mesh.file_normals = Some(corner_n.into_iter().map(Option::unwrap).collect());
+    }
+    Ok(Scene { parts: vec![Part { name, mesh }], up: UpAxis::Y, unit: Unit::Unspecified, textures })
 }
 
-/// Each `newmtl` in an MTL file and its `Kd`.
-fn read_mtl(text: &str) -> HashMap<String, [f32; 3]> {
-    let mut out = HashMap::new();
+/// Each `newmtl` in an MTL file and what it says; texture paths are
+/// resolved against the MTL file's folder.
+fn read_mtl(text: &str, dir: &Path) -> HashMap<String, MtlEntry> {
+    let mut out: HashMap<String, MtlEntry> = HashMap::new();
     let mut name: Option<String> = None;
     for line in text.lines() {
+        let line = line.split('#').next().unwrap_or("");
         let mut words = line.split_whitespace();
-        match words.next() {
-            Some("newmtl") => name = words.next().map(str::to_string),
-            Some("Kd") => {
-                let rgb: Vec<f32> = words.take(3).filter_map(|w| w.parse().ok()).collect();
-                if let (Some(n), [r, g, b]) = (&name, rgb.as_slice()) {
-                    out.insert(n.clone(), [*r, *g, *b]);
+        let Some(key) = words.next() else { continue };
+        if key == "newmtl" {
+            name = words.next().map(str::to_string);
+            if let Some(n) = &name {
+                out.entry(n.clone()).or_default();
+            }
+            continue;
+        }
+        let Some(entry) = name.as_ref().and_then(|n| out.get_mut(n)) else { continue };
+        let rest: Vec<&str> = words.collect();
+        let first = rest.first().and_then(|w| w.parse::<f32>().ok());
+        match key {
+            "Kd" => {
+                let rgb: Vec<f32> = rest.iter().take(3).filter_map(|w| w.parse().ok()).collect();
+                if let [r, g, b] = rgb.as_slice() {
+                    entry.kd = Some([*r, *g, *b]);
                 }
             }
+            // Options (`-s 1 1 1`, `-bm 0.5`) come before the file name, so
+            // the name is the last word (file names with spaces are rare in
+            // MTL, and ambiguous there anyway).
+            "map_Kd" => entry.map_kd = rest.last().map(|f| dir.join(f.replace('\\', "/"))),
+            "Ns" => entry.ns = first,
+            "Pm" => entry.pm = first,
+            "Pr" => entry.pr = first,
             _ => {}
         }
     }
@@ -104,25 +202,66 @@ fn read_mtl(text: &str) -> HashMap<String, [f32; 3]> {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::CLAY;
+
+    fn temp_dir(tag: &str) -> PathBuf {
+        let dir = std::env::temp_dir().join(format!("cce-mesh-io-obj-{tag}-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        dir
+    }
 
     #[test]
     fn a_quad_is_two_triangles_and_negative_indices_count_back() {
-        let s = read(b"o quad\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf -4/1/1 -3/2/1 -2/3/1 -1/4/1\n", None).unwrap();
+        let s = read(b"o quad\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\nf -4/-4 -3/-3 -2/-2 -1/-1\n", None).unwrap();
         assert_eq!(s.parts[0].name, "quad");
-        assert_eq!(s.parts[0].mesh.triangles, vec![[0, 1, 2], [0, 2, 3]]);
+        let m = &s.parts[0].mesh;
+        assert_eq!(m.triangles, vec![[0, 1, 2], [0, 2, 3]]);
+        let uv = m.corner_uvs.as_ref().unwrap();
+        assert_eq!(uv[0], [0.0, 1.0], "vt (0, 0) is the image's bottom-left");
+        assert_eq!(uv[2], [1.0, 0.0]);
+        assert!(m.file_normals.is_none());
+    }
+
+    #[test]
+    fn normals_are_kept_when_every_corner_has_one() {
+        let s = read(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 2\nf 1//1 2//1 3//1\n", None).unwrap();
+        assert_eq!(s.parts[0].mesh.file_normals.as_ref().unwrap()[1], Vec3::Z, "normalized");
+        let s = read(b"v 0 0 0\nv 1 0 0\nv 0 1 0\nv 1 1 0\nvn 0 0 1\nf 1//1 2//1 3//1\nf 2 4 3\n", None).unwrap();
+        assert!(s.parts[0].mesh.file_normals.is_none(), "one face without normals");
     }
 
     #[test]
     fn materials_colour_their_faces() {
-        let dir = std::env::temp_dir().join(format!("cce-model-obj-{}", std::process::id()));
-        std::fs::create_dir_all(&dir).unwrap();
-        std::fs::write(dir.join("m.mtl"), "newmtl red\nKd 0.8 0.1 0.1\n").unwrap();
+        let dir = temp_dir("colour");
+        std::fs::write(dir.join("m.mtl"), "newmtl red\nKd 0.8 0.1 0.1\nNs 0\n").unwrap();
         let obj = b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\nusemtl red\nf 1 3 2\nusemtl missing\nf 2 1 3\n";
         let m = read(obj, Some(&dir)).unwrap().parts.remove(0).mesh;
         std::fs::remove_dir_all(&dir).ok();
-        assert_eq!(m.colors[m.tri_color[0] as usize], CLAY, "before any usemtl");
-        assert_eq!(m.colors[m.tri_color[1] as usize], [0.8, 0.1, 0.1]);
-        assert_eq!(m.colors[m.tri_color[2] as usize], CLAY, "a material the library lacks");
+        assert_eq!(m.material(0).color, CLAY, "before any usemtl");
+        assert_eq!(m.material(1).color, [0.8, 0.1, 0.1]);
+        assert_eq!(m.material(1).roughness, 1.0, "Ns 0 is fully rough");
+        assert_eq!(m.material(2).color, CLAY, "a material the library lacks");
+    }
+
+    #[test]
+    fn map_kd_loads_a_texture_once() {
+        let dir = temp_dir("texture");
+        let mut png = Vec::new();
+        image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255]))
+            .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
+            .unwrap();
+        std::fs::create_dir_all(dir.join("tex")).unwrap();
+        std::fs::write(dir.join("tex/red.png"), &png).unwrap();
+        std::fs::write(dir.join("m.mtl"), "newmtl a\nmap_Kd -s 1 1 1 tex\\red.png\nnewmtl b\nKd 0.5 0.5 0.5\nmap_Kd tex/red.png\n").unwrap();
+        let obj = b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl a\nf 1 2 3\nusemtl b\nf 1 3 2\n";
+        let s = read(obj, Some(&dir)).unwrap();
+        std::fs::remove_dir_all(&dir).ok();
+        assert_eq!(s.textures.len(), 1, "one image, named twice");
+        assert_eq!((s.textures[0].width, s.textures[0].height), (2, 1));
+        let m = &s.parts[0].mesh;
+        assert_eq!(m.material(0).texture, Some(0));
+        assert_eq!(m.material(0).color, [1.0; 3], "no Kd: the texture untinted");
+        assert_eq!(m.material(1).color, [0.5; 3]);
     }
 
     #[test]
diff --git a/src/ply.rs b/src/ply.rs
index 7a38733..d0511a1 100644
--- a/src/ply.rs
+++ b/src/ply.rs
@@ -15,7 +15,7 @@
 
 use glam::Vec3;
 
-use crate::mesh::{srgb_to_linear, Mesh, CLAY};
+use crate::mesh::{srgb_to_linear, Material, Mesh};
 use crate::{Part, Scene, Unit, UpAxis};
 
 #[derive(Debug, Clone, Copy, PartialEq)]
@@ -212,8 +212,16 @@ pub fn read(bytes: &[u8]) -> Result<Scene, String> {
     }
     let corner_colors = (!point_colors.is_empty())
         .then(|| faces.iter().flat_map(|f| f.map(|i| point_colors[i as usize])).collect());
-    let mesh = Mesh { tri_color: vec![0; faces.len()], positions, triangles: faces, colors: vec![CLAY], corner_colors };
-    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Y, unit: Unit::Unspecified })
+    let mesh = Mesh {
+        tri_material: vec![0; faces.len()],
+        positions,
+        triangles: faces,
+        materials: vec![Material::default()],
+        corner_colors,
+        corner_uvs: None,
+        file_normals: None,
+    };
+    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Y, unit: Unit::Unspecified, textures: Vec::new() })
 }
 
 fn skip_list(body: &mut Body, count: Scalar, item: Scalar) -> Result<(), String> {
diff --git a/src/stl.rs b/src/stl.rs
index 0ef52dc..7173d8b 100644
--- a/src/stl.rs
+++ b/src/stl.rs
@@ -12,7 +12,7 @@
 
 use glam::Vec3;
 
-use crate::mesh::{Mesh, CLAY};
+use crate::mesh::{Material, Mesh};
 use crate::{Part, Scene, Unit, UpAxis};
 
 pub fn read(bytes: &[u8]) -> Result<Scene, String> {
@@ -21,11 +21,13 @@ pub fn read(bytes: &[u8]) -> Result<Scene, String> {
     let mesh = Mesh {
         positions: corners,
         triangles: (0..n as u32).map(|t| [t * 3, t * 3 + 1, t * 3 + 2]).collect(),
-        tri_color: vec![0; n],
-        colors: vec![CLAY],
+        tri_material: vec![0; n],
+        materials: vec![Material::default()],
         corner_colors: None,
+        corner_uvs: None,
+        file_normals: None,
     };
-    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Z, unit: Unit::Millimetre })
+    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Z, unit: Unit::Millimetre, textures: Vec::new() })
 }
 
 fn is_binary(bytes: &[u8]) -> bool {