Files
craft-libs/crates/mesh/src/obj.rs
T
Dylan M. TaylorandNetterSilver e3c6ec4a23 craft-mesh: ignore empty OBJ material assignments
* Ignore empty OBJ material assignments

* Preserve empty material resets in OBJ writes

Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>

---------

Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
2026-11-12 06:26:04 -05:00

626 lines
27 KiB
Rust

//! Wavefront OBJ geometry and MTL materials, read and written.
//!
//! OBJ read: `v x y z` with optional vertex colours (`v x y z r g b`), `vt`, `vn`, `f` with
//! `v`, `v/vt`, `v//vn` and `v/vt/vn` references (negative ones count back from the end),
//! polygons of any size kept whole, `o` objects (one [`Mesh`] each, holding only the vertices
//! it uses), `g` groups, `usemtl` and `s` smoothing groups (a [`Part`] per combination),
//! `mtllib` (asked of the resolver), `\` line continuations and `#` comments. Lines (`l`),
//! points (`p`) and free-form geometry are passed over. A vertex or face line that cannot be
//! read is left out and counted in one warning; the numbering of later vertices is kept.
//!
//! MTL materials map onto metallic-roughness: `Kd` → base colour, `d`/`Tr` → alpha (blended
//! when below 1), `Ke` → emissive, `Pr`/`Pm` (the PBR extension) → roughness/metallic,
//! otherwise roughness from the Phong exponent `Ns` (`sqrt(2 / (Ns + 2))`) and metallic 0;
//! `map_Kd`, `map_Ke`, `map_Bump`/`bump`/`norm` name base colour, emissive and normal images.
//! An image is only named ([`Image::uri`]); reading and decoding it is the caller's.
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use crate::{
AlphaMode, Corner, Error, Image, Limits, Material, Mesh, NONE, Part, Resolver, Result, Scene, TextureRef, count_warning, is_mirror,
linear_to_srgb, malformed, srgb_to_linear, too_large, transform_normal, transform_point,
};
const KIND: &str = "OBJ";
/// The last component of a referenced path, treating `\` as a separator too: a path from a
/// machine of another kind (`C:\textures\checker.png`) still names `checker.png`. `None` when
/// the name carries no path at all.
pub fn sibling_name(name: &str) -> Option<&str> {
let file = name.rsplit(['/', '\\']).next()?;
(!file.is_empty() && file.len() < name.len()).then_some(file)
}
/// The image a `map_*` line names: the rest of the line after its options
/// (`-bm 1 -o 0.5 0.5 my file.png`). Options are passed over by their argument counts, so a
/// name with spaces is one name; after an option this reader does not know, the name is the
/// final token.
fn map_name(args: &str) -> &str {
fn split(s: &str) -> (&str, &str) {
s.split_once(char::is_whitespace).map_or((s, ""), |(a, b)| (a, b.trim_start()))
}
let mut rest = args.trim();
loop {
let (token, after) = split(rest);
if after.is_empty() {
break;
}
// How many arguments the option takes, and from which one they may be left out:
// `-o`, `-s` and `-t` take one to three numbers.
let (count, optional_from) = match token {
"-blendu" | "-blendv" | "-bm" | "-boost" | "-cc" | "-clamp" | "-imfchan" | "-texres" | "-type" => (1, 1),
"-mm" => (2, 2),
"-o" | "-s" | "-t" => (3, 1),
t if t.strip_prefix('-').is_some_and(|o| o.starts_with(|c: char| c.is_ascii_alphabetic())) => {
return args.split_whitespace().last().unwrap_or(args);
}
_ => break,
};
rest = after;
for i in 0..count {
let (arg, after) = split(rest);
// The last token is always the name.
if after.is_empty() || (i >= optional_from && arg.parse::<f32>().is_err()) {
break;
}
rest = after;
}
}
rest.trim_end()
}
/// Materials of an MTL library and the images they name. `TextureRef::image` indexes the
/// returned images.
pub fn read_mtl(bytes: &[u8], limits: &Limits) -> Result<(Vec<Material>, Vec<Image>)> {
let text = String::from_utf8_lossy(bytes);
let mut out: Vec<Material> = vec![];
let mut images: Vec<Image> = vec![];
let mut pr_seen = false;
for line in text.lines() {
let line = line.trim();
let (key, rest) = line.split_once(char::is_whitespace).map(|(k, r)| (k, r.trim())).unwrap_or((line, ""));
if key == "newmtl" {
if out.len() >= limits.max_materials {
return Err(too_large("MTL", format!("more than {} materials", limits.max_materials)));
}
pr_seen = false;
out.push(Material::plain(rest, [1.0; 4]));
continue;
}
let Some(m) = out.last_mut() else { continue };
mtl_line(m, key, rest, &mut pr_seen, &mut images, limits)?;
}
for m in &mut out {
if m.base_color[3] < 0.999 {
m.alpha_mode = AlphaMode::Blend;
}
}
Ok((out, images))
}
fn mtl_line(m: &mut Material, key: &str, rest: &str, pr_seen: &mut bool, images: &mut Vec<Image>, limits: &Limits) -> Result<()> {
let finite = |x: &str| x.parse::<f32>().ok().filter(|v| v.is_finite());
let f3 = || -> [f32; 3] {
let v: Vec<f32> = rest.split_whitespace().take(3).filter_map(finite).collect();
match v[..] {
[a, b, c] => [a, b, c],
[a, ..] => [a; 3],
[] => [0.0; 3],
}
};
let f1 = || rest.split_whitespace().next().and_then(finite);
let mut tex = || -> Result<Option<TextureRef>> {
let name = map_name(rest);
if name.is_empty() {
return Ok(None);
}
let image = match images.iter().position(|i| i.uri.as_deref() == Some(name)) {
Some(i) => i,
None if images.len() >= limits.max_materials => return Err(too_large("MTL", format!("more than {} images", limits.max_materials))),
None => {
images.push(Image { name: name.to_string(), uri: Some(name.to_string()), ..Image::default() });
images.len() - 1
}
};
Ok(Some(TextureRef { image, ..TextureRef::default() }))
};
match key {
"Kd" => {
let c = f3().map(srgb_to_linear);
m.base_color = [c[0], c[1], c[2], m.base_color[3]];
}
"Ke" => m.emissive = f3().map(srgb_to_linear),
"d" => m.base_color[3] = f1().unwrap_or(1.0).clamp(0.0, 1.0),
"Tr" => m.base_color[3] = (1.0 - f1().unwrap_or(0.0)).clamp(0.0, 1.0),
"Ns" if !*pr_seen => m.roughness = (2.0 / (f1().unwrap_or(0.0).max(0.0) + 2.0)).sqrt(),
"Pr" => {
m.roughness = f1().unwrap_or(0.5).clamp(0.0, 1.0);
*pr_seen = true;
}
"Pm" => m.metallic = f1().unwrap_or(0.0).clamp(0.0, 1.0),
"map_Kd" => m.base_color_texture = tex()?,
"map_Ke" => {
m.emissive_texture = tex()?;
if m.emissive == [0.0; 3] {
m.emissive = [1.0; 3];
}
}
"map_Bump" | "map_bump" | "bump" | "norm" => m.normal_texture = tex()?,
_ => {}
}
Ok(())
}
/// A 1-based (or negative, counting back) OBJ reference as an index into `len` entries.
fn index(token: &str, len: usize) -> Option<u32> {
let value: i64 = token.parse().ok()?;
let len = i64::try_from(len).ok()?;
let value = if value < 0 { len.checked_add(value)? } else { value.checked_sub(1)? };
(0..len).contains(&value).then(|| u32::try_from(value).ok()).flatten()
}
#[derive(Default)]
struct Object {
name: String,
parts: Vec<Part>,
slots: HashMap<(String, Option<usize>, u32), usize>,
}
struct Reader<'a> {
limits: &'a Limits,
v: Vec<[f64; 3]>,
colors: Vec<[f32; 4]>,
vt: Vec<[f32; 2]>,
vn: Vec<[f64; 3]>,
objects: Vec<Object>,
scene: Scene,
material_ids: HashMap<String, usize>,
/// The `mtllib` names already read: exporters repeat the line for every object.
libraries: HashSet<String>,
material: Option<usize>,
group: String,
smoothing: u32,
/// The part faces go to until the object, group, material or smoothing changes.
slot: Option<usize>,
triangles: usize,
parts: usize,
bad_lines: usize,
data_bytes: usize,
}
const NAN3: [f64; 3] = [f64::NAN; 3];
/// `N` finite numbers from the next `N` tokens.
fn numbers<'a, const N: usize>(mut it: impl Iterator<Item = &'a str>) -> Option<[f64; N]> {
let mut out = [0.0; N];
for v in &mut out {
*v = it.next()?.parse::<f64>().ok().filter(|v| v.is_finite())?;
}
Some(out)
}
impl Reader<'_> {
fn pool_room(&self, len: usize, what: &str) -> Result<()> {
if len >= self.limits.max_vertices {
return Err(too_large(KIND, format!("more than {} {what}", self.limits.max_vertices)));
}
Ok(())
}
fn vertex(&mut self, rest: &[&str]) -> Result<()> {
self.pool_room(self.v.len(), "vertices")?;
let p = numbers::<3>(rest.iter().copied());
// Keep the numbering: later faces count vertices by position.
self.v.push(p.unwrap_or(NAN3));
self.bad_lines += usize::from(p.is_none());
// `v x y z r g b`: vertices without a colour are white.
if let (true, Some([r, g, b])) = (rest.len() >= 6, numbers::<3>(rest.iter().skip(3).copied())) {
self.colors.resize(self.v.len() - 1, [1.0; 4]);
self.colors.push([r as f32, g as f32, b as f32, 1.0]);
}
Ok(())
}
fn start_object(&mut self, name: String) -> Result<()> {
self.slot = None;
let full = self.objects.len() >= self.limits.max_meshes;
match self.objects.last_mut() {
// An empty object takes the name instead of starting another.
Some(o) if o.parts.is_empty() => o.name = name,
_ if full => return Err(too_large(KIND, format!("more than {} objects", self.limits.max_meshes))),
_ => self.objects.push(Object { name, ..Object::default() }),
}
Ok(())
}
fn use_material(&mut self, name: &str) -> Result<()> {
self.slot = None;
if name.trim().is_empty() {
// A bare `usemtl` selects no named material. In particular, do not create a
// material with an empty name: callers can map the missing material to their own
// default, and writers need not emit a meaningless empty material name.
self.material = None;
return Ok(());
}
self.material = match self.material_ids.get(name) {
Some(i) => Some(*i),
None if self.scene.materials.len() >= self.limits.max_materials => {
return Err(too_large(KIND, format!("more than {} materials", self.limits.max_materials)));
}
None => {
// Not in any library read: a named grey default.
self.scene.materials.push(Material::plain(name, [0.8, 0.8, 0.8, 1.0]));
self.material_ids.insert(name.to_string(), self.scene.materials.len() - 1);
Some(self.scene.materials.len() - 1)
}
};
Ok(())
}
fn library(&mut self, name: &str, resolve: Resolver) -> Result<()> {
// A library named again is already read: reading it again would add its materials and
// images a second time and count its bytes against the limit once per line.
if name.is_empty() || !self.libraries.insert(name.to_string()) {
return Ok(());
}
if self.scene.external.len() >= self.limits.max_materials {
return Err(too_large(KIND, format!("more than {} material libraries", self.limits.max_materials)));
}
self.scene.external.push(name.to_string());
// The name as written (it may hold spaces), then each of several names, then the file
// name of a path from another machine.
let Some(bytes) = std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)).find_map(resolve) else {
self.scene.warnings.push(format!("material library `{name}` was not supplied"));
return Ok(());
};
self.data_bytes = self.data_bytes.saturating_add(bytes.len());
if self.data_bytes > self.limits.max_data_bytes {
return Err(too_large(KIND, format!("material libraries of more than {} bytes", self.limits.max_data_bytes)));
}
let (materials, images) = read_mtl(&bytes, self.limits)?;
let base = self.scene.images.len();
if base.saturating_add(images.len()) > self.limits.max_materials
|| self.scene.materials.len().saturating_add(materials.len()) > self.limits.max_materials
{
return Err(too_large(KIND, format!("more than {} materials or images", self.limits.max_materials)));
}
self.scene.external.extend(images.iter().filter_map(|i| i.uri.clone()));
self.scene.images.extend(images);
for mut m in materials {
for t in [&mut m.base_color_texture, &mut m.emissive_texture, &mut m.normal_texture].into_iter().flatten() {
t.image += base;
}
// The first definition of a name wins, as for a reader that looks names up in order.
self.material_ids.entry(m.name.clone()).or_insert(self.scene.materials.len());
self.scene.materials.push(m);
}
Ok(())
}
fn corner(&self, token: &str) -> Option<Corner> {
let mut fields = token.split('/');
let position = index(fields.next()?, self.v.len())?;
self.v.get(position as usize).filter(|p| p[0].is_finite())?;
let uv = fields.next().and_then(|t| index(t, self.vt.len())).filter(|i| self.vt.get(*i as usize).is_some_and(|t| t[0].is_finite()));
let normal = fields.next().and_then(|t| index(t, self.vn.len())).filter(|i| self.vn.get(*i as usize).is_some_and(|n| n[0].is_finite()));
Some(Corner { position, normal: normal.unwrap_or(NONE), uv: uv.unwrap_or(NONE) })
}
fn part(&mut self) -> Result<Option<&mut Part>> {
if self.objects.is_empty() {
self.objects.push(Object::default());
}
let Some(object) = self.objects.last_mut() else { return Ok(None) };
let slot = match self.slot {
Some(s) => s,
None => match object.slots.get(&(self.group.clone(), self.material, self.smoothing)) {
Some(s) => *s,
None => {
if self.parts >= self.limits.max_parts {
return Err(too_large(KIND, format!("more than {} groups", self.limits.max_parts)));
}
self.parts += 1;
object.parts.push(Part { name: self.group.clone(), material: self.material, smoothing: self.smoothing, ..Part::default() });
object.slots.insert((self.group.clone(), self.material, self.smoothing), object.parts.len() - 1);
object.parts.len() - 1
}
},
};
self.slot = Some(slot);
Ok(object.parts.get_mut(slot))
}
fn face(&mut self, rest: &[&str]) -> Result<()> {
if rest.len() > self.limits.max_face_corners {
return Err(too_large(KIND, format!("a face of more than {} corners", self.limits.max_face_corners)));
}
let corners: Option<Vec<Corner>> = rest.iter().map(|t| self.corner(t)).collect();
let Some(corners) = corners.filter(|c| c.len() >= 3) else {
self.bad_lines += 1;
return Ok(());
};
self.triangles = self.triangles.saturating_add(corners.len() - 2);
if self.triangles > self.limits.max_triangles {
return Err(too_large(KIND, format!("more than {} triangles", self.limits.max_triangles)));
}
let Some(part) = self.part()? else { return Ok(()) };
// Sizes are only kept once a face is not a triangle.
if corners.len() != 3 && part.face_sizes.is_empty() {
part.face_sizes = vec![3; part.corners.len() / 3];
}
if corners.len() != 3 || !part.face_sizes.is_empty() {
part.face_sizes.push(corners.len() as u32);
}
part.corners.extend(corners);
Ok(())
}
fn line(&mut self, line: &str, resolve: Resolver) -> Result<()> {
let mut it = line.split_whitespace();
let Some(key) = it.next() else { return Ok(()) };
let after = line.get(key.len()..).unwrap_or("").trim();
match key {
"v" => self.vertex(&it.collect::<Vec<_>>())?,
"vt" => {
self.pool_room(self.vt.len(), "texture coordinates")?;
// `vt u` alone is a 1D coordinate; OBJ's v axis points up the image.
let uv = numbers::<2>(it.chain(["0"]));
self.vt.push(uv.map_or([f32::NAN; 2], |[u, v]| [u as f32, 1.0 - v as f32]));
self.bad_lines += usize::from(uv.is_none());
}
"vn" => {
self.pool_room(self.vn.len(), "normals")?;
let n = numbers::<3>(it);
self.vn.push(n.unwrap_or(NAN3));
self.bad_lines += usize::from(n.is_none());
}
"s" => {
self.slot = None;
self.smoothing = match it.next() {
Some("off" | "0") | None => 0,
Some("on") => 1,
Some(v) => v.parse::<u32>().unwrap_or(0),
};
}
"o" => self.start_object(after.to_string())?,
"g" => {
self.slot = None;
self.group = after.to_string();
}
"usemtl" => self.use_material(after)?,
"mtllib" => self.library(after, resolve)?,
"f" => self.face(&it.collect::<Vec<_>>())?,
_ => {}
}
Ok(())
}
/// Each object as a mesh holding only the vertices it uses.
fn finish(mut self) -> Result<Scene> {
let colored = !self.colors.is_empty();
self.colors.resize(self.v.len(), [1.0; 4]);
let mut meshes = Vec::new();
for object in std::mem::take(&mut self.objects) {
if object.parts.is_empty() {
continue;
}
let mut mesh = Mesh { name: object.name, parts: object.parts, ..Mesh::default() };
// The file's order is kept within each pool.
let used = |of: fn(&Corner) -> u32| {
let mut u: Vec<u32> = mesh.parts.iter().flat_map(|p| p.corners.iter().map(of)).filter(|i| *i != NONE).collect();
u.sort_unstable();
u.dedup();
u
};
let (pu, nu, tu) = (used(|c| c.position), used(|c| c.normal), used(|c| c.uv));
let place = |u: &[u32], i: u32| if i == NONE { NONE } else { u.binary_search(&i).map_or(NONE, |k| k as u32) };
for c in mesh.parts.iter_mut().flat_map(|p| p.corners.iter_mut()) {
*c = Corner { position: place(&pu, c.position), normal: place(&nu, c.normal), uv: place(&tu, c.uv) };
}
mesh.positions = pu.iter().map(|i| self.v.get(*i as usize).copied().unwrap_or_default()).collect();
if colored {
mesh.colors = pu.iter().map(|i| self.colors.get(*i as usize).copied().unwrap_or([1.0; 4])).collect();
}
mesh.normals = nu.iter().map(|i| self.vn.get(*i as usize).copied().unwrap_or_default()).collect();
let uvs: Vec<[f32; 2]> = tu.iter().map(|i| self.vt.get(*i as usize).copied().unwrap_or_default()).collect();
if !uvs.is_empty() {
mesh.uv_sets.push(uvs);
}
meshes.push(mesh);
}
if meshes.is_empty() {
return Err(malformed(KIND, "no faces"));
}
let mut scene = Scene::from_meshes(meshes, std::mem::take(&mut self.scene.materials));
scene.images = std::mem::take(&mut self.scene.images);
scene.external = std::mem::take(&mut self.scene.external);
scene.warnings = std::mem::take(&mut self.scene.warnings);
count_warning(&mut scene.warnings, self.bad_lines, "vertex or face line(s) could not be read and were left out");
Ok(scene)
}
}
/// Read an OBJ file. Its material libraries are asked of `resolve` by the name the file
/// writes; every name asked, and every image a library names, is listed in
/// [`Scene::external`].
pub fn read(bytes: &[u8], limits: &Limits, resolve: Resolver) -> Result<Scene> {
let text = String::from_utf8_lossy(bytes);
let mut r = Reader {
limits,
v: vec![],
colors: vec![],
vt: vec![],
vn: vec![],
objects: vec![],
scene: Scene::default(),
material_ids: HashMap::new(),
libraries: HashSet::new(),
material: None,
group: String::new(),
smoothing: 0,
slot: None,
triangles: 0,
parts: 0,
bad_lines: 0,
data_bytes: 0,
};
// A line ending in `\` continues on the next.
let mut joined = String::new();
for raw in text.lines() {
let line = raw.split('#').next().unwrap_or("");
if let Some(head) = line.trim_end().strip_suffix('\\') {
joined.push_str(head);
joined.push(' ');
continue;
}
if joined.is_empty() {
r.line(line.trim(), resolve)?;
} else {
joined.push_str(line);
r.line(joined.trim(), resolve)?;
joined.clear();
}
}
r.line(joined.trim(), resolve)?;
r.finish()
}
/// A name as one OBJ token: whitespace and control characters become `_`.
fn token(name: &str) -> String {
let t: String = name.trim().chars().map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect();
if t.is_empty() { "material".into() } else { t }
}
/// The name each material is written with: its [`token`], made unique with `_2`, `_3`… where
/// two would be the same, also ignoring case (the names may become file names).
pub fn material_names(scene: &Scene) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
scene
.materials
.iter()
.map(|m| {
let base = token(&m.name);
let mut unique = base.clone();
let mut n = 1u32;
while !seen.insert(unique.to_lowercase()) {
n = n.saturating_add(1);
unique = format!("{base}_{n}");
}
unique
})
.collect()
}
/// The scene as OBJ text: every mesh placed by its nodes as an object (`o`), with its vertex
/// colours, first UV set and normals, a group (`g`), material (`usemtl`) and smoothing group
/// (`s`) per part, and polygons kept whole. `mtllib` is the material library's file name, to
/// be written next to the OBJ from [`write_mtl`]; `None` writes no `mtllib` line.
pub fn write(scene: &Scene, mtllib: Option<&str>, limits: &Limits) -> Result<String> {
let mut text = String::new();
if let Some(lib) = mtllib.filter(|_| !scene.materials.is_empty()) {
let _ = writeln!(text, "mtllib {}", lib.replace(['\n', '\r'], " "));
}
let materials = material_names(scene);
// OBJ indices run on across objects.
let (mut vbase, mut tbase, mut nbase) = (1u64, 1u64, 1u64);
let mut smoothing = 0;
let mut current_material: Option<Option<usize>> = None;
for (index, world) in scene.instances(limits)? {
let Some(mesh) = scene.meshes.get(index) else { continue };
mesh.validate()?;
if !mesh.name.trim().is_empty() {
let _ = writeln!(text, "o {}", mesh.name.trim().replace(|c: char| c.is_control(), " "));
}
let colors = mesh.colors.len() == mesh.positions.len();
let mirror = is_mirror(&world);
for (i, p) in mesh.positions.iter().enumerate() {
let p = transform_point(&world, *p);
let _ = write!(text, "v {} {} {}", p[0], p[1], p[2]);
if let Some(c) = mesh.colors.get(i).filter(|_| colors) {
let _ = write!(text, " {} {} {}", c[0], c[1], c[2]);
}
text.push('\n');
}
let uvs = mesh.uv_sets.first().map_or(&[][..], Vec::as_slice);
for t in uvs {
let _ = writeln!(text, "vt {} {}", t[0], 1.0 - t[1]);
}
for n in &mesh.normals {
let n = transform_normal(&world, *n);
let _ = writeln!(text, "vn {} {} {}", n[0], n[1], n[2]);
}
for part in &mesh.parts {
if !part.name.trim().is_empty() {
let _ = writeln!(text, "g {}", part.name.trim().replace(|c: char| c.is_control(), " "));
}
if current_material != Some(part.material) {
match part.material.and_then(|m| materials.get(m)) {
Some(m) => {
let _ = writeln!(text, "usemtl {m}");
}
None if current_material.is_some() => {
let _ = writeln!(text, "usemtl");
}
None => {}
}
current_material = Some(part.material);
}
if part.smoothing != smoothing {
smoothing = part.smoothing;
let _ = if smoothing == 0 { writeln!(text, "s off") } else { writeln!(text, "s {smoothing}") };
}
for face in part.faces() {
text.push('f');
// A mirrored placement turns the winding; turn it back so the face faces out.
let mut face = face.to_vec();
if mirror {
face.reverse();
}
for c in face {
let v = vbase + u64::from(c.position);
let _ = match (c.uv != NONE, c.normal != NONE) {
(true, true) => write!(text, " {v}/{}/{}", tbase + u64::from(c.uv), nbase + u64::from(c.normal)),
(true, false) => write!(text, " {v}/{}", tbase + u64::from(c.uv)),
(false, true) => write!(text, " {v}//{}", nbase + u64::from(c.normal)),
(false, false) => write!(text, " {v}"),
};
}
text.push('\n');
}
}
vbase += mesh.positions.len() as u64;
tbase += uvs.len() as u64;
nbase += mesh.normals.len() as u64;
}
if vbase == 1 {
return Err(Error::Malformed("OBJ: the scene has no placed mesh to write".into()));
}
Ok(text)
}
/// The scene's materials as an MTL library, named as [`write`] names them: `Kd`, `d`, `Ke`,
/// `Ns` (for readers without the PBR extension) with `Pr` and `Pm`, and `map_Kd`, `map_Ke`
/// and `norm` for images that have a [`Image::uri`].
pub fn write_mtl(scene: &Scene) -> String {
let mut mtl = String::new();
for (m, name) in scene.materials.iter().zip(material_names(scene)) {
let kd = [m.base_color[0], m.base_color[1], m.base_color[2]].map(linear_to_srgb);
let ke = m.emissive.map(linear_to_srgb);
let r = m.roughness.clamp(0.0, 1.0);
let ns = if r > 0.0 { (2.0 / (r * r) - 2.0).clamp(0.0, 1000.0) } else { 1000.0 };
let _ = writeln!(
mtl,
"newmtl {name}\nKd {} {} {}\nd {}\nKe {} {} {}\nNs {ns}\nPr {r}\nPm {}",
kd[0], kd[1], kd[2], m.base_color[3], ke[0], ke[1], ke[2], m.metallic
);
for (key, tex) in [("map_Kd", m.base_color_texture), ("map_Ke", m.emissive_texture), ("norm", m.normal_texture)] {
if let Some(uri) = tex.and_then(|t| scene.images.get(t.image)).and_then(|i| i.uri.as_deref()) {
let _ = writeln!(mtl, "{key} {}", uri.replace(['\n', '\r'], " "));
}
}
mtl.push('\n');
}
mtl
}