use oxideav_mesh3d::{AlphaMode, Error, ImageData, Material, Result, Sampler, Texture, TextureRef};
#[derive(Debug, Default, Clone)]
struct PendingTextures {
base_color: Option<String>,
normal: Option<String>,
metallic_roughness: Option<String>,
emissive: Option<String>,
}
#[derive(Debug, Clone)]
struct ParsedMaterial {
material: Material,
pending: PendingTextures,
}
pub fn parse_mtl(text: &str) -> Result<Vec<Material>> {
let parsed = parse_mtl_internal(text)?;
let mut out: Vec<Material> = Vec::with_capacity(parsed.len());
for pm in parsed {
let mut mat = pm.material;
let mut pending_obj = serde_json::Map::new();
if let Some(p) = pm.pending.base_color {
pending_obj.insert("base_color".into(), serde_json::Value::String(p));
}
if let Some(p) = pm.pending.normal {
pending_obj.insert("normal".into(), serde_json::Value::String(p));
}
if let Some(p) = pm.pending.metallic_roughness {
pending_obj.insert("metallic_roughness".into(), serde_json::Value::String(p));
}
if let Some(p) = pm.pending.emissive {
pending_obj.insert("emissive".into(), serde_json::Value::String(p));
}
if !pending_obj.is_empty() {
mat.extras.insert(
"mtl:pending_textures".to_string(),
serde_json::Value::Object(pending_obj),
);
}
out.push(mat);
}
Ok(out)
}
pub fn merge_materials_into_scene(
scene: &mut oxideav_mesh3d::Scene3D,
materials: Vec<Material>,
) -> Vec<oxideav_mesh3d::MaterialId> {
let mut ids = Vec::with_capacity(materials.len());
for mut mat in materials {
let pending = mat.extras.remove("mtl:pending_textures");
if let Some(serde_json::Value::Object(obj)) = pending {
for (slot, val) in obj {
let serde_json::Value::String(uri) = val else {
continue;
};
let tex = Texture {
name: Some(uri.clone()),
image: ImageData::External {
uri: uri.clone(),
mime: None,
},
sampler: Sampler::default_sampler(),
};
let tex_id = scene.add_texture(tex);
let tex_ref = TextureRef::new(tex_id);
match slot.as_str() {
"base_color" => mat.base_color_texture = Some(tex_ref),
"normal" => mat.normal_texture = Some(tex_ref),
"metallic_roughness" => mat.metallic_roughness_texture = Some(tex_ref),
"emissive" => mat.emissive_texture = Some(tex_ref),
_ => {}
}
}
}
ids.push(scene.add_material(mat));
}
ids
}
pub fn parse_mtl_with_scene(text: &str) -> Result<oxideav_mesh3d::Scene3D> {
let mut scene = oxideav_mesh3d::Scene3D::new();
let materials = parse_mtl(text)?;
let _ = merge_materials_into_scene(&mut scene, materials);
Ok(scene)
}
fn parse_mtl_internal(text: &str) -> Result<Vec<ParsedMaterial>> {
let mut out: Vec<ParsedMaterial> = Vec::new();
let mut current: Option<ParsedMaterial> = None;
fn strip_comment(line: &str) -> &str {
match line.find('#') {
Some(idx) => &line[..idx],
None => line,
}
}
for raw_line in text.split('\n') {
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
let line = strip_comment(line).trim();
if line.is_empty() {
continue;
}
let mut tokens = line.split_whitespace();
let Some(keyword) = tokens.next() else {
continue;
};
match keyword {
"newmtl" => {
if let Some(prev) = current.take() {
out.push(prev);
}
let name: String = tokens.collect::<Vec<_>>().join(" ");
let mut mat = Material::new();
mat.metallic = 0.0;
mat.roughness = 0.5;
mat.name = Some(name);
current = Some(ParsedMaterial {
material: mat,
pending: PendingTextures::default(),
});
}
other => {
let Some(pm) = current.as_mut() else {
return Err(Error::invalid(format!(
"MTL: {other:?} appears before any newmtl directive"
)));
};
apply_directive(other, &mut tokens, pm)?;
}
}
}
if let Some(last) = current.take() {
out.push(last);
}
Ok(out)
}
fn parse_floats<'a, I: Iterator<Item = &'a str>>(tokens: I, keyword: &str) -> Result<Vec<f32>> {
tokens
.map(str::parse::<f32>)
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| Error::invalid(format!("MTL {keyword}: bad float ({e})")))
}
#[derive(Debug, Clone)]
enum ColorStatement {
Rgb { r: f32, g: f32, b: f32 },
Spectral { file: String, factor: f32 },
Xyz { x: f32, y: f32, z: f32 },
}
fn parse_color_statement(toks: &[&str], keyword: &str) -> Result<ColorStatement> {
if toks.is_empty() {
return Err(Error::invalid(format!(
"{keyword}: needs at least 1 argument"
)));
}
match toks[0] {
"spectral" => {
if toks.len() < 2 {
return Err(Error::invalid(format!(
"{keyword} spectral: missing file.rfl"
)));
}
let file = toks[1].to_string();
let factor: f32 = if let Some(f) = toks.get(2) {
f.parse()
.map_err(|e| Error::invalid(format!("{keyword} spectral: bad factor ({e})")))?
} else {
1.0
};
Ok(ColorStatement::Spectral { file, factor })
}
"xyz" => {
let v: Vec<f32> = toks[1..]
.iter()
.map(|s| s.parse::<f32>())
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| Error::invalid(format!("{keyword} xyz: bad float ({e})")))?;
if v.is_empty() {
return Err(Error::invalid(format!(
"{keyword} xyz: needs at least 1 float"
)));
}
let x = v[0];
let y = v.get(1).copied().unwrap_or(x);
let z = v.get(2).copied().unwrap_or(x);
Ok(ColorStatement::Xyz { x, y, z })
}
_ => {
let v: Vec<f32> = toks
.iter()
.map(|s| s.parse::<f32>())
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| Error::invalid(format!("{keyword}: bad float ({e})")))?;
let r = v[0];
let g = v.get(1).copied().unwrap_or(r);
let b = v.get(2).copied().unwrap_or(r);
Ok(ColorStatement::Rgb { r, g, b })
}
}
}
fn apply_directive(
keyword: &str,
tokens: &mut std::str::SplitWhitespace<'_>,
pm: &mut ParsedMaterial,
) -> Result<()> {
let mat = &mut pm.material;
match keyword {
"Ka" => {
let toks: Vec<&str> = tokens.collect();
match parse_color_statement(&toks, "Ka")? {
ColorStatement::Rgb { r, g, b } => {
mat.extras
.insert("mtl:Ka".to_string(), serde_json::json!([r, g, b]));
}
ColorStatement::Spectral { file, factor } => {
mat.extras.insert(
"mtl:Ka:spectral".to_string(),
serde_json::json!({ "file": file, "factor": factor }),
);
}
ColorStatement::Xyz { x, y, z } => {
mat.extras
.insert("mtl:Ka:xyz".to_string(), serde_json::json!([x, y, z]));
}
}
}
"Kd" => {
let toks: Vec<&str> = tokens.collect();
match parse_color_statement(&toks, "Kd")? {
ColorStatement::Rgb { r, g, b } => {
let alpha = mat.base_color[3];
mat.base_color = [r, g, b, alpha];
}
ColorStatement::Spectral { file, factor } => {
mat.extras.insert(
"mtl:Kd:spectral".to_string(),
serde_json::json!({ "file": file, "factor": factor }),
);
}
ColorStatement::Xyz { x, y, z } => {
mat.extras
.insert("mtl:Kd:xyz".to_string(), serde_json::json!([x, y, z]));
}
}
}
"Ks" => {
let toks: Vec<&str> = tokens.collect();
match parse_color_statement(&toks, "Ks")? {
ColorStatement::Rgb { r, g, b } => {
mat.extras
.insert("mtl:Ks".to_string(), serde_json::json!([r, g, b]));
}
ColorStatement::Spectral { file, factor } => {
mat.extras.insert(
"mtl:Ks:spectral".to_string(),
serde_json::json!({ "file": file, "factor": factor }),
);
}
ColorStatement::Xyz { x, y, z } => {
mat.extras
.insert("mtl:Ks:xyz".to_string(), serde_json::json!([x, y, z]));
}
}
}
"Ke" => {
let v = parse_floats(tokens.by_ref(), keyword)?;
if v.len() < 3 {
return Err(Error::invalid(format!(
"Ke: needs 3 floats, got {}",
v.len()
)));
}
mat.emissive_factor = [v[0], v[1], v[2]];
}
"Tf" => {
let toks: Vec<&str> = tokens.collect();
match parse_color_statement(&toks, "Tf")? {
ColorStatement::Rgb { r, g, b } => {
mat.extras
.insert("mtl:Tf".to_string(), serde_json::json!([r, g, b]));
}
ColorStatement::Spectral { file, factor } => {
mat.extras.insert(
"mtl:Tf:spectral".to_string(),
serde_json::json!({ "file": file, "factor": factor }),
);
}
ColorStatement::Xyz { x, y, z } => {
mat.extras
.insert("mtl:Tf:xyz".to_string(), serde_json::json!([x, y, z]));
}
}
}
"sharpness" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid("sharpness: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("sharpness: bad float ({e})")))?;
mat.extras
.insert("mtl:sharpness".to_string(), serde_json::json!(v));
}
"Ns" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid("Ns: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("Ns: bad float ({e})")))?;
mat.extras
.insert("mtl:Ns".to_string(), serde_json::json!(v));
}
"Ni" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid("Ni: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("Ni: bad float ({e})")))?;
mat.extras
.insert("mtl:Ni".to_string(), serde_json::json!(v));
}
"d" => {
let mut halo = false;
let mut value: Option<f32> = None;
for tok in tokens.by_ref() {
if tok == "-halo" {
halo = true;
continue;
}
value = Some(
tok.parse()
.map_err(|e| Error::invalid(format!("d: bad float ({e})")))?,
);
break;
}
let v = value.ok_or_else(|| Error::invalid("d: missing value"))?;
mat.base_color[3] = v;
if v < 1.0 {
mat.alpha_mode = AlphaMode::Blend;
}
if halo {
mat.extras
.insert("mtl:d_halo_factor".to_string(), serde_json::json!(v));
}
}
"Tr" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid("Tr: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("Tr: bad float ({e})")))?;
let d = 1.0 - v;
mat.base_color[3] = d;
if d < 1.0 {
mat.alpha_mode = AlphaMode::Blend;
}
}
"illum" => {
let v: i32 = tokens
.next()
.ok_or_else(|| Error::invalid("illum: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("illum: bad integer ({e})")))?;
mat.extras
.insert("mtl:illum".to_string(), serde_json::json!(v));
if let Some(props) = illum_property_map(v) {
mat.extras.insert("mtl:illum_props".to_string(), props);
}
}
"Pr" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid("Pr: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("Pr: bad float ({e})")))?;
mat.roughness = v;
}
"Pm" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid("Pm: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("Pm: bad float ({e})")))?;
mat.metallic = v;
}
"Pc" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid("Pc: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("Pc: bad float ({e})")))?;
mat.extras
.insert("mtl:Pc".to_string(), serde_json::json!(v));
}
"Pcr" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid("Pcr: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("Pcr: bad float ({e})")))?;
mat.extras
.insert("mtl:Pcr".to_string(), serde_json::json!(v));
}
"Ps" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid("Ps: missing value"))?
.parse()
.map_err(|e| Error::invalid(format!("Ps: bad float ({e})")))?;
mat.extras
.insert("mtl:Ps".to_string(), serde_json::json!(v));
}
"aniso" | "anisor" => {
let v: f32 = tokens
.next()
.ok_or_else(|| Error::invalid(format!("{keyword}: missing value")))?
.parse()
.map_err(|e| Error::invalid(format!("{keyword}: bad float ({e})")))?;
mat.extras
.insert(format!("mtl:{keyword}"), serde_json::json!(v));
}
"map_aat" => {
if let Some(tok) = tokens.next() {
let flag = match tok {
"on" => Some(true),
"off" => Some(false),
_ => None,
};
if let Some(b) = flag {
mat.extras
.insert("mtl:map_aat".to_string(), serde_json::json!(b));
}
}
}
"map_Kd" => {
pm.pending.base_color = Some(parse_map_with_options(keyword, tokens, &mut mat.extras));
}
"map_Bump" | "map_bump" | "bump" | "norm" => {
pm.pending.normal = Some(parse_map_with_options(keyword, tokens, &mut mat.extras));
}
"map_Ke" => {
pm.pending.emissive = Some(parse_map_with_options(keyword, tokens, &mut mat.extras));
}
"map_Pr" | "map_Pm" => {
let s = parse_map_with_options(keyword, tokens, &mut mat.extras);
if let Some(prev) = pm.pending.metallic_roughness.replace(s.clone()) {
mat.extras.insert(
"mtl:displaced_pbr_map".to_string(),
serde_json::Value::String(prev),
);
}
mat.extras
.insert(format!("mtl:{keyword}"), serde_json::Value::String(s));
}
"refl" | "map_refl" => {
let toks: Vec<&str> = tokens.collect();
let mut iter = toks.iter().copied().peekable();
let mut refl_kind: Option<&'static str> = None;
if iter.peek() == Some(&"-type") {
let _ = iter.next();
if let Some(kind) = iter.next() {
refl_kind = match kind {
"sphere" => Some("sphere"),
"cube_top" => Some("cube_top"),
"cube_bottom" => Some("cube_bottom"),
"cube_front" => Some("cube_front"),
"cube_back" => Some("cube_back"),
"cube_left" => Some("cube_left"),
"cube_right" => Some("cube_right"),
"cube_side" => Some("cube_side"),
_ => None,
};
if refl_kind.is_none() {
}
}
}
let remaining: Vec<&str> = iter.collect();
let joined = remaining.join(" ");
let mut split = joined.split_whitespace();
let (opts, filename) = map_options_and_filename(&mut split);
match refl_kind {
Some(face) if face != "sphere" && face != "cube_side" => {
let mut entry = serde_json::Map::new();
entry.insert(
"file".to_string(),
serde_json::Value::String(filename.clone()),
);
if !opts.is_empty() {
if let Some(typed) = decompose_map_options(&opts) {
entry.insert("options_typed".to_string(), typed);
}
entry.insert(
"options".to_string(),
serde_json::Value::Array(
opts.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
);
}
let cube_key = "mtl:refl:cube".to_string();
let cube_obj = match mat.extras.remove(&cube_key) {
Some(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
let mut cube_obj = cube_obj;
cube_obj.insert(face.to_string(), serde_json::Value::Object(entry));
mat.extras
.insert(cube_key, serde_json::Value::Object(cube_obj));
}
Some("sphere") => {
let mut entry = serde_json::Map::new();
entry.insert(
"file".to_string(),
serde_json::Value::String(filename.clone()),
);
if !opts.is_empty() {
if let Some(typed) = decompose_map_options(&opts) {
entry.insert("options_typed".to_string(), typed);
}
entry.insert(
"options".to_string(),
serde_json::Value::Array(
opts.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
);
}
mat.extras.insert(
"mtl:refl:sphere".to_string(),
serde_json::Value::Object(entry),
);
}
_ => {
if !opts.is_empty() {
if let Some(typed) = decompose_map_options(&opts) {
mat.extras
.insert(format!("mtl:{keyword}:options_typed"), typed);
}
mat.extras.insert(
format!("mtl:{keyword}:options"),
serde_json::Value::Array(
opts.into_iter().map(serde_json::Value::String).collect(),
),
);
}
mat.extras.insert(
format!("mtl:{keyword}"),
serde_json::Value::String(filename),
);
}
}
}
"map_Ka" | "map_Ks" | "map_Ns" | "map_d" | "disp" | "map_disp" | "decal" | "map_decal" => {
let s = parse_map_with_options(keyword, tokens, &mut mat.extras);
mat.extras
.insert(format!("mtl:{keyword}"), serde_json::Value::String(s));
}
_ => {}
}
Ok(())
}
fn map_options_and_filename(tokens: &mut std::str::SplitWhitespace<'_>) -> (Vec<String>, String) {
let toks: Vec<&str> = tokens.collect();
let mut opts: Vec<String> = Vec::new();
let mut i = 0;
while i < toks.len() {
let t = toks[i];
let is_flag = t.starts_with('-')
&& t.len() > 1
&& t.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic());
if !is_flag {
break;
}
let arg_count = flag_arg_count(t);
if arg_count == 0 {
opts.push(t.to_string());
i += 1;
continue;
}
let end = (i + 1 + arg_count).min(toks.len());
let chunk: Vec<&str> = toks[i..end].to_vec();
opts.push(chunk.join(" "));
i = end;
}
let filename = toks[i..].join(" ");
(opts, filename)
}
fn flag_arg_count(flag: &str) -> usize {
match flag {
"-blendu" | "-blendv" | "-cc" | "-clamp" => 1, "-bm" | "-boost" | "-texres" => 1, "-imfchan" | "-type" => 1, "-mm" => 2, "-o" | "-s" | "-t" => 3,
_ => 0,
}
}
fn parse_map_with_options(
keyword: &str,
tokens: &mut std::str::SplitWhitespace<'_>,
extras: &mut std::collections::HashMap<String, serde_json::Value>,
) -> String {
let (opts, filename) = map_options_and_filename(tokens);
if !opts.is_empty() {
if let Some(typed) = decompose_map_options(&opts) {
extras.insert(format!("mtl:{keyword}:options_typed"), typed);
}
extras.insert(
format!("mtl:{keyword}:options"),
serde_json::Value::Array(opts.into_iter().map(serde_json::Value::String).collect()),
);
}
filename
}
fn decompose_map_options(opts: &[String]) -> Option<serde_json::Value> {
let mut obj = serde_json::Map::new();
for chunk in opts {
let mut it = chunk.split_whitespace();
let flag = it.next()?;
let args: Vec<&str> = it.collect();
match flag {
"-blendu" | "-blendv" | "-clamp" | "-cc" => {
if args.len() != 1 {
continue;
}
let v = match args[0] {
"on" => true,
"off" => false,
_ => continue,
};
let key = &flag[1..];
obj.insert(key.to_string(), serde_json::Value::Bool(v));
}
"-bm" | "-boost" | "-texres" => {
if args.len() != 1 {
continue;
}
let Ok(v) = args[0].parse::<f64>() else {
continue;
};
let key = &flag[1..];
obj.insert(
key.to_string(),
serde_json::Number::from_f64(v)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
);
}
"-imfchan" => {
if args.len() != 1 {
continue;
}
let v = args[0];
if !matches!(v, "r" | "g" | "b" | "m" | "l" | "z") {
continue;
}
obj.insert(
"imfchan".to_string(),
serde_json::Value::String(v.to_string()),
);
}
"-type" => {
if args.len() != 1 {
continue;
}
let v = args[0];
if !matches!(
v,
"sphere"
| "cube_top"
| "cube_bottom"
| "cube_front"
| "cube_back"
| "cube_left"
| "cube_right"
) {
continue;
}
obj.insert("type".to_string(), serde_json::Value::String(v.to_string()));
}
"-mm" => {
if args.len() != 2 {
continue;
}
let Ok(base) = args[0].parse::<f64>() else {
continue;
};
let Ok(gain) = args[1].parse::<f64>() else {
continue;
};
obj.insert(
"mm".to_string(),
serde_json::Value::Array(vec![
serde_json::Number::from_f64(base)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
serde_json::Number::from_f64(gain)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
]),
);
}
"-o" | "-s" | "-t" => {
if args.is_empty() || args.len() > 3 {
continue;
}
let mut parsed: Vec<f64> = Vec::with_capacity(args.len());
let mut ok = true;
for a in &args {
match a.parse::<f64>() {
Ok(n) => parsed.push(n),
Err(_) => {
ok = false;
break;
}
}
}
if !ok {
continue;
}
let default = if flag == "-s" { 1.0 } else { 0.0 };
while parsed.len() < 3 {
parsed.push(default);
}
let arr: Vec<serde_json::Value> = parsed
.into_iter()
.map(|v| {
serde_json::Number::from_f64(v)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
})
.collect();
let key = &flag[1..];
obj.insert(key.to_string(), serde_json::Value::Array(arr));
}
_ => {}
}
}
if obj.is_empty() {
None
} else {
Some(serde_json::Value::Object(obj))
}
}
fn illum_property_map(n: i32) -> Option<serde_json::Value> {
if !(0..=10).contains(&n) {
return None;
}
let color = (0..=9).contains(&n);
let ambient = (1..=9).contains(&n);
let highlight = (2..=9).contains(&n);
let reflection = matches!(n, 3..=9);
let ray_trace = matches!(n, 3..=7);
let transparency_glass = matches!(n, 4 | 9);
let transparency_refraction = matches!(n, 6 | 7);
let fresnel = matches!(n, 5 | 7);
let casts_shadow_on_invisible = n == 10;
Some(serde_json::json!({
"color": color,
"ambient": ambient,
"highlight": highlight,
"reflection": reflection,
"ray_trace": ray_trace,
"transparency_glass": transparency_glass,
"transparency_refraction": transparency_refraction,
"fresnel": fresnel,
"casts_shadow_on_invisible": casts_shadow_on_invisible,
}))
}
pub fn serialize_mtl(materials: &[Material], textures: &[Texture]) -> Result<Vec<u8>> {
use std::fmt::Write;
let mut out = String::new();
writeln!(out, "# MTL generated by oxideav-obj").unwrap();
for (i, mat) in materials.iter().enumerate() {
let name = mat.name.clone().unwrap_or_else(|| format!("material_{i}"));
writeln!(out, "newmtl {name}").unwrap();
emit_color_statement(&mut out, "Ka", &mat.extras);
if let Some(serde_json::Value::Object(o)) = mat.extras.get("mtl:Kd:spectral") {
let file = o.get("file").and_then(|v| v.as_str()).unwrap_or("");
let factor = o.get("factor").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
if (factor - 1.0).abs() < f32::EPSILON {
writeln!(out, "Kd spectral {file}").unwrap();
} else {
writeln!(out, "Kd spectral {file} {}", fmt_f(factor)).unwrap();
}
} else if let Some(serde_json::Value::Array(v)) = mat.extras.get("mtl:Kd:xyz") {
if let [a, b, c] = v.as_slice() {
writeln!(
out,
"Kd xyz {} {} {}",
fmt_f(a.as_f64().unwrap_or(0.0) as f32),
fmt_f(b.as_f64().unwrap_or(0.0) as f32),
fmt_f(c.as_f64().unwrap_or(0.0) as f32)
)
.unwrap();
}
} else {
writeln!(
out,
"Kd {} {} {}",
fmt_f(mat.base_color[0]),
fmt_f(mat.base_color[1]),
fmt_f(mat.base_color[2])
)
.unwrap();
}
emit_color_statement(&mut out, "Ks", &mat.extras);
if mat.emissive_factor != [0.0, 0.0, 0.0] {
writeln!(
out,
"Ke {} {} {}",
fmt_f(mat.emissive_factor[0]),
fmt_f(mat.emissive_factor[1]),
fmt_f(mat.emissive_factor[2])
)
.unwrap();
}
if let Some(v) = mat.extras.get("mtl:Ns").and_then(|v| v.as_f64()) {
writeln!(out, "Ns {}", fmt_f(v as f32)).unwrap();
}
if let Some(v) = mat.extras.get("mtl:Ni").and_then(|v| v.as_f64()) {
writeln!(out, "Ni {}", fmt_f(v as f32)).unwrap();
}
if let Some(serde_json::Value::Array(v)) = mat.extras.get("mtl:Tf") {
if let [a, b, c] = v.as_slice() {
writeln!(
out,
"Tf {} {} {}",
fmt_f(a.as_f64().unwrap_or(0.0) as f32),
fmt_f(b.as_f64().unwrap_or(0.0) as f32),
fmt_f(c.as_f64().unwrap_or(0.0) as f32)
)
.unwrap();
}
} else if let Some(serde_json::Value::Object(o)) = mat.extras.get("mtl:Tf:spectral") {
let file = o.get("file").and_then(|v| v.as_str()).unwrap_or("");
let factor = o.get("factor").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
if (factor - 1.0).abs() < f32::EPSILON {
writeln!(out, "Tf spectral {file}").unwrap();
} else {
writeln!(out, "Tf spectral {file} {}", fmt_f(factor)).unwrap();
}
} else if let Some(serde_json::Value::Array(v)) = mat.extras.get("mtl:Tf:xyz") {
if let [a, b, c] = v.as_slice() {
writeln!(
out,
"Tf xyz {} {} {}",
fmt_f(a.as_f64().unwrap_or(0.0) as f32),
fmt_f(b.as_f64().unwrap_or(0.0) as f32),
fmt_f(c.as_f64().unwrap_or(0.0) as f32)
)
.unwrap();
}
}
if let Some(v) = mat.extras.get("mtl:sharpness").and_then(|v| v.as_f64()) {
writeln!(out, "sharpness {}", fmt_f(v as f32)).unwrap();
}
if mat.base_color[3] < 1.0 || matches!(mat.alpha_mode, AlphaMode::Blend) {
if mat.extras.contains_key("mtl:d_halo_factor") {
writeln!(out, "d -halo {}", fmt_f(mat.base_color[3])).unwrap();
} else {
writeln!(out, "d {}", fmt_f(mat.base_color[3])).unwrap();
}
}
if let Some(v) = mat.extras.get("mtl:illum").and_then(|v| v.as_i64()) {
writeln!(out, "illum {v}").unwrap();
}
let pbr_in_use = mat.metallic != 0.0
|| (mat.roughness - 0.5).abs() > f32::EPSILON
|| mat.metallic_roughness_texture.is_some()
|| mat.extras.contains_key("mtl:Pc")
|| mat.extras.contains_key("mtl:Ps");
if pbr_in_use {
writeln!(out, "Pr {}", fmt_f(mat.roughness)).unwrap();
writeln!(out, "Pm {}", fmt_f(mat.metallic)).unwrap();
}
if let Some(v) = mat.extras.get("mtl:Pc").and_then(|v| v.as_f64()) {
writeln!(out, "Pc {}", fmt_f(v as f32)).unwrap();
}
if let Some(v) = mat.extras.get("mtl:Ps").and_then(|v| v.as_f64()) {
writeln!(out, "Ps {}", fmt_f(v as f32)).unwrap();
}
write_tex_ref(
&mut out,
"map_Kd",
mat.base_color_texture,
textures,
&mat.extras,
);
write_tex_ref(
&mut out,
"map_Bump",
mat.normal_texture,
textures,
&mat.extras,
);
write_tex_ref(
&mut out,
"map_Pr",
mat.metallic_roughness_texture,
textures,
&mat.extras,
);
write_tex_ref(
&mut out,
"map_Ke",
mat.emissive_texture,
textures,
&mat.extras,
);
if let Some(serde_json::Value::Object(o)) = mat.extras.get("mtl:refl:sphere") {
let file = o.get("file").and_then(|v| v.as_str()).unwrap_or("");
let opts: Vec<&str> = o
.get("options")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|s| s.as_str()).collect())
.unwrap_or_default();
if opts.is_empty() {
writeln!(out, "refl -type sphere {file}").unwrap();
} else {
writeln!(out, "refl -type sphere {} {file}", opts.join(" ")).unwrap();
}
}
if let Some(serde_json::Value::Object(faces)) = mat.extras.get("mtl:refl:cube") {
for face in [
"cube_top",
"cube_bottom",
"cube_front",
"cube_back",
"cube_left",
"cube_right",
"cube_side",
] {
let Some(serde_json::Value::Object(entry)) = faces.get(face) else {
continue;
};
let file = entry.get("file").and_then(|v| v.as_str()).unwrap_or("");
let opts: Vec<&str> = entry
.get("options")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|s| s.as_str()).collect())
.unwrap_or_default();
if opts.is_empty() {
writeln!(out, "refl -type {face} {file}").unwrap();
} else {
writeln!(out, "refl -type {face} {} {file}", opts.join(" ")).unwrap();
}
}
}
if let Some(serde_json::Value::Bool(b)) = mat.extras.get("mtl:map_aat") {
writeln!(out, "map_aat {}", if *b { "on" } else { "off" }).unwrap();
}
for (k, v) in &mat.extras {
if !k.starts_with("mtl:") {
continue;
}
match k.as_str() {
"mtl:Ka"
| "mtl:Ka:spectral"
| "mtl:Ka:xyz"
| "mtl:Kd:spectral"
| "mtl:Kd:xyz"
| "mtl:Ks"
| "mtl:Ks:spectral"
| "mtl:Ks:xyz"
| "mtl:Ns"
| "mtl:Ni"
| "mtl:illum"
| "mtl:illum_props"
| "mtl:Pc"
| "mtl:Ps"
| "mtl:Tf"
| "mtl:Tf:spectral"
| "mtl:Tf:xyz"
| "mtl:sharpness"
| "mtl:displaced_pbr_map"
| "mtl:d_halo_factor"
| "mtl:refl:sphere"
| "mtl:refl:cube"
| "mtl:map_aat" => continue,
_ => {}
}
if k.ends_with(":options") || k.ends_with(":options_typed") {
continue;
}
if let Some(s) = v.as_str() {
let kw = k.strip_prefix("mtl:").unwrap_or(k.as_str());
let opts_key = format!("mtl:{kw}:options");
if let Some(serde_json::Value::Array(opts)) = mat.extras.get(&opts_key) {
let parts: Vec<&str> = opts.iter().filter_map(|o| o.as_str()).collect();
writeln!(out, "{kw} {} {s}", parts.join(" ")).unwrap();
} else {
writeln!(out, "{kw} {s}").unwrap();
}
}
}
out.push('\n');
}
Ok(out.into_bytes())
}
fn write_tex_ref(
out: &mut String,
keyword: &str,
ref_: Option<TextureRef>,
textures: &[Texture],
extras: &std::collections::HashMap<String, serde_json::Value>,
) {
use std::fmt::Write;
let Some(r) = ref_ else { return };
let Some(tex) = textures.get(r.texture.0 as usize) else {
return;
};
if let ImageData::External { uri, .. } = &tex.image {
let opts_key = format!("mtl:{keyword}:options");
let alt_keys: &[&str] = match keyword {
"map_Bump" => &[
"mtl:map_bump:options",
"mtl:bump:options",
"mtl:norm:options",
],
_ => &[],
};
let opts = extras
.get(&opts_key)
.or_else(|| alt_keys.iter().find_map(|k| extras.get(*k)));
if let Some(serde_json::Value::Array(arr)) = opts {
let parts: Vec<&str> = arr.iter().filter_map(|o| o.as_str()).collect();
if parts.is_empty() {
writeln!(out, "{keyword} {uri}").unwrap();
} else {
writeln!(out, "{keyword} {} {uri}", parts.join(" ")).unwrap();
}
} else {
writeln!(out, "{keyword} {uri}").unwrap();
}
}
}
fn emit_color_statement(
out: &mut String,
keyword: &str,
extras: &std::collections::HashMap<String, serde_json::Value>,
) {
use std::fmt::Write;
let spectral_key = format!("mtl:{keyword}:spectral");
let xyz_key = format!("mtl:{keyword}:xyz");
let rgb_key = format!("mtl:{keyword}");
if let Some(serde_json::Value::Object(o)) = extras.get(&spectral_key) {
let file = o.get("file").and_then(|v| v.as_str()).unwrap_or("");
let factor = o.get("factor").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
if (factor - 1.0).abs() < f32::EPSILON {
writeln!(out, "{keyword} spectral {file}").unwrap();
} else {
writeln!(out, "{keyword} spectral {file} {}", fmt_f(factor)).unwrap();
}
} else if let Some(serde_json::Value::Array(v)) = extras.get(&xyz_key) {
if let [a, b, c] = v.as_slice() {
writeln!(
out,
"{keyword} xyz {} {} {}",
fmt_f(a.as_f64().unwrap_or(0.0) as f32),
fmt_f(b.as_f64().unwrap_or(0.0) as f32),
fmt_f(c.as_f64().unwrap_or(0.0) as f32)
)
.unwrap();
}
} else if let Some(serde_json::Value::Array(v)) = extras.get(&rgb_key) {
if let [a, b, c] = v.as_slice() {
writeln!(
out,
"{keyword} {} {} {}",
fmt_f(a.as_f64().unwrap_or(0.0) as f32),
fmt_f(b.as_f64().unwrap_or(0.0) as f32),
fmt_f(c.as_f64().unwrap_or(0.0) as f32)
)
.unwrap();
}
}
}
fn fmt_f(x: f32) -> String {
if x == 0.0 {
return "0".to_string();
}
let s = format!("{x:.6}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.').to_string();
if trimmed.is_empty() || trimmed == "-" {
"0".to_string()
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_phong() {
let text = "newmtl Red\nKd 1.0 0.0 0.0\nKa 0.1 0.1 0.1\nNs 32\n";
let mats = parse_mtl(text).unwrap();
assert_eq!(mats.len(), 1);
let m = &mats[0];
assert_eq!(m.name.as_deref(), Some("Red"));
assert_eq!(m.base_color[0..3], [1.0, 0.0, 0.0]);
assert_eq!(
m.extras
.get("mtl:Ka")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(3)
);
assert_eq!(m.extras.get("mtl:Ns").and_then(|v| v.as_f64()), Some(32.0));
}
#[test]
fn dissolve_sets_alpha_blend() {
let mats = parse_mtl("newmtl Glass\nKd 0.5 0.5 0.5\nd 0.4\n").unwrap();
assert_eq!(mats[0].base_color[3], 0.4);
assert!(matches!(mats[0].alpha_mode, AlphaMode::Blend));
}
#[test]
fn tr_alternate_dissolve() {
let mats = parse_mtl("newmtl Glass\nKd 0.5 0.5 0.5\nTr 0.4\n").unwrap();
assert!((mats[0].base_color[3] - 0.6).abs() < 1e-6);
assert!(matches!(mats[0].alpha_mode, AlphaMode::Blend));
}
#[test]
fn pbr_extension_lands_in_pbr_slots() {
let mats =
parse_mtl("newmtl Steel\nKd 0.7 0.7 0.7\nPr 0.25\nPm 0.95\nPc 0.5\nPs 0.1\n").unwrap();
let m = &mats[0];
assert!((m.roughness - 0.25).abs() < 1e-6);
assert!((m.metallic - 0.95).abs() < 1e-6);
let pc = m.extras.get("mtl:Pc").and_then(|v| v.as_f64()).unwrap();
assert!((pc - 0.5).abs() < 1e-6);
let ps = m.extras.get("mtl:Ps").and_then(|v| v.as_f64()).unwrap();
assert!((ps - 0.1).abs() < 1e-6);
}
#[test]
fn map_kd_pending_uri_round_trips() {
let mats = parse_mtl("newmtl Tex\nKd 1 1 1\nmap_Kd diffuse.png\n").unwrap();
let pending = mats[0].extras.get("mtl:pending_textures").unwrap();
assert_eq!(pending["base_color"].as_str(), Some("diffuse.png"));
}
}