Skip to main content

oxideav_obj/
obj.rs

1//! Wavefront OBJ ASCII parser + serialiser.
2//!
3//! Strictly the polygonal subset (vertex / face / line / grouping /
4//! material directives). Free-form curves/surfaces and the `.mod`
5//! binary form are intentionally not handled.
6//!
7//! The grammar is line-oriented; whitespace-separated; `#` introduces
8//! a comment to end of line. Continuation lines (trailing `\\`) are
9//! supported by gluing the next line on before tokenisation.
10
11use std::collections::HashMap;
12
13use oxideav_mesh3d::{Error, Indices, Mesh, Primitive, Result, Scene3D, Topology};
14
15use crate::mtl::parse_mtl;
16
17// ---------------------------------------------------------------------------
18// Parsing
19// ---------------------------------------------------------------------------
20
21/// Per-face-vertex index triple. `0` means "not present".
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
23struct FaceVert {
24    /// 1-based geometric-vertex index (resolved from raw OBJ).
25    v: u32,
26    /// 1-based texture-coord index, or 0 if absent.
27    vt: u32,
28    /// 1-based normal index, or 0 if absent.
29    vn: u32,
30}
31
32/// One face-or-line element captured during the first parse pass.
33#[derive(Debug)]
34enum Element {
35    Face(Vec<FaceVert>),
36    Line(Vec<FaceVert>),
37}
38
39/// One open primitive — accumulates face/line elements while a single
40/// `usemtl` (or "no material") is active.
41#[derive(Debug, Default)]
42struct PrimAccum {
43    elements: Vec<Element>,
44    material: Option<String>,
45    /// Last seen smoothing group token (`"off"` or an integer string).
46    smoothing_group: Option<String>,
47    /// All distinct group names seen during this primitive.
48    groups: Vec<String>,
49}
50
51/// One open mesh — accumulates primitives while a single `o <name>`
52/// (or default object) is active.
53#[derive(Debug, Default)]
54struct MeshAccum {
55    name: Option<String>,
56    primitives: Vec<PrimAccum>,
57}
58
59impl MeshAccum {
60    fn current_or_new(&mut self) -> &mut PrimAccum {
61        if self.primitives.is_empty() {
62            self.primitives.push(PrimAccum::default());
63        }
64        self.primitives.last_mut().unwrap()
65    }
66}
67
68/// The polygonal data parsed out of an OBJ document.
69///
70/// This intermediate form keeps positions / texcoords / normals in
71/// their original 1-based numbering so the resolution of negative and
72/// 1-based face indices into 0-based primitive-local indices happens
73/// in one well-defined place ([`build_scene`]).
74#[derive(Debug, Default)]
75struct ObjDoc {
76    positions: Vec<[f32; 3]>,
77    texcoords: Vec<[f32; 2]>,
78    normals: Vec<[f32; 3]>,
79    /// Material library file names referenced by `mtllib`.
80    mtllibs: Vec<String>,
81    /// All material definitions resolved from `mtllib` references
82    /// supplied via [`ObjDoc::with_resolved_mtllibs`]. Round 1 ships
83    /// no IO so we accept these via an external resolver hook on the
84    /// caller.
85    resolved_materials: HashMap<String, oxideav_mesh3d::Material>,
86    meshes: Vec<MeshAccum>,
87}
88
89/// Glue line-continuation (`\\` + newline) before line splitting and
90/// strip comments (`#…` to end of line). Returns owned strings since
91/// continuation gluing rewrites the input.
92fn preprocess_lines(text: &str) -> Vec<String> {
93    let mut out: Vec<String> = Vec::new();
94    let mut acc = String::new();
95    for raw_line in text.split('\n') {
96        // Strip a trailing CR so CRLF inputs land cleanly.
97        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
98        // Strip comments — `#` past the start of a token introduces
99        // an end-of-line comment per the spec.
100        let no_comment = match line.find('#') {
101            Some(idx) => &line[..idx],
102            None => line,
103        };
104        let trimmed = no_comment.trim_end();
105        if let Some(stripped) = trimmed.strip_suffix('\\') {
106            acc.push_str(stripped);
107            acc.push(' ');
108        } else {
109            acc.push_str(trimmed);
110            out.push(std::mem::take(&mut acc));
111        }
112    }
113    if !acc.is_empty() {
114        out.push(acc);
115    }
116    out
117}
118
119/// Parse a face-vertex token. Accepts `v`, `v/vt`, `v//vn`, `v/vt/vn`.
120/// Each component is a non-zero integer (negative => relative-from-end).
121/// Resolution to 1-based positive indices happens here; 0-based
122/// primitive-local indexing happens in [`build_scene`].
123fn parse_face_vertex(tok: &str, n_pos: i64, n_tex: i64, n_norm: i64) -> Result<FaceVert> {
124    let mut parts = tok.split('/');
125    let v = parts
126        .next()
127        .ok_or_else(|| Error::invalid(format!("face vertex missing position: {tok:?}")))?;
128    let vt = parts.next().unwrap_or("");
129    let vn = parts.next().unwrap_or("");
130
131    let resolve = |s: &str, n: i64, kind: &str| -> Result<u32> {
132        if s.is_empty() {
133            return Ok(0);
134        }
135        let raw: i64 = s.parse().map_err(|_| {
136            Error::invalid(format!(
137                "invalid {kind} index in face vertex {tok:?}: {s:?}"
138            ))
139        })?;
140        let resolved = if raw < 0 { n + 1 + raw } else { raw };
141        if resolved <= 0 || resolved > n {
142            return Err(Error::invalid(format!(
143                "{kind} index out of range in face vertex {tok:?}: {raw} (have {n})"
144            )));
145        }
146        Ok(resolved as u32)
147    };
148
149    Ok(FaceVert {
150        v: resolve(v, n_pos, "position")?,
151        vt: resolve(vt, n_tex, "texcoord")?,
152        vn: resolve(vn, n_norm, "normal")?,
153    })
154}
155
156/// Parse the geometry part of an OBJ document into the intermediate
157/// [`ObjDoc`] form. No I/O — `mtllib` lines are recorded by name only;
158/// the caller resolves them.
159fn parse_obj_doc(text: &str) -> Result<ObjDoc> {
160    let mut doc = ObjDoc::default();
161    // One implicit mesh until an `o` directive opens a named one.
162    doc.meshes.push(MeshAccum::default());
163
164    let lines = preprocess_lines(text);
165    for line in &lines {
166        let mut tokens = line.split_whitespace();
167        let Some(keyword) = tokens.next() else {
168            continue;
169        };
170        match keyword {
171            "v" => {
172                let coords: Vec<f32> = tokens
173                    .map(str::parse)
174                    .collect::<std::result::Result<Vec<f32>, _>>()
175                    .map_err(|e| Error::invalid(format!("v: bad float ({e})")))?;
176                if coords.len() < 3 {
177                    return Err(Error::invalid(format!(
178                        "v: expected ≥3 coords, got {}",
179                        coords.len()
180                    )));
181                }
182                // The optional 4th `w` is silently dropped (rare in practice).
183                doc.positions.push([coords[0], coords[1], coords[2]]);
184            }
185            "vt" => {
186                let coords: Vec<f32> = tokens
187                    .map(str::parse)
188                    .collect::<std::result::Result<Vec<f32>, _>>()
189                    .map_err(|e| Error::invalid(format!("vt: bad float ({e})")))?;
190                if coords.is_empty() {
191                    return Err(Error::invalid("vt: expected ≥1 coord"));
192                }
193                let u = coords[0];
194                let v = coords.get(1).copied().unwrap_or(0.0);
195                // Drop optional 3rd `w` — meaningless to glTF UV.
196                doc.texcoords.push([u, v]);
197            }
198            "vn" => {
199                let coords: Vec<f32> = tokens
200                    .map(str::parse)
201                    .collect::<std::result::Result<Vec<f32>, _>>()
202                    .map_err(|e| Error::invalid(format!("vn: bad float ({e})")))?;
203                if coords.len() != 3 {
204                    return Err(Error::invalid(format!(
205                        "vn: expected 3 coords, got {}",
206                        coords.len()
207                    )));
208                }
209                doc.normals.push([coords[0], coords[1], coords[2]]);
210            }
211            "vp" => {
212                // Parameter-space vertex — silently skipped (free-form
213                // surface support is out of scope for round 1).
214            }
215            "f" => {
216                let n_pos = doc.positions.len() as i64;
217                let n_tex = doc.texcoords.len() as i64;
218                let n_norm = doc.normals.len() as i64;
219                let verts: Vec<FaceVert> = tokens
220                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
221                    .collect::<Result<Vec<_>>>()?;
222                if verts.len() < 3 {
223                    return Err(Error::invalid(format!(
224                        "f: face needs ≥3 vertices, got {}",
225                        verts.len()
226                    )));
227                }
228                let mesh = doc.meshes.last_mut().unwrap();
229                mesh.current_or_new().elements.push(Element::Face(verts));
230            }
231            "l" => {
232                let n_pos = doc.positions.len() as i64;
233                let n_tex = doc.texcoords.len() as i64;
234                let n_norm = doc.normals.len() as i64;
235                let verts: Vec<FaceVert> = tokens
236                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
237                    .collect::<Result<Vec<_>>>()?;
238                if verts.len() < 2 {
239                    return Err(Error::invalid(format!(
240                        "l: line needs ≥2 vertices, got {}",
241                        verts.len()
242                    )));
243                }
244                let mesh = doc.meshes.last_mut().unwrap();
245                mesh.current_or_new().elements.push(Element::Line(verts));
246            }
247            "p" => {
248                // Point element — not modelled (mesh3d Topology::Points
249                // exists but OBJ point usage is vanishingly rare).
250                // Silently skip.
251            }
252            "o" => {
253                let name: String = tokens.collect::<Vec<_>>().join(" ");
254                // Open a fresh mesh — but if the current mesh is still
255                // empty (no primitives accumulated yet), reuse it so we
256                // don't end up with a leading empty mesh.
257                let last = doc.meshes.last_mut().unwrap();
258                if last.name.is_none() && last.primitives.is_empty() {
259                    last.name = if name.is_empty() { None } else { Some(name) };
260                } else {
261                    doc.meshes.push(MeshAccum {
262                        name: if name.is_empty() { None } else { Some(name) },
263                        primitives: Vec::new(),
264                    });
265                }
266            }
267            "g" => {
268                let name: String = tokens.collect::<Vec<_>>().join(" ");
269                if name.is_empty() {
270                    continue;
271                }
272                let mesh = doc.meshes.last_mut().unwrap();
273                let prim = mesh.current_or_new();
274                if !prim.groups.iter().any(|g| g == &name) {
275                    prim.groups.push(name);
276                }
277            }
278            "s" => {
279                let v: String = tokens.collect::<Vec<_>>().join(" ");
280                if v.is_empty() {
281                    continue;
282                }
283                let mesh = doc.meshes.last_mut().unwrap();
284                mesh.current_or_new().smoothing_group = Some(v);
285            }
286            "usemtl" => {
287                let name: String = tokens.collect::<Vec<_>>().join(" ");
288                let mesh = doc.meshes.last_mut().unwrap();
289                let last = mesh.current_or_new();
290                if last.elements.is_empty() && last.material.is_none() {
291                    // First usemtl in this primitive — adopt directly.
292                    last.material = if name.is_empty() { None } else { Some(name) };
293                } else {
294                    // Subsequent usemtl — start a new primitive.
295                    mesh.primitives.push(PrimAccum {
296                        material: if name.is_empty() { None } else { Some(name) },
297                        ..PrimAccum::default()
298                    });
299                }
300            }
301            "mtllib" => {
302                // Each `mtllib` line can list multiple .mtl files.
303                for tok in tokens {
304                    if !doc.mtllibs.iter().any(|m| m == tok) {
305                        doc.mtllibs.push(tok.to_string());
306                    }
307                }
308            }
309            // Unhandled keywords (curves/surfaces/display attributes/etc.) are
310            // silently skipped per spec lenient-loader convention.
311            _ => {}
312        }
313    }
314
315    Ok(doc)
316}
317
318// ---------------------------------------------------------------------------
319// Scene assembly
320// ---------------------------------------------------------------------------
321
322/// Convert the intermediate [`ObjDoc`] into a [`Scene3D`].
323///
324/// Indices are de-duplicated per-primitive so the resulting vertex
325/// buffer carries `unique_face_vertices` entries (matching glTF's
326/// per-primitive interleaved-attribute model). Original face arities
327/// are stored in `Mesh::extras["obj:original_face_arities"]` so the
328/// encoder can reconstruct the n-gons.
329fn build_scene(doc: ObjDoc) -> Result<Scene3D> {
330    use oxideav_mesh3d::{Axis, Material, Unit};
331
332    let mut scene = Scene3D::new();
333    // OBJ has no unit metadata; the primer says "Metres is the safe
334    // default" and "Y-up matches the glTF default".
335    scene.up_axis = Axis::PosY;
336    scene.unit = Unit::Metres;
337
338    // Materials first so primitives can point at their MaterialId.
339    // Insertion order is preserved (HashMap iteration order is
340    // unspecified, so sort by name to keep round-trip deterministic).
341    let mut material_ids: HashMap<String, oxideav_mesh3d::MaterialId> = HashMap::new();
342    let mut material_names: Vec<String> = doc.resolved_materials.keys().cloned().collect();
343    material_names.sort();
344    for name in &material_names {
345        let mut mat = doc
346            .resolved_materials
347            .get(name)
348            .cloned()
349            .unwrap_or_else(Material::new);
350        if mat.name.is_none() {
351            mat.name = Some(name.clone());
352        }
353        let id = scene.add_material(mat);
354        material_ids.insert(name.clone(), id);
355    }
356
357    for mesh_acc in doc.meshes {
358        // Drop genuinely empty meshes (no primitives that emit anything).
359        let has_anything = mesh_acc.primitives.iter().any(|p| !p.elements.is_empty());
360        if !has_anything {
361            continue;
362        }
363
364        let mut mesh = Mesh::new(mesh_acc.name.clone());
365
366        for prim_acc in mesh_acc.primitives {
367            let (mut primitive, arities) = build_primitive(
368                &prim_acc,
369                &doc.positions,
370                &doc.texcoords,
371                &doc.normals,
372                &material_ids,
373            )?;
374            // Skip primitives that never accumulated any element.
375            if primitive.positions.is_empty() {
376                continue;
377            }
378            // Stash original face arities per-primitive when the primitive
379            // contained at least one non-triangle face. Mesh has no
380            // `extras` field, so the round-trip annotation lives on the
381            // primitive — symmetrical with the smoothing-group / groups /
382            // usemtl extras already populated by `build_primitive`.
383            if arities.iter().any(|&a| a != 3) {
384                primitive.extras.insert(
385                    "obj:original_face_arities".to_string(),
386                    serde_json::to_value(&arities).unwrap(),
387                );
388            }
389            mesh.primitives.push(primitive);
390        }
391
392        scene.add_mesh(mesh);
393    }
394
395    // Keep the mtllib references in scene extras so a re-encode that
396    // wants to point back at a specific MTL file can find them.
397    if !doc.mtllibs.is_empty() {
398        scene.extras.insert(
399            "obj:mtllibs".to_string(),
400            serde_json::to_value(&doc.mtllibs).unwrap(),
401        );
402    }
403
404    Ok(scene)
405}
406
407/// Build one [`Primitive`] from an accumulated [`PrimAccum`].
408///
409/// Returns the primitive plus a per-element arity vector — one entry
410/// per face (3 for a triangle, 4 for a quad, ≥5 for an n-gon). Lines
411/// don't contribute arity entries (the encoder switches on topology
412/// instead).
413fn build_primitive(
414    prim_acc: &PrimAccum,
415    positions: &[[f32; 3]],
416    texcoords: &[[f32; 2]],
417    normals: &[[f32; 3]],
418    material_ids: &HashMap<String, oxideav_mesh3d::MaterialId>,
419) -> Result<(Primitive, Vec<u32>)> {
420    // Decide topology + attribute presence by looking at the first
421    // element. Mixed-element primitives (lines + faces under one
422    // `usemtl`) aren't representable in mesh3d so we error cleanly.
423    let first = prim_acc.elements.first();
424    let topology = match first {
425        Some(Element::Face(_)) => Topology::Triangles,
426        Some(Element::Line(_)) => Topology::Lines,
427        None => Topology::Triangles,
428    };
429    for elt in &prim_acc.elements {
430        let ok = matches!(
431            (&topology, elt),
432            (Topology::Triangles, Element::Face(_)) | (Topology::Lines, Element::Line(_))
433        );
434        if !ok {
435            return Err(Error::unsupported(
436                "OBJ primitive mixes face and line elements under one usemtl",
437            ));
438        }
439    }
440
441    let has_uv = prim_acc.elements.iter().any(|elt| match elt {
442        Element::Face(verts) | Element::Line(verts) => verts.iter().any(|fv| fv.vt != 0),
443    });
444    let has_normal = prim_acc.elements.iter().any(|elt| match elt {
445        Element::Face(verts) | Element::Line(verts) => verts.iter().any(|fv| fv.vn != 0),
446    });
447
448    let mut prim = Primitive::new(topology);
449    if has_uv {
450        prim.uvs.push(Vec::new());
451    }
452    if has_normal {
453        prim.normals = Some(Vec::new());
454    }
455
456    // De-duplicate face-vertices into a single interleaved buffer.
457    let mut indexer: HashMap<FaceVert, u32> = HashMap::new();
458    let mut arities: Vec<u32> = Vec::new();
459    let mut local_indices: Vec<u32> = Vec::new();
460
461    let intern =
462        |fv: FaceVert, prim: &mut Primitive, indexer: &mut HashMap<FaceVert, u32>| -> Result<u32> {
463            if let Some(&idx) = indexer.get(&fv) {
464                return Ok(idx);
465            }
466            let pos = positions.get((fv.v - 1) as usize).ok_or_else(|| {
467                Error::invalid(format!("face references missing position {}", fv.v))
468            })?;
469            prim.positions.push(*pos);
470            if has_uv {
471                let uv = if fv.vt == 0 {
472                    [0.0, 0.0]
473                } else {
474                    *texcoords.get((fv.vt - 1) as usize).ok_or_else(|| {
475                        Error::invalid(format!("face references missing texcoord {}", fv.vt))
476                    })?
477                };
478                prim.uvs[0].push(uv);
479            }
480            if has_normal {
481                let n = if fv.vn == 0 {
482                    [0.0, 0.0, 0.0]
483                } else {
484                    *normals.get((fv.vn - 1) as usize).ok_or_else(|| {
485                        Error::invalid(format!("face references missing normal {}", fv.vn))
486                    })?
487                };
488                prim.normals.as_mut().unwrap().push(n);
489            }
490            let new_idx = (prim.positions.len() - 1) as u32;
491            indexer.insert(fv, new_idx);
492            Ok(new_idx)
493        };
494
495    for elt in &prim_acc.elements {
496        match elt {
497            Element::Face(verts) => {
498                let arity = verts.len() as u32;
499                arities.push(arity);
500                let resolved: Vec<u32> = verts
501                    .iter()
502                    .map(|&fv| intern(fv, &mut prim, &mut indexer))
503                    .collect::<Result<Vec<_>>>()?;
504                // Fan triangulate: (v0, v1, v2), (v0, v2, v3), …
505                for i in 1..(resolved.len() - 1) {
506                    local_indices.push(resolved[0]);
507                    local_indices.push(resolved[i]);
508                    local_indices.push(resolved[i + 1]);
509                }
510            }
511            Element::Line(verts) => {
512                let resolved: Vec<u32> = verts
513                    .iter()
514                    .map(|&fv| intern(fv, &mut prim, &mut indexer))
515                    .collect::<Result<Vec<_>>>()?;
516                // Decompose polyline into Lines (pairs).
517                for w in resolved.windows(2) {
518                    local_indices.push(w[0]);
519                    local_indices.push(w[1]);
520                }
521            }
522        }
523    }
524
525    // Promote to U32 if any index >= 65536; U16 otherwise.
526    if local_indices.iter().any(|&i| i >= u16::MAX as u32) {
527        prim.indices = Some(Indices::U32(local_indices));
528    } else {
529        prim.indices = Some(Indices::U16(
530            local_indices.into_iter().map(|i| i as u16).collect(),
531        ));
532    }
533
534    if let Some(name) = &prim_acc.material {
535        if let Some(id) = material_ids.get(name) {
536            prim.material = Some(*id);
537        }
538        prim.extras.insert(
539            "obj:usemtl".to_string(),
540            serde_json::Value::String(name.clone()),
541        );
542    }
543    if let Some(s) = &prim_acc.smoothing_group {
544        prim.extras.insert(
545            "obj:smoothing_group".to_string(),
546            serde_json::Value::String(s.clone()),
547        );
548    }
549    if !prim_acc.groups.is_empty() {
550        prim.extras.insert(
551            "obj:groups".to_string(),
552            serde_json::to_value(&prim_acc.groups).unwrap(),
553        );
554    }
555
556    Ok((prim, arities))
557}
558
559// ---------------------------------------------------------------------------
560// Public API
561// ---------------------------------------------------------------------------
562
563/// Parse an OBJ document (no MTL resolution).
564///
565/// `usemtl` directives still create one `Primitive` per switch and the
566/// material name lands in `Primitive::extras["obj:usemtl"]` even with
567/// no actual `Material` constructed. Use [`parse_obj_with_resolver`]
568/// when companion MTL data is available.
569pub fn parse_obj(text: &str) -> Result<Scene3D> {
570    parse_obj_with_resolver(text, |_path| Ok(Vec::new()))
571}
572
573/// Parse an OBJ document, calling `resolve` once per `mtllib` entry to
574/// fetch the bytes of the named material library. Each library is
575/// parsed via [`parse_mtl`] and its materials merged into the resulting
576/// scene; references in `usemtl` directives bind to those materials by
577/// name.
578///
579/// The resolver returns `Ok(Vec::new())` to signal "this library
580/// couldn't be located but skip silently"; any other `Err` aborts the
581/// parse.
582pub fn parse_obj_with_resolver<R>(text: &str, mut resolve: R) -> Result<Scene3D>
583where
584    R: FnMut(&str) -> Result<Vec<u8>>,
585{
586    let mut doc = parse_obj_doc(text)?;
587
588    // Resolve material libraries, if any.
589    for lib in doc.mtllibs.clone() {
590        let bytes = resolve(&lib)?;
591        if bytes.is_empty() {
592            continue;
593        }
594        let lib_text = std::str::from_utf8(&bytes)
595            .map_err(|_| Error::invalid(format!("mtllib {lib:?} contained non-UTF-8 bytes")))?;
596        let materials = parse_mtl(lib_text)?;
597        for mat in materials {
598            if let Some(name) = mat.name.clone() {
599                doc.resolved_materials.insert(name, mat);
600            }
601        }
602    }
603
604    build_scene(doc)
605}
606
607/// Serialise a [`Scene3D`] to OBJ format.
608///
609/// `mtl_basename`, when supplied, emits an `mtllib <basename>.mtl`
610/// directive at the top so a sibling MTL file (written separately via
611/// [`crate::mtl::serialize_mtl`]) is referenced.
612pub fn serialize_obj(scene: &Scene3D, mtl_basename: Option<&str>) -> Result<Vec<u8>> {
613    use std::fmt::Write;
614    let mut out = String::new();
615    writeln!(out, "# OBJ generated by oxideav-obj").unwrap();
616    if let Some(base) = mtl_basename {
617        writeln!(out, "mtllib {base}.mtl").unwrap();
618    }
619    // Replay any mtllib refs preserved on the scene itself when no
620    // explicit basename was supplied.
621    if mtl_basename.is_none() {
622        if let Some(serde_json::Value::Array(list)) = scene.extras.get("obj:mtllibs") {
623            for entry in list {
624                if let Some(s) = entry.as_str() {
625                    writeln!(out, "mtllib {s}").unwrap();
626                }
627            }
628        }
629    }
630
631    // Deduplicated global vertex / texcoord / normal pools so emitted
632    // index references match the canonical 1-based numbering.
633    let mut positions: Vec<[f32; 3]> = Vec::new();
634    let mut texcoords: Vec<[f32; 2]> = Vec::new();
635    let mut normals: Vec<[f32; 3]> = Vec::new();
636    let mut pos_map: HashMap<KeyVec3, u32> = HashMap::new();
637    let mut tex_map: HashMap<KeyVec2, u32> = HashMap::new();
638    let mut nor_map: HashMap<KeyVec3, u32> = HashMap::new();
639
640    let intern_pos =
641        |p: [f32; 3], positions: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
642            let key = KeyVec3::from(p);
643            if let Some(&i) = map.get(&key) {
644                return i;
645            }
646            positions.push(p);
647            let idx = positions.len() as u32;
648            map.insert(key, idx);
649            idx
650        };
651    let intern_tex =
652        |p: [f32; 2], texcoords: &mut Vec<[f32; 2]>, map: &mut HashMap<KeyVec2, u32>| -> u32 {
653            let key = KeyVec2::from(p);
654            if let Some(&i) = map.get(&key) {
655                return i;
656            }
657            texcoords.push(p);
658            let idx = texcoords.len() as u32;
659            map.insert(key, idx);
660            idx
661        };
662    let intern_nor =
663        |p: [f32; 3], normals: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
664            let key = KeyVec3::from(p);
665            if let Some(&i) = map.get(&key) {
666                return i;
667            }
668            normals.push(p);
669            let idx = normals.len() as u32;
670            map.insert(key, idx);
671            idx
672        };
673
674    // First pass: emit `v` / `vt` / `vn` lists and remember the global
675    // indices for each (mesh, primitive, vertex) triple.
676    type GlobalTriple = (u32, u32, u32); // (v_idx, vt_idx_or_0, vn_idx_or_0)
677    let mut global_indices: Vec<Vec<Vec<GlobalTriple>>> = Vec::new();
678    for mesh in &scene.meshes {
679        let mut mesh_globals: Vec<Vec<GlobalTriple>> = Vec::new();
680        for prim in &mesh.primitives {
681            let has_uv = !prim.uvs.is_empty();
682            let has_normal = prim.normals.is_some();
683            let mut prim_globals: Vec<GlobalTriple> = Vec::with_capacity(prim.positions.len());
684            for vi in 0..prim.positions.len() {
685                let v_idx = intern_pos(prim.positions[vi], &mut positions, &mut pos_map);
686                let vt_idx = if has_uv {
687                    intern_tex(prim.uvs[0][vi], &mut texcoords, &mut tex_map)
688                } else {
689                    0
690                };
691                let vn_idx = if has_normal {
692                    intern_nor(
693                        prim.normals.as_ref().unwrap()[vi],
694                        &mut normals,
695                        &mut nor_map,
696                    )
697                } else {
698                    0
699                };
700                prim_globals.push((v_idx, vt_idx, vn_idx));
701            }
702            mesh_globals.push(prim_globals);
703        }
704        global_indices.push(mesh_globals);
705    }
706
707    for p in &positions {
708        writeln!(
709            out,
710            "v {} {} {}",
711            fmt_float(p[0]),
712            fmt_float(p[1]),
713            fmt_float(p[2])
714        )
715        .unwrap();
716    }
717    for t in &texcoords {
718        writeln!(out, "vt {} {}", fmt_float(t[0]), fmt_float(t[1])).unwrap();
719    }
720    for n in &normals {
721        writeln!(
722            out,
723            "vn {} {} {}",
724            fmt_float(n[0]),
725            fmt_float(n[1]),
726            fmt_float(n[2])
727        )
728        .unwrap();
729    }
730
731    // Second pass: per-mesh `o` directive, per-primitive `usemtl` +
732    // groups + smoothing-group, then face/line elements.
733    for (mi, mesh) in scene.meshes.iter().enumerate() {
734        if let Some(name) = &mesh.name {
735            writeln!(out, "o {name}").unwrap();
736        }
737
738        for (pi, prim) in mesh.primitives.iter().enumerate() {
739            // Per-primitive arity vector for n-gon re-emission, if any.
740            let arities: Option<Vec<u32>> = prim
741                .extras
742                .get("obj:original_face_arities")
743                .and_then(|v| serde_json::from_value(v.clone()).ok());
744            // Groups + smoothing first (spec convention: state tokens
745            // precede the elements they apply to).
746            if let Some(serde_json::Value::Array(gs)) = prim.extras.get("obj:groups") {
747                let names: Vec<&str> = gs.iter().filter_map(|v| v.as_str()).collect();
748                if !names.is_empty() {
749                    writeln!(out, "g {}", names.join(" ")).unwrap();
750                }
751            }
752            if let Some(s) = prim
753                .extras
754                .get("obj:smoothing_group")
755                .and_then(|v| v.as_str())
756            {
757                writeln!(out, "s {s}").unwrap();
758            }
759
760            // usemtl: prefer extras["obj:usemtl"] (loss-tolerant
761            // round-trip name), fall back to the bound material's name.
762            let mtl_name: Option<String> = prim
763                .extras
764                .get("obj:usemtl")
765                .and_then(|v| v.as_str())
766                .map(|s| s.to_string())
767                .or_else(|| {
768                    prim.material.and_then(|id| {
769                        scene
770                            .materials
771                            .get(id.0 as usize)
772                            .and_then(|m| m.name.clone())
773                    })
774                });
775            if let Some(name) = &mtl_name {
776                writeln!(out, "usemtl {name}").unwrap();
777            }
778
779            let prim_globals = &global_indices[mi][pi];
780            let has_uv = !prim.uvs.is_empty();
781            let has_normal = prim.normals.is_some();
782
783            // Build the per-element index iterator. For Triangles topology
784            // re-shape into n-gons via `arities` if present; otherwise emit
785            // one triangle per 3 indices. For Lines topology emit `l`
786            // per pair (we don't reverse strips back into polylines —
787            // that's lossy and the round-trip test doesn't need it).
788            match prim.topology {
789                Topology::Triangles => {
790                    let face_indices: Vec<u32> = match &prim.indices {
791                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
792                        Some(Indices::U32(v)) => v.clone(),
793                        None => {
794                            // Implicit indices: 0, 1, 2, …
795                            (0..prim.positions.len() as u32).collect()
796                        }
797                    };
798                    if let Some(per_prim_arities) = arities.as_ref() {
799                        // Reconstruct n-gons from triangle fans. Each
800                        // n-gon contributed (n - 2) triangles.
801                        let mut tri_pos: usize = 0;
802                        for &arity in per_prim_arities {
803                            let mut verts: Vec<u32> = Vec::with_capacity(arity as usize);
804                            // The fan was: (v0, v1, v2), (v0, v2, v3), (v0, v3, v4), …
805                            let n_tris = (arity as usize).saturating_sub(2);
806                            // First triangle gives v0, v1, v2.
807                            verts.push(face_indices[tri_pos * 3]);
808                            verts.push(face_indices[tri_pos * 3 + 1]);
809                            verts.push(face_indices[tri_pos * 3 + 2]);
810                            // Each subsequent triangle adds one new vertex (the third index).
811                            for k in 1..n_tris {
812                                verts.push(face_indices[(tri_pos + k) * 3 + 2]);
813                            }
814                            tri_pos += n_tris;
815
816                            write_face(&mut out, &verts, prim_globals, has_uv, has_normal);
817                        }
818                        // Any leftover triangles after the recorded arities
819                        // (e.g. a primitive grew after the arity vector was
820                        // captured) are emitted as plain triangles.
821                        let consumed = per_prim_arities
822                            .iter()
823                            .map(|&a| (a as usize).saturating_sub(2))
824                            .sum::<usize>();
825                        for tri in consumed..(face_indices.len() / 3) {
826                            let verts = [
827                                face_indices[tri * 3],
828                                face_indices[tri * 3 + 1],
829                                face_indices[tri * 3 + 2],
830                            ];
831                            write_face(&mut out, &verts, prim_globals, has_uv, has_normal);
832                        }
833                    } else {
834                        for tri in 0..(face_indices.len() / 3) {
835                            let verts = [
836                                face_indices[tri * 3],
837                                face_indices[tri * 3 + 1],
838                                face_indices[tri * 3 + 2],
839                            ];
840                            write_face(&mut out, &verts, prim_globals, has_uv, has_normal);
841                        }
842                    }
843                }
844                Topology::Lines => {
845                    let line_indices: Vec<u32> = match &prim.indices {
846                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
847                        Some(Indices::U32(v)) => v.clone(),
848                        None => (0..prim.positions.len() as u32).collect(),
849                    };
850                    for w in line_indices.chunks_exact(2) {
851                        let a = prim_globals[w[0] as usize];
852                        let b = prim_globals[w[1] as usize];
853                        writeln!(out, "l {} {}", a.0, b.0).unwrap();
854                    }
855                }
856                other => {
857                    return Err(Error::unsupported(format!(
858                        "OBJ encoder: topology {other:?} not representable"
859                    )));
860                }
861            }
862        }
863    }
864
865    Ok(out.into_bytes())
866}
867
868fn write_face(
869    out: &mut String,
870    verts: &[u32],
871    prim_globals: &[(u32, u32, u32)],
872    has_uv: bool,
873    has_normal: bool,
874) {
875    use std::fmt::Write;
876    out.push('f');
877    for &local in verts {
878        let (v, vt, vn) = prim_globals[local as usize];
879        match (has_uv, has_normal) {
880            (true, true) => write!(out, " {v}/{vt}/{vn}").unwrap(),
881            (true, false) => write!(out, " {v}/{vt}").unwrap(),
882            (false, true) => write!(out, " {v}//{vn}").unwrap(),
883            (false, false) => write!(out, " {v}").unwrap(),
884        }
885    }
886    out.push('\n');
887}
888
889/// Format a float without scientific notation; trims trailing zeros
890/// while keeping at least one digit after the decimal point. Keeps the
891/// emitted file human-diffable.
892fn fmt_float(x: f32) -> String {
893    if x == 0.0 {
894        return "0".to_string();
895    }
896    let s = format!("{x:.6}");
897    let trimmed = s.trim_end_matches('0').trim_end_matches('.').to_string();
898    if trimmed.is_empty() || trimmed == "-" {
899        "0".to_string()
900    } else {
901        trimmed
902    }
903}
904
905// ---------------------------------------------------------------------------
906// Float keys for the dedup HashMap (f32 isn't Hash).
907// ---------------------------------------------------------------------------
908
909#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
910struct KeyVec2 {
911    a: u32,
912    b: u32,
913}
914impl From<[f32; 2]> for KeyVec2 {
915    fn from(v: [f32; 2]) -> Self {
916        Self {
917            a: v[0].to_bits(),
918            b: v[1].to_bits(),
919        }
920    }
921}
922
923#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
924struct KeyVec3 {
925    a: u32,
926    b: u32,
927    c: u32,
928}
929impl From<[f32; 3]> for KeyVec3 {
930    fn from(v: [f32; 3]) -> Self {
931        Self {
932            a: v[0].to_bits(),
933            b: v[1].to_bits(),
934            c: v[2].to_bits(),
935        }
936    }
937}
938
939// ---------------------------------------------------------------------------
940// Tests (unit-level — integration tests live under `tests/`).
941// ---------------------------------------------------------------------------
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946
947    #[test]
948    fn preprocess_strips_comments_and_glues_continuations() {
949        let lines =
950            preprocess_lines("v 1.0 2.0 \\\n3.0 # comment\nv 4 5 6\n# pure comment\nf 1 2 3");
951        assert_eq!(lines[0].trim(), "v 1.0 2.0  3.0");
952        assert_eq!(lines[1].trim(), "v 4 5 6");
953        // The pure-comment line collapses to an empty preprocessed line.
954        assert_eq!(lines[2].trim(), "");
955        assert_eq!(lines[3].trim(), "f 1 2 3");
956    }
957
958    #[test]
959    fn fmt_float_is_diff_friendly() {
960        assert_eq!(fmt_float(1.0), "1");
961        assert_eq!(fmt_float(0.0), "0");
962        assert_eq!(fmt_float(-0.5), "-0.5");
963        assert_eq!(fmt_float(1.0 / 3.0), "0.333333");
964    }
965}