Skip to main content

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}