oxideav_obj/mtl.rs
1//! Wavefront MTL (material library) ASCII parser + serialiser.
2//!
3//! The grammar mirrors OBJ's: line-oriented, whitespace-separated,
4//! `#` introduces a comment to end of line. Each `newmtl <name>`
5//! opens a fresh material; subsequent lines populate the material's
6//! parameters until the next `newmtl` or end of file.
7//!
8//! This crate maps the Phong-Blinn vocabulary onto the glTF
9//! metallic-roughness model in [`Material`], preserving the original
10//! field values in [`Material::extras`] so a re-serialise reproduces
11//! the input. The Wavefront-PBR extension (`Pr`, `Pm`, `Pc`, `Ps`,
12//! `map_Pr`, `map_Pm`) lands directly in the corresponding PBR slots.
13
14use oxideav_mesh3d::{AlphaMode, Error, ImageData, Material, Result, Sampler, Texture, TextureRef};
15
16// ---------------------------------------------------------------------------
17// Parsing
18// ---------------------------------------------------------------------------
19
20/// Pending texture references from the parser. We can't allocate a
21/// `TextureRef` at parse time because that needs a `TextureId` (only
22/// known once textures land in the [`Scene3D`](oxideav_mesh3d::Scene3D)).
23/// The OBJ→Scene3D path bridges this in
24/// [`merge_materials_into_scene`].
25#[derive(Debug, Default, Clone)]
26struct PendingTextures {
27 base_color: Option<String>,
28 normal: Option<String>,
29 metallic_roughness: Option<String>,
30 emissive: Option<String>,
31}
32
33/// Parsed material plus its yet-to-be-resolved texture URIs.
34#[derive(Debug, Clone)]
35struct ParsedMaterial {
36 material: Material,
37 pending: PendingTextures,
38}
39
40/// Parse an MTL document.
41///
42/// Returns one [`Material`] per `newmtl` block. Texture references
43/// are resolved lazily by [`merge_materials_into_scene`] (used by the
44/// OBJ decoder) — direct callers get materials with `*_texture` slots
45/// wired to fresh textures stored in the same returned vector via
46/// the `extras["mtl:pending_textures"]` side-channel; consumers
47/// integrating with a real `Scene3D` should use [`parse_mtl_with_scene`]
48/// instead.
49pub fn parse_mtl(text: &str) -> Result<Vec<Material>> {
50 let parsed = parse_mtl_internal(text)?;
51 let mut out: Vec<Material> = Vec::with_capacity(parsed.len());
52 for pm in parsed {
53 let mut mat = pm.material;
54 // Stash pending texture URIs in extras so a downstream pass
55 // can hoist them into a Scene3D's texture pool. Direct callers
56 // who want the URIs without a Scene3D can pull them from here.
57 let mut pending_obj = serde_json::Map::new();
58 if let Some(p) = pm.pending.base_color {
59 pending_obj.insert("base_color".into(), serde_json::Value::String(p));
60 }
61 if let Some(p) = pm.pending.normal {
62 pending_obj.insert("normal".into(), serde_json::Value::String(p));
63 }
64 if let Some(p) = pm.pending.metallic_roughness {
65 pending_obj.insert("metallic_roughness".into(), serde_json::Value::String(p));
66 }
67 if let Some(p) = pm.pending.emissive {
68 pending_obj.insert("emissive".into(), serde_json::Value::String(p));
69 }
70 if !pending_obj.is_empty() {
71 mat.extras.insert(
72 "mtl:pending_textures".to_string(),
73 serde_json::Value::Object(pending_obj),
74 );
75 }
76 out.push(mat);
77 }
78 Ok(out)
79}
80
81/// Hoist pending texture URIs into the supplied scene as
82/// [`Texture`]s and bind the result on each material via
83/// [`TextureRef`]. Materials are also added to the scene; returns the
84/// `MaterialId` for each input material in declaration order.
85///
86/// Provided as a convenience for `obj.rs` and direct MTL-decoder
87/// callers; symmetrical with the OBJ→Scene3D pipeline so reload of an
88/// MTL standalone produces the same in-scene structure as a full OBJ
89/// decode would.
90pub fn merge_materials_into_scene(
91 scene: &mut oxideav_mesh3d::Scene3D,
92 materials: Vec<Material>,
93) -> Vec<oxideav_mesh3d::MaterialId> {
94 let mut ids = Vec::with_capacity(materials.len());
95 for mut mat in materials {
96 // Resolve any `mtl:pending_textures` field into real Textures.
97 let pending = mat.extras.remove("mtl:pending_textures");
98 if let Some(serde_json::Value::Object(obj)) = pending {
99 for (slot, val) in obj {
100 let serde_json::Value::String(uri) = val else {
101 continue;
102 };
103 let tex = Texture {
104 name: Some(uri.clone()),
105 image: ImageData::External {
106 uri: uri.clone(),
107 mime: None,
108 },
109 sampler: Sampler::default_sampler(),
110 };
111 let tex_id = scene.add_texture(tex);
112 let tex_ref = TextureRef::new(tex_id);
113 match slot.as_str() {
114 "base_color" => mat.base_color_texture = Some(tex_ref),
115 "normal" => mat.normal_texture = Some(tex_ref),
116 "metallic_roughness" => mat.metallic_roughness_texture = Some(tex_ref),
117 "emissive" => mat.emissive_texture = Some(tex_ref),
118 _ => {}
119 }
120 }
121 }
122 ids.push(scene.add_material(mat));
123 }
124 ids
125}
126
127/// One-shot parse + scene-hoist for direct MTL-decoder callers.
128pub fn parse_mtl_with_scene(text: &str) -> Result<oxideav_mesh3d::Scene3D> {
129 let mut scene = oxideav_mesh3d::Scene3D::new();
130 let materials = parse_mtl(text)?;
131 let _ = merge_materials_into_scene(&mut scene, materials);
132 Ok(scene)
133}
134
135fn parse_mtl_internal(text: &str) -> Result<Vec<ParsedMaterial>> {
136 let mut out: Vec<ParsedMaterial> = Vec::new();
137 let mut current: Option<ParsedMaterial> = None;
138
139 fn strip_comment(line: &str) -> &str {
140 match line.find('#') {
141 Some(idx) => &line[..idx],
142 None => line,
143 }
144 }
145
146 for raw_line in text.split('\n') {
147 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
148 let line = strip_comment(line).trim();
149 if line.is_empty() {
150 continue;
151 }
152 let mut tokens = line.split_whitespace();
153 let Some(keyword) = tokens.next() else {
154 continue;
155 };
156
157 match keyword {
158 "newmtl" => {
159 if let Some(prev) = current.take() {
160 out.push(prev);
161 }
162 let name: String = tokens.collect::<Vec<_>>().join(" ");
163 let mut mat = Material::new();
164 // Spec primer says fallback to metallic=0/roughness=0.5 when
165 // PBR fields aren't present.
166 mat.metallic = 0.0;
167 mat.roughness = 0.5;
168 mat.name = Some(name);
169 current = Some(ParsedMaterial {
170 material: mat,
171 pending: PendingTextures::default(),
172 });
173 }
174 other => {
175 let Some(pm) = current.as_mut() else {
176 return Err(Error::invalid(format!(
177 "MTL: {other:?} appears before any newmtl directive"
178 )));
179 };
180 apply_directive(other, &mut tokens, pm)?;
181 }
182 }
183 }
184
185 if let Some(last) = current.take() {
186 out.push(last);
187 }
188 Ok(out)
189}
190
191fn parse_floats<'a, I: Iterator<Item = &'a str>>(tokens: I, keyword: &str) -> Result<Vec<f32>> {
192 tokens
193 .map(str::parse::<f32>)
194 .collect::<std::result::Result<Vec<_>, _>>()
195 .map_err(|e| Error::invalid(format!("MTL {keyword}: bad float ({e})")))
196}
197
198/// One of the three mutually-exclusive forms a `K*` colour statement
199/// (or `Tf`) can take per Wavefront MTL spec §"Ka r g b" / §"Kd r g b"
200/// / §"Ks r g b" / §"Tf r g b":
201///
202/// * Plain RGB triple (g/b default to r when omitted).
203/// * `spectral file.rfl factor` (factor defaults to 1.0).
204/// * `xyz x y z` CIEXYZ tristimulus (y/z default to x when omitted).
205///
206/// Used to keep the four colour-statement parsers consistent.
207#[derive(Debug, Clone)]
208enum ColorStatement {
209 Rgb { r: f32, g: f32, b: f32 },
210 Spectral { file: String, factor: f32 },
211 Xyz { x: f32, y: f32, z: f32 },
212}
213
214/// Discriminate and parse a `K* … ` / `Tf …` argument list. The first
215/// token decides the shape:
216///
217/// * Numeric → RGB form (spec §"…r g b": g and b default to r when
218/// omitted; we eagerly broadcast so the canonical 3-tuple is what
219/// lands in `extras`).
220/// * `spectral` → `spectral file.rfl [factor]` (spec §"… spectral
221/// file.rfl factor": factor defaults to 1.0 when omitted).
222/// * `xyz` → `xyz x [y z]` (spec §"… xyz x y z": y and z default to x
223/// when omitted).
224///
225/// `keyword` names the originating directive for error messages.
226fn parse_color_statement(toks: &[&str], keyword: &str) -> Result<ColorStatement> {
227 if toks.is_empty() {
228 return Err(Error::invalid(format!(
229 "{keyword}: needs at least 1 argument"
230 )));
231 }
232 match toks[0] {
233 "spectral" => {
234 if toks.len() < 2 {
235 return Err(Error::invalid(format!(
236 "{keyword} spectral: missing file.rfl"
237 )));
238 }
239 let file = toks[1].to_string();
240 let factor: f32 = if let Some(f) = toks.get(2) {
241 f.parse()
242 .map_err(|e| Error::invalid(format!("{keyword} spectral: bad factor ({e})")))?
243 } else {
244 1.0
245 };
246 Ok(ColorStatement::Spectral { file, factor })
247 }
248 "xyz" => {
249 let v: Vec<f32> = toks[1..]
250 .iter()
251 .map(|s| s.parse::<f32>())
252 .collect::<std::result::Result<Vec<_>, _>>()
253 .map_err(|e| Error::invalid(format!("{keyword} xyz: bad float ({e})")))?;
254 if v.is_empty() {
255 return Err(Error::invalid(format!(
256 "{keyword} xyz: needs at least 1 float"
257 )));
258 }
259 let x = v[0];
260 let y = v.get(1).copied().unwrap_or(x);
261 let z = v.get(2).copied().unwrap_or(x);
262 Ok(ColorStatement::Xyz { x, y, z })
263 }
264 _ => {
265 let v: Vec<f32> = toks
266 .iter()
267 .map(|s| s.parse::<f32>())
268 .collect::<std::result::Result<Vec<_>, _>>()
269 .map_err(|e| Error::invalid(format!("{keyword}: bad float ({e})")))?;
270 let r = v[0];
271 let g = v.get(1).copied().unwrap_or(r);
272 let b = v.get(2).copied().unwrap_or(r);
273 Ok(ColorStatement::Rgb { r, g, b })
274 }
275 }
276}
277
278fn apply_directive(
279 keyword: &str,
280 tokens: &mut std::str::SplitWhitespace<'_>,
281 pm: &mut ParsedMaterial,
282) -> Result<()> {
283 let mat = &mut pm.material;
284 match keyword {
285 "Ka" => {
286 // Ambient reflectivity. Spec §"Ka r g b" lists three
287 // mutually-exclusive forms (RGB / spectral / xyz); the alt
288 // forms ride on sibling extras keys (`mtl:Ka:spectral` /
289 // `mtl:Ka:xyz`) so an MTL emit reproduces the operator's
290 // chosen spelling.
291 let toks: Vec<&str> = tokens.collect();
292 match parse_color_statement(&toks, "Ka")? {
293 ColorStatement::Rgb { r, g, b } => {
294 mat.extras
295 .insert("mtl:Ka".to_string(), serde_json::json!([r, g, b]));
296 }
297 ColorStatement::Spectral { file, factor } => {
298 mat.extras.insert(
299 "mtl:Ka:spectral".to_string(),
300 serde_json::json!({ "file": file, "factor": factor }),
301 );
302 }
303 ColorStatement::Xyz { x, y, z } => {
304 mat.extras
305 .insert("mtl:Ka:xyz".to_string(), serde_json::json!([x, y, z]));
306 }
307 }
308 }
309 "Kd" => {
310 // Diffuse reflectivity. Spec §"Kd r g b" — RGB form sets
311 // `base_color[0..3]` (canonical glTF base colour); the
312 // mutually-exclusive `Kd spectral` / `Kd xyz` forms ride
313 // on sibling extras (`mtl:Kd:spectral` / `mtl:Kd:xyz`)
314 // and leave `base_color` untouched so the encoder
315 // suppresses the canonical `Kd r g b` emit.
316 let toks: Vec<&str> = tokens.collect();
317 match parse_color_statement(&toks, "Kd")? {
318 ColorStatement::Rgb { r, g, b } => {
319 // Preserve the alpha channel that may have been set
320 // by an earlier `d` line so the assignment ordering
321 // matches the file (`d` typically follows `Kd`,
322 // but defensive).
323 let alpha = mat.base_color[3];
324 mat.base_color = [r, g, b, alpha];
325 }
326 ColorStatement::Spectral { file, factor } => {
327 mat.extras.insert(
328 "mtl:Kd:spectral".to_string(),
329 serde_json::json!({ "file": file, "factor": factor }),
330 );
331 }
332 ColorStatement::Xyz { x, y, z } => {
333 mat.extras
334 .insert("mtl:Kd:xyz".to_string(), serde_json::json!([x, y, z]));
335 }
336 }
337 }
338 "Ks" => {
339 // Specular reflectivity. Spec §"Ks r g b" — same three
340 // mutually-exclusive forms as `Ka` / `Kd`.
341 let toks: Vec<&str> = tokens.collect();
342 match parse_color_statement(&toks, "Ks")? {
343 ColorStatement::Rgb { r, g, b } => {
344 mat.extras
345 .insert("mtl:Ks".to_string(), serde_json::json!([r, g, b]));
346 }
347 ColorStatement::Spectral { file, factor } => {
348 mat.extras.insert(
349 "mtl:Ks:spectral".to_string(),
350 serde_json::json!({ "file": file, "factor": factor }),
351 );
352 }
353 ColorStatement::Xyz { x, y, z } => {
354 mat.extras
355 .insert("mtl:Ks:xyz".to_string(), serde_json::json!([x, y, z]));
356 }
357 }
358 }
359 "Ke" => {
360 let v = parse_floats(tokens.by_ref(), keyword)?;
361 if v.len() < 3 {
362 return Err(Error::invalid(format!(
363 "Ke: needs 3 floats, got {}",
364 v.len()
365 )));
366 }
367 mat.emissive_factor = [v[0], v[1], v[2]];
368 }
369 "Tf" => {
370 // Transmission filter. Spec §"Tf r g b" lists the same
371 // three mutually-exclusive forms as `Ka` / `Kd` / `Ks`:
372 //
373 // Tf r g b — RGB triple (g/b default to r)
374 // Tf spectral file.rfl factor — spectral .rfl curve
375 // Tf xyz x y z — CIEXYZ tristimulus (y/z default to x)
376 //
377 // The RGB form lands in `extras["mtl:Tf"]` as an
378 // `[r,g,b]` array (the round-1 behaviour); the alt forms
379 // land under sibling keys (`mtl:Tf:spectral` /
380 // `mtl:Tf:xyz`) so a re-emit reproduces the operator's
381 // chosen spelling. PBR transmission is its own KHR
382 // extension on the glTF side, so we don't model any of
383 // the variants as a first-class `Material` field.
384 let toks: Vec<&str> = tokens.collect();
385 match parse_color_statement(&toks, "Tf")? {
386 ColorStatement::Rgb { r, g, b } => {
387 mat.extras
388 .insert("mtl:Tf".to_string(), serde_json::json!([r, g, b]));
389 }
390 ColorStatement::Spectral { file, factor } => {
391 mat.extras.insert(
392 "mtl:Tf:spectral".to_string(),
393 serde_json::json!({ "file": file, "factor": factor }),
394 );
395 }
396 ColorStatement::Xyz { x, y, z } => {
397 mat.extras
398 .insert("mtl:Tf:xyz".to_string(), serde_json::json!([x, y, z]));
399 }
400 }
401 }
402 "sharpness" => {
403 // Reflection-map sharpness; spec range 0..1000, default 60.
404 let v: f32 = tokens
405 .next()
406 .ok_or_else(|| Error::invalid("sharpness: missing value"))?
407 .parse()
408 .map_err(|e| Error::invalid(format!("sharpness: bad float ({e})")))?;
409 mat.extras
410 .insert("mtl:sharpness".to_string(), serde_json::json!(v));
411 }
412 "Ns" => {
413 let v: f32 = tokens
414 .next()
415 .ok_or_else(|| Error::invalid("Ns: missing value"))?
416 .parse()
417 .map_err(|e| Error::invalid(format!("Ns: bad float ({e})")))?;
418 mat.extras
419 .insert("mtl:Ns".to_string(), serde_json::json!(v));
420 }
421 "Ni" => {
422 let v: f32 = tokens
423 .next()
424 .ok_or_else(|| Error::invalid("Ni: missing value"))?
425 .parse()
426 .map_err(|e| Error::invalid(format!("Ni: bad float ({e})")))?;
427 mat.extras
428 .insert("mtl:Ni".to_string(), serde_json::json!(v));
429 }
430 "d" => {
431 // The first non-flag token is the dissolve value. The
432 // optional `-halo` flag (per spec §"d -halo factor")
433 // makes the dissolve orientation-dependent — surface it
434 // via extras so the round-trip emits the same form.
435 let mut halo = false;
436 let mut value: Option<f32> = None;
437 for tok in tokens.by_ref() {
438 if tok == "-halo" {
439 halo = true;
440 continue;
441 }
442 value = Some(
443 tok.parse()
444 .map_err(|e| Error::invalid(format!("d: bad float ({e})")))?,
445 );
446 break;
447 }
448 let v = value.ok_or_else(|| Error::invalid("d: missing value"))?;
449 mat.base_color[3] = v;
450 if v < 1.0 {
451 mat.alpha_mode = AlphaMode::Blend;
452 }
453 if halo {
454 mat.extras
455 .insert("mtl:d_halo_factor".to_string(), serde_json::json!(v));
456 }
457 }
458 "Tr" => {
459 // Tr = 1 - d (Wavefront alternate dissolve form).
460 let v: f32 = tokens
461 .next()
462 .ok_or_else(|| Error::invalid("Tr: missing value"))?
463 .parse()
464 .map_err(|e| Error::invalid(format!("Tr: bad float ({e})")))?;
465 let d = 1.0 - v;
466 mat.base_color[3] = d;
467 if d < 1.0 {
468 mat.alpha_mode = AlphaMode::Blend;
469 }
470 }
471 "illum" => {
472 let v: i32 = tokens
473 .next()
474 .ok_or_else(|| Error::invalid("illum: missing value"))?
475 .parse()
476 .map_err(|e| Error::invalid(format!("illum: bad integer ({e})")))?;
477 mat.extras
478 .insert("mtl:illum".to_string(), serde_json::json!(v));
479 // Surface the spec's per-model property breakdown alongside
480 // the raw integer so consumers can branch on shading flags
481 // without re-deriving the table. Spec §"illum illum_#"
482 // (Wavefront Advanced Visualizer manual p.5-30, summary
483 // table) enumerates which lighting terms each model turns
484 // on; we mirror that table verbatim into `mtl:illum_props`.
485 // For values outside 0..=10 (out-of-spec) we still record
486 // the integer but emit a null props object so downstream
487 // can tell "unknown model" from "model 0 with no flags".
488 if let Some(props) = illum_property_map(v) {
489 mat.extras.insert("mtl:illum_props".to_string(), props);
490 }
491 }
492 "Pr" => {
493 let v: f32 = tokens
494 .next()
495 .ok_or_else(|| Error::invalid("Pr: missing value"))?
496 .parse()
497 .map_err(|e| Error::invalid(format!("Pr: bad float ({e})")))?;
498 mat.roughness = v;
499 }
500 "Pm" => {
501 let v: f32 = tokens
502 .next()
503 .ok_or_else(|| Error::invalid("Pm: missing value"))?
504 .parse()
505 .map_err(|e| Error::invalid(format!("Pm: bad float ({e})")))?;
506 mat.metallic = v;
507 }
508 "Pc" => {
509 let v: f32 = tokens
510 .next()
511 .ok_or_else(|| Error::invalid("Pc: missing value"))?
512 .parse()
513 .map_err(|e| Error::invalid(format!("Pc: bad float ({e})")))?;
514 mat.extras
515 .insert("mtl:Pc".to_string(), serde_json::json!(v));
516 }
517 "Pcr" => {
518 let v: f32 = tokens
519 .next()
520 .ok_or_else(|| Error::invalid("Pcr: missing value"))?
521 .parse()
522 .map_err(|e| Error::invalid(format!("Pcr: bad float ({e})")))?;
523 mat.extras
524 .insert("mtl:Pcr".to_string(), serde_json::json!(v));
525 }
526 "Ps" => {
527 let v: f32 = tokens
528 .next()
529 .ok_or_else(|| Error::invalid("Ps: missing value"))?
530 .parse()
531 .map_err(|e| Error::invalid(format!("Ps: bad float ({e})")))?;
532 mat.extras
533 .insert("mtl:Ps".to_string(), serde_json::json!(v));
534 }
535 "aniso" | "anisor" => {
536 let v: f32 = tokens
537 .next()
538 .ok_or_else(|| Error::invalid(format!("{keyword}: missing value")))?
539 .parse()
540 .map_err(|e| Error::invalid(format!("{keyword}: bad float ({e})")))?;
541 mat.extras
542 .insert(format!("mtl:{keyword}"), serde_json::json!(v));
543 }
544 "map_aat" => {
545 // Per-material texture anti-aliasing toggle (spec
546 // §"map_aat on"). The spec documents only the `on` form,
547 // but the keyword is a boolean state-setter, so `off` is
548 // accepted symmetrically. Surfaced as a bool extra so a
549 // re-emit reproduces the exact `on` / `off` token; any
550 // other / missing argument drops the line silently
551 // (lenient-loader convention) without failing the parse.
552 if let Some(tok) = tokens.next() {
553 let flag = match tok {
554 "on" => Some(true),
555 "off" => Some(false),
556 _ => None,
557 };
558 if let Some(b) = flag {
559 mat.extras
560 .insert("mtl:map_aat".to_string(), serde_json::json!(b));
561 }
562 }
563 }
564 "map_Kd" => {
565 pm.pending.base_color = Some(parse_map_with_options(keyword, tokens, &mut mat.extras));
566 }
567 "map_Bump" | "map_bump" | "bump" | "norm" => {
568 pm.pending.normal = Some(parse_map_with_options(keyword, tokens, &mut mat.extras));
569 }
570 "map_Ke" => {
571 pm.pending.emissive = Some(parse_map_with_options(keyword, tokens, &mut mat.extras));
572 }
573 "map_Pr" | "map_Pm" => {
574 // Either of the two PBR maps lands in metallic_roughness — the
575 // glTF channel-packing convention is B = metallic, G = roughness.
576 // We can't fuse two file references into one packed texture
577 // without decoding pixels, so the last-seen wins; the other
578 // is stashed in extras for round-trip.
579 let s = parse_map_with_options(keyword, tokens, &mut mat.extras);
580 if let Some(prev) = pm.pending.metallic_roughness.replace(s.clone()) {
581 mat.extras.insert(
582 "mtl:displaced_pbr_map".to_string(),
583 serde_json::Value::String(prev),
584 );
585 }
586 mat.extras
587 .insert(format!("mtl:{keyword}"), serde_json::Value::String(s));
588 }
589 "refl" | "map_refl" => {
590 // Reflection-map statements per spec §"Reflection Map" come
591 // in three discriminated forms via the `-type` flag:
592 //
593 // refl -type sphere -options -args filename
594 // refl -type cube_top|cube_bottom|cube_front|cube_back|cube_left|cube_right ... filename
595 //
596 // (plus the legacy bare-`refl filename` form which we
597 // preserve under `mtl:refl` as before).
598 //
599 // Cube faces span SIX separate `refl` lines that together
600 // describe one cubemap; bundle them into a single
601 // `mtl:refl:cube` object keyed by face name so consumers
602 // see one cubemap declaration rather than six unrelated
603 // textures. Sphere lands as `mtl:refl:sphere = filename`.
604 //
605 // Per-line option flags (`-blendu`, `-mm`, …) attached to
606 // a typed reflection-map line live next to the filename in
607 // a `{file, options: [...]}` object so the round-trip is
608 // bit-stable.
609 let toks: Vec<&str> = tokens.collect();
610 let mut iter = toks.iter().copied().peekable();
611 // Pull a `-type <kind>` flag out of the option stream when
612 // it is the first option; bare-refl with no `-type` falls
613 // through to the legacy single-string form.
614 let mut refl_kind: Option<&'static str> = None;
615 if iter.peek() == Some(&"-type") {
616 let _ = iter.next();
617 if let Some(kind) = iter.next() {
618 refl_kind = match kind {
619 "sphere" => Some("sphere"),
620 "cube_top" => Some("cube_top"),
621 "cube_bottom" => Some("cube_bottom"),
622 "cube_front" => Some("cube_front"),
623 "cube_back" => Some("cube_back"),
624 "cube_left" => Some("cube_left"),
625 "cube_right" => Some("cube_right"),
626 // Spec also lists the legacy `cube_side` keyword
627 // as an alias-shape; surface it verbatim.
628 "cube_side" => Some("cube_side"),
629 _ => None,
630 };
631 if refl_kind.is_none() {
632 // Unknown -type kind — preserve verbatim via
633 // the legacy single-string slot below.
634 }
635 }
636 }
637 // Re-collect the remaining tokens into a SplitWhitespace-
638 // shaped helper so `map_options_and_filename` can work over
639 // them without regressing the existing API.
640 let remaining: Vec<&str> = iter.collect();
641 let joined = remaining.join(" ");
642 let mut split = joined.split_whitespace();
643 let (opts, filename) = map_options_and_filename(&mut split);
644
645 match refl_kind {
646 Some(face) if face != "sphere" && face != "cube_side" => {
647 // Cube face — fold into the per-material cubemap
648 // bundle. Each face is a `{file, options}` object;
649 // missing options arrays are omitted.
650 let mut entry = serde_json::Map::new();
651 entry.insert(
652 "file".to_string(),
653 serde_json::Value::String(filename.clone()),
654 );
655 if !opts.is_empty() {
656 if let Some(typed) = decompose_map_options(&opts) {
657 entry.insert("options_typed".to_string(), typed);
658 }
659 entry.insert(
660 "options".to_string(),
661 serde_json::Value::Array(
662 opts.iter()
663 .map(|s| serde_json::Value::String(s.clone()))
664 .collect(),
665 ),
666 );
667 }
668 let cube_key = "mtl:refl:cube".to_string();
669 let cube_obj = match mat.extras.remove(&cube_key) {
670 Some(serde_json::Value::Object(map)) => map,
671 _ => serde_json::Map::new(),
672 };
673 let mut cube_obj = cube_obj;
674 cube_obj.insert(face.to_string(), serde_json::Value::Object(entry));
675 mat.extras
676 .insert(cube_key, serde_json::Value::Object(cube_obj));
677 }
678 Some("sphere") => {
679 let mut entry = serde_json::Map::new();
680 entry.insert(
681 "file".to_string(),
682 serde_json::Value::String(filename.clone()),
683 );
684 if !opts.is_empty() {
685 if let Some(typed) = decompose_map_options(&opts) {
686 entry.insert("options_typed".to_string(), typed);
687 }
688 entry.insert(
689 "options".to_string(),
690 serde_json::Value::Array(
691 opts.iter()
692 .map(|s| serde_json::Value::String(s.clone()))
693 .collect(),
694 ),
695 );
696 }
697 mat.extras.insert(
698 "mtl:refl:sphere".to_string(),
699 serde_json::Value::Object(entry),
700 );
701 }
702 _ => {
703 // Bare `refl filename` (legacy) or unknown -type
704 // kind — preserve via the original single-string
705 // slot used in r3.
706 if !opts.is_empty() {
707 if let Some(typed) = decompose_map_options(&opts) {
708 mat.extras
709 .insert(format!("mtl:{keyword}:options_typed"), typed);
710 }
711 mat.extras.insert(
712 format!("mtl:{keyword}:options"),
713 serde_json::Value::Array(
714 opts.into_iter().map(serde_json::Value::String).collect(),
715 ),
716 );
717 }
718 mat.extras.insert(
719 format!("mtl:{keyword}"),
720 serde_json::Value::String(filename),
721 );
722 }
723 }
724 }
725 "map_Ka" | "map_Ks" | "map_Ns" | "map_d" | "disp" | "map_disp" | "decal" | "map_decal" => {
726 // Less-PBR-friendly maps preserved in extras for round-trip.
727 // Both the bare (`disp`, `decal`) and `map_*` variants are
728 // accepted; the original spelling is kept as the extras key
729 // so the encoder re-emits the same form.
730 let s = parse_map_with_options(keyword, tokens, &mut mat.extras);
731 mat.extras
732 .insert(format!("mtl:{keyword}"), serde_json::Value::String(s));
733 }
734 // Unknown directives are silently skipped (lenient-loader convention).
735 _ => {}
736 }
737 Ok(())
738}
739
740/// Split a `map_*` token stream into `(options, filename)`.
741///
742/// `map_Kd -blendu off -clamp on -mm 0 1 path/to/diffuse.png`
743/// returns `(["-blendu off", "-clamp on", "-mm 0 1"], "path/to/diffuse.png")`.
744///
745/// Each leading `-flag` token consumes a known number of arguments
746/// per the MTL spec ("Options for texture map statements", Bourke
747/// mirror line 540 onwards). Once a token that is neither a flag nor
748/// a flag argument is encountered, the rest of the line is treated
749/// as the filename (joined with single spaces so paths with embedded
750/// whitespace round-trip).
751fn map_options_and_filename(tokens: &mut std::str::SplitWhitespace<'_>) -> (Vec<String>, String) {
752 let toks: Vec<&str> = tokens.collect();
753 let mut opts: Vec<String> = Vec::new();
754 let mut i = 0;
755 while i < toks.len() {
756 let t = toks[i];
757 // Only `-letter…` is a flag; bare integers / negative numbers
758 // for paths starting with `-` would also start with `-`,
759 // but the second char is the discriminator (alphabetic ⇒ flag).
760 let is_flag = t.starts_with('-')
761 && t.len() > 1
762 && t.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic());
763 if !is_flag {
764 break;
765 }
766 let arg_count = flag_arg_count(t);
767 if arg_count == 0 {
768 // Unknown flag — preserve verbatim and hope the next token
769 // is the filename. Bumps the index by 1.
770 opts.push(t.to_string());
771 i += 1;
772 continue;
773 }
774 // Make sure we have enough remaining tokens; if not, the file
775 // name was truncated mid-flag and we surface the original
776 // tail verbatim so the user sees the malformed input.
777 let end = (i + 1 + arg_count).min(toks.len());
778 let chunk: Vec<&str> = toks[i..end].to_vec();
779 opts.push(chunk.join(" "));
780 i = end;
781 }
782 let filename = toks[i..].join(" ");
783 (opts, filename)
784}
785
786/// Number of arguments that follow a known `map_*` option flag, per
787/// the MTL spec. Unknown flags return 0 → the parser preserves the
788/// flag literally and treats the next token as the filename.
789fn flag_arg_count(flag: &str) -> usize {
790 match flag {
791 "-blendu" | "-blendv" | "-cc" | "-clamp" => 1, // on | off
792 "-bm" | "-boost" | "-texres" => 1, // single float / int
793 "-imfchan" | "-type" => 1, // single char / keyword
794 "-mm" => 2, // base gain
795 // `-o`, `-s`, `-t` are documented as `u [v] [w]` — variable
796 // arity. We greedily consume up to three numeric tokens after
797 // the flag in `consume_uvw`, but the static count is 3 so
798 // well-formed inputs round-trip cleanly. If a path follows
799 // earlier than expected (e.g. `-o 1 path.png`), the path
800 // accidentally absorbs the missing v / w; users who need that
801 // edge case can supply explicit zeros.
802 "-o" | "-s" | "-t" => 3,
803 _ => 0,
804 }
805}
806
807/// Parse a `map_*`-style keyword: split into (options, filename),
808/// stash the options in `extras["mtl:<keyword>:options"]`, and return
809/// the bare filename for caller-side TextureRef wiring.
810///
811/// In addition to the verbatim `:options` array (which drives encoder
812/// round-trip), a decomposed typed view of each recognised flag lands
813/// on `extras["mtl:<keyword>:options_typed"]`. See
814/// [`decompose_map_options`] for the schema.
815fn parse_map_with_options(
816 keyword: &str,
817 tokens: &mut std::str::SplitWhitespace<'_>,
818 extras: &mut std::collections::HashMap<String, serde_json::Value>,
819) -> String {
820 let (opts, filename) = map_options_and_filename(tokens);
821 if !opts.is_empty() {
822 if let Some(typed) = decompose_map_options(&opts) {
823 extras.insert(format!("mtl:{keyword}:options_typed"), typed);
824 }
825 extras.insert(
826 format!("mtl:{keyword}:options"),
827 serde_json::Value::Array(opts.into_iter().map(serde_json::Value::String).collect()),
828 );
829 }
830 filename
831}
832
833/// Decompose a parsed `:options` array into a typed object per spec
834/// §"Options for texture map statements".
835///
836/// The spec defines twelve flags that may appear before a `map_*` /
837/// `bump` / `disp` / `decal` / `refl` filename. Each parsed `-flag
838/// args` chunk lands under a stable lowercase key with a per-flag
839/// value shape; flags the parser didn't recognise are silently skipped
840/// so unknown chunks don't pollute the typed view (they still ride on
841/// the raw `:options` array, which drives the encoder).
842///
843/// Key / value schema (each present only when the flag appeared):
844///
845/// * `blendu`, `blendv`, `clamp`, `cc` — `bool`. The spec writes these
846/// as `on` / `off`; the typed value is `true` for `on` and `false`
847/// for `off`. Any other argument value drops the flag from the typed
848/// view (the raw array still preserves it).
849/// * `bm`, `boost`, `texres` — `f64`. The spec writes these as a
850/// single positive (`boost`, `texres`) or signed (`bm`) float; the
851/// typed value is the parsed number.
852/// * `imfchan` — `String` over the spec alphabet `r | g | b | m | l |
853/// z` (§"-imfchan"). Anything else drops the flag.
854/// * `type` — `String` over the spec alphabet `sphere | cube_top |
855/// cube_bottom | cube_front | cube_back | cube_left | cube_right`
856/// (§"refl -type"); other values drop the flag.
857/// * `mm` — `[base, gain]` as a two-element `[f64; 2]` array (spec
858/// §"-mm base gain"). Both arguments are required.
859/// * `o`, `s`, `t` — `[u, v, w]` as a three-element `[f64; 3]` array
860/// (spec §"-o u v w" / §"-s u v w" / §"-t u v w"). The spec marks
861/// `v` and `w` optional (defaulting to 0 for `-o` / `-t` and 1 for
862/// `-s`); the typed view fills the omitted slots accordingly so the
863/// array shape stays stable.
864///
865/// Returns `None` when none of the recognised flags appeared, so
866/// callers can skip the `options_typed` key entirely for option lists
867/// composed of only unknown flags. The raw `:options` array still
868/// drives encoder output; this typed view is parse-time-only and is
869/// not consulted by the encoder (so the encoder still round-trips the
870/// exact source-order tokens).
871fn decompose_map_options(opts: &[String]) -> Option<serde_json::Value> {
872 let mut obj = serde_json::Map::new();
873 for chunk in opts {
874 let mut it = chunk.split_whitespace();
875 let flag = it.next()?;
876 let args: Vec<&str> = it.collect();
877 match flag {
878 // Boolean on|off flags
879 "-blendu" | "-blendv" | "-clamp" | "-cc" => {
880 if args.len() != 1 {
881 continue;
882 }
883 let v = match args[0] {
884 "on" => true,
885 "off" => false,
886 _ => continue,
887 };
888 let key = &flag[1..];
889 obj.insert(key.to_string(), serde_json::Value::Bool(v));
890 }
891 // Single-float flags
892 "-bm" | "-boost" | "-texres" => {
893 if args.len() != 1 {
894 continue;
895 }
896 let Ok(v) = args[0].parse::<f64>() else {
897 continue;
898 };
899 let key = &flag[1..];
900 obj.insert(
901 key.to_string(),
902 serde_json::Number::from_f64(v)
903 .map(serde_json::Value::Number)
904 .unwrap_or(serde_json::Value::Null),
905 );
906 }
907 // -imfchan: single-letter channel selector per spec alphabet
908 "-imfchan" => {
909 if args.len() != 1 {
910 continue;
911 }
912 let v = args[0];
913 if !matches!(v, "r" | "g" | "b" | "m" | "l" | "z") {
914 continue;
915 }
916 obj.insert(
917 "imfchan".to_string(),
918 serde_json::Value::String(v.to_string()),
919 );
920 }
921 // -type: keyword selector used by `refl` per spec §"refl -type"
922 "-type" => {
923 if args.len() != 1 {
924 continue;
925 }
926 let v = args[0];
927 if !matches!(
928 v,
929 "sphere"
930 | "cube_top"
931 | "cube_bottom"
932 | "cube_front"
933 | "cube_back"
934 | "cube_left"
935 | "cube_right"
936 ) {
937 continue;
938 }
939 obj.insert("type".to_string(), serde_json::Value::String(v.to_string()));
940 }
941 // -mm base gain: exactly two floats
942 "-mm" => {
943 if args.len() != 2 {
944 continue;
945 }
946 let Ok(base) = args[0].parse::<f64>() else {
947 continue;
948 };
949 let Ok(gain) = args[1].parse::<f64>() else {
950 continue;
951 };
952 obj.insert(
953 "mm".to_string(),
954 serde_json::Value::Array(vec![
955 serde_json::Number::from_f64(base)
956 .map(serde_json::Value::Number)
957 .unwrap_or(serde_json::Value::Null),
958 serde_json::Number::from_f64(gain)
959 .map(serde_json::Value::Number)
960 .unwrap_or(serde_json::Value::Null),
961 ]),
962 );
963 }
964 // -o / -s / -t: 1..=3 floats, defaults fill the spec-defined slots
965 "-o" | "-s" | "-t" => {
966 if args.is_empty() || args.len() > 3 {
967 continue;
968 }
969 let mut parsed: Vec<f64> = Vec::with_capacity(args.len());
970 let mut ok = true;
971 for a in &args {
972 match a.parse::<f64>() {
973 Ok(n) => parsed.push(n),
974 Err(_) => {
975 ok = false;
976 break;
977 }
978 }
979 }
980 if !ok {
981 continue;
982 }
983 // Spec defaults per §"-o u v w" / §"-s u v w" / §"-t u v w":
984 // -o: default (0, 0, 0)
985 // -s: default (1, 1, 1)
986 // -t: default (0, 0, 0)
987 let default = if flag == "-s" { 1.0 } else { 0.0 };
988 while parsed.len() < 3 {
989 parsed.push(default);
990 }
991 let arr: Vec<serde_json::Value> = parsed
992 .into_iter()
993 .map(|v| {
994 serde_json::Number::from_f64(v)
995 .map(serde_json::Value::Number)
996 .unwrap_or(serde_json::Value::Null)
997 })
998 .collect();
999 let key = &flag[1..];
1000 obj.insert(key.to_string(), serde_json::Value::Array(arr));
1001 }
1002 // Unknown flag — leave to the raw :options array.
1003 _ => {}
1004 }
1005 }
1006 if obj.is_empty() {
1007 None
1008 } else {
1009 Some(serde_json::Value::Object(obj))
1010 }
1011}
1012
1013/// Decompose an `illum` integer into the spec's property table.
1014///
1015/// The Wavefront MTL spec §"illum illum_#" summarises each model
1016/// (0..=10) as a small set of shading-property flags ("Color on /
1017/// Ambient on / Highlight on / Reflection on / Ray trace on /
1018/// Transparency: Glass on / Transparency: Refraction on /
1019/// Reflection: Fresnel on / Casts shadows onto invisible surfaces").
1020/// This routine mirrors that table verbatim so consumers can
1021/// introspect a material's shading intent without re-deriving it.
1022///
1023/// The returned [`serde_json::Value`] is always an object with **all**
1024/// boolean flag keys present, set `true` or `false` per the spec
1025/// table, so callers can safely `.get(key).and_then(as_bool)` without
1026/// distinguishing "key missing" from "explicitly false". Values
1027/// outside `0..=10` return `None` (the raw integer still lands in
1028/// `mtl:illum`).
1029///
1030/// Flag keys (stable, lowercase, underscore-separated):
1031///
1032/// * `color` — true for models 0–9 (model 10 is a shadowmatte, no
1033/// colour). Per spec, every non-shadowmatte model emits Kd-driven
1034/// colour.
1035/// * `ambient` — true for models 1–9 (model 0 is "Color on and
1036/// Ambient *off*"; model 10 has no shading at all).
1037/// * `highlight` — true for models with a specular term (2–9).
1038/// * `reflection` — true when the model includes a reflection term
1039/// (3, 4, 5, 6, 7, 8, 9).
1040/// * `ray_trace` — true when the spec table says "Ray trace on"
1041/// (3, 4, 5, 6, 7); models 8 and 9 explicitly say "Ray trace off".
1042/// * `transparency_glass` — true for models 4 and 9 ("Transparency:
1043/// Glass on" per spec).
1044/// * `transparency_refraction` — true for models 6 and 7
1045/// ("Transparency: Refraction on" per spec).
1046/// * `fresnel` — true for models 5 and 7 ("Reflection: Fresnel on");
1047/// explicitly false for 6 ("Reflection: Fresnel off"). Other
1048/// models leave Fresnel unmentioned and the flag is false.
1049/// * `casts_shadow_on_invisible` — true only for model 10
1050/// ("Casts shadows onto invisible surfaces").
1051fn illum_property_map(n: i32) -> Option<serde_json::Value> {
1052 if !(0..=10).contains(&n) {
1053 return None;
1054 }
1055 // Spec table from p.5-30, mirrored verbatim per row:
1056 // 0 Color on and Ambient off
1057 // 1 Color on and Ambient on
1058 // 2 Highlight on
1059 // 3 Reflection on and Ray trace on
1060 // 4 Transparency: Glass on; Reflection: Ray trace on
1061 // 5 Reflection: Fresnel on and Ray trace on
1062 // 6 Transparency: Refraction on; Reflection: Fresnel off, Ray trace on
1063 // 7 Transparency: Refraction on; Reflection: Fresnel on, Ray trace on
1064 // 8 Reflection on and Ray trace off
1065 // 9 Transparency: Glass on; Reflection: Ray trace off
1066 // 10 Casts shadows onto invisible surfaces
1067 //
1068 // Model 2's spec row ("Highlight on") doesn't restate "Color on /
1069 // Ambient on" because those carry over from model 1; models 3..=9
1070 // similarly inherit the diffuse+ambient base by virtue of starting
1071 // from model 2's equation. We surface that inheritance explicitly:
1072 // every non-shadowmatte (0..=9) gets `color = true`, and every
1073 // shaded non-flat model (1..=9) gets `ambient = true`.
1074 let color = (0..=9).contains(&n);
1075 let ambient = (1..=9).contains(&n);
1076 let highlight = (2..=9).contains(&n);
1077 let reflection = matches!(n, 3..=9);
1078 let ray_trace = matches!(n, 3..=7);
1079 let transparency_glass = matches!(n, 4 | 9);
1080 let transparency_refraction = matches!(n, 6 | 7);
1081 let fresnel = matches!(n, 5 | 7);
1082 let casts_shadow_on_invisible = n == 10;
1083 Some(serde_json::json!({
1084 "color": color,
1085 "ambient": ambient,
1086 "highlight": highlight,
1087 "reflection": reflection,
1088 "ray_trace": ray_trace,
1089 "transparency_glass": transparency_glass,
1090 "transparency_refraction": transparency_refraction,
1091 "fresnel": fresnel,
1092 "casts_shadow_on_invisible": casts_shadow_on_invisible,
1093 }))
1094}
1095
1096// ---------------------------------------------------------------------------
1097// Serialisation
1098// ---------------------------------------------------------------------------
1099
1100/// Serialise a slice of materials to MTL format.
1101///
1102/// Texture references are emitted via the `External { uri, .. }`
1103/// variant — the URI is written verbatim. Embedded / Source textures
1104/// are skipped (no on-disk path to point at); a one-line comment
1105/// identifies the gap so the file is round-trip-able under the same
1106/// invariants as the decoder.
1107pub fn serialize_mtl(materials: &[Material], textures: &[Texture]) -> Result<Vec<u8>> {
1108 use std::fmt::Write;
1109 let mut out = String::new();
1110 writeln!(out, "# MTL generated by oxideav-obj").unwrap();
1111
1112 for (i, mat) in materials.iter().enumerate() {
1113 let name = mat.name.clone().unwrap_or_else(|| format!("material_{i}"));
1114 writeln!(out, "newmtl {name}").unwrap();
1115
1116 // Ka — one of three mutually-exclusive forms per spec §"Ka r g b" /
1117 // §"Ka spectral" / §"Ka xyz". The sibling-key precedence order
1118 // mirrors the source ordering in the spec listing.
1119 emit_color_statement(&mut out, "Ka", &mat.extras);
1120 // Kd — same three forms per spec §"Kd r g b" / §"Kd spectral" /
1121 // §"Kd xyz". The canonical RGB form populates `base_color`
1122 // directly, so we emit it from there; the alt forms ride on
1123 // extras (`mtl:Kd:spectral` / `mtl:Kd:xyz`) and suppress the
1124 // canonical line so the round-trip matches the operator-written
1125 // spelling.
1126 if let Some(serde_json::Value::Object(o)) = mat.extras.get("mtl:Kd:spectral") {
1127 let file = o.get("file").and_then(|v| v.as_str()).unwrap_or("");
1128 let factor = o.get("factor").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
1129 if (factor - 1.0).abs() < f32::EPSILON {
1130 writeln!(out, "Kd spectral {file}").unwrap();
1131 } else {
1132 writeln!(out, "Kd spectral {file} {}", fmt_f(factor)).unwrap();
1133 }
1134 } else if let Some(serde_json::Value::Array(v)) = mat.extras.get("mtl:Kd:xyz") {
1135 if let [a, b, c] = v.as_slice() {
1136 writeln!(
1137 out,
1138 "Kd xyz {} {} {}",
1139 fmt_f(a.as_f64().unwrap_or(0.0) as f32),
1140 fmt_f(b.as_f64().unwrap_or(0.0) as f32),
1141 fmt_f(c.as_f64().unwrap_or(0.0) as f32)
1142 )
1143 .unwrap();
1144 }
1145 } else {
1146 // Canonical RGB form: always emit (it's the glTF base
1147 // color → MTL Phong diffuse).
1148 writeln!(
1149 out,
1150 "Kd {} {} {}",
1151 fmt_f(mat.base_color[0]),
1152 fmt_f(mat.base_color[1]),
1153 fmt_f(mat.base_color[2])
1154 )
1155 .unwrap();
1156 }
1157 // Ks — same three forms per spec §"Ks r g b" / §"Ks spectral" /
1158 // §"Ks xyz".
1159 emit_color_statement(&mut out, "Ks", &mat.extras);
1160 if mat.emissive_factor != [0.0, 0.0, 0.0] {
1161 writeln!(
1162 out,
1163 "Ke {} {} {}",
1164 fmt_f(mat.emissive_factor[0]),
1165 fmt_f(mat.emissive_factor[1]),
1166 fmt_f(mat.emissive_factor[2])
1167 )
1168 .unwrap();
1169 }
1170 if let Some(v) = mat.extras.get("mtl:Ns").and_then(|v| v.as_f64()) {
1171 writeln!(out, "Ns {}", fmt_f(v as f32)).unwrap();
1172 }
1173 if let Some(v) = mat.extras.get("mtl:Ni").and_then(|v| v.as_f64()) {
1174 writeln!(out, "Ni {}", fmt_f(v as f32)).unwrap();
1175 }
1176 // Tf transmission filter — one of three mutually exclusive
1177 // forms per spec §"Tf". Only the first present extras key is
1178 // emitted (per the spec's mutual-exclusion clause).
1179 if let Some(serde_json::Value::Array(v)) = mat.extras.get("mtl:Tf") {
1180 if let [a, b, c] = v.as_slice() {
1181 writeln!(
1182 out,
1183 "Tf {} {} {}",
1184 fmt_f(a.as_f64().unwrap_or(0.0) as f32),
1185 fmt_f(b.as_f64().unwrap_or(0.0) as f32),
1186 fmt_f(c.as_f64().unwrap_or(0.0) as f32)
1187 )
1188 .unwrap();
1189 }
1190 } else if let Some(serde_json::Value::Object(o)) = mat.extras.get("mtl:Tf:spectral") {
1191 // `Tf spectral file.rfl factor` — `factor` defaults to 1.0
1192 // and is omitted from the emit when it equals the default,
1193 // so the round-trip matches the most common operator-written
1194 // form.
1195 let file = o.get("file").and_then(|v| v.as_str()).unwrap_or("");
1196 let factor = o.get("factor").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
1197 if (factor - 1.0).abs() < f32::EPSILON {
1198 writeln!(out, "Tf spectral {file}").unwrap();
1199 } else {
1200 writeln!(out, "Tf spectral {file} {}", fmt_f(factor)).unwrap();
1201 }
1202 } else if let Some(serde_json::Value::Array(v)) = mat.extras.get("mtl:Tf:xyz") {
1203 if let [a, b, c] = v.as_slice() {
1204 writeln!(
1205 out,
1206 "Tf xyz {} {} {}",
1207 fmt_f(a.as_f64().unwrap_or(0.0) as f32),
1208 fmt_f(b.as_f64().unwrap_or(0.0) as f32),
1209 fmt_f(c.as_f64().unwrap_or(0.0) as f32)
1210 )
1211 .unwrap();
1212 }
1213 }
1214 // sharpness — scalar, MTL spec §"sharpness value".
1215 if let Some(v) = mat.extras.get("mtl:sharpness").and_then(|v| v.as_f64()) {
1216 writeln!(out, "sharpness {}", fmt_f(v as f32)).unwrap();
1217 }
1218 if mat.base_color[3] < 1.0 || matches!(mat.alpha_mode, AlphaMode::Blend) {
1219 // Emit `d -halo <factor>` when the parser captured a halo
1220 // dissolve, otherwise the canonical `d <value>` form.
1221 if mat.extras.contains_key("mtl:d_halo_factor") {
1222 writeln!(out, "d -halo {}", fmt_f(mat.base_color[3])).unwrap();
1223 } else {
1224 writeln!(out, "d {}", fmt_f(mat.base_color[3])).unwrap();
1225 }
1226 }
1227 if let Some(v) = mat.extras.get("mtl:illum").and_then(|v| v.as_i64()) {
1228 writeln!(out, "illum {v}").unwrap();
1229 }
1230 // PBR fields — only emit when the user actually carries PBR values.
1231 // The mesh3d default is metallic=1.0 / roughness=1.0; our parser
1232 // resets those to 0 / 0.5 when constructing from MTL, so any
1233 // non-default value is taken to indicate "PBR is in use".
1234 let pbr_in_use = mat.metallic != 0.0
1235 || (mat.roughness - 0.5).abs() > f32::EPSILON
1236 || mat.metallic_roughness_texture.is_some()
1237 || mat.extras.contains_key("mtl:Pc")
1238 || mat.extras.contains_key("mtl:Ps");
1239 if pbr_in_use {
1240 writeln!(out, "Pr {}", fmt_f(mat.roughness)).unwrap();
1241 writeln!(out, "Pm {}", fmt_f(mat.metallic)).unwrap();
1242 }
1243 if let Some(v) = mat.extras.get("mtl:Pc").and_then(|v| v.as_f64()) {
1244 writeln!(out, "Pc {}", fmt_f(v as f32)).unwrap();
1245 }
1246 if let Some(v) = mat.extras.get("mtl:Ps").and_then(|v| v.as_f64()) {
1247 writeln!(out, "Ps {}", fmt_f(v as f32)).unwrap();
1248 }
1249
1250 // Texture references — splice any saved `-flag value` option
1251 // chunks back ahead of the filename so the round-trip emits
1252 // `map_Kd -clamp on path.png` instead of just `map_Kd path.png`.
1253 write_tex_ref(
1254 &mut out,
1255 "map_Kd",
1256 mat.base_color_texture,
1257 textures,
1258 &mat.extras,
1259 );
1260 write_tex_ref(
1261 &mut out,
1262 "map_Bump",
1263 mat.normal_texture,
1264 textures,
1265 &mat.extras,
1266 );
1267 write_tex_ref(
1268 &mut out,
1269 "map_Pr",
1270 mat.metallic_roughness_texture,
1271 textures,
1272 &mat.extras,
1273 );
1274 write_tex_ref(
1275 &mut out,
1276 "map_Ke",
1277 mat.emissive_texture,
1278 textures,
1279 &mat.extras,
1280 );
1281
1282 // Typed reflection-map sets per spec §"Reflection Map":
1283 // `refl -type sphere file` and the six `refl -type cube_*`
1284 // faces. Each face emits as its own line; option flags
1285 // captured per-face are spliced ahead of the filename.
1286 if let Some(serde_json::Value::Object(o)) = mat.extras.get("mtl:refl:sphere") {
1287 let file = o.get("file").and_then(|v| v.as_str()).unwrap_or("");
1288 let opts: Vec<&str> = o
1289 .get("options")
1290 .and_then(|v| v.as_array())
1291 .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
1292 .unwrap_or_default();
1293 if opts.is_empty() {
1294 writeln!(out, "refl -type sphere {file}").unwrap();
1295 } else {
1296 writeln!(out, "refl -type sphere {} {file}", opts.join(" ")).unwrap();
1297 }
1298 }
1299 if let Some(serde_json::Value::Object(faces)) = mat.extras.get("mtl:refl:cube") {
1300 // Fixed face order — keeps the round-trip diff stable
1301 // regardless of HashMap insertion order.
1302 for face in [
1303 "cube_top",
1304 "cube_bottom",
1305 "cube_front",
1306 "cube_back",
1307 "cube_left",
1308 "cube_right",
1309 "cube_side",
1310 ] {
1311 let Some(serde_json::Value::Object(entry)) = faces.get(face) else {
1312 continue;
1313 };
1314 let file = entry.get("file").and_then(|v| v.as_str()).unwrap_or("");
1315 let opts: Vec<&str> = entry
1316 .get("options")
1317 .and_then(|v| v.as_array())
1318 .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
1319 .unwrap_or_default();
1320 if opts.is_empty() {
1321 writeln!(out, "refl -type {face} {file}").unwrap();
1322 } else {
1323 writeln!(out, "refl -type {face} {} {file}", opts.join(" ")).unwrap();
1324 }
1325 }
1326 }
1327
1328 // Per-material texture anti-aliasing toggle (spec §"map_aat
1329 // on"). Emitted from the bool extra as the exact `on` / `off`
1330 // token; handled explicitly here (and skipped in the
1331 // string-only pass-through below) because the pass-through loop
1332 // only re-emits string-valued keys.
1333 if let Some(serde_json::Value::Bool(b)) = mat.extras.get("mtl:map_aat") {
1334 writeln!(out, "map_aat {}", if *b { "on" } else { "off" }).unwrap();
1335 }
1336
1337 // Pass-through extras — `mtl:*` keys we didn't consume above.
1338 for (k, v) in &mat.extras {
1339 if !k.starts_with("mtl:") {
1340 continue;
1341 }
1342 // Skip the keys we already printed above.
1343 match k.as_str() {
1344 "mtl:Ka"
1345 | "mtl:Ka:spectral"
1346 | "mtl:Ka:xyz"
1347 | "mtl:Kd:spectral"
1348 | "mtl:Kd:xyz"
1349 | "mtl:Ks"
1350 | "mtl:Ks:spectral"
1351 | "mtl:Ks:xyz"
1352 | "mtl:Ns"
1353 | "mtl:Ni"
1354 | "mtl:illum"
1355 | "mtl:illum_props"
1356 | "mtl:Pc"
1357 | "mtl:Ps"
1358 | "mtl:Tf"
1359 | "mtl:Tf:spectral"
1360 | "mtl:Tf:xyz"
1361 | "mtl:sharpness"
1362 | "mtl:displaced_pbr_map"
1363 | "mtl:d_halo_factor"
1364 | "mtl:refl:sphere"
1365 | "mtl:refl:cube"
1366 | "mtl:map_aat" => continue,
1367 _ => {}
1368 }
1369 // `mtl:<map>:options` chunks are spliced inline by
1370 // write_tex_ref / the bare-`disp`-etc pass-through; skip
1371 // them here so they don't double-emit as a standalone line.
1372 // `mtl:<map>:options_typed` is the parse-time-only
1373 // decomposed view of the same data — never emitted.
1374 if k.ends_with(":options") || k.ends_with(":options_typed") {
1375 continue;
1376 }
1377 // Only emit string-valued passthrough keys (textures we didn't model);
1378 // numeric ones we don't consume just stay as side-channel metadata.
1379 if let Some(s) = v.as_str() {
1380 let kw = k.strip_prefix("mtl:").unwrap_or(k.as_str());
1381 // Splice options ahead of the filename for keys that
1382 // have an associated `:options` companion (disp /
1383 // decal / refl / map_Ka / map_Ks / map_Ns / map_d).
1384 let opts_key = format!("mtl:{kw}:options");
1385 if let Some(serde_json::Value::Array(opts)) = mat.extras.get(&opts_key) {
1386 let parts: Vec<&str> = opts.iter().filter_map(|o| o.as_str()).collect();
1387 writeln!(out, "{kw} {} {s}", parts.join(" ")).unwrap();
1388 } else {
1389 writeln!(out, "{kw} {s}").unwrap();
1390 }
1391 }
1392 }
1393
1394 out.push('\n');
1395 }
1396
1397 Ok(out.into_bytes())
1398}
1399
1400fn write_tex_ref(
1401 out: &mut String,
1402 keyword: &str,
1403 ref_: Option<TextureRef>,
1404 textures: &[Texture],
1405 extras: &std::collections::HashMap<String, serde_json::Value>,
1406) {
1407 use std::fmt::Write;
1408 let Some(r) = ref_ else { return };
1409 let Some(tex) = textures.get(r.texture.0 as usize) else {
1410 return;
1411 };
1412 if let ImageData::External { uri, .. } = &tex.image {
1413 // Splice any saved option flags ahead of the filename. The
1414 // options key uses the canonical map keyword (e.g. `map_Bump`)
1415 // even when the user originally wrote `bump` / `map_bump` /
1416 // `norm` — those alias keywords store options under whatever
1417 // spelling the user used, so try both.
1418 let opts_key = format!("mtl:{keyword}:options");
1419 let alt_keys: &[&str] = match keyword {
1420 "map_Bump" => &[
1421 "mtl:map_bump:options",
1422 "mtl:bump:options",
1423 "mtl:norm:options",
1424 ],
1425 _ => &[],
1426 };
1427 let opts = extras
1428 .get(&opts_key)
1429 .or_else(|| alt_keys.iter().find_map(|k| extras.get(*k)));
1430 if let Some(serde_json::Value::Array(arr)) = opts {
1431 let parts: Vec<&str> = arr.iter().filter_map(|o| o.as_str()).collect();
1432 if parts.is_empty() {
1433 writeln!(out, "{keyword} {uri}").unwrap();
1434 } else {
1435 writeln!(out, "{keyword} {} {uri}", parts.join(" ")).unwrap();
1436 }
1437 } else {
1438 writeln!(out, "{keyword} {uri}").unwrap();
1439 }
1440 }
1441}
1442
1443/// Emit a `Ka` / `Ks` MTL colour statement, picking whichever of the
1444/// three mutually-exclusive forms (RGB / `spectral` / `xyz`) the parser
1445/// captured into extras. The sibling-key precedence is `spectral` →
1446/// `xyz` → plain `mtl:<keyword>` array (the RGB form). When no key is
1447/// present the statement is omitted entirely.
1448///
1449/// `Kd` is handled inline because its canonical RGB form is mirrored on
1450/// `Material::base_color` (the glTF base colour), not on
1451/// `extras["mtl:Kd"]`.
1452fn emit_color_statement(
1453 out: &mut String,
1454 keyword: &str,
1455 extras: &std::collections::HashMap<String, serde_json::Value>,
1456) {
1457 use std::fmt::Write;
1458 let spectral_key = format!("mtl:{keyword}:spectral");
1459 let xyz_key = format!("mtl:{keyword}:xyz");
1460 let rgb_key = format!("mtl:{keyword}");
1461 if let Some(serde_json::Value::Object(o)) = extras.get(&spectral_key) {
1462 let file = o.get("file").and_then(|v| v.as_str()).unwrap_or("");
1463 let factor = o.get("factor").and_then(|v| v.as_f64()).unwrap_or(1.0) as f32;
1464 if (factor - 1.0).abs() < f32::EPSILON {
1465 writeln!(out, "{keyword} spectral {file}").unwrap();
1466 } else {
1467 writeln!(out, "{keyword} spectral {file} {}", fmt_f(factor)).unwrap();
1468 }
1469 } else if let Some(serde_json::Value::Array(v)) = extras.get(&xyz_key) {
1470 if let [a, b, c] = v.as_slice() {
1471 writeln!(
1472 out,
1473 "{keyword} xyz {} {} {}",
1474 fmt_f(a.as_f64().unwrap_or(0.0) as f32),
1475 fmt_f(b.as_f64().unwrap_or(0.0) as f32),
1476 fmt_f(c.as_f64().unwrap_or(0.0) as f32)
1477 )
1478 .unwrap();
1479 }
1480 } else if let Some(serde_json::Value::Array(v)) = extras.get(&rgb_key) {
1481 if let [a, b, c] = v.as_slice() {
1482 writeln!(
1483 out,
1484 "{keyword} {} {} {}",
1485 fmt_f(a.as_f64().unwrap_or(0.0) as f32),
1486 fmt_f(b.as_f64().unwrap_or(0.0) as f32),
1487 fmt_f(c.as_f64().unwrap_or(0.0) as f32)
1488 )
1489 .unwrap();
1490 }
1491 }
1492}
1493
1494fn fmt_f(x: f32) -> String {
1495 if x == 0.0 {
1496 return "0".to_string();
1497 }
1498 let s = format!("{x:.6}");
1499 let trimmed = s.trim_end_matches('0').trim_end_matches('.').to_string();
1500 if trimmed.is_empty() || trimmed == "-" {
1501 "0".to_string()
1502 } else {
1503 trimmed
1504 }
1505}
1506
1507// ---------------------------------------------------------------------------
1508// Tests
1509// ---------------------------------------------------------------------------
1510
1511#[cfg(test)]
1512mod tests {
1513 use super::*;
1514
1515 #[test]
1516 fn parses_minimal_phong() {
1517 let text = "newmtl Red\nKd 1.0 0.0 0.0\nKa 0.1 0.1 0.1\nNs 32\n";
1518 let mats = parse_mtl(text).unwrap();
1519 assert_eq!(mats.len(), 1);
1520 let m = &mats[0];
1521 assert_eq!(m.name.as_deref(), Some("Red"));
1522 assert_eq!(m.base_color[0..3], [1.0, 0.0, 0.0]);
1523 assert_eq!(
1524 m.extras
1525 .get("mtl:Ka")
1526 .and_then(|v| v.as_array())
1527 .map(|a| a.len()),
1528 Some(3)
1529 );
1530 assert_eq!(m.extras.get("mtl:Ns").and_then(|v| v.as_f64()), Some(32.0));
1531 }
1532
1533 #[test]
1534 fn dissolve_sets_alpha_blend() {
1535 let mats = parse_mtl("newmtl Glass\nKd 0.5 0.5 0.5\nd 0.4\n").unwrap();
1536 assert_eq!(mats[0].base_color[3], 0.4);
1537 assert!(matches!(mats[0].alpha_mode, AlphaMode::Blend));
1538 }
1539
1540 #[test]
1541 fn tr_alternate_dissolve() {
1542 let mats = parse_mtl("newmtl Glass\nKd 0.5 0.5 0.5\nTr 0.4\n").unwrap();
1543 // Tr = 1 - d ⇒ d = 0.6
1544 assert!((mats[0].base_color[3] - 0.6).abs() < 1e-6);
1545 assert!(matches!(mats[0].alpha_mode, AlphaMode::Blend));
1546 }
1547
1548 #[test]
1549 fn pbr_extension_lands_in_pbr_slots() {
1550 let mats =
1551 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();
1552 let m = &mats[0];
1553 assert!((m.roughness - 0.25).abs() < 1e-6);
1554 assert!((m.metallic - 0.95).abs() < 1e-6);
1555 let pc = m.extras.get("mtl:Pc").and_then(|v| v.as_f64()).unwrap();
1556 assert!((pc - 0.5).abs() < 1e-6);
1557 let ps = m.extras.get("mtl:Ps").and_then(|v| v.as_f64()).unwrap();
1558 assert!((ps - 0.1).abs() < 1e-6);
1559 }
1560
1561 #[test]
1562 fn map_kd_pending_uri_round_trips() {
1563 let mats = parse_mtl("newmtl Tex\nKd 1 1 1\nmap_Kd diffuse.png\n").unwrap();
1564 let pending = mats[0].extras.get("mtl:pending_textures").unwrap();
1565 assert_eq!(pending["base_color"].as_str(), Some("diffuse.png"));
1566 }
1567}