mesh files in: STL, OBJ, glTF and PLY
git clone https://git.lucas.co/cce-mesh-io.git
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 {