Skip to main content

oxideav_obj/
obj.rs

1//! Wavefront OBJ ASCII parser + serialiser.
2//!
3//! Polygonal subset (vertex / face / line / point / grouping / material
4//! directives) is fully decoded into the typed [`Scene3D`] model. The
5//! free-form curve/surface directives — `vp`, `cstype`, `deg`, `curv`,
6//! `curv2`, `surf`, `parm`, `trim`, `hole`, `scrv`, `sp`, `end`, plus
7//! the superseded `bzp` / `bsp` patches — are captured verbatim into
8//! `Scene3D::extras["obj:vp"]` and
9//! `Scene3D::extras["obj:freeform_directives"]` so a decode → encode
10//! round-trip preserves the directive sequence and arguments without
11//! semantic interpretation. The `.mod` binary form remains out of
12//! scope.
13//!
14//! The grammar is line-oriented; whitespace-separated; `#` introduces
15//! a comment to end of line. Continuation lines (trailing `\\`) are
16//! supported by gluing the next line on before tokenisation.
17
18use std::collections::HashMap;
19
20use oxideav_mesh3d::{Error, Indices, Mesh, Primitive, Result, Scene3D, Topology};
21
22use crate::mtl::parse_mtl;
23
24// ---------------------------------------------------------------------------
25// Parsing
26// ---------------------------------------------------------------------------
27
28/// Per-face-vertex index triple. `0` means "not present".
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
30struct FaceVert {
31    /// 1-based geometric-vertex index (resolved from raw OBJ).
32    v: u32,
33    /// 1-based texture-coord index, or 0 if absent.
34    vt: u32,
35    /// 1-based normal index, or 0 if absent.
36    vn: u32,
37}
38
39/// One face / line / point element captured during the first parse pass.
40///
41/// Different element kinds map to different [`Topology`] variants and
42/// can't share a single [`Primitive`]; the accumulator splits into
43/// fresh primitives whenever the kind changes.
44#[derive(Debug)]
45enum Element {
46    Face(Vec<FaceVert>),
47    Line(Vec<FaceVert>),
48    Point(Vec<FaceVert>),
49}
50
51/// One open primitive — accumulates face/line elements while a single
52/// `usemtl` (or "no material") is active.
53#[derive(Debug, Default)]
54struct PrimAccum {
55    elements: Vec<Element>,
56    material: Option<String>,
57    /// Last seen smoothing group token (`"off"` or an integer string).
58    smoothing_group: Option<String>,
59    /// All distinct group names seen during this primitive.
60    groups: Vec<String>,
61    /// Last seen merging-group token (`"off"` / `"0"` or `"<n> <res>"`).
62    /// Captured as a single state value rather than per-element since
63    /// `mg` is state-setting per spec §"mg group_number res".
64    merging_group: Option<String>,
65    /// Display-attribute state — bevel-interpolation flag (`"on"` /
66    /// `"off"`). Spec §"bevel on/off" — state-setting; default off.
67    bevel: Option<String>,
68    /// Color-interpolation flag (`"on"` / `"off"`). Spec
69    /// §"c_interp on/off" — state-setting; default off.
70    c_interp: Option<String>,
71    /// Dissolve-interpolation flag (`"on"` / `"off"`). Spec
72    /// §"d_interp on/off" — state-setting; default off.
73    d_interp: Option<String>,
74    /// Level-of-detail integer (1..100, or 0 / absent for "all").
75    /// Spec §"lod level" — state-setting.
76    lod: Option<String>,
77}
78
79/// One open mesh — accumulates primitives while a single `o <name>`
80/// (or default object) is active.
81#[derive(Debug, Default)]
82struct MeshAccum {
83    name: Option<String>,
84    primitives: Vec<PrimAccum>,
85}
86
87impl MeshAccum {
88    fn current_or_new(&mut self) -> &mut PrimAccum {
89        if self.primitives.is_empty() {
90            self.primitives.push(PrimAccum::default());
91        }
92        self.primitives.last_mut().unwrap()
93    }
94}
95
96/// The polygonal data parsed out of an OBJ document.
97///
98/// This intermediate form keeps positions / texcoords / normals in
99/// their original 1-based numbering so the resolution of negative and
100/// 1-based face indices into 0-based primitive-local indices happens
101/// in one well-defined place ([`build_scene`]).
102#[derive(Debug, Default)]
103struct ObjDoc {
104    positions: Vec<[f32; 3]>,
105    /// Per-position rational weight from the optional 4th `w` component
106    /// of `v x y z w`. `None` means "no weight given" (the spec default
107    /// is `1.0`); `Some(w)` is preserved verbatim so a round-trip emits
108    /// the original 4-token form rather than collapsing to 3 tokens.
109    /// Parallel to `positions` (1-based / 0-based index parity).
110    /// Spec §"v x y z w" — w defaults to 1.0 for non-rational geometry.
111    position_weights: Vec<Option<f32>>,
112    /// Per-position vertex colour from the widely-deployed
113    /// `v x y z r g b` extension (MeshLab, libigl, Meshroom, OpenCV).
114    /// `None` for vertices written in the standard 3-token form.
115    /// `Some([r, g, b, 1.0])` carries the linear-space RGB triplet
116    /// (alpha pinned to opaque since the extension only spells out
117    /// three colour channels). Parallel to `positions`.
118    /// Not in the original spec — flagged in `docs/3d/obj/README.md`
119    /// as the canonical "widely used but never standardised" extension.
120    position_colors: Vec<Option<[f32; 4]>>,
121    texcoords: Vec<[f32; 2]>,
122    normals: Vec<[f32; 3]>,
123    /// Parameter-space vertices (`vp u v [w]`) from the free-form
124    /// geometry portion of the spec — 1-based numbering, parallel to
125    /// `positions` / `texcoords` / `normals`. Stored as a 3-tuple
126    /// where missing components default to `0.0` (this matches what
127    /// the spec calls out: `v` defaults to 0 for 1D points, `w`
128    /// defaults to 1.0 for rational trimming curves but we leave the
129    /// raw "what the file said" in extras and let the consumer
130    /// interpret).
131    vp: Vec<[f32; 3]>,
132    /// Material library file names referenced by `mtllib`.
133    mtllibs: Vec<String>,
134    /// All material definitions resolved from `mtllib` references
135    /// supplied via [`ObjDoc::with_resolved_mtllibs`]. Round 1 ships
136    /// no IO so we accept these via an external resolver hook on the
137    /// caller.
138    resolved_materials: HashMap<String, oxideav_mesh3d::Material>,
139    meshes: Vec<MeshAccum>,
140    /// Verbatim sequence of free-form-geometry directives (`cstype`,
141    /// `deg`, `curv`, `surf`, `parm`, `trim`, `hole`, `scrv`, `sp`,
142    /// `end`, `bzp`, plus the older `bsp`). Each entry is the keyword
143    /// followed by its whitespace-separated arguments. Round-trip
144    /// preservation: the encoder replays the sequence verbatim after
145    /// the polygonal section so consumers can carry free-form data
146    /// through us without semantic loss. Body statements (`parm`,
147    /// `trim`, `hole`, `scrv`, `sp`, `end`) are accepted in document
148    /// order; the spec mandates they appear between an element start
149    /// (`curv` / `surf`) and `end`, but we don't enforce that — a
150    /// lenient loader pattern matches what tools in the wild emit.
151    freeform_directives: Vec<Vec<String>>,
152}
153
154/// Glue line-continuation (`\\` + newline) before line splitting and
155/// strip comments (`#…` to end of line). Returns owned strings since
156/// continuation gluing rewrites the input.
157fn preprocess_lines(text: &str) -> Vec<String> {
158    let mut out: Vec<String> = Vec::new();
159    let mut acc = String::new();
160    for raw_line in text.split('\n') {
161        // Strip a trailing CR so CRLF inputs land cleanly.
162        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
163        // Strip comments — `#` past the start of a token introduces
164        // an end-of-line comment per the spec.
165        let no_comment = match line.find('#') {
166            Some(idx) => &line[..idx],
167            None => line,
168        };
169        let trimmed = no_comment.trim_end();
170        if let Some(stripped) = trimmed.strip_suffix('\\') {
171            acc.push_str(stripped);
172            acc.push(' ');
173        } else {
174            acc.push_str(trimmed);
175            out.push(std::mem::take(&mut acc));
176        }
177    }
178    if !acc.is_empty() {
179        out.push(acc);
180    }
181    out
182}
183
184/// Parse a face-vertex token. Accepts `v`, `v/vt`, `v//vn`, `v/vt/vn`.
185/// Each component is a non-zero integer (negative => relative-from-end).
186/// Resolution to 1-based positive indices happens here; 0-based
187/// primitive-local indexing happens in [`build_scene`].
188fn parse_face_vertex(tok: &str, n_pos: i64, n_tex: i64, n_norm: i64) -> Result<FaceVert> {
189    let mut parts = tok.split('/');
190    let v = parts
191        .next()
192        .ok_or_else(|| Error::invalid(format!("face vertex missing position: {tok:?}")))?;
193    let vt = parts.next().unwrap_or("");
194    let vn = parts.next().unwrap_or("");
195
196    let resolve = |s: &str, n: i64, kind: &str| -> Result<u32> {
197        if s.is_empty() {
198            return Ok(0);
199        }
200        let raw: i64 = s.parse().map_err(|_| {
201            Error::invalid(format!(
202                "invalid {kind} index in face vertex {tok:?}: {s:?}"
203            ))
204        })?;
205        let resolved = if raw < 0 { n + 1 + raw } else { raw };
206        if resolved <= 0 || resolved > n {
207            return Err(Error::invalid(format!(
208                "{kind} index out of range in face vertex {tok:?}: {raw} (have {n})"
209            )));
210        }
211        Ok(resolved as u32)
212    };
213
214    Ok(FaceVert {
215        v: resolve(v, n_pos, "position")?,
216        vt: resolve(vt, n_tex, "texcoord")?,
217        vn: resolve(vn, n_norm, "normal")?,
218    })
219}
220
221/// Parse the geometry part of an OBJ document into the intermediate
222/// [`ObjDoc`] form. No I/O — `mtllib` lines are recorded by name only;
223/// the caller resolves them.
224fn parse_obj_doc(text: &str) -> Result<ObjDoc> {
225    let mut doc = ObjDoc::default();
226    // One implicit mesh until an `o` directive opens a named one.
227    doc.meshes.push(MeshAccum::default());
228
229    let lines = preprocess_lines(text);
230    for line in &lines {
231        let mut tokens = line.split_whitespace();
232        let Some(keyword) = tokens.next() else {
233            continue;
234        };
235        match keyword {
236            "v" => {
237                let coords: Vec<f32> = tokens
238                    .map(str::parse)
239                    .collect::<std::result::Result<Vec<f32>, _>>()
240                    .map_err(|e| Error::invalid(format!("v: bad float ({e})")))?;
241                // Spec §"v x y z w" defines 3 or 4 components (the 4th
242                // is the rational weight, default 1.0). The
243                // widely-deployed MeshLab / libigl / Meshroom extension
244                // adds a per-vertex RGB triplet making 6 (`x y z r g b`)
245                // or 7 (`x y z w r g b`) the supported widths in the
246                // wild. We accept all four shapes and surface the extra
247                // information through parallel `position_weights` /
248                // `position_colors` arrays so the encoder can re-emit
249                // the original token width on round-trip.
250                let (w, rgb) = match coords.len() {
251                    3 => (None, None),
252                    4 => (Some(coords[3]), None),
253                    6 => (None, Some([coords[3], coords[4], coords[5], 1.0])),
254                    7 => (
255                        Some(coords[3]),
256                        Some([coords[4], coords[5], coords[6], 1.0]),
257                    ),
258                    n => {
259                        return Err(Error::invalid(format!(
260                            "v: expected 3, 4, 6, or 7 floats (xyz, xyzw, xyzrgb, or \
261                             xyzwrgb per spec + MeshLab vertex-colour extension), got {n}"
262                        )));
263                    }
264                };
265                doc.positions.push([coords[0], coords[1], coords[2]]);
266                doc.position_weights.push(w);
267                doc.position_colors.push(rgb);
268            }
269            "vt" => {
270                let coords: Vec<f32> = tokens
271                    .map(str::parse)
272                    .collect::<std::result::Result<Vec<f32>, _>>()
273                    .map_err(|e| Error::invalid(format!("vt: bad float ({e})")))?;
274                if coords.is_empty() {
275                    return Err(Error::invalid("vt: expected ≥1 coord"));
276                }
277                let u = coords[0];
278                let v = coords.get(1).copied().unwrap_or(0.0);
279                // Drop optional 3rd `w` — meaningless to glTF UV.
280                doc.texcoords.push([u, v]);
281            }
282            "vn" => {
283                let coords: Vec<f32> = tokens
284                    .map(str::parse)
285                    .collect::<std::result::Result<Vec<f32>, _>>()
286                    .map_err(|e| Error::invalid(format!("vn: bad float ({e})")))?;
287                if coords.len() != 3 {
288                    return Err(Error::invalid(format!(
289                        "vn: expected 3 coords, got {}",
290                        coords.len()
291                    )));
292                }
293                doc.normals.push([coords[0], coords[1], coords[2]]);
294            }
295            "vp" => {
296                // Parameter-space vertex (`vp u v [w]`) — used as the
297                // control-point pool for free-form 2D trimming curves
298                // (`curv2`, referenced by `trim`/`hole`/`scrv`) and
299                // for special points (`sp`). Spec §"vp u v w".
300                //
301                // The number of meaningful coordinates depends on the
302                // usage (1D for 1D special points, 2D for trimming
303                // curves, 3D for rational trimming curves with a
304                // weight). We always store a 3-tuple, padding with
305                // `0.0` so the encoder can emit a faithful
306                // `vp <u> <v> <w>` line for the rational case and a
307                // shorter `vp <u> <v>` / `vp <u>` for the others.
308                let coords: Vec<f32> = tokens
309                    .map(str::parse)
310                    .collect::<std::result::Result<Vec<f32>, _>>()
311                    .map_err(|e| Error::invalid(format!("vp: bad float ({e})")))?;
312                if coords.is_empty() {
313                    return Err(Error::invalid("vp: expected ≥1 coord"));
314                }
315                let u = coords[0];
316                let v = coords.get(1).copied().unwrap_or(0.0);
317                let w = coords.get(2).copied().unwrap_or(0.0);
318                doc.vp.push([u, v, w]);
319            }
320            "cstype" | "deg" | "curv" | "curv2" | "surf" | "parm" | "trim" | "hole" | "scrv"
321            | "sp" | "end" | "bzp" | "bsp" => {
322                // Free-form geometry directives. Captured verbatim as
323                // a `(keyword, args)` sequence on the document so the
324                // encoder can replay them after the polygonal section.
325                // No semantic interpretation: the round-trip preserves
326                // the operator's exact token sequence.
327                //
328                // Spec §"Free-form curve/surface attributes" /
329                // §"Specifying free-form curves/surfaces" /
330                // §"Free-form curve/surface body statements" /
331                // §"Superseded statements (bzp / bsp)".
332                let mut entry: Vec<String> = Vec::new();
333                entry.push(keyword.to_string());
334                for tok in tokens {
335                    entry.push(tok.to_string());
336                }
337                doc.freeform_directives.push(entry);
338            }
339            "f" => {
340                let n_pos = doc.positions.len() as i64;
341                let n_tex = doc.texcoords.len() as i64;
342                let n_norm = doc.normals.len() as i64;
343                let verts: Vec<FaceVert> = tokens
344                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
345                    .collect::<Result<Vec<_>>>()?;
346                if verts.len() < 3 {
347                    return Err(Error::invalid(format!(
348                        "f: face needs ≥3 vertices, got {}",
349                        verts.len()
350                    )));
351                }
352                let mesh = doc.meshes.last_mut().unwrap();
353                mesh.current_or_new().elements.push(Element::Face(verts));
354            }
355            "l" => {
356                let n_pos = doc.positions.len() as i64;
357                let n_tex = doc.texcoords.len() as i64;
358                let n_norm = doc.normals.len() as i64;
359                let verts: Vec<FaceVert> = tokens
360                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
361                    .collect::<Result<Vec<_>>>()?;
362                if verts.len() < 2 {
363                    return Err(Error::invalid(format!(
364                        "l: line needs ≥2 vertices, got {}",
365                        verts.len()
366                    )));
367                }
368                let mesh = doc.meshes.last_mut().unwrap();
369                mesh.current_or_new().elements.push(Element::Line(verts));
370            }
371            "p" => {
372                // Point elements are state-incompatible with face/line
373                // primitives (different `Topology`); mirror the `usemtl`
374                // pattern and split into a fresh primitive whenever the
375                // current one already holds incompatible elements.
376                let n_pos = doc.positions.len() as i64;
377                let n_tex = doc.texcoords.len() as i64;
378                let n_norm = doc.normals.len() as i64;
379                // `p` only takes vertex references (no `/vt` or `//vn`),
380                // but parse_face_vertex degrades gracefully when the
381                // separators are absent.
382                let verts: Vec<FaceVert> = tokens
383                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
384                    .collect::<Result<Vec<_>>>()?;
385                if verts.is_empty() {
386                    return Err(Error::invalid("p: needs ≥1 vertex"));
387                }
388                let mesh = doc.meshes.last_mut().unwrap();
389                let prim = mesh.current_or_new();
390                if prim
391                    .elements
392                    .iter()
393                    .any(|e| !matches!(e, Element::Point(_)))
394                {
395                    // Mixed-kind elements aren't representable; open a
396                    // fresh primitive that inherits material + groups +
397                    // smoothing/merging/display-attr state.
398                    let mat = prim.material.clone();
399                    let groups = prim.groups.clone();
400                    let smoothing = prim.smoothing_group.clone();
401                    let merging = prim.merging_group.clone();
402                    let bevel = prim.bevel.clone();
403                    let c_interp = prim.c_interp.clone();
404                    let d_interp = prim.d_interp.clone();
405                    let lod = prim.lod.clone();
406                    mesh.primitives.push(PrimAccum {
407                        material: mat,
408                        groups,
409                        smoothing_group: smoothing,
410                        merging_group: merging,
411                        bevel,
412                        c_interp,
413                        d_interp,
414                        lod,
415                        elements: vec![Element::Point(verts)],
416                    });
417                } else {
418                    prim.elements.push(Element::Point(verts));
419                }
420            }
421            "bevel" | "c_interp" | "d_interp" | "lod" => {
422                // Display-attribute state-setting — `bevel on/off`,
423                // `c_interp on/off`, `d_interp on/off`, `lod <level>`.
424                // Captured per-primitive; a mid-stream change splits
425                // the primitive so each one carries one consistent
426                // value (mirrors `s`/`mg`).
427                let v: String = tokens.collect::<Vec<_>>().join(" ");
428                if v.is_empty() {
429                    continue;
430                }
431                let mesh = doc.meshes.last_mut().unwrap();
432                let last = mesh.current_or_new();
433                let current: Option<&str> = match keyword {
434                    "bevel" => last.bevel.as_deref(),
435                    "c_interp" => last.c_interp.as_deref(),
436                    "d_interp" => last.d_interp.as_deref(),
437                    "lod" => last.lod.as_deref(),
438                    _ => unreachable!(),
439                };
440                if last.elements.is_empty() {
441                    // Overwrite the pending value.
442                    match keyword {
443                        "bevel" => last.bevel = Some(v),
444                        "c_interp" => last.c_interp = Some(v),
445                        "d_interp" => last.d_interp = Some(v),
446                        "lod" => last.lod = Some(v),
447                        _ => unreachable!(),
448                    }
449                } else if current != Some(v.as_str()) {
450                    let mat = last.material.clone();
451                    let groups = last.groups.clone();
452                    let smoothing = last.smoothing_group.clone();
453                    let merging = last.merging_group.clone();
454                    let mut bevel = last.bevel.clone();
455                    let mut c_interp = last.c_interp.clone();
456                    let mut d_interp = last.d_interp.clone();
457                    let mut lod = last.lod.clone();
458                    match keyword {
459                        "bevel" => bevel = Some(v),
460                        "c_interp" => c_interp = Some(v),
461                        "d_interp" => d_interp = Some(v),
462                        "lod" => lod = Some(v),
463                        _ => unreachable!(),
464                    }
465                    mesh.primitives.push(PrimAccum {
466                        material: mat,
467                        smoothing_group: smoothing,
468                        merging_group: merging,
469                        groups,
470                        bevel,
471                        c_interp,
472                        d_interp,
473                        lod,
474                        elements: Vec::new(),
475                    });
476                }
477            }
478            "mg" => {
479                // Merging group — `mg <group_number> [res]` or `mg off`
480                // / `mg 0`. Like `s`, it's state-setting; preserve the
481                // operator's spelling verbatim. The semantic value
482                // (smoothing across surface joins for free-form
483                // surfaces) is meaningless without the free-form
484                // surface support, but the round-trip preservation
485                // matters for tools that round-trip mesh data through
486                // us.
487                let v: String = tokens.collect::<Vec<_>>().join(" ");
488                if v.is_empty() {
489                    continue;
490                }
491                let mesh = doc.meshes.last_mut().unwrap();
492                let last = mesh.current_or_new();
493                if last.elements.is_empty() {
494                    // No elements yet — overwrite the pending value.
495                    last.merging_group = Some(v);
496                } else if last.merging_group.as_deref() != Some(v.as_str()) {
497                    // Merging-group changed mid-stream; split into a
498                    // fresh primitive so each one carries one
499                    // consistent assignment (mirrors smoothing-group
500                    // behaviour).
501                    let mat = last.material.clone();
502                    let groups = last.groups.clone();
503                    let smoothing = last.smoothing_group.clone();
504                    let bevel = last.bevel.clone();
505                    let c_interp = last.c_interp.clone();
506                    let d_interp = last.d_interp.clone();
507                    let lod = last.lod.clone();
508                    mesh.primitives.push(PrimAccum {
509                        material: mat,
510                        smoothing_group: smoothing,
511                        groups,
512                        merging_group: Some(v),
513                        bevel,
514                        c_interp,
515                        d_interp,
516                        lod,
517                        elements: Vec::new(),
518                    });
519                }
520            }
521            "o" => {
522                let name: String = tokens.collect::<Vec<_>>().join(" ");
523                // Open a fresh mesh — but if the current mesh is still
524                // empty (no primitives accumulated yet), reuse it so we
525                // don't end up with a leading empty mesh.
526                let last = doc.meshes.last_mut().unwrap();
527                if last.name.is_none() && last.primitives.is_empty() {
528                    last.name = if name.is_empty() { None } else { Some(name) };
529                } else {
530                    doc.meshes.push(MeshAccum {
531                        name: if name.is_empty() { None } else { Some(name) },
532                        primitives: Vec::new(),
533                    });
534                }
535            }
536            "g" => {
537                // The spec (Wavefront *Advanced Visualizer* Appendix B,
538                // §"Grouping") explicitly permits multiple group names
539                // on one line: `g group_name1 group_name2 …`. Each
540                // whitespace-separated token is its own group; the
541                // following elements belong to ALL listed groups.
542                let names: Vec<String> = tokens.map(|t| t.to_string()).collect();
543                if names.is_empty() {
544                    continue;
545                }
546                let mesh = doc.meshes.last_mut().unwrap();
547                let prim = mesh.current_or_new();
548                for name in names {
549                    if !prim.groups.iter().any(|g| g == &name) {
550                        prim.groups.push(name);
551                    }
552                }
553            }
554            "s" => {
555                // `s 0` and `s off` both mean "no smoothing"; preserve
556                // the operator's chosen spelling verbatim for round-trip.
557                let v: String = tokens.collect::<Vec<_>>().join(" ");
558                if v.is_empty() {
559                    continue;
560                }
561                let mesh = doc.meshes.last_mut().unwrap();
562                let last = mesh.current_or_new();
563                if last.elements.is_empty() {
564                    // No elements yet — overwrite the pending value.
565                    last.smoothing_group = Some(v);
566                } else if last.smoothing_group.as_deref() != Some(v.as_str()) {
567                    // Smoothing changed mid-stream; spec says it's
568                    // state-setting and applies to subsequent
569                    // elements, so split into a new primitive that
570                    // inherits the current material + groups +
571                    // merging-group + display attributes.
572                    let mat = last.material.clone();
573                    let groups = last.groups.clone();
574                    let merging = last.merging_group.clone();
575                    let bevel = last.bevel.clone();
576                    let c_interp = last.c_interp.clone();
577                    let d_interp = last.d_interp.clone();
578                    let lod = last.lod.clone();
579                    mesh.primitives.push(PrimAccum {
580                        material: mat,
581                        smoothing_group: Some(v),
582                        groups,
583                        merging_group: merging,
584                        bevel,
585                        c_interp,
586                        d_interp,
587                        lod,
588                        elements: Vec::new(),
589                    });
590                }
591            }
592            "usemtl" => {
593                let name: String = tokens.collect::<Vec<_>>().join(" ");
594                let mesh = doc.meshes.last_mut().unwrap();
595                let last = mesh.current_or_new();
596                if last.elements.is_empty() && last.material.is_none() {
597                    // First usemtl in this primitive — adopt directly.
598                    last.material = if name.is_empty() { None } else { Some(name) };
599                } else {
600                    // Subsequent usemtl — start a new primitive.
601                    mesh.primitives.push(PrimAccum {
602                        material: if name.is_empty() { None } else { Some(name) },
603                        ..PrimAccum::default()
604                    });
605                }
606            }
607            "mtllib" => {
608                // Each `mtllib` line can list multiple .mtl files.
609                for tok in tokens {
610                    if !doc.mtllibs.iter().any(|m| m == tok) {
611                        doc.mtllibs.push(tok.to_string());
612                    }
613                }
614            }
615            // Unhandled keywords (curves/surfaces/display attributes/etc.) are
616            // silently skipped per spec lenient-loader convention.
617            _ => {}
618        }
619    }
620
621    Ok(doc)
622}
623
624// ---------------------------------------------------------------------------
625// Scene assembly
626// ---------------------------------------------------------------------------
627
628/// Convert the intermediate [`ObjDoc`] into a [`Scene3D`].
629///
630/// Indices are de-duplicated per-primitive so the resulting vertex
631/// buffer carries `unique_face_vertices` entries (matching glTF's
632/// per-primitive interleaved-attribute model). Original face arities
633/// are stored in `Mesh::extras["obj:original_face_arities"]` so the
634/// encoder can reconstruct the n-gons.
635fn build_scene(doc: ObjDoc) -> Result<Scene3D> {
636    use oxideav_mesh3d::{Axis, Material, Unit};
637
638    let mut scene = Scene3D::new();
639    // OBJ has no unit metadata; the primer says "Metres is the safe
640    // default" and "Y-up matches the glTF default".
641    scene.up_axis = Axis::PosY;
642    scene.unit = Unit::Metres;
643
644    // Materials first so primitives can point at their MaterialId.
645    // Insertion order is preserved (HashMap iteration order is
646    // unspecified, so sort by name to keep round-trip deterministic).
647    let mut material_ids: HashMap<String, oxideav_mesh3d::MaterialId> = HashMap::new();
648    let mut material_names: Vec<String> = doc.resolved_materials.keys().cloned().collect();
649    material_names.sort();
650    for name in &material_names {
651        let mut mat = doc
652            .resolved_materials
653            .get(name)
654            .cloned()
655            .unwrap_or_else(Material::new);
656        if mat.name.is_none() {
657            mat.name = Some(name.clone());
658        }
659        let id = scene.add_material(mat);
660        material_ids.insert(name.clone(), id);
661    }
662
663    for mesh_acc in doc.meshes {
664        // Drop genuinely empty meshes (no primitives that emit anything).
665        let has_anything = mesh_acc.primitives.iter().any(|p| !p.elements.is_empty());
666        if !has_anything {
667            continue;
668        }
669
670        let mut mesh = Mesh::new(mesh_acc.name.clone());
671
672        for prim_acc in mesh_acc.primitives {
673            let (mut primitive, arities) = build_primitive(
674                &prim_acc,
675                &doc.positions,
676                &doc.position_weights,
677                &doc.position_colors,
678                &doc.texcoords,
679                &doc.normals,
680                &material_ids,
681            )?;
682            // Skip primitives that never accumulated any element.
683            if primitive.positions.is_empty() {
684                continue;
685            }
686            // Stash original face arities per-primitive when the primitive
687            // contained at least one non-triangle face. Mesh has no
688            // `extras` field, so the round-trip annotation lives on the
689            // primitive — symmetrical with the smoothing-group / groups /
690            // usemtl extras already populated by `build_primitive`.
691            if arities.iter().any(|&a| a != 3) {
692                primitive.extras.insert(
693                    "obj:original_face_arities".to_string(),
694                    serde_json::to_value(&arities).unwrap(),
695                );
696            }
697            mesh.primitives.push(primitive);
698        }
699
700        scene.add_mesh(mesh);
701    }
702
703    // Keep the mtllib references in scene extras so a re-encode that
704    // wants to point back at a specific MTL file can find them.
705    if !doc.mtllibs.is_empty() {
706        scene.extras.insert(
707            "obj:mtllibs".to_string(),
708            serde_json::to_value(&doc.mtllibs).unwrap(),
709        );
710    }
711
712    // Free-form geometry side-channel: the parameter-space vertex pool
713    // (`vp`) and the verbatim sequence of `cstype` / `deg` / `curv` /
714    // `surf` / `parm` / `trim` / `hole` / `scrv` / `sp` / `end` / `bzp`
715    // / `bsp` directives. The encoder replays these after the
716    // polygonal section so consumers that don't care about free-form
717    // geometry simply ignore the keys, while consumers that do can
718    // walk the directive sequence themselves.
719    if !doc.vp.is_empty() {
720        scene
721            .extras
722            .insert("obj:vp".to_string(), serde_json::to_value(&doc.vp).unwrap());
723    }
724    if !doc.freeform_directives.is_empty() {
725        scene.extras.insert(
726            "obj:freeform_directives".to_string(),
727            serde_json::to_value(&doc.freeform_directives).unwrap(),
728        );
729    }
730
731    Ok(scene)
732}
733
734/// Promote a single-`l`-element primitive to `LineStrip` / `LineLoop`
735/// when applicable; fall back to `Lines` for multi-element or 2-vertex
736/// segments. See [`build_primitive`] for the surrounding context.
737fn single_line_topology(elements: &[Element]) -> Topology {
738    if elements.len() != 1 {
739        return Topology::Lines;
740    }
741    let Element::Line(verts) = &elements[0] else {
742        return Topology::Lines;
743    };
744    if verts.len() < 2 {
745        return Topology::Lines;
746    }
747    // A 2-vertex `l` is a plain segment — keep it on `Lines` so the
748    // round-trip stays minimal (one `l v1 v2` line either way).
749    if verts.len() == 2 {
750        return Topology::Lines;
751    }
752    // Closed polyline: first / last vertex coincide on the position
753    // index. We don't need to compare uv/normal — `l` references only
754    // ever populate the position component for the loop-detection
755    // semantics specified by the spec §"Line elements".
756    let same_start_end = verts.first().map(|fv| fv.v) == verts.last().map(|fv| fv.v);
757    if same_start_end {
758        Topology::LineLoop
759    } else {
760        Topology::LineStrip
761    }
762}
763
764/// Build one [`Primitive`] from an accumulated [`PrimAccum`].
765///
766/// Returns the primitive plus a per-element arity vector — one entry
767/// per face (3 for a triangle, 4 for a quad, ≥5 for an n-gon). Lines
768/// don't contribute arity entries (the encoder switches on topology
769/// instead).
770fn build_primitive(
771    prim_acc: &PrimAccum,
772    positions: &[[f32; 3]],
773    position_weights: &[Option<f32>],
774    position_colors: &[Option<[f32; 4]>],
775    texcoords: &[[f32; 2]],
776    normals: &[[f32; 3]],
777    material_ids: &HashMap<String, oxideav_mesh3d::MaterialId>,
778) -> Result<(Primitive, Vec<u32>)> {
779    // Decide topology + attribute presence by looking at the first
780    // element. Mixed-element primitives (lines + faces under one
781    // `usemtl`) aren't representable in mesh3d so we error cleanly.
782    //
783    // For a single `l` element we promote to the more specific
784    // `LineStrip` / `LineLoop` topology so consumers don't have to
785    // reconstruct the polyline shape from disjoint segment pairs:
786    //
787    //   * exactly one `l` element with N ≥ 2 vertices whose last
788    //     vertex equals its first → `LineLoop` (the redundant
789    //     closing vertex is dropped from the index buffer).
790    //   * exactly one `l` element with N ≥ 2 distinct end vertices →
791    //     `LineStrip`.
792    //   * multiple `l` elements (or a single 2-vertex `l` that is a
793    //     plain segment) fall back to `Lines` for the existing
794    //     contiguous-chain re-emit path on the encoder side.
795    let first = prim_acc.elements.first();
796    let topology = match first {
797        Some(Element::Face(_)) => Topology::Triangles,
798        Some(Element::Line(_)) => single_line_topology(&prim_acc.elements),
799        Some(Element::Point(_)) => Topology::Points,
800        None => Topology::Triangles,
801    };
802    for elt in &prim_acc.elements {
803        let ok = matches!(
804            (&topology, elt),
805            (Topology::Triangles, Element::Face(_))
806                | (Topology::Lines, Element::Line(_))
807                | (Topology::LineStrip, Element::Line(_))
808                | (Topology::LineLoop, Element::Line(_))
809                | (Topology::Points, Element::Point(_))
810        );
811        if !ok {
812            return Err(Error::unsupported(
813                "OBJ primitive mixes face / line / point elements under one usemtl",
814            ));
815        }
816    }
817
818    let has_uv = prim_acc.elements.iter().any(|elt| match elt {
819        Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
820            verts.iter().any(|fv| fv.vt != 0)
821        }
822    });
823    let has_normal = prim_acc.elements.iter().any(|elt| match elt {
824        Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
825            verts.iter().any(|fv| fv.vn != 0)
826        }
827    });
828    // Per-vertex colour applies to a primitive whenever any of its
829    // referenced positions carries the `v x y z r g b` extension. We
830    // promote to a single-channel `colors[0]` set; vertices that
831    // don't carry RGB fall back to white (the obvious "no colour
832    // information" sentinel — preserves the standard glTF expectation
833    // that a colour buffer is fully populated when present). The
834    // round-trip-aware `obj:vertex_color_present` per-position
835    // bitmap below guards the encoder against re-emitting a
836    // synthetic white that the original file didn't spell out.
837    let has_color = prim_acc.elements.iter().any(|elt| match elt {
838        Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
839            verts.iter().any(|fv| {
840                position_colors
841                    .get((fv.v - 1) as usize)
842                    .is_some_and(Option::is_some)
843            })
844        }
845    });
846
847    let mut prim = Primitive::new(topology);
848    if has_uv {
849        prim.uvs.push(Vec::new());
850    }
851    if has_normal {
852        prim.normals = Some(Vec::new());
853    }
854    if has_color {
855        prim.colors.push(Vec::new());
856    }
857    // Track per-interned-vertex "did this position carry RGB / a
858    // weight in the source file?" so the encoder doesn't fabricate
859    // colours / weights that the user never wrote. Both vectors are
860    // parallel to `prim.positions` after interning completes.
861    let mut color_present: Vec<bool> = Vec::new();
862    let mut weights_seen: Vec<Option<f32>> = Vec::new();
863
864    // De-duplicate face-vertices into a single interleaved buffer.
865    let mut indexer: HashMap<FaceVert, u32> = HashMap::new();
866    let mut arities: Vec<u32> = Vec::new();
867    let mut local_indices: Vec<u32> = Vec::new();
868
869    let intern = |fv: FaceVert,
870                  prim: &mut Primitive,
871                  indexer: &mut HashMap<FaceVert, u32>,
872                  color_present: &mut Vec<bool>,
873                  weights_seen: &mut Vec<Option<f32>>|
874     -> Result<u32> {
875        if let Some(&idx) = indexer.get(&fv) {
876            return Ok(idx);
877        }
878        let pos = positions
879            .get((fv.v - 1) as usize)
880            .ok_or_else(|| Error::invalid(format!("face references missing position {}", fv.v)))?;
881        prim.positions.push(*pos);
882        if has_uv {
883            let uv = if fv.vt == 0 {
884                [0.0, 0.0]
885            } else {
886                *texcoords.get((fv.vt - 1) as usize).ok_or_else(|| {
887                    Error::invalid(format!("face references missing texcoord {}", fv.vt))
888                })?
889            };
890            prim.uvs[0].push(uv);
891        }
892        if has_normal {
893            let n = if fv.vn == 0 {
894                [0.0, 0.0, 0.0]
895            } else {
896                *normals.get((fv.vn - 1) as usize).ok_or_else(|| {
897                    Error::invalid(format!("face references missing normal {}", fv.vn))
898                })?
899            };
900            prim.normals.as_mut().unwrap().push(n);
901        }
902        if has_color {
903            // Either the source file carried RGB for this vertex, or
904            // we synthesise opaque white so the colour buffer stays
905            // length-parallel with positions (mesh3d invariant).
906            let rgba = position_colors
907                .get((fv.v - 1) as usize)
908                .copied()
909                .flatten()
910                .unwrap_or([1.0, 1.0, 1.0, 1.0]);
911            prim.colors[0].push(rgba);
912            color_present.push(
913                position_colors
914                    .get((fv.v - 1) as usize)
915                    .is_some_and(Option::is_some),
916            );
917        }
918        weights_seen.push(position_weights.get((fv.v - 1) as usize).copied().flatten());
919        let new_idx = (prim.positions.len() - 1) as u32;
920        indexer.insert(fv, new_idx);
921        Ok(new_idx)
922    };
923
924    for elt in &prim_acc.elements {
925        match elt {
926            Element::Face(verts) => {
927                let arity = verts.len() as u32;
928                arities.push(arity);
929                let resolved: Vec<u32> = verts
930                    .iter()
931                    .map(|&fv| {
932                        intern(
933                            fv,
934                            &mut prim,
935                            &mut indexer,
936                            &mut color_present,
937                            &mut weights_seen,
938                        )
939                    })
940                    .collect::<Result<Vec<_>>>()?;
941                // Fan triangulate: (v0, v1, v2), (v0, v2, v3), …
942                for i in 1..(resolved.len() - 1) {
943                    local_indices.push(resolved[0]);
944                    local_indices.push(resolved[i]);
945                    local_indices.push(resolved[i + 1]);
946                }
947            }
948            Element::Line(verts) => {
949                let resolved: Vec<u32> = verts
950                    .iter()
951                    .map(|&fv| {
952                        intern(
953                            fv,
954                            &mut prim,
955                            &mut indexer,
956                            &mut color_present,
957                            &mut weights_seen,
958                        )
959                    })
960                    .collect::<Result<Vec<_>>>()?;
961                match topology {
962                    Topology::LineStrip => {
963                        // Emit the polyline as a contiguous index list.
964                        local_indices.extend_from_slice(&resolved);
965                    }
966                    Topology::LineLoop => {
967                        // Drop the redundant closing vertex; consumers
968                        // treat the strip as closed at draw time.
969                        let n = resolved.len().saturating_sub(1);
970                        local_indices.extend_from_slice(&resolved[..n]);
971                    }
972                    _ => {
973                        // Plain `Lines` — decompose polyline into
974                        // disjoint segment pairs (encoder rejoins
975                        // contiguous chains on the way out).
976                        for w in resolved.windows(2) {
977                            local_indices.push(w[0]);
978                            local_indices.push(w[1]);
979                        }
980                    }
981                }
982            }
983            Element::Point(verts) => {
984                // Each `p` line can carry multiple vertex references;
985                // every reference becomes one element index for
986                // `Topology::Points`. Original arities aren't tracked
987                // since a re-emit can pack them on one line freely.
988                for &fv in verts {
989                    let idx = intern(
990                        fv,
991                        &mut prim,
992                        &mut indexer,
993                        &mut color_present,
994                        &mut weights_seen,
995                    )?;
996                    local_indices.push(idx);
997                }
998            }
999        }
1000    }
1001
1002    // Promote to U32 if any index >= 65536; U16 otherwise.
1003    if local_indices.iter().any(|&i| i >= u16::MAX as u32) {
1004        prim.indices = Some(Indices::U32(local_indices));
1005    } else {
1006        prim.indices = Some(Indices::U16(
1007            local_indices.into_iter().map(|i| i as u16).collect(),
1008        ));
1009    }
1010
1011    // Per-vertex extension state — surfaced through `Primitive::extras`
1012    // so the encoder knows which `v` lines to expand to the 4-token
1013    // `xyzw`, 6-token `xyzrgb`, or 7-token `xyzwrgb` form. We only stash
1014    // the bitmaps when at least one vertex used the extension; the
1015    // common no-extension case stays free of decode-time noise.
1016    if has_color && color_present.iter().any(|&b| b) {
1017        prim.extras.insert(
1018            "obj:vertex_color_present".to_string(),
1019            serde_json::to_value(&color_present).unwrap(),
1020        );
1021    }
1022    if weights_seen.iter().any(Option::is_some) {
1023        prim.extras.insert(
1024            "obj:vertex_weight".to_string(),
1025            serde_json::to_value(&weights_seen).unwrap(),
1026        );
1027    }
1028
1029    if let Some(name) = &prim_acc.material {
1030        if let Some(id) = material_ids.get(name) {
1031            prim.material = Some(*id);
1032        }
1033        prim.extras.insert(
1034            "obj:usemtl".to_string(),
1035            serde_json::Value::String(name.clone()),
1036        );
1037    }
1038    if let Some(s) = &prim_acc.smoothing_group {
1039        prim.extras.insert(
1040            "obj:smoothing_group".to_string(),
1041            serde_json::Value::String(s.clone()),
1042        );
1043    }
1044    if let Some(s) = &prim_acc.merging_group {
1045        prim.extras.insert(
1046            "obj:merging_group".to_string(),
1047            serde_json::Value::String(s.clone()),
1048        );
1049    }
1050    if let Some(s) = &prim_acc.bevel {
1051        prim.extras.insert(
1052            "obj:bevel".to_string(),
1053            serde_json::Value::String(s.clone()),
1054        );
1055    }
1056    if let Some(s) = &prim_acc.c_interp {
1057        prim.extras.insert(
1058            "obj:c_interp".to_string(),
1059            serde_json::Value::String(s.clone()),
1060        );
1061    }
1062    if let Some(s) = &prim_acc.d_interp {
1063        prim.extras.insert(
1064            "obj:d_interp".to_string(),
1065            serde_json::Value::String(s.clone()),
1066        );
1067    }
1068    if let Some(s) = &prim_acc.lod {
1069        prim.extras
1070            .insert("obj:lod".to_string(), serde_json::Value::String(s.clone()));
1071    }
1072    if !prim_acc.groups.is_empty() {
1073        prim.extras.insert(
1074            "obj:groups".to_string(),
1075            serde_json::to_value(&prim_acc.groups).unwrap(),
1076        );
1077    }
1078
1079    Ok((prim, arities))
1080}
1081
1082// ---------------------------------------------------------------------------
1083// Public API
1084// ---------------------------------------------------------------------------
1085
1086/// Parse an OBJ document (no MTL resolution).
1087///
1088/// `usemtl` directives still create one `Primitive` per switch and the
1089/// material name lands in `Primitive::extras["obj:usemtl"]` even with
1090/// no actual `Material` constructed. Use [`parse_obj_with_resolver`]
1091/// when companion MTL data is available.
1092pub fn parse_obj(text: &str) -> Result<Scene3D> {
1093    parse_obj_with_resolver(text, |_path| Ok(Vec::new()))
1094}
1095
1096/// Parse an OBJ document at `path`, resolving `mtllib` references
1097/// against the OBJ file's parent directory.
1098///
1099/// Convenience wrapper around [`parse_obj_with_resolver`] for the
1100/// overwhelmingly common case of "I have a path, please load it and
1101/// follow the MTL references". Each `mtllib foo.mtl` directive becomes
1102/// a sibling-file read; missing libraries surface the underlying
1103/// [`std::io::Error`] (wrapped in [`Error::invalid`]) rather than
1104/// silently dropping. If you want lenient missing-MTL handling, use
1105/// [`parse_obj_with_resolver`] directly.
1106pub fn parse_obj_from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Scene3D> {
1107    let path = path.as_ref();
1108    let bytes =
1109        std::fs::read(path).map_err(|e| Error::invalid(format!("OBJ read {path:?}: {e}")))?;
1110    let text = std::str::from_utf8(&bytes)
1111        .map_err(|_| Error::invalid(format!("OBJ {path:?} contained non-UTF-8 bytes")))?;
1112    let parent = path.parent().map(std::path::Path::to_path_buf);
1113    parse_obj_with_resolver(text, |libname| {
1114        // Empty / absolute / parent-relative library names are honoured
1115        // verbatim; bare names are resolved against the OBJ's parent
1116        // directory.
1117        let lib_path = match &parent {
1118            Some(dir) => dir.join(libname),
1119            None => std::path::PathBuf::from(libname),
1120        };
1121        std::fs::read(&lib_path)
1122            .map_err(|e| Error::invalid(format!("mtllib read {lib_path:?}: {e}")))
1123    })
1124}
1125
1126/// Parse an OBJ document, calling `resolve` once per `mtllib` entry to
1127/// fetch the bytes of the named material library. Each library is
1128/// parsed via [`parse_mtl`] and its materials merged into the resulting
1129/// scene; references in `usemtl` directives bind to those materials by
1130/// name.
1131///
1132/// The resolver returns `Ok(Vec::new())` to signal "this library
1133/// couldn't be located but skip silently"; any other `Err` aborts the
1134/// parse.
1135pub fn parse_obj_with_resolver<R>(text: &str, mut resolve: R) -> Result<Scene3D>
1136where
1137    R: FnMut(&str) -> Result<Vec<u8>>,
1138{
1139    let mut doc = parse_obj_doc(text)?;
1140
1141    // Resolve material libraries, if any.
1142    for lib in doc.mtllibs.clone() {
1143        let bytes = resolve(&lib)?;
1144        if bytes.is_empty() {
1145            continue;
1146        }
1147        let lib_text = std::str::from_utf8(&bytes)
1148            .map_err(|_| Error::invalid(format!("mtllib {lib:?} contained non-UTF-8 bytes")))?;
1149        let materials = parse_mtl(lib_text)?;
1150        for mat in materials {
1151            if let Some(name) = mat.name.clone() {
1152                doc.resolved_materials.insert(name, mat);
1153            }
1154        }
1155    }
1156
1157    build_scene(doc)
1158}
1159
1160/// Serialiser configuration. Keeps the public free-function signature
1161/// stable while letting the [`crate::ObjEncoder`] thread richer options
1162/// through.
1163#[derive(Clone, Debug, Default)]
1164pub struct SerializeOptions<'a> {
1165    /// Reference an external MTL file via an `mtllib <basename>.mtl`
1166    /// header line. Equivalent to the `mtl_basename` parameter on
1167    /// [`serialize_obj`].
1168    pub mtl_basename: Option<&'a str>,
1169    /// When `true`, emit face/line vertex indices in the relative
1170    /// negative-index form (`f -1 -2 -3`) instead of absolute 1-based.
1171    /// Round-trips verbatim back through the parser; useful when the
1172    /// caller wants their re-encoded OBJ to mirror an input that used
1173    /// negative indices throughout.
1174    pub negative_indices: bool,
1175}
1176
1177/// Serialise a [`Scene3D`] to OBJ format.
1178///
1179/// `mtl_basename`, when supplied, emits an `mtllib <basename>.mtl`
1180/// directive at the top so a sibling MTL file (written separately via
1181/// [`crate::mtl::serialize_mtl`]) is referenced.
1182pub fn serialize_obj(scene: &Scene3D, mtl_basename: Option<&str>) -> Result<Vec<u8>> {
1183    serialize_obj_with_options(
1184        scene,
1185        &SerializeOptions {
1186            mtl_basename,
1187            ..SerializeOptions::default()
1188        },
1189    )
1190}
1191
1192/// Serialise a [`Scene3D`] to OBJ format with explicit options.
1193///
1194/// See [`SerializeOptions`] for the supported knobs.
1195pub fn serialize_obj_with_options(
1196    scene: &Scene3D,
1197    options: &SerializeOptions<'_>,
1198) -> Result<Vec<u8>> {
1199    let mtl_basename = options.mtl_basename;
1200    let negative = options.negative_indices;
1201    use std::fmt::Write;
1202    let mut out = String::new();
1203    writeln!(out, "# OBJ generated by oxideav-obj").unwrap();
1204    if let Some(base) = mtl_basename {
1205        writeln!(out, "mtllib {base}.mtl").unwrap();
1206    }
1207    // Replay any mtllib refs preserved on the scene itself when no
1208    // explicit basename was supplied.
1209    if mtl_basename.is_none() {
1210        if let Some(serde_json::Value::Array(list)) = scene.extras.get("obj:mtllibs") {
1211            for entry in list {
1212                if let Some(s) = entry.as_str() {
1213                    writeln!(out, "mtllib {s}").unwrap();
1214                }
1215            }
1216        }
1217    }
1218
1219    // Deduplicated global vertex / texcoord / normal pools so emitted
1220    // index references match the canonical 1-based numbering.
1221    let mut positions: Vec<[f32; 3]> = Vec::new();
1222    // Parallel to `positions` — `Some(rgb)` when the source flagged
1223    // this vertex through the `obj:vertex_color_present` extras
1224    // bitmap, `None` otherwise. We *don't* emit synthetic white for a
1225    // `None` entry: the round-trip rule is "only re-emit RGB for
1226    // vertices that originally had it". When at least one position
1227    // carries colour the encoder also sets a flag so the entire
1228    // colour set isn't dropped on a partial-colouring file (mixed
1229    // colored / uncolored vertices in one primitive — re-emit
1230    // standard `v x y z` for the uncolored).
1231    let mut position_colors: Vec<Option<[f32; 4]>> = Vec::new();
1232    // Parallel to `positions` — preserved `v` 4th `w` weight whenever
1233    // the source carried it. `None` re-emits the standard 3-token form.
1234    let mut position_weights: Vec<Option<f32>> = Vec::new();
1235    let mut texcoords: Vec<[f32; 2]> = Vec::new();
1236    let mut normals: Vec<[f32; 3]> = Vec::new();
1237    let mut pos_map: HashMap<KeyVec3, u32> = HashMap::new();
1238    let mut tex_map: HashMap<KeyVec2, u32> = HashMap::new();
1239    let mut nor_map: HashMap<KeyVec3, u32> = HashMap::new();
1240
1241    // Intern a position into the shared global pool, attaching the
1242    // (optional) per-vertex colour + weight derived from the
1243    // `obj:vertex_color_present` / `obj:vertex_weight` extras. When the
1244    // same position appears across primitives, the *first* non-`None`
1245    // colour / weight wins — silently ignoring later overrides keeps
1246    // round-trip determinism without forcing a partition of duplicate
1247    // positions on differing colour metadata (which would force the
1248    // encoder to emit redundant `v` lines and bloat the output).
1249    let intern_pos = |p: [f32; 3],
1250                      colour: Option<[f32; 4]>,
1251                      weight: Option<f32>,
1252                      positions: &mut Vec<[f32; 3]>,
1253                      colours: &mut Vec<Option<[f32; 4]>>,
1254                      weights: &mut Vec<Option<f32>>,
1255                      map: &mut HashMap<KeyVec3, u32>|
1256     -> u32 {
1257        let key = KeyVec3::from(p);
1258        if let Some(&i) = map.get(&key) {
1259            // First-write-wins on extension metadata.
1260            let slot = (i - 1) as usize;
1261            if colours[slot].is_none() {
1262                colours[slot] = colour;
1263            }
1264            if weights[slot].is_none() {
1265                weights[slot] = weight;
1266            }
1267            return i;
1268        }
1269        positions.push(p);
1270        colours.push(colour);
1271        weights.push(weight);
1272        let idx = positions.len() as u32;
1273        map.insert(key, idx);
1274        idx
1275    };
1276    let intern_tex =
1277        |p: [f32; 2], texcoords: &mut Vec<[f32; 2]>, map: &mut HashMap<KeyVec2, u32>| -> u32 {
1278            let key = KeyVec2::from(p);
1279            if let Some(&i) = map.get(&key) {
1280                return i;
1281            }
1282            texcoords.push(p);
1283            let idx = texcoords.len() as u32;
1284            map.insert(key, idx);
1285            idx
1286        };
1287    let intern_nor =
1288        |p: [f32; 3], normals: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
1289            let key = KeyVec3::from(p);
1290            if let Some(&i) = map.get(&key) {
1291                return i;
1292            }
1293            normals.push(p);
1294            let idx = normals.len() as u32;
1295            map.insert(key, idx);
1296            idx
1297        };
1298
1299    // First pass: emit `v` / `vt` / `vn` lists and remember the global
1300    // indices for each (mesh, primitive, vertex) triple.
1301    type GlobalTriple = (u32, u32, u32); // (v_idx, vt_idx_or_0, vn_idx_or_0)
1302    let mut global_indices: Vec<Vec<Vec<GlobalTriple>>> = Vec::new();
1303    for mesh in &scene.meshes {
1304        let mut mesh_globals: Vec<Vec<GlobalTriple>> = Vec::new();
1305        for prim in &mesh.primitives {
1306            let has_uv = !prim.uvs.is_empty();
1307            let has_normal = prim.normals.is_some();
1308            let has_color = !prim.colors.is_empty();
1309            // Per-vertex bitmap saying "did the source spell out RGB on
1310            // this vertex?". Missing extras / no-colors-set means every
1311            // vertex stays in the standard 3-token form.
1312            let color_present: Vec<bool> = prim
1313                .extras
1314                .get("obj:vertex_color_present")
1315                .and_then(serde_json::Value::as_array)
1316                .map(|arr| arr.iter().map(|v| v.as_bool().unwrap_or(false)).collect())
1317                .unwrap_or_else(|| vec![has_color; prim.positions.len()]);
1318            // Per-vertex weight overrides — preserved through extras.
1319            let weight_overrides: Vec<Option<f32>> = prim
1320                .extras
1321                .get("obj:vertex_weight")
1322                .and_then(serde_json::Value::as_array)
1323                .map(|arr| arr.iter().map(|v| v.as_f64().map(|f| f as f32)).collect())
1324                .unwrap_or_default();
1325            let mut prim_globals: Vec<GlobalTriple> = Vec::with_capacity(prim.positions.len());
1326            for vi in 0..prim.positions.len() {
1327                let colour = if has_color && color_present.get(vi).copied().unwrap_or(false) {
1328                    Some(prim.colors[0][vi])
1329                } else {
1330                    None
1331                };
1332                let weight = weight_overrides.get(vi).copied().flatten();
1333                let v_idx = intern_pos(
1334                    prim.positions[vi],
1335                    colour,
1336                    weight,
1337                    &mut positions,
1338                    &mut position_colors,
1339                    &mut position_weights,
1340                    &mut pos_map,
1341                );
1342                let vt_idx = if has_uv {
1343                    intern_tex(prim.uvs[0][vi], &mut texcoords, &mut tex_map)
1344                } else {
1345                    0
1346                };
1347                let vn_idx = if has_normal {
1348                    intern_nor(
1349                        prim.normals.as_ref().unwrap()[vi],
1350                        &mut normals,
1351                        &mut nor_map,
1352                    )
1353                } else {
1354                    0
1355                };
1356                prim_globals.push((v_idx, vt_idx, vn_idx));
1357            }
1358            mesh_globals.push(prim_globals);
1359        }
1360        global_indices.push(mesh_globals);
1361    }
1362
1363    for (i, p) in positions.iter().enumerate() {
1364        // Pick the most-compact `v` form that still carries the
1365        // extension data: `xyz`, `xyzw` (rational weight), `xyzrgb`
1366        // (MeshLab vertex colour), or `xyzwrgb` (both). Each
1367        // extension is silently dropped if it would just spell out
1368        // the spec default (`w == 1.0`, no colour).
1369        let weight = position_weights[i];
1370        let colour = position_colors[i];
1371        let mut s = String::with_capacity(40);
1372        s.push_str("v ");
1373        s.push_str(&fmt_float(p[0]));
1374        s.push(' ');
1375        s.push_str(&fmt_float(p[1]));
1376        s.push(' ');
1377        s.push_str(&fmt_float(p[2]));
1378        if let Some(w) = weight {
1379            s.push(' ');
1380            s.push_str(&fmt_float(w));
1381        }
1382        if let Some(rgb) = colour {
1383            s.push(' ');
1384            s.push_str(&fmt_float(rgb[0]));
1385            s.push(' ');
1386            s.push_str(&fmt_float(rgb[1]));
1387            s.push(' ');
1388            s.push_str(&fmt_float(rgb[2]));
1389        }
1390        writeln!(out, "{s}").unwrap();
1391    }
1392    // Parameter-space vertices for the free-form geometry section. We
1393    // emit these after `v` and before `vt` to mirror the typical layout
1394    // produced by Wavefront-era authoring tools (the spec doesn't
1395    // mandate an ordering, but co-locating `vp` with the other vertex
1396    // pools keeps human diffs tidy).
1397    if let Some(serde_json::Value::Array(vps)) = scene.extras.get("obj:vp") {
1398        for entry in vps {
1399            if let serde_json::Value::Array(coords) = entry {
1400                let parts: Vec<f32> = coords
1401                    .iter()
1402                    .filter_map(|v| v.as_f64().map(|f| f as f32))
1403                    .collect();
1404                if parts.is_empty() {
1405                    continue;
1406                }
1407                // Emit only as many coordinates as carry meaningful
1408                // information. The decoder padded with `0.0`, so a
1409                // trailing `0` is a strong signal "the operator
1410                // didn't supply this component". 1D / 2D / 3D `vp`
1411                // statements are all valid per spec §"vp u v w".
1412                let trim = if parts.len() >= 3 && parts[2] != 0.0 {
1413                    3
1414                } else if parts.len() >= 2 && parts[1] != 0.0 {
1415                    2
1416                } else {
1417                    1
1418                };
1419                let mut s = String::from("vp");
1420                for coord in parts.iter().take(trim) {
1421                    s.push(' ');
1422                    s.push_str(&fmt_float(*coord));
1423                }
1424                writeln!(out, "{s}").unwrap();
1425            }
1426        }
1427    }
1428    for t in &texcoords {
1429        writeln!(out, "vt {} {}", fmt_float(t[0]), fmt_float(t[1])).unwrap();
1430    }
1431    for n in &normals {
1432        writeln!(
1433            out,
1434            "vn {} {} {}",
1435            fmt_float(n[0]),
1436            fmt_float(n[1]),
1437            fmt_float(n[2])
1438        )
1439        .unwrap();
1440    }
1441
1442    // Second pass: per-mesh `o` directive, per-primitive `usemtl` +
1443    // groups + smoothing-group, then face/line elements.
1444    for (mi, mesh) in scene.meshes.iter().enumerate() {
1445        if let Some(name) = &mesh.name {
1446            writeln!(out, "o {name}").unwrap();
1447        }
1448
1449        for (pi, prim) in mesh.primitives.iter().enumerate() {
1450            // Per-primitive arity vector for n-gon re-emission, if any.
1451            let arities: Option<Vec<u32>> = prim
1452                .extras
1453                .get("obj:original_face_arities")
1454                .and_then(|v| serde_json::from_value(v.clone()).ok());
1455            // Groups + smoothing first (spec convention: state tokens
1456            // precede the elements they apply to).
1457            if let Some(serde_json::Value::Array(gs)) = prim.extras.get("obj:groups") {
1458                let names: Vec<&str> = gs.iter().filter_map(|v| v.as_str()).collect();
1459                if !names.is_empty() {
1460                    writeln!(out, "g {}", names.join(" ")).unwrap();
1461                }
1462            }
1463            if let Some(s) = prim
1464                .extras
1465                .get("obj:smoothing_group")
1466                .and_then(|v| v.as_str())
1467            {
1468                writeln!(out, "s {s}").unwrap();
1469            }
1470            if let Some(s) = prim
1471                .extras
1472                .get("obj:merging_group")
1473                .and_then(|v| v.as_str())
1474            {
1475                writeln!(out, "mg {s}").unwrap();
1476            }
1477            // Display-attribute state-setters — emitted ahead of the
1478            // elements they apply to. Order is fixed to keep round-trip
1479            // diffs deterministic.
1480            for keyword in ["bevel", "c_interp", "d_interp", "lod"] {
1481                let key = format!("obj:{keyword}");
1482                if let Some(s) = prim.extras.get(&key).and_then(|v| v.as_str()) {
1483                    writeln!(out, "{keyword} {s}").unwrap();
1484                }
1485            }
1486
1487            // usemtl: prefer extras["obj:usemtl"] (loss-tolerant
1488            // round-trip name), fall back to the bound material's name.
1489            let mtl_name: Option<String> = prim
1490                .extras
1491                .get("obj:usemtl")
1492                .and_then(|v| v.as_str())
1493                .map(|s| s.to_string())
1494                .or_else(|| {
1495                    prim.material.and_then(|id| {
1496                        scene
1497                            .materials
1498                            .get(id.0 as usize)
1499                            .and_then(|m| m.name.clone())
1500                    })
1501                });
1502            if let Some(name) = &mtl_name {
1503                writeln!(out, "usemtl {name}").unwrap();
1504            }
1505
1506            let prim_globals = &global_indices[mi][pi];
1507            let has_uv = !prim.uvs.is_empty();
1508            let has_normal = prim.normals.is_some();
1509
1510            // Build the per-element index iterator. For Triangles topology
1511            // re-shape into n-gons via `arities` if present; otherwise emit
1512            // one triangle per 3 indices. For Lines topology emit `l`
1513            // per pair (we don't reverse strips back into polylines —
1514            // that's lossy and the round-trip test doesn't need it).
1515            match prim.topology {
1516                Topology::Triangles => {
1517                    let face_indices: Vec<u32> = match &prim.indices {
1518                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
1519                        Some(Indices::U32(v)) => v.clone(),
1520                        None => {
1521                            // Implicit indices: 0, 1, 2, …
1522                            (0..prim.positions.len() as u32).collect()
1523                        }
1524                    };
1525                    if let Some(per_prim_arities) = arities.as_ref() {
1526                        // Reconstruct n-gons from triangle fans. Each
1527                        // n-gon contributed (n - 2) triangles.
1528                        let mut tri_pos: usize = 0;
1529                        for &arity in per_prim_arities {
1530                            let mut verts: Vec<u32> = Vec::with_capacity(arity as usize);
1531                            // The fan was: (v0, v1, v2), (v0, v2, v3), (v0, v3, v4), …
1532                            let n_tris = (arity as usize).saturating_sub(2);
1533                            // First triangle gives v0, v1, v2.
1534                            verts.push(face_indices[tri_pos * 3]);
1535                            verts.push(face_indices[tri_pos * 3 + 1]);
1536                            verts.push(face_indices[tri_pos * 3 + 2]);
1537                            // Each subsequent triangle adds one new vertex (the third index).
1538                            for k in 1..n_tris {
1539                                verts.push(face_indices[(tri_pos + k) * 3 + 2]);
1540                            }
1541                            tri_pos += n_tris;
1542
1543                            write_face(
1544                                &mut out,
1545                                &verts,
1546                                prim_globals,
1547                                has_uv,
1548                                has_normal,
1549                                negative,
1550                                positions.len() as u32,
1551                                texcoords.len() as u32,
1552                                normals.len() as u32,
1553                            );
1554                        }
1555                        // Any leftover triangles after the recorded arities
1556                        // (e.g. a primitive grew after the arity vector was
1557                        // captured) are emitted as plain triangles.
1558                        let consumed = per_prim_arities
1559                            .iter()
1560                            .map(|&a| (a as usize).saturating_sub(2))
1561                            .sum::<usize>();
1562                        for tri in consumed..(face_indices.len() / 3) {
1563                            let verts = [
1564                                face_indices[tri * 3],
1565                                face_indices[tri * 3 + 1],
1566                                face_indices[tri * 3 + 2],
1567                            ];
1568                            write_face(
1569                                &mut out,
1570                                &verts,
1571                                prim_globals,
1572                                has_uv,
1573                                has_normal,
1574                                negative,
1575                                positions.len() as u32,
1576                                texcoords.len() as u32,
1577                                normals.len() as u32,
1578                            );
1579                        }
1580                    } else {
1581                        for tri in 0..(face_indices.len() / 3) {
1582                            let verts = [
1583                                face_indices[tri * 3],
1584                                face_indices[tri * 3 + 1],
1585                                face_indices[tri * 3 + 2],
1586                            ];
1587                            write_face(
1588                                &mut out,
1589                                &verts,
1590                                prim_globals,
1591                                has_uv,
1592                                has_normal,
1593                                negative,
1594                                positions.len() as u32,
1595                                texcoords.len() as u32,
1596                                normals.len() as u32,
1597                            );
1598                        }
1599                    }
1600                }
1601                Topology::Lines => {
1602                    let line_indices: Vec<u32> = match &prim.indices {
1603                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
1604                        Some(Indices::U32(v)) => v.clone(),
1605                        None => (0..prim.positions.len() as u32).collect(),
1606                    };
1607                    let total_v = positions.len() as u32;
1608                    // Walk segment pairs and join contiguous chains
1609                    // (segment N's end == segment N+1's start) into
1610                    // one polyline before emit. Saves bytes on the
1611                    // common case of a long polyline that round-tripped
1612                    // through `Topology::Lines` decomposition.
1613                    let mut chain: Vec<u32> = Vec::new();
1614                    let flush = |chain: &mut Vec<u32>, out: &mut String| {
1615                        if chain.len() < 2 {
1616                            chain.clear();
1617                            return;
1618                        }
1619                        let parts: Vec<String> = chain
1620                            .iter()
1621                            .map(|&local| {
1622                                fmt_index(prim_globals[local as usize].0, total_v, negative)
1623                            })
1624                            .collect();
1625                        writeln!(out, "l {}", parts.join(" ")).unwrap();
1626                        chain.clear();
1627                    };
1628                    for w in line_indices.chunks_exact(2) {
1629                        let (a, b) = (w[0], w[1]);
1630                        if chain.is_empty() {
1631                            chain.push(a);
1632                            chain.push(b);
1633                        } else if *chain.last().unwrap() == a {
1634                            chain.push(b);
1635                        } else {
1636                            flush(&mut chain, &mut out);
1637                            chain.push(a);
1638                            chain.push(b);
1639                        }
1640                    }
1641                    flush(&mut chain, &mut out);
1642                }
1643                Topology::LineStrip | Topology::LineLoop => {
1644                    // Reconstruct the strip's index list from whichever
1645                    // backing storage the primitive carries; bare
1646                    // positions imply implicit `0..N` indices. For
1647                    // `LineLoop` we re-append the first index so the
1648                    // emitted `l` line spells out the closing edge —
1649                    // the parser then detects start == end and round-
1650                    // trips back to `LineLoop`.
1651                    let mut strip_indices: Vec<u32> = match &prim.indices {
1652                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
1653                        Some(Indices::U32(v)) => v.clone(),
1654                        None => (0..prim.positions.len() as u32).collect(),
1655                    };
1656                    if matches!(prim.topology, Topology::LineLoop)
1657                        && let Some(&first) = strip_indices.first()
1658                    {
1659                        strip_indices.push(first);
1660                    }
1661                    if strip_indices.len() >= 2 {
1662                        let total_v = positions.len() as u32;
1663                        let parts: Vec<String> = strip_indices
1664                            .iter()
1665                            .map(|&local| {
1666                                fmt_index(prim_globals[local as usize].0, total_v, negative)
1667                            })
1668                            .collect();
1669                        writeln!(out, "l {}", parts.join(" ")).unwrap();
1670                    }
1671                }
1672                Topology::Points => {
1673                    let pt_indices: Vec<u32> = match &prim.indices {
1674                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
1675                        Some(Indices::U32(v)) => v.clone(),
1676                        None => (0..prim.positions.len() as u32).collect(),
1677                    };
1678                    let total_v = positions.len() as u32;
1679                    if !pt_indices.is_empty() {
1680                        // Pack every reference onto a single `p` line —
1681                        // the spec explicitly permits the multi-vertex
1682                        // form (`p v1 v2 v3 …`) and it's what most
1683                        // tools emit.
1684                        let parts: Vec<String> = pt_indices
1685                            .iter()
1686                            .map(|&local| {
1687                                fmt_index(prim_globals[local as usize].0, total_v, negative)
1688                            })
1689                            .collect();
1690                        writeln!(out, "p {}", parts.join(" ")).unwrap();
1691                    }
1692                }
1693                other => {
1694                    return Err(Error::unsupported(format!(
1695                        "OBJ encoder: topology {other:?} not representable"
1696                    )));
1697                }
1698            }
1699        }
1700    }
1701
1702    // Free-form geometry section: replay the captured directive
1703    // sequence verbatim. The decoder records every `cstype` / `deg` /
1704    // `curv` / `surf` / `parm` / `trim` / `hole` / `scrv` / `sp` /
1705    // `end` / `bzp` / `bsp` line as `[keyword, arg1, arg2, …]` so the
1706    // encoder is purely textual — no semantic interpretation, which
1707    // means the round-trip is bit-exact for the directive args even
1708    // when the polygonal section sits between `vp` and the free-form
1709    // body.
1710    if let Some(serde_json::Value::Array(directives)) = scene.extras.get("obj:freeform_directives")
1711    {
1712        for entry in directives {
1713            if let serde_json::Value::Array(toks) = entry {
1714                let parts: Vec<&str> = toks.iter().filter_map(|v| v.as_str()).collect();
1715                if parts.is_empty() {
1716                    continue;
1717                }
1718                writeln!(out, "{}", parts.join(" ")).unwrap();
1719            }
1720        }
1721    }
1722
1723    Ok(out.into_bytes())
1724}
1725
1726#[allow(clippy::too_many_arguments)]
1727fn write_face(
1728    out: &mut String,
1729    verts: &[u32],
1730    prim_globals: &[(u32, u32, u32)],
1731    has_uv: bool,
1732    has_normal: bool,
1733    negative: bool,
1734    total_v: u32,
1735    total_vt: u32,
1736    total_vn: u32,
1737) {
1738    use std::fmt::Write;
1739    out.push('f');
1740    for &local in verts {
1741        let (v, vt, vn) = prim_globals[local as usize];
1742        let v_s = fmt_index(v, total_v, negative);
1743        let vt_s = fmt_index(vt, total_vt, negative);
1744        let vn_s = fmt_index(vn, total_vn, negative);
1745        match (has_uv, has_normal) {
1746            (true, true) => write!(out, " {v_s}/{vt_s}/{vn_s}").unwrap(),
1747            (true, false) => write!(out, " {v_s}/{vt_s}").unwrap(),
1748            (false, true) => write!(out, " {v_s}//{vn_s}").unwrap(),
1749            (false, false) => write!(out, " {v_s}").unwrap(),
1750        }
1751    }
1752    out.push('\n');
1753}
1754
1755/// Render a 1-based positive index as either its absolute form
1756/// (`5`) or a negative-from-end form (`-3`, when `total = 7`).
1757/// `idx == 0` means "no index" — we always emit `0` regardless of
1758/// the negative flag so the parser still treats it as absent.
1759fn fmt_index(idx: u32, total: u32, negative: bool) -> String {
1760    if idx == 0 || !negative {
1761        idx.to_string()
1762    } else {
1763        // total = 7, idx = 5  ⇒  -3  (i.e. "third from the end").
1764        // Parser computes: resolved = total + 1 + raw  ⇒  raw = idx - total - 1.
1765        let raw = (idx as i64) - (total as i64) - 1;
1766        raw.to_string()
1767    }
1768}
1769
1770/// Format a float without scientific notation; trims trailing zeros
1771/// while keeping at least one digit after the decimal point. Keeps the
1772/// emitted file human-diffable.
1773fn fmt_float(x: f32) -> String {
1774    if x == 0.0 {
1775        return "0".to_string();
1776    }
1777    let s = format!("{x:.6}");
1778    let trimmed = s.trim_end_matches('0').trim_end_matches('.').to_string();
1779    if trimmed.is_empty() || trimmed == "-" {
1780        "0".to_string()
1781    } else {
1782        trimmed
1783    }
1784}
1785
1786// ---------------------------------------------------------------------------
1787// Float keys for the dedup HashMap (f32 isn't Hash).
1788// ---------------------------------------------------------------------------
1789
1790#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1791struct KeyVec2 {
1792    a: u32,
1793    b: u32,
1794}
1795impl From<[f32; 2]> for KeyVec2 {
1796    fn from(v: [f32; 2]) -> Self {
1797        Self {
1798            a: v[0].to_bits(),
1799            b: v[1].to_bits(),
1800        }
1801    }
1802}
1803
1804#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1805struct KeyVec3 {
1806    a: u32,
1807    b: u32,
1808    c: u32,
1809}
1810impl From<[f32; 3]> for KeyVec3 {
1811    fn from(v: [f32; 3]) -> Self {
1812        Self {
1813            a: v[0].to_bits(),
1814            b: v[1].to_bits(),
1815            c: v[2].to_bits(),
1816        }
1817    }
1818}
1819
1820// ---------------------------------------------------------------------------
1821// Tests (unit-level — integration tests live under `tests/`).
1822// ---------------------------------------------------------------------------
1823
1824#[cfg(test)]
1825mod tests {
1826    use super::*;
1827
1828    #[test]
1829    fn preprocess_strips_comments_and_glues_continuations() {
1830        let lines =
1831            preprocess_lines("v 1.0 2.0 \\\n3.0 # comment\nv 4 5 6\n# pure comment\nf 1 2 3");
1832        assert_eq!(lines[0].trim(), "v 1.0 2.0  3.0");
1833        assert_eq!(lines[1].trim(), "v 4 5 6");
1834        // The pure-comment line collapses to an empty preprocessed line.
1835        assert_eq!(lines[2].trim(), "");
1836        assert_eq!(lines[3].trim(), "f 1 2 3");
1837    }
1838
1839    #[test]
1840    fn fmt_float_is_diff_friendly() {
1841        assert_eq!(fmt_float(1.0), "1");
1842        assert_eq!(fmt_float(0.0), "0");
1843        assert_eq!(fmt_float(-0.5), "-0.5");
1844        assert_eq!(fmt_float(1.0 / 3.0), "0.333333");
1845    }
1846}