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" | "bmat" | "step" => {
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                // §"bmat u/v matrix" + §"step stepu stepv".
333                let mut entry: Vec<String> = Vec::new();
334                entry.push(keyword.to_string());
335                for tok in tokens {
336                    entry.push(tok.to_string());
337                }
338                doc.freeform_directives.push(entry);
339            }
340            "f" => {
341                let n_pos = doc.positions.len() as i64;
342                let n_tex = doc.texcoords.len() as i64;
343                let n_norm = doc.normals.len() as i64;
344                let verts: Vec<FaceVert> = tokens
345                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
346                    .collect::<Result<Vec<_>>>()?;
347                if verts.len() < 3 {
348                    return Err(Error::invalid(format!(
349                        "f: face needs ≥3 vertices, got {}",
350                        verts.len()
351                    )));
352                }
353                let mesh = doc.meshes.last_mut().unwrap();
354                mesh.current_or_new().elements.push(Element::Face(verts));
355            }
356            "l" => {
357                let n_pos = doc.positions.len() as i64;
358                let n_tex = doc.texcoords.len() as i64;
359                let n_norm = doc.normals.len() as i64;
360                let verts: Vec<FaceVert> = tokens
361                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
362                    .collect::<Result<Vec<_>>>()?;
363                if verts.len() < 2 {
364                    return Err(Error::invalid(format!(
365                        "l: line needs ≥2 vertices, got {}",
366                        verts.len()
367                    )));
368                }
369                let mesh = doc.meshes.last_mut().unwrap();
370                mesh.current_or_new().elements.push(Element::Line(verts));
371            }
372            "p" => {
373                // Point elements are state-incompatible with face/line
374                // primitives (different `Topology`); mirror the `usemtl`
375                // pattern and split into a fresh primitive whenever the
376                // current one already holds incompatible elements.
377                let n_pos = doc.positions.len() as i64;
378                let n_tex = doc.texcoords.len() as i64;
379                let n_norm = doc.normals.len() as i64;
380                // `p` only takes vertex references (no `/vt` or `//vn`),
381                // but parse_face_vertex degrades gracefully when the
382                // separators are absent.
383                let verts: Vec<FaceVert> = tokens
384                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
385                    .collect::<Result<Vec<_>>>()?;
386                if verts.is_empty() {
387                    return Err(Error::invalid("p: needs ≥1 vertex"));
388                }
389                let mesh = doc.meshes.last_mut().unwrap();
390                let prim = mesh.current_or_new();
391                if prim
392                    .elements
393                    .iter()
394                    .any(|e| !matches!(e, Element::Point(_)))
395                {
396                    // Mixed-kind elements aren't representable; open a
397                    // fresh primitive that inherits material + groups +
398                    // smoothing/merging/display-attr state.
399                    let mat = prim.material.clone();
400                    let groups = prim.groups.clone();
401                    let smoothing = prim.smoothing_group.clone();
402                    let merging = prim.merging_group.clone();
403                    let bevel = prim.bevel.clone();
404                    let c_interp = prim.c_interp.clone();
405                    let d_interp = prim.d_interp.clone();
406                    let lod = prim.lod.clone();
407                    mesh.primitives.push(PrimAccum {
408                        material: mat,
409                        groups,
410                        smoothing_group: smoothing,
411                        merging_group: merging,
412                        bevel,
413                        c_interp,
414                        d_interp,
415                        lod,
416                        elements: vec![Element::Point(verts)],
417                    });
418                } else {
419                    prim.elements.push(Element::Point(verts));
420                }
421            }
422            "bevel" | "c_interp" | "d_interp" | "lod" => {
423                // Display-attribute state-setting — `bevel on/off`,
424                // `c_interp on/off`, `d_interp on/off`, `lod <level>`.
425                // Captured per-primitive; a mid-stream change splits
426                // the primitive so each one carries one consistent
427                // value (mirrors `s`/`mg`).
428                let v: String = tokens.collect::<Vec<_>>().join(" ");
429                if v.is_empty() {
430                    continue;
431                }
432                let mesh = doc.meshes.last_mut().unwrap();
433                let last = mesh.current_or_new();
434                let current: Option<&str> = match keyword {
435                    "bevel" => last.bevel.as_deref(),
436                    "c_interp" => last.c_interp.as_deref(),
437                    "d_interp" => last.d_interp.as_deref(),
438                    "lod" => last.lod.as_deref(),
439                    _ => unreachable!(),
440                };
441                if last.elements.is_empty() {
442                    // Overwrite the pending value.
443                    match keyword {
444                        "bevel" => last.bevel = Some(v),
445                        "c_interp" => last.c_interp = Some(v),
446                        "d_interp" => last.d_interp = Some(v),
447                        "lod" => last.lod = Some(v),
448                        _ => unreachable!(),
449                    }
450                } else if current != Some(v.as_str()) {
451                    let mat = last.material.clone();
452                    let groups = last.groups.clone();
453                    let smoothing = last.smoothing_group.clone();
454                    let merging = last.merging_group.clone();
455                    let mut bevel = last.bevel.clone();
456                    let mut c_interp = last.c_interp.clone();
457                    let mut d_interp = last.d_interp.clone();
458                    let mut lod = last.lod.clone();
459                    match keyword {
460                        "bevel" => bevel = Some(v),
461                        "c_interp" => c_interp = Some(v),
462                        "d_interp" => d_interp = Some(v),
463                        "lod" => lod = Some(v),
464                        _ => unreachable!(),
465                    }
466                    mesh.primitives.push(PrimAccum {
467                        material: mat,
468                        smoothing_group: smoothing,
469                        merging_group: merging,
470                        groups,
471                        bevel,
472                        c_interp,
473                        d_interp,
474                        lod,
475                        elements: Vec::new(),
476                    });
477                }
478            }
479            "mg" => {
480                // Merging group — `mg <group_number> [res]` or `mg off`
481                // / `mg 0`. Like `s`, it's state-setting; preserve the
482                // operator's spelling verbatim. The semantic value
483                // (smoothing across surface joins for free-form
484                // surfaces) is meaningless without the free-form
485                // surface support, but the round-trip preservation
486                // matters for tools that round-trip mesh data through
487                // us.
488                let v: String = tokens.collect::<Vec<_>>().join(" ");
489                if v.is_empty() {
490                    continue;
491                }
492                let mesh = doc.meshes.last_mut().unwrap();
493                let last = mesh.current_or_new();
494                if last.elements.is_empty() {
495                    // No elements yet — overwrite the pending value.
496                    last.merging_group = Some(v);
497                } else if last.merging_group.as_deref() != Some(v.as_str()) {
498                    // Merging-group changed mid-stream; split into a
499                    // fresh primitive so each one carries one
500                    // consistent assignment (mirrors smoothing-group
501                    // behaviour).
502                    let mat = last.material.clone();
503                    let groups = last.groups.clone();
504                    let smoothing = last.smoothing_group.clone();
505                    let bevel = last.bevel.clone();
506                    let c_interp = last.c_interp.clone();
507                    let d_interp = last.d_interp.clone();
508                    let lod = last.lod.clone();
509                    mesh.primitives.push(PrimAccum {
510                        material: mat,
511                        smoothing_group: smoothing,
512                        groups,
513                        merging_group: Some(v),
514                        bevel,
515                        c_interp,
516                        d_interp,
517                        lod,
518                        elements: Vec::new(),
519                    });
520                }
521            }
522            "o" => {
523                let name: String = tokens.collect::<Vec<_>>().join(" ");
524                // Open a fresh mesh — but if the current mesh is still
525                // empty (no primitives accumulated yet), reuse it so we
526                // don't end up with a leading empty mesh.
527                let last = doc.meshes.last_mut().unwrap();
528                if last.name.is_none() && last.primitives.is_empty() {
529                    last.name = if name.is_empty() { None } else { Some(name) };
530                } else {
531                    doc.meshes.push(MeshAccum {
532                        name: if name.is_empty() { None } else { Some(name) },
533                        primitives: Vec::new(),
534                    });
535                }
536            }
537            "g" => {
538                // The spec (Wavefront *Advanced Visualizer* Appendix B,
539                // §"Grouping") explicitly permits multiple group names
540                // on one line: `g group_name1 group_name2 …`. Each
541                // whitespace-separated token is its own group; the
542                // following elements belong to ALL listed groups.
543                let names: Vec<String> = tokens.map(|t| t.to_string()).collect();
544                if names.is_empty() {
545                    continue;
546                }
547                let mesh = doc.meshes.last_mut().unwrap();
548                let prim = mesh.current_or_new();
549                for name in names {
550                    if !prim.groups.iter().any(|g| g == &name) {
551                        prim.groups.push(name);
552                    }
553                }
554            }
555            "s" => {
556                // `s 0` and `s off` both mean "no smoothing"; preserve
557                // the operator's chosen spelling verbatim for round-trip.
558                let v: String = tokens.collect::<Vec<_>>().join(" ");
559                if v.is_empty() {
560                    continue;
561                }
562                let mesh = doc.meshes.last_mut().unwrap();
563                let last = mesh.current_or_new();
564                if last.elements.is_empty() {
565                    // No elements yet — overwrite the pending value.
566                    last.smoothing_group = Some(v);
567                } else if last.smoothing_group.as_deref() != Some(v.as_str()) {
568                    // Smoothing changed mid-stream; spec says it's
569                    // state-setting and applies to subsequent
570                    // elements, so split into a new primitive that
571                    // inherits the current material + groups +
572                    // merging-group + display attributes.
573                    let mat = last.material.clone();
574                    let groups = last.groups.clone();
575                    let merging = last.merging_group.clone();
576                    let bevel = last.bevel.clone();
577                    let c_interp = last.c_interp.clone();
578                    let d_interp = last.d_interp.clone();
579                    let lod = last.lod.clone();
580                    mesh.primitives.push(PrimAccum {
581                        material: mat,
582                        smoothing_group: Some(v),
583                        groups,
584                        merging_group: merging,
585                        bevel,
586                        c_interp,
587                        d_interp,
588                        lod,
589                        elements: Vec::new(),
590                    });
591                }
592            }
593            "usemtl" => {
594                let name: String = tokens.collect::<Vec<_>>().join(" ");
595                let mesh = doc.meshes.last_mut().unwrap();
596                let last = mesh.current_or_new();
597                if last.elements.is_empty() && last.material.is_none() {
598                    // First usemtl in this primitive — adopt directly.
599                    last.material = if name.is_empty() { None } else { Some(name) };
600                } else {
601                    // Subsequent usemtl — start a new primitive.
602                    mesh.primitives.push(PrimAccum {
603                        material: if name.is_empty() { None } else { Some(name) },
604                        ..PrimAccum::default()
605                    });
606                }
607            }
608            "mtllib" => {
609                // Each `mtllib` line can list multiple .mtl files.
610                for tok in tokens {
611                    if !doc.mtllibs.iter().any(|m| m == tok) {
612                        doc.mtllibs.push(tok.to_string());
613                    }
614                }
615            }
616            // Unhandled keywords (curves/surfaces/display attributes/etc.) are
617            // silently skipped per spec lenient-loader convention.
618            _ => {}
619        }
620    }
621
622    Ok(doc)
623}
624
625// ---------------------------------------------------------------------------
626// Scene assembly
627// ---------------------------------------------------------------------------
628
629/// Convert the intermediate [`ObjDoc`] into a [`Scene3D`].
630///
631/// Indices are de-duplicated per-primitive so the resulting vertex
632/// buffer carries `unique_face_vertices` entries (matching glTF's
633/// per-primitive interleaved-attribute model). Original face arities
634/// are stored in `Mesh::extras["obj:original_face_arities"]` so the
635/// encoder can reconstruct the n-gons.
636fn build_scene(doc: ObjDoc) -> Result<Scene3D> {
637    use oxideav_mesh3d::{Axis, Material, Unit};
638
639    let mut scene = Scene3D::new();
640    // OBJ has no unit metadata; the primer says "Metres is the safe
641    // default" and "Y-up matches the glTF default".
642    scene.up_axis = Axis::PosY;
643    scene.unit = Unit::Metres;
644
645    // Materials first so primitives can point at their MaterialId.
646    // Insertion order is preserved (HashMap iteration order is
647    // unspecified, so sort by name to keep round-trip deterministic).
648    let mut material_ids: HashMap<String, oxideav_mesh3d::MaterialId> = HashMap::new();
649    let mut material_names: Vec<String> = doc.resolved_materials.keys().cloned().collect();
650    material_names.sort();
651    for name in &material_names {
652        let mut mat = doc
653            .resolved_materials
654            .get(name)
655            .cloned()
656            .unwrap_or_else(Material::new);
657        if mat.name.is_none() {
658            mat.name = Some(name.clone());
659        }
660        let id = scene.add_material(mat);
661        material_ids.insert(name.clone(), id);
662    }
663
664    for mesh_acc in doc.meshes {
665        // Drop genuinely empty meshes (no primitives that emit anything).
666        let has_anything = mesh_acc.primitives.iter().any(|p| !p.elements.is_empty());
667        if !has_anything {
668            continue;
669        }
670
671        let mut mesh = Mesh::new(mesh_acc.name.clone());
672
673        for prim_acc in mesh_acc.primitives {
674            let (mut primitive, arities) = build_primitive(
675                &prim_acc,
676                &doc.positions,
677                &doc.position_weights,
678                &doc.position_colors,
679                &doc.texcoords,
680                &doc.normals,
681                &material_ids,
682            )?;
683            // Skip primitives that never accumulated any element.
684            if primitive.positions.is_empty() {
685                continue;
686            }
687            // Stash original face arities per-primitive when the primitive
688            // contained at least one non-triangle face. Mesh has no
689            // `extras` field, so the round-trip annotation lives on the
690            // primitive — symmetrical with the smoothing-group / groups /
691            // usemtl extras already populated by `build_primitive`.
692            if arities.iter().any(|&a| a != 3) {
693                primitive.extras.insert(
694                    "obj:original_face_arities".to_string(),
695                    serde_json::to_value(&arities).unwrap(),
696                );
697            }
698            mesh.primitives.push(primitive);
699        }
700
701        scene.add_mesh(mesh);
702    }
703
704    // Keep the mtllib references in scene extras so a re-encode that
705    // wants to point back at a specific MTL file can find them.
706    if !doc.mtllibs.is_empty() {
707        scene.extras.insert(
708            "obj:mtllibs".to_string(),
709            serde_json::to_value(&doc.mtllibs).unwrap(),
710        );
711    }
712
713    // Source-of-truth position pool — kept in 1-based parallel order
714    // for free-form directives (`curv` / `surf`) that reference
715    // vertices by index. Without this, an OBJ whose free-form section
716    // is the *only* consumer of those positions would lose them on
717    // re-encode (the encoder pools positions only from polygonal
718    // primitives). The encoder re-emits any `obj:positions` entry not
719    // already covered by polygonal primitives, in their original
720    // 1-based order, so `curv 0 1 N M K` directives keep resolving
721    // to the same coordinates after a decode → encode → decode cycle.
722    //
723    // Position colours / weights ride along on the same parallel
724    // arrays so the `xyzrgb` / `xyzw` extension widths survive.
725    if !doc.positions.is_empty()
726        && (doc.freeform_directives.iter().any(|d| {
727            matches!(
728                d.first().map(String::as_str),
729                Some("curv" | "curv2" | "surf" | "bzp" | "bsp")
730            )
731        }))
732    {
733        scene.extras.insert(
734            "obj:positions".to_string(),
735            serde_json::to_value(&doc.positions).unwrap(),
736        );
737        if doc.position_weights.iter().any(Option::is_some) {
738            scene.extras.insert(
739                "obj:position_weights".to_string(),
740                serde_json::to_value(&doc.position_weights).unwrap(),
741            );
742        }
743        if doc.position_colors.iter().any(Option::is_some) {
744            scene.extras.insert(
745                "obj:position_colors".to_string(),
746                serde_json::to_value(&doc.position_colors).unwrap(),
747            );
748        }
749    }
750
751    // Free-form geometry side-channel: the parameter-space vertex pool
752    // (`vp`) and the verbatim sequence of `cstype` / `deg` / `curv` /
753    // `surf` / `parm` / `trim` / `hole` / `scrv` / `sp` / `end` / `bzp`
754    // / `bsp` directives. The encoder replays these after the
755    // polygonal section so consumers that don't care about free-form
756    // geometry simply ignore the keys, while consumers that do can
757    // walk the directive sequence themselves.
758    if !doc.vp.is_empty() {
759        scene
760            .extras
761            .insert("obj:vp".to_string(), serde_json::to_value(&doc.vp).unwrap());
762    }
763    if !doc.freeform_directives.is_empty() {
764        scene.extras.insert(
765            "obj:freeform_directives".to_string(),
766            serde_json::to_value(&doc.freeform_directives).unwrap(),
767        );
768    }
769
770    Ok(scene)
771}
772
773/// Walk the captured free-form directive sequence in [`ObjDoc`] and
774/// synthesise one [`Primitive`] (Topology::LineStrip, indexed) per
775/// `curv` directive that sits under a supported `cstype` header.
776///
777/// Supported `cstype` values:
778///   * `bmatrix` — round 10, evaluated via the user-supplied basis
779///     matrix from `bmat u` and the step size from `step` (spec §"Basis
780///     matrix"). Each polynomial segment is constructed by walking the
781///     control-point list at the step size and computing
782///     `P(t) = Σ_i Σ_j B[i][j] · t^j · p_i` per axis (`bmat u`
783///     stores `B` in row-major order with column index `j` varying
784///     fastest, per spec §"bmat u/v matrix").
785///
786///   * `bezier` / `rat bezier` — round 7, de Casteljau evaluation on the
787///     `[0, 1]` basis domain.
788///   * `bspline` / `rat bspline` — round 8, Cox-deBoor recursive basis
789///     functions evaluated on `[t_min, t_max]` derived from the curve's
790///     `u_min` / `u_max` clipped against the active knot vector parsed
791///     from the most-recent `parm u` body statement.
792///   * `cardinal` — round 9, cubic Catmull-Rom evaluation via the spec's
793///     conversion to Bezier control points (`b1 = c1 + (c2 - c0) / 6`,
794///     `b2 = c2 - (c3 - c1) / 6`, `b0 = c1`, `b3 = c2`). Sliding-window
795///     piecewise: each segment i uses `c[i..i+4]`. Cardinal is cubic only
796///     per spec §"Cardinal" — non-cubic `deg` is rejected.
797///   * `taylor` — round 9, direct polynomial evaluation
798///     `P(t) = Σ_{i=0..n} c_i · t^i` where each control point IS a
799///     coefficient vector (spec §"Taylor": "control points are the
800///     polynomial coefficients"). Sample range `[u_min, u_max]`.
801///
802/// Each curve is evaluated at `samples + 1` uniformly-spaced parameter
803/// values across its evaluation interval. The resulting points become a
804/// polyline.
805///
806/// `cstype` modifiers other than the listed kinds are ignored. This
807/// function handles only 1D `curv` directives; 2-parameter `surf`
808/// surfaces are evaluated separately by [`tessellate_surfaces`] (Bezier
809/// tensor-product, round 11). NURBS surfaces remain captured-only.
810///
811/// Per-curve provenance lands on `Primitive::extras`:
812///
813///   * `obj:tessellated_curve` — `true` (sentinel for filters).
814///   * `obj:curve_kind` — `"bezier"` / `"rat_bezier"` / `"bspline"` /
815///     `"rat_bspline"` / `"cardinal"` / `"taylor"` / `"bmatrix"`.
816///   * `obj:curve_degree` — basis polynomial degree.
817///   * `obj:curve_u_range` — `[u_min, u_max]` from the `curv` directive.
818///   * `obj:curve_samples` — sample count emitted.
819///
820/// Spec references: §"Curve and surface type" (cstype), §"Degree"
821/// (deg), §"Curve" (curv), §"Parameter values and knot vectors"
822/// (parm), §"B-spline" (Cox-deBoor recursion), §"Cardinal" (Catmull-Rom
823/// conversion to Bezier), §"Taylor" (polynomial-coefficient basis),
824/// §"Basis matrix" (general arbitrary-degree user-defined basis,
825/// `bmat u/v` + `step` body statements),
826/// §"Free-form curve/surface body statements" (rational weight semantics).
827fn tessellate_curves(doc: &ObjDoc, samples: u32) -> Vec<Primitive> {
828    // Spec §"Specifying free-form curves/surfaces": the curve / surface
829    // header (`curv` / `surf`) lists control points, and the *body*
830    // statements (`parm`, `trim`, `hole`, `scrv`, `sp`) follow before
831    // the block-terminating `end`. That means a `curv` directive is
832    // syntactically ahead of the `parm u …` knot vector it depends on
833    // — we can't tessellate B-splines on a single linear walk.
834    //
835    // Strategy: scan into per-block records (`cstype` opens, `end`
836    // closes), accumulate the relevant directives, then evaluate every
837    // pending `curv` once the body is fully visible. The Bezier path
838    // doesn't need the body but uses the same scaffolding for
839    // simplicity.
840    let mut out: Vec<Primitive> = Vec::new();
841
842    // Pending state inside the current `cstype` … `end` block.
843    let mut active_kind: Option<&'static str> = None;
844    let mut active_degree: Option<u32> = None;
845    let mut parm_u: Vec<f32> = Vec::new();
846    // Basis-matrix block state (spec §"Basis matrix"): `bmat u <matrix>`
847    // supplies the (n+1)×(n+1) basis stored row-major (column j varies
848    // fastest per spec); `step <stepu>` supplies the integer stride
849    // between successive segment windows of control points.
850    let mut bmat_u: Vec<f32> = Vec::new();
851    let mut step_u: Option<u32> = None;
852    // `curv` directives queued for this block — evaluated on `end`.
853    let mut pending_curves: Vec<&Vec<String>> = Vec::new();
854
855    for entry in &doc.freeform_directives {
856        if entry.is_empty() {
857            continue;
858        }
859        match entry[0].as_str() {
860            "cstype" => {
861                // Flush the previous block (rare — OBJ usually ends
862                // each block with `end`, but be defensive).
863                flush_block(
864                    &mut out,
865                    doc,
866                    active_kind,
867                    active_degree,
868                    &parm_u,
869                    &bmat_u,
870                    step_u,
871                    &pending_curves,
872                    samples,
873                );
874                pending_curves.clear();
875                parm_u.clear();
876                bmat_u.clear();
877                step_u = None;
878                active_degree = None;
879
880                // Spec §"Curve and surface type": `cstype [rat] type`.
881                let mut iter = entry.iter().skip(1);
882                let first = iter.next().map(String::as_str);
883                let second = iter.next().map(String::as_str);
884                active_kind = match (first, second) {
885                    (Some("bezier"), _) => Some("bezier"),
886                    (Some("rat"), Some("bezier")) => Some("rat_bezier"),
887                    (Some("bspline"), _) => Some("bspline"),
888                    (Some("rat"), Some("bspline")) => Some("rat_bspline"),
889                    // Spec §"Cardinal": cubic Catmull-Rom. The `rat`
890                    // qualifier is permitted but the spec note says the
891                    // unit-weight default is reasonable for Cardinal
892                    // because its basis functions sum to 1; we don't
893                    // currently differentiate rat_cardinal from cardinal
894                    // because the per-vertex weight is rarely populated
895                    // in real Cardinal data.
896                    (Some("cardinal"), _) => Some("cardinal"),
897                    (Some("rat"), Some("cardinal")) => Some("cardinal"),
898                    // Spec §"Taylor": polynomial-coefficient basis. The
899                    // spec note explicitly warns that the rational form
900                    // "does not make sense for Taylor" so we accept the
901                    // `rat` qualifier but route to the same evaluator.
902                    (Some("taylor"), _) => Some("taylor"),
903                    (Some("rat"), Some("taylor")) => Some("taylor"),
904                    // Spec §"Basis matrix": `cstype bmatrix` — the
905                    // user supplies the basis via `bmat u <matrix>` and
906                    // the segment stride via `step <stepu>`. The spec
907                    // note on rational forms says the unit-weight
908                    // default "may or may not make sense for a
909                    // representation given in basis-matrix form", so
910                    // we accept `rat bmatrix` but don't apply weights
911                    // (the user's basis is the source of truth).
912                    (Some("bmatrix"), _) => Some("bmatrix"),
913                    (Some("rat"), Some("bmatrix")) => Some("bmatrix"),
914                    _ => None,
915                };
916            }
917            "deg" => {
918                // Spec §"Degree": `deg degu [degv]`. We only consume
919                // `degu` for 1D `curv` tessellation; `degv` is captured
920                // in the directive sequence but unused here.
921                if let Some(d) = entry.get(1).and_then(|t| t.parse::<u32>().ok()) {
922                    active_degree = Some(d);
923                }
924            }
925            // Spec §"Parameter values and knot vectors":
926            // `parm u p1 p2 p3 …` (or `parm v …`). For 1D curves we
927            // only need the `u` knot vector / parameter vector.
928            "parm" if entry.get(1).map(String::as_str) == Some("u") => {
929                parm_u = entry[2..]
930                    .iter()
931                    .filter_map(|t| t.parse::<f32>().ok())
932                    .collect();
933            }
934            // Spec §"bmat u/v matrix": `bmat u m_00 m_01 … m_nn` (row-
935            // major with column index `j` varying fastest). Only the
936            // u-direction matrix is consumed by 1D `curv` evaluation;
937            // `bmat v` is captured in the directive sequence but only
938            // matters for surface tessellation (deferred).
939            "bmat" if entry.get(1).map(String::as_str) == Some("u") => {
940                bmat_u = entry[2..]
941                    .iter()
942                    .filter_map(|t| t.parse::<f32>().ok())
943                    .collect();
944            }
945            // Spec §"step stepu stepv": `step stepu [stepv]`. `stepu`
946            // is the integer stride between successive segment windows
947            // of control points (`stepv` is required only for
948            // surfaces).
949            "step" => {
950                step_u = entry.get(1).and_then(|t| t.parse::<u32>().ok());
951            }
952            "curv" => {
953                // Defer evaluation until `end` — the body statement
954                // `parm u …` that supplies the B-spline knot vector
955                // hasn't been seen yet at this point.
956                pending_curves.push(entry);
957            }
958            "end" => {
959                flush_block(
960                    &mut out,
961                    doc,
962                    active_kind,
963                    active_degree,
964                    &parm_u,
965                    &bmat_u,
966                    step_u,
967                    &pending_curves,
968                    samples,
969                );
970                pending_curves.clear();
971                parm_u.clear();
972                bmat_u.clear();
973                step_u = None;
974                active_kind = None;
975                active_degree = None;
976            }
977            // `surf`, `curv2`, `trim`, `hole`, `scrv`, `sp`, `bzp`,
978            // `bsp` etc. are tracked through `freeform_directives` but
979            // don't influence 1D-curve tessellation directly. `surf`
980            // (a 2-parameter surface) is evaluated by the separate
981            // `tessellate_surfaces` pass (round 11, Bezier tensor-
982            // product).
983            _ => {}
984        }
985    }
986    // Tail flush — a malformed OBJ might omit the closing `end`. Spec
987    // §"Free-form curve/surface body statements" requires it, but the
988    // rest of the loader is lenient so we are too.
989    flush_block(
990        &mut out,
991        doc,
992        active_kind,
993        active_degree,
994        &parm_u,
995        &bmat_u,
996        step_u,
997        &pending_curves,
998        samples,
999    );
1000    out
1001}
1002
1003/// Evaluate every `curv` entry queued for the current `cstype … end`
1004/// block, appending tessellated primitives to `out`. A block whose
1005/// state is incomplete (missing `cstype`, missing knot vector for
1006/// B-spline, malformed control-point indices, …) is silently dropped —
1007/// the directive sequence already rides on `Scene3D::extras` for
1008/// downstream consumers.
1009#[allow(clippy::too_many_arguments)]
1010fn flush_block(
1011    out: &mut Vec<Primitive>,
1012    doc: &ObjDoc,
1013    active_kind: Option<&'static str>,
1014    active_degree: Option<u32>,
1015    parm_u: &[f32],
1016    bmat_u: &[f32],
1017    step_u: Option<u32>,
1018    pending_curves: &[&Vec<String>],
1019    samples: u32,
1020) {
1021    let Some(kind) = active_kind else {
1022        return;
1023    };
1024    for entry in pending_curves {
1025        // tokens past "curv" — first two are u_min / u_max,
1026        // remaining are 1-based / negative position indices.
1027        if entry.len() < 5 {
1028            // Minimum: keyword + u0 + u1 + at least 2 control points
1029            // (a line / degree-1 curve). Anything shorter is malformed;
1030            // skip rather than abort — the lenient-loader pattern
1031            // matches the rest of the codebase.
1032            continue;
1033        }
1034        let Ok(u_min) = entry[1].parse::<f32>() else {
1035            continue;
1036        };
1037        let Ok(u_max) = entry[2].parse::<f32>() else {
1038            continue;
1039        };
1040        let n_pos = doc.positions.len() as i64;
1041        let mut control_points: Vec<[f32; 3]> = Vec::new();
1042        let mut control_weights: Vec<f32> = Vec::new();
1043        let mut bad = false;
1044        for tok in &entry[3..] {
1045            let Ok(raw) = tok.parse::<i64>() else {
1046                bad = true;
1047                break;
1048            };
1049            let resolved = if raw < 0 { n_pos + 1 + raw } else { raw };
1050            if resolved <= 0 || resolved > n_pos {
1051                bad = true;
1052                break;
1053            }
1054            let pos = doc.positions[(resolved as usize) - 1];
1055            control_points.push(pos);
1056            // For rational forms, take the position's 4th-w weight from
1057            // the parallel `position_weights` pool (`v x y z w`).
1058            // Default 1.0 per spec when absent.
1059            let w = doc.position_weights[(resolved as usize) - 1].unwrap_or(1.0);
1060            control_weights.push(w);
1061        }
1062        if bad || control_points.len() < 2 {
1063            continue;
1064        }
1065
1066        let curve_points = match kind {
1067            "bezier" | "rat_bezier" => sample_bezier(
1068                &control_points,
1069                &control_weights,
1070                kind,
1071                u_min,
1072                u_max,
1073                samples,
1074            ),
1075            "bspline" | "rat_bspline" => {
1076                // B-spline needs a knot vector and a degree. Spec
1077                // §"B-spline" condition 6: K = q - n - 1 ⇒ knot count
1078                // must equal control-point count + degree + 1. Skip
1079                // silently when missing — the source OBJ is incomplete
1080                // in spec terms but we don't want to abort the whole
1081                // decode.
1082                let Some(degree) = active_degree else {
1083                    continue;
1084                };
1085                if parm_u.len() != control_points.len() + degree as usize + 1 {
1086                    continue;
1087                }
1088                sample_bspline(
1089                    &control_points,
1090                    &control_weights,
1091                    kind,
1092                    degree,
1093                    parm_u,
1094                    u_min,
1095                    u_max,
1096                    samples,
1097                )
1098            }
1099            "cardinal" => {
1100                // Spec §"Cardinal": "Cardinal splines are only defined
1101                // for the cubic case." Reject non-cubic `deg`. The
1102                // `parm` count requirement (K - n + 2 values, ⇒ K - 2
1103                // segments) is informational here — we slide a window
1104                // of 4 control points and emit segments directly
1105                // without needing the global parameter vector for the
1106                // basis evaluation itself, since the Catmull-Rom
1107                // tangent definition is purely local (segment i uses
1108                // c[i..i+4]).
1109                if active_degree.is_some_and(|d| d != 3) {
1110                    continue;
1111                }
1112                // Need at least 4 control points for one segment.
1113                if control_points.len() < 4 {
1114                    continue;
1115                }
1116                sample_cardinal(&control_points, samples)
1117            }
1118            "taylor" => {
1119                // Spec §"Taylor": basis function is t^i; control points
1120                // are the polynomial coefficients. `deg n` ⇒ n + 1
1121                // coefficient vectors expected. Reject when the count
1122                // doesn't match (lenient: also accept missing `deg` and
1123                // infer n = K).
1124                let degree = match active_degree {
1125                    Some(d) => d as usize,
1126                    None => control_points.len().saturating_sub(1),
1127                };
1128                if control_points.len() != degree + 1 {
1129                    continue;
1130                }
1131                sample_taylor(&control_points, u_min, u_max, samples)
1132            }
1133            "bmatrix" => {
1134                // Spec §"Basis matrix": needs `deg n` + `bmat u <(n+1)²
1135                // floats>` + `step <stepu>` body statements. Without any
1136                // of those, the block is malformed in spec terms — skip
1137                // silently (lenient-loader pattern). The basis matrix is
1138                // (n + 1) × (n + 1) per spec §"Consistency conditions":
1139                // "the size of the basis matrix is (n + 1) x (n + 1)".
1140                let Some(degree) = active_degree else {
1141                    continue;
1142                };
1143                let Some(step) = step_u else {
1144                    continue;
1145                };
1146                let n_plus_1 = degree as usize + 1;
1147                if bmat_u.len() != n_plus_1 * n_plus_1 {
1148                    continue;
1149                }
1150                if step == 0 {
1151                    continue;
1152                }
1153                // Need at least n + 1 control points for one segment.
1154                if control_points.len() < n_plus_1 {
1155                    continue;
1156                }
1157                sample_bmatrix(&control_points, bmat_u, degree, step, samples)
1158            }
1159            _ => continue,
1160        };
1161        if curve_points.len() < 2 {
1162            continue;
1163        }
1164
1165        let mut prim = Primitive::new(Topology::LineStrip);
1166        let n = curve_points.len() as u32;
1167        prim.positions = curve_points;
1168        // Implicit 0..N strip indices keep the buffer compact and
1169        // match how `LineStrip` consumers normally walk the vertex
1170        // array.
1171        if n > u16::MAX as u32 {
1172            prim.indices = Some(Indices::U32((0..n).collect()));
1173        } else {
1174            prim.indices = Some(Indices::U16((0..n).map(|i| i as u16).collect()));
1175        }
1176
1177        prim.extras.insert(
1178            "obj:tessellated_curve".to_string(),
1179            serde_json::Value::Bool(true),
1180        );
1181        prim.extras.insert(
1182            "obj:curve_kind".to_string(),
1183            serde_json::Value::String(kind.to_string()),
1184        );
1185        // Reported degree: for Bezier the basis degree always equals
1186        // N − 1 (control-point count − 1). For B-spline the basis
1187        // degree is the `deg` value (independent of the control-point
1188        // count). We report whichever is semantically correct for the
1189        // basis.
1190        let reported_degree = match kind {
1191            "bezier" | "rat_bezier" => (control_points.len() - 1) as u64,
1192            "bspline" | "rat_bspline" => active_degree.unwrap_or(0) as u64,
1193            // Spec §"Cardinal": "Cardinal splines are only defined for
1194            // the cubic case." Always 3.
1195            "cardinal" => 3,
1196            // Spec §"Taylor": degree n ⇒ K + 1 = n + 1 coefficients.
1197            "taylor" => active_degree
1198                .map(u64::from)
1199                .unwrap_or_else(|| (control_points.len() - 1) as u64),
1200            // Spec §"Basis matrix": degree comes from `deg n`; the
1201            // basis matrix is (n + 1) × (n + 1).
1202            "bmatrix" => active_degree.map(u64::from).unwrap_or(0),
1203            _ => 0,
1204        };
1205        prim.extras.insert(
1206            "obj:curve_degree".to_string(),
1207            serde_json::Value::Number(serde_json::Number::from(reported_degree)),
1208        );
1209        let range_arr = serde_json::Value::Array(vec![
1210            serde_json::Value::from(u_min as f64),
1211            serde_json::Value::from(u_max as f64),
1212        ]);
1213        prim.extras
1214            .insert("obj:curve_u_range".to_string(), range_arr);
1215        prim.extras.insert(
1216            "obj:curve_samples".to_string(),
1217            serde_json::Value::Number(serde_json::Number::from(samples as u64)),
1218        );
1219
1220        out.push(prim);
1221    }
1222}
1223
1224/// Tessellate every `surf` element that sits under a supported `cstype`
1225/// header into a triangulated [`Topology::Triangles`] primitive. Mirrors
1226/// [`tessellate_curves`] but evaluates a bivariate tensor product (spec
1227/// §"Rational and non-rational curves and surfaces", §"Bezier",
1228/// §"B-spline", §"Surface vertex data — control points").
1229///
1230/// Supported `cstype` values:
1231///   * `bezier` / `rat bezier` (round 11) — bivariate tensor-product de
1232///     Casteljau; single patch of `(degu + 1) × (degv + 1)` control
1233///     points.
1234///   * `bspline` / `rat bspline` (round 12) — bivariate tensor-product
1235///     Cox-deBoor evaluation; the `parm u` / `parm v` knot vectors define
1236///     the control-grid extents (`(len(parm u) − degu − 1) ×
1237///     (len(parm v) − degv − 1)` per spec §"B-spline" condition 6).
1238///   * `cardinal` / `rat cardinal` (round 13) — cubic-only bivariate
1239///     tensor-product Cardinal (Catmull-Rom) evaluation via the spec
1240///     §"Cardinal" Cardinal→Bezier conversion applied per parametric
1241///     direction over a sliding 4-point window; the control grid is the
1242///     `parm`-derived extent (`parm_count + 1` per direction) or a
1243///     square single patch when `parm` only carries the 2-value range.
1244///
1245/// Taylor / basis-matrix surfaces remain captured-only (the directive
1246/// sequence still round-trips through
1247/// `Scene3D::extras["obj:freeform_directives"]`).
1248///
1249/// `surf` token layout (spec §"surf s0 s1 t0 t1 v1/vt1/vn1 …"):
1250/// `surf s0 s1 t0 t1` followed by `v/vt/vn` control-vertex references.
1251/// Only the leading position index of each `v/vt/vn` token is consumed;
1252/// texture / normal references are interpolation extras the renderer
1253/// would blend with the same basis (spec §"Texture vertices …",
1254/// §"Vertex normals …") but they don't change the surface shape, so the
1255/// position-only evaluation is sufficient for the polyline/triangle
1256/// approximation.
1257///
1258/// Control-point ordering (spec §"Surface vertex data — control
1259/// points"): "listed in the order i = 0 to K1 for j = 0, followed by
1260/// i = 0 to K1 for j = 1, and so on until j = K2." That is row-major
1261/// with the u index (`i`) varying fastest. For a single Bezier patch
1262/// `K1 = degu` and `K2 = degv`, so the control grid is
1263/// `(degu + 1) × (degv + 1)`.
1264///
1265/// Per-surface provenance lands on `Primitive::extras`:
1266///   * `obj:tessellated_curve` — `true` (shared sentinel so the encoder's
1267///     existing filter skips this synthetic geometry).
1268///   * `obj:tessellated_surface` — `true` (surface-specific sentinel).
1269///   * `obj:surface_kind` — `"bezier"` / `"rat_bezier"` / `"bspline"` /
1270///     `"rat_bspline"` / `"cardinal"`.
1271///   * `obj:surface_degree` — `[degu, degv]`.
1272///   * `obj:surface_u_range` / `obj:surface_v_range` — `[s0, s1]` /
1273///     `[t0, t1]` from the `surf` directive.
1274///   * `obj:surface_samples` — sample count per parametric direction.
1275fn tessellate_surfaces(doc: &ObjDoc, samples: u32) -> Vec<Primitive> {
1276    let mut out: Vec<Primitive> = Vec::new();
1277    if samples == 0 {
1278        return out;
1279    }
1280
1281    // Block state, accumulated between `cstype` … `end`. Like the curve
1282    // tessellator, a `surf` header is syntactically ahead of the `parm u`
1283    // / `parm v` body statements that supply the B-spline knot vectors,
1284    // so the whole block is buffered and evaluated on `end` (or `cstype`
1285    // / tail flush) once the body is fully visible.
1286    let mut active_kind: Option<&'static str> = None;
1287    let mut deg_u: Option<u32> = None;
1288    let mut deg_v: Option<u32> = None;
1289    // Spec §"parm u/v": for B-spline surfaces these are the u/v knot
1290    // vectors (unused by the Bezier basis but parsed regardless).
1291    let mut parm_u: Vec<f32> = Vec::new();
1292    let mut parm_v: Vec<f32> = Vec::new();
1293    let mut pending_surfs: Vec<&Vec<String>> = Vec::new();
1294
1295    #[allow(clippy::too_many_arguments)]
1296    let flush = |out: &mut Vec<Primitive>,
1297                 kind: Option<&'static str>,
1298                 deg_u: Option<u32>,
1299                 deg_v: Option<u32>,
1300                 parm_u: &[f32],
1301                 parm_v: &[f32],
1302                 surfs: &[&Vec<String>]| {
1303        let Some(kind) = kind else {
1304            return;
1305        };
1306        for entry in surfs {
1307            if let Some(prim) =
1308                flush_surface(doc, kind, deg_u, deg_v, parm_u, parm_v, entry, samples)
1309            {
1310                out.push(prim);
1311            }
1312        }
1313    };
1314
1315    for entry in &doc.freeform_directives {
1316        if entry.is_empty() {
1317            continue;
1318        }
1319        match entry[0].as_str() {
1320            "cstype" => {
1321                flush(
1322                    &mut out,
1323                    active_kind,
1324                    deg_u,
1325                    deg_v,
1326                    &parm_u,
1327                    &parm_v,
1328                    &pending_surfs,
1329                );
1330                pending_surfs.clear();
1331                deg_u = None;
1332                deg_v = None;
1333                parm_u.clear();
1334                parm_v.clear();
1335                // Spec §"Curve and surface type": `cstype [rat] type`.
1336                let mut iter = entry.iter().skip(1);
1337                let first = iter.next().map(String::as_str);
1338                let second = iter.next().map(String::as_str);
1339                active_kind = match (first, second) {
1340                    (Some("bezier"), _) => Some("bezier"),
1341                    (Some("rat"), Some("bezier")) => Some("rat_bezier"),
1342                    (Some("bspline"), _) => Some("bspline"),
1343                    (Some("rat"), Some("bspline")) => Some("rat_bspline"),
1344                    // Spec §"Cardinal": cubic, first-derivative-continuous
1345                    // surface (round 13). The `rat` qualifier maps to the
1346                    // same kind — the spec note (§"Free-form curve/surface
1347                    // body statements") says the unit-weight default is
1348                    // reasonable for Cardinal because its basis functions
1349                    // sum to 1, so we don't differentiate `rat cardinal`.
1350                    (Some("cardinal"), _) => Some("cardinal"),
1351                    (Some("rat"), Some("cardinal")) => Some("cardinal"),
1352                    // Taylor / basis-matrix surfaces stay captured-only.
1353                    _ => None,
1354                };
1355            }
1356            "deg" => {
1357                // Spec §"Degree": `deg degu [degv]`. For surfaces both
1358                // are required; `degv` defaults to `degu` only if a
1359                // single value was given (matches the spec note that
1360                // `degv` is "required only for surfaces").
1361                deg_u = entry.get(1).and_then(|t| t.parse::<u32>().ok());
1362                deg_v = entry.get(2).and_then(|t| t.parse::<u32>().ok()).or(deg_u);
1363            }
1364            // Spec §"parm u/v": `parm u p1 p2 …` / `parm v p1 p2 …`. For
1365            // B-spline surfaces these are the knot vectors in each
1366            // parametric direction.
1367            "parm" if entry.get(1).map(String::as_str) == Some("u") => {
1368                parm_u = entry[2..]
1369                    .iter()
1370                    .filter_map(|t| t.parse::<f32>().ok())
1371                    .collect();
1372            }
1373            "parm" if entry.get(1).map(String::as_str) == Some("v") => {
1374                parm_v = entry[2..]
1375                    .iter()
1376                    .filter_map(|t| t.parse::<f32>().ok())
1377                    .collect();
1378            }
1379            "surf" => pending_surfs.push(entry),
1380            "end" => {
1381                flush(
1382                    &mut out,
1383                    active_kind,
1384                    deg_u,
1385                    deg_v,
1386                    &parm_u,
1387                    &parm_v,
1388                    &pending_surfs,
1389                );
1390                pending_surfs.clear();
1391                active_kind = None;
1392                deg_u = None;
1393                deg_v = None;
1394                parm_u.clear();
1395                parm_v.clear();
1396            }
1397            _ => {}
1398        }
1399    }
1400    // Tail flush — defensive against a missing closing `end`.
1401    flush(
1402        &mut out,
1403        active_kind,
1404        deg_u,
1405        deg_v,
1406        &parm_u,
1407        &parm_v,
1408        &pending_surfs,
1409    );
1410    out
1411}
1412
1413/// Evaluate one `surf` element against an active Bezier or B-spline
1414/// `cstype` and return the triangulated primitive, or `None` when the
1415/// directive is incomplete / malformed (lenient-loader pattern — the
1416/// directive still round-trips through `obj:freeform_directives`).
1417#[allow(clippy::too_many_arguments)]
1418fn flush_surface(
1419    doc: &ObjDoc,
1420    kind: &'static str,
1421    deg_u: Option<u32>,
1422    deg_v: Option<u32>,
1423    parm_u: &[f32],
1424    parm_v: &[f32],
1425    entry: &[String],
1426    samples: u32,
1427) -> Option<Primitive> {
1428    // `surf s0 s1 t0 t1 v1/vt1/vn1 …` — minimum is the keyword + 4
1429    // range scalars + at least one control vertex.
1430    if entry.len() < 6 {
1431        return None;
1432    }
1433    let s0 = entry[1].parse::<f32>().ok()?;
1434    let s1 = entry[2].parse::<f32>().ok()?;
1435    let t0 = entry[3].parse::<f32>().ok()?;
1436    let t1 = entry[4].parse::<f32>().ok()?;
1437
1438    // Spec §"surf": both degu and degv are required for a surface.
1439    let du = deg_u? as usize;
1440    let dv = deg_v? as usize;
1441
1442    let bspline = matches!(kind, "bspline" | "rat_bspline");
1443    let cardinal = kind == "cardinal";
1444    // Determine the expected single-patch control grid.
1445    //   * Bezier: a single patch is exactly (degu + 1) × (degv + 1)
1446    //     control points (spec §"Bezier"). Larger grids are multi-patch
1447    //     and need a `step` stride the Bezier basis doesn't carry, so they
1448    //     stay captured-only.
1449    //   * B-spline: the control-point count per direction is fixed by the
1450    //     knot vector — spec §"B-spline" condition 6, `K = q − n − 1`, so
1451    //     there are `len(parm) − deg − 1` control points in that
1452    //     direction. A single `surf` already covers the whole grid (the
1453    //     knot vector internally encodes the piecewise segments), so no
1454    //     patch decomposition is needed.
1455    //   * Cardinal: cubic-only (spec §"Cardinal": "only defined for the
1456    //     cubic case"). The control count per direction relates to the
1457    //     `parm` count by the spec condition `parm = K − n + 2` (n = 3),
1458    //     i.e. `K_dir = parm_count + 1`. When a `parm` directive only
1459    //     spells out the 2-value global parameter range (as the spec
1460    //     Cardinal-surface example does), there is no per-direction split
1461    //     to read, so the grid is taken to be square — `cols = rows =
1462    //     sqrt(total)` — which recovers the canonical single 4×4 patch.
1463    let (cols, rows) = if bspline {
1464        // Need at least `deg + 2` knots per direction for ≥ 1 control
1465        // point.
1466        if parm_u.len() < du + 2 || parm_v.len() < dv + 2 {
1467            return None;
1468        }
1469        (parm_u.len() - du - 1, parm_v.len() - dv - 1) // (K1 + 1, K2 + 1)
1470    } else if cardinal {
1471        // Cardinal must be cubic per spec; reject any other degree (the
1472        // directive still round-trips verbatim through extras).
1473        if du != 3 || dv != 3 {
1474            return None;
1475        }
1476        let total = entry.len() - 5; // control-vertex token count.
1477        // Prefer the per-direction `parm` extents when they carry more
1478        // than just the range endpoints (`parm = K − n + 2`); otherwise
1479        // fall back to a square single-patch grid.
1480        let cols = if parm_u.len() > 2 {
1481            parm_u.len() + 1
1482        } else {
1483            isqrt_exact(total)?
1484        };
1485        let rows = if parm_v.len() > 2 {
1486            parm_v.len() + 1
1487        } else if cols != 0 && total % cols == 0 {
1488            total / cols
1489        } else {
1490            return None;
1491        };
1492        (cols, rows)
1493    } else {
1494        (du + 1, dv + 1)
1495    };
1496    let expected = cols * rows;
1497
1498    let n_pos = doc.positions.len() as i64;
1499    let mut grid: Vec<[f32; 3]> = Vec::with_capacity(expected);
1500    let mut weights: Vec<f32> = Vec::with_capacity(expected);
1501    for tok in &entry[5..] {
1502        // Each control vertex is a `v/vt/vn` reference; we only need the
1503        // leading position index.
1504        let first_field = tok.split('/').next().unwrap_or(tok);
1505        let raw = first_field.parse::<i64>().ok()?;
1506        let resolved = if raw < 0 { n_pos + 1 + raw } else { raw };
1507        if resolved <= 0 || resolved > n_pos {
1508            return None;
1509        }
1510        grid.push(doc.positions[(resolved as usize) - 1]);
1511        let w = doc.position_weights[(resolved as usize) - 1].unwrap_or(1.0);
1512        weights.push(w);
1513    }
1514    if grid.len() != expected {
1515        // Not a single patch of the declared degree (Bezier) or the knot-
1516        // vector-implied grid size (B-spline) — leave it captured-only
1517        // rather than guessing the patch decomposition.
1518        return None;
1519    }
1520
1521    let positions = if bspline {
1522        sample_bspline_surface(
1523            &grid, &weights, kind, du as u32, dv as u32, parm_u, parm_v, s0, s1, t0, t1, cols,
1524            rows, samples,
1525        )
1526    } else if cardinal {
1527        sample_cardinal_surface(&grid, cols, rows, samples)
1528    } else {
1529        sample_bezier_surface(&grid, &weights, kind, cols, rows, samples)
1530    };
1531    if positions.is_empty() {
1532        return None;
1533    }
1534
1535    // Build a triangle grid over the (samples + 1) × (samples + 1)
1536    // sample lattice. Vertex (su, sv) lives at index sv * stride + su.
1537    let stride = samples as usize + 1;
1538    let mut indices: Vec<u32> = Vec::with_capacity((samples as usize) * (samples as usize) * 6);
1539    for sv in 0..samples as usize {
1540        for su in 0..samples as usize {
1541            let i00 = (sv * stride + su) as u32;
1542            let i10 = (sv * stride + su + 1) as u32;
1543            let i01 = ((sv + 1) * stride + su) as u32;
1544            let i11 = ((sv + 1) * stride + su + 1) as u32;
1545            // Two CCW triangles per cell (spec §"surf" note: the front
1546            // of the surface is the side where u increases to the right
1547            // and v increases upward).
1548            indices.push(i00);
1549            indices.push(i10);
1550            indices.push(i11);
1551            indices.push(i00);
1552            indices.push(i11);
1553            indices.push(i01);
1554        }
1555    }
1556
1557    let mut prim = Primitive::new(Topology::Triangles);
1558    let n_verts = positions.len() as u32;
1559    prim.positions = positions;
1560    prim.indices = if n_verts > u16::MAX as u32 {
1561        Some(Indices::U32(indices))
1562    } else {
1563        Some(Indices::U16(indices.iter().map(|&i| i as u16).collect()))
1564    };
1565
1566    prim.extras.insert(
1567        "obj:tessellated_curve".to_string(),
1568        serde_json::Value::Bool(true),
1569    );
1570    prim.extras.insert(
1571        "obj:tessellated_surface".to_string(),
1572        serde_json::Value::Bool(true),
1573    );
1574    prim.extras.insert(
1575        "obj:surface_kind".to_string(),
1576        serde_json::Value::String(kind.to_string()),
1577    );
1578    prim.extras.insert(
1579        "obj:surface_degree".to_string(),
1580        serde_json::Value::Array(vec![
1581            serde_json::Value::from(du as u64),
1582            serde_json::Value::from(dv as u64),
1583        ]),
1584    );
1585    prim.extras.insert(
1586        "obj:surface_u_range".to_string(),
1587        serde_json::Value::Array(vec![
1588            serde_json::Value::from(s0 as f64),
1589            serde_json::Value::from(s1 as f64),
1590        ]),
1591    );
1592    prim.extras.insert(
1593        "obj:surface_v_range".to_string(),
1594        serde_json::Value::Array(vec![
1595            serde_json::Value::from(t0 as f64),
1596            serde_json::Value::from(t1 as f64),
1597        ]),
1598    );
1599    prim.extras.insert(
1600        "obj:surface_samples".to_string(),
1601        serde_json::Value::Number(serde_json::Number::from(samples as u64)),
1602    );
1603
1604    Some(prim)
1605}
1606
1607/// Evaluate a Bezier (or rational-Bezier) surface patch at a
1608/// `(samples + 1) × (samples + 1)` lattice via the tensor-product de
1609/// Casteljau algorithm.
1610///
1611/// `grid` is the control mesh in row-major order with the u index
1612/// varying fastest (spec §"Surface vertex data — control points"):
1613/// `cols` control points per v-row, `rows` v-rows. For each `(u, v)`
1614/// sample the surface is `S(u, v) = Σ_i Σ_j B_i(u) · B_j(v) · d_{i,j}`.
1615/// We collapse the inner u sum first by running de Casteljau on each
1616/// v-row, then a second de Casteljau on the resulting `rows` points in
1617/// the v direction.
1618///
1619/// For `kind == "rat_bezier"` each control point is lifted to its
1620/// homogeneous `(w·x, w·y, w·z, w)` form, both de Casteljau passes run
1621/// in 4D, and the result is projected back via `x / w` (spec
1622/// §"Rational and non-rational curves and surfaces").
1623///
1624/// Output vertices are ordered row-major in the sample lattice: sample
1625/// `(su, sv)` lands at index `sv * (samples + 1) + su`.
1626fn sample_bezier_surface(
1627    grid: &[[f32; 3]],
1628    weights: &[f32],
1629    kind: &str,
1630    cols: usize,
1631    rows: usize,
1632    samples: u32,
1633) -> Vec<[f32; 3]> {
1634    if samples == 0 || cols == 0 || rows == 0 || grid.len() != cols * rows {
1635        return Vec::new();
1636    }
1637    let rational = kind == "rat_bezier";
1638    // Lift to homogeneous 4D so a single de Casteljau loop handles both
1639    // forms (non-rational uses w == 1).
1640    let homo: Vec<[f32; 4]> = grid
1641        .iter()
1642        .zip(weights.iter())
1643        .map(|(p, w)| {
1644            let weight = if rational { *w } else { 1.0 };
1645            [p[0] * weight, p[1] * weight, p[2] * weight, weight]
1646        })
1647        .collect();
1648
1649    let n = samples as usize + 1;
1650    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n * n);
1651    for sv in 0..n {
1652        let v = if n == 1 {
1653            0.0
1654        } else {
1655            sv as f32 / (n - 1) as f32
1656        };
1657        for su in 0..n {
1658            let u = if n == 1 {
1659                0.0
1660            } else {
1661                su as f32 / (n - 1) as f32
1662            };
1663            // Inner pass: de Casteljau across each v-row in u, leaving
1664            // one homogeneous point per row.
1665            let mut col_pts: Vec<[f32; 4]> = Vec::with_capacity(rows);
1666            for r in 0..rows {
1667                let row = &homo[r * cols..r * cols + cols];
1668                col_pts.push(de_casteljau_4d(row, u));
1669            }
1670            // Outer pass: de Casteljau in v over the collapsed points.
1671            let pt = de_casteljau_4d(&col_pts, v);
1672            let [x, y, z, w] = pt;
1673            if rational && w.abs() > f32::EPSILON {
1674                out.push([x / w, y / w, z / w]);
1675            } else {
1676                out.push([x, y, z]);
1677            }
1678        }
1679    }
1680    out
1681}
1682
1683/// de Casteljau evaluation of a homogeneous 4D Bezier control polygon at
1684/// parameter `t ∈ [0, 1]`. Shared by the row and column passes of
1685/// [`sample_bezier_surface`].
1686fn de_casteljau_4d(points: &[[f32; 4]], t: f32) -> [f32; 4] {
1687    if points.is_empty() {
1688        return [0.0, 0.0, 0.0, 1.0];
1689    }
1690    let mut buf: Vec<[f32; 4]> = points.to_vec();
1691    let n = buf.len();
1692    for level in 1..n {
1693        for j in 0..(n - level) {
1694            buf[j] = [
1695                (1.0 - t) * buf[j][0] + t * buf[j + 1][0],
1696                (1.0 - t) * buf[j][1] + t * buf[j + 1][1],
1697                (1.0 - t) * buf[j][2] + t * buf[j + 1][2],
1698                (1.0 - t) * buf[j][3] + t * buf[j + 1][3],
1699            ];
1700        }
1701    }
1702    buf[0]
1703}
1704
1705/// Evaluate a B-spline (or rational B-spline / NURBS) surface patch at a
1706/// `(samples + 1) × (samples + 1)` lattice via the bivariate
1707/// tensor-product Cox-deBoor formula (spec §"B-spline", §"Rational and
1708/// non-rational curves and surfaces", §"Surface vertex data — control
1709/// points").
1710///
1711/// `grid` is the control mesh in row-major order with the u index varying
1712/// fastest (`cols` control points per v-row, `rows` v-rows). The surface
1713/// is
1714///
1715///   S(u, v) = Σ_i Σ_j N_{i,nu}(u) · N_{j,nv}(v) · d_{i,j}
1716///
1717/// for the non-rational case and
1718///
1719///   S(u, v) = Σ_i Σ_j N_{i,nu}(u) · N_{j,nv}(v) · w_{i,j} · d_{i,j}
1720///             ─────────────────────────────────────────────────────
1721///                  Σ_i Σ_j N_{i,nu}(u) · N_{j,nv}(v) · w_{i,j}
1722///
1723/// for the rational (NURBS) case. `nu` / `nv` are the u / v degrees and
1724/// `knots_u` (`parm u`) / `knots_v` (`parm v`) are the per-direction knot
1725/// vectors. The basis functions are evaluated with the same
1726/// [`bspline_basis`] routine the 1D curve path uses.
1727///
1728/// `s0`..`s1` and `t0`..`t1` are the `surf` parameter ranges; each is
1729/// clipped against the spec §"B-spline" condition-5 evaluation window
1730/// `[x_n, x_{K+1}]` of its direction's knot vector. The half-open
1731/// knot-span convention `x_i ≤ t < x_{i+1}` means an endpoint exactly at
1732/// the upper bound would yield an all-zero basis, so the last sample in
1733/// each direction is nudged fractionally below the bound (the same
1734/// standard NURBS-evaluator pattern as [`sample_bspline`]).
1735///
1736/// Output vertices are ordered row-major in the sample lattice: sample
1737/// `(su, sv)` lands at index `sv * (samples + 1) + su`.
1738#[allow(clippy::too_many_arguments)]
1739fn sample_bspline_surface(
1740    grid: &[[f32; 3]],
1741    weights: &[f32],
1742    kind: &str,
1743    deg_u: u32,
1744    deg_v: u32,
1745    knots_u: &[f32],
1746    knots_v: &[f32],
1747    s0: f32,
1748    s1: f32,
1749    t0: f32,
1750    t1: f32,
1751    cols: usize,
1752    rows: usize,
1753    samples: u32,
1754) -> Vec<[f32; 3]> {
1755    if samples == 0 || cols == 0 || rows == 0 || grid.len() != cols * rows {
1756        return Vec::new();
1757    }
1758    let nu = deg_u as usize;
1759    let nv = deg_v as usize;
1760    // Spec §"B-spline" condition 6: q + 1 knots ⇒ K + 1 = q − n control
1761    // points ⇒ knots.len() == control_count + degree + 1.
1762    if knots_u.len() != cols + nu + 1 || knots_v.len() != rows + nv + 1 {
1763        return Vec::new();
1764    }
1765
1766    // Per-direction evaluation windows (spec condition 5:
1767    // x_n ≤ t_min < t_max ≤ x_{K+1}). Clip the `surf` ranges into the
1768    // valid span of each knot vector.
1769    let u_lo_bound = knots_u[nu];
1770    let u_hi_bound = knots_u[cols]; // x_{K1+1}, K1+1 = cols.
1771    let v_lo_bound = knots_v[nv];
1772    let v_hi_bound = knots_v[rows]; // x_{K2+1}, K2+1 = rows.
1773    let u_min = s0.max(u_lo_bound);
1774    let u_max = s1.min(u_hi_bound);
1775    let v_min = t0.max(v_lo_bound);
1776    let v_max = t1.min(v_hi_bound);
1777    if u_min > u_max || v_min > v_max {
1778        return Vec::new();
1779    }
1780
1781    let rational = kind == "rat_bspline";
1782    let n = samples as usize + 1;
1783
1784    // Precompute one row of u-basis values per sample column and one
1785    // column of v-basis values per sample row; the tensor product reuses
1786    // them across the lattice.
1787    let nudge = |t: f32, lo: f32, hi: f32| -> f32 {
1788        // When t lands exactly on the upper bound the half-open spans give
1789        // an all-zero basis; bias it fractionally inside the last span.
1790        if t >= hi {
1791            let biased = hi - (hi - lo).abs() * 1e-7 - f32::EPSILON;
1792            if biased < lo { lo } else { biased }
1793        } else {
1794            t
1795        }
1796    };
1797
1798    let u_basis_rows: Vec<Vec<f32>> = (0..n)
1799        .map(|i| {
1800            let t01 = if n == 1 {
1801                0.0
1802            } else {
1803                i as f32 / (n - 1) as f32
1804            };
1805            let u = nudge(u_min + t01 * (u_max - u_min), u_lo_bound, u_hi_bound);
1806            bspline_basis(u, knots_u, nu)
1807        })
1808        .collect();
1809    let v_basis_rows: Vec<Vec<f32>> = (0..n)
1810        .map(|j| {
1811            let t01 = if n == 1 {
1812                0.0
1813            } else {
1814                j as f32 / (n - 1) as f32
1815            };
1816            let v = nudge(v_min + t01 * (v_max - v_min), v_lo_bound, v_hi_bound);
1817            bspline_basis(v, knots_v, nv)
1818        })
1819        .collect();
1820
1821    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n * n);
1822    for vb in v_basis_rows.iter() {
1823        for ub in u_basis_rows.iter() {
1824            // Tensor product: S = Σ_j vb[j] · Σ_i ub[i] · w_{i,j} · d_{i,j}
1825            // accumulated together with the weighted denominator.
1826            let mut acc = [0.0f32; 3];
1827            let mut wsum = 0.0f32;
1828            for (j, &bv) in vb.iter().enumerate().take(rows) {
1829                if bv == 0.0 {
1830                    continue;
1831                }
1832                for (i, &bu) in ub.iter().enumerate().take(cols) {
1833                    if bu == 0.0 {
1834                        continue;
1835                    }
1836                    let idx = j * cols + i;
1837                    let w = if rational { weights[idx] } else { 1.0 };
1838                    let coeff = bu * bv * w;
1839                    if coeff == 0.0 {
1840                        continue;
1841                    }
1842                    wsum += coeff;
1843                    acc[0] += coeff * grid[idx][0];
1844                    acc[1] += coeff * grid[idx][1];
1845                    acc[2] += coeff * grid[idx][2];
1846                }
1847            }
1848            if wsum.abs() > f32::EPSILON {
1849                // Non-rational basis functions form a partition of unity
1850                // inside the valid window, so the division is a no-op there
1851                // (wsum ≈ 1); the rational form needs it. Dividing in both
1852                // cases keeps a single code path and is numerically safe.
1853                out.push([acc[0] / wsum, acc[1] / wsum, acc[2] / wsum]);
1854            } else {
1855                // Sample fell outside the support of every basis function
1856                // (pathological knot vector); emit the zero accumulator so
1857                // the lattice size still matches (samples + 1)^2.
1858                out.push(acc);
1859            }
1860        }
1861    }
1862    out
1863}
1864
1865/// Evaluate a Bezier (or rational-Bezier) curve at `samples + 1`
1866/// uniformly-spaced parameter values from `u_min` to `u_max` via the
1867/// numerically-stable de Casteljau algorithm.
1868///
1869/// For `kind == "bezier"` weights are ignored and the result is the
1870/// straight 3D control-point combination.
1871///
1872/// For `kind == "rat_bezier"` each control point is treated as a
1873/// homogeneous `(w·x, w·y, w·z, w)` 4-tuple, de Casteljau runs on the
1874/// 4D form, and the final point is projected back to 3D by `x/w`.
1875/// This matches the spec §"Curve" rational form.
1876fn sample_bezier(
1877    control_points: &[[f32; 3]],
1878    control_weights: &[f32],
1879    kind: &str,
1880    _u_min: f32,
1881    _u_max: f32,
1882    samples: u32,
1883) -> Vec<[f32; 3]> {
1884    if control_points.is_empty() || samples == 0 {
1885        return Vec::new();
1886    }
1887    let rational = kind == "rat_bezier";
1888    // Build the working buffer in 4D so the same de Casteljau loop
1889    // covers both rational and non-rational cases (non-rational uses
1890    // w == 1).
1891    let homogeneous: Vec<[f32; 4]> = control_points
1892        .iter()
1893        .zip(control_weights.iter())
1894        .map(|(p, w)| {
1895            let weight = if rational { *w } else { 1.0 };
1896            [p[0] * weight, p[1] * weight, p[2] * weight, weight]
1897        })
1898        .collect();
1899
1900    let n_samples = samples + 1;
1901    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
1902    for i in 0..n_samples {
1903        // Normalise sample index into the curve's parameter range so
1904        // `u_min` and `u_max` aren't mandatorily [0, 1].
1905        let t01 = if n_samples == 1 {
1906            0.0
1907        } else {
1908            i as f32 / (n_samples - 1) as f32
1909        };
1910        // The `u_min` / `u_max` arguments on `curv` are spec-defined
1911        // clip bounds for trimming the basis evaluation, not a
1912        // re-parameterisation of the basis. For a single un-trimmed
1913        // Bezier segment they have no effect on shape — the curve
1914        // domain is `[0, 1]` in basis space. We sample uniformly on
1915        // `t01 ∈ [0, 1]` (so a non-trivial `u_min, u_max` doesn't
1916        // distort the polyline), which is what every other OBJ
1917        // tessellator does.
1918        let t = t01;
1919        let mut buf: Vec<[f32; 4]> = homogeneous.clone();
1920        let n = buf.len();
1921        for level in 1..n {
1922            for j in 0..(n - level) {
1923                buf[j] = [
1924                    (1.0 - t) * buf[j][0] + t * buf[j + 1][0],
1925                    (1.0 - t) * buf[j][1] + t * buf[j + 1][1],
1926                    (1.0 - t) * buf[j][2] + t * buf[j + 1][2],
1927                    (1.0 - t) * buf[j][3] + t * buf[j + 1][3],
1928                ];
1929            }
1930        }
1931        let [x, y, z, w] = buf[0];
1932        if rational && w.abs() > f32::EPSILON {
1933            out.push([x / w, y / w, z / w]);
1934        } else {
1935            out.push([x, y, z]);
1936        }
1937    }
1938    out
1939}
1940
1941/// Evaluate a B-spline (or rational B-spline / NURBS) curve at
1942/// `samples + 1` uniformly-spaced parameter values from `t_min` to
1943/// `t_max`, where the interval is clipped against the spec-required
1944/// `[x_n, x_{K+1}]` evaluation range of the knot vector (spec §"B-spline"
1945/// condition 5: `x_n ≤ t_min < t_max ≤ x_{K+1}`).
1946///
1947/// Mathematics — Cox-deBoor recursion (spec §"B-spline"):
1948///
1949///   N_{i,0}(t) = 1 if x_i ≤ t < x_{i+1} else 0
1950///   N_{i,k}(t) = (t - x_i) / (x_{i+k} - x_i)         · N_{i,k-1}(t)
1951///              + (x_{i+k+1} - t) / (x_{i+k+1} - x_{i+1}) · N_{i+1,k-1}(t)
1952///
1953/// by convention `0/0 = 0`. The curve at parameter t is
1954///
1955///   C(t) = Σ_{i=0..K} N_{i,n}(t) · d_i
1956///
1957/// For the rational form, the weighted homogeneous sum is computed and
1958/// projected back to 3D via `x/w`:
1959///
1960///   C(t) = Σ N_{i,n}(t) · w_i · d_i / Σ N_{i,n}(t) · w_i
1961///
1962/// `kind` selects `"bspline"` (weights ignored, w = 1) or
1963/// `"rat_bspline"` (per-vertex `w` from `v x y z w`).
1964#[allow(clippy::too_many_arguments)]
1965fn sample_bspline(
1966    control_points: &[[f32; 3]],
1967    control_weights: &[f32],
1968    kind: &str,
1969    degree: u32,
1970    knots: &[f32],
1971    u_min: f32,
1972    u_max: f32,
1973    samples: u32,
1974) -> Vec<[f32; 3]> {
1975    if control_points.is_empty() || samples == 0 {
1976        return Vec::new();
1977    }
1978    let n = degree as usize;
1979    let k_plus_1 = control_points.len(); // = K + 1 control points.
1980    // Spec §"B-spline" condition 6: K = q - n - 1 ⇒ knots.len() must
1981    // equal control_points.len() + degree + 1. The caller already
1982    // checks this; double-check defensively.
1983    if knots.len() != k_plus_1 + n + 1 {
1984        return Vec::new();
1985    }
1986    // Spec condition 5: evaluation parameter t must satisfy
1987    //   x_n ≤ t_min < t_max ≤ x_{K+1}
1988    // Clip the caller-supplied u_min / u_max against that window so the
1989    // basis functions evaluate to defined values (any t outside the
1990    // window gives N = 0 across the support and a degenerate sample).
1991    let t_lo_bound = knots[n];
1992    let t_hi_bound = knots[k_plus_1]; // x_{K+1} index = K+1 = k_plus_1.
1993    let t_min = u_min.max(t_lo_bound);
1994    let t_max = u_max.min(t_hi_bound);
1995    if t_min > t_max {
1996        return Vec::new();
1997    }
1998
1999    let rational = kind == "rat_bspline";
2000    let n_samples = samples + 1;
2001    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
2002
2003    for i in 0..n_samples {
2004        let t01 = if n_samples == 1 {
2005            0.0
2006        } else {
2007            i as f32 / (n_samples - 1) as f32
2008        };
2009        let mut t = t_min + t01 * (t_max - t_min);
2010        // Numerical guard — when t == t_hi_bound, the half-open interval
2011        // convention `x_i ≤ t < x_{i+1}` makes N_{i,0} zero everywhere.
2012        // Nudge the last sample fractionally below the upper bound so
2013        // it lies inside the last non-empty knot span (a standard NURBS-
2014        // evaluator pattern; the resulting blend converges to the curve
2015        // endpoint as the bias shrinks).
2016        if t >= t_hi_bound {
2017            t = t_hi_bound - (t_hi_bound - t_lo_bound).abs() * 1e-7 - f32::EPSILON;
2018            if t < t_lo_bound {
2019                t = t_lo_bound;
2020            }
2021        }
2022        let basis = bspline_basis(t, knots, n);
2023        // Σ N_{i,n}(t) · w_i · d_i  (3D positions blended).
2024        // For non-rational, w_i = 1 ⇒ standard polynomial blend.
2025        let mut acc = [0.0f32; 3];
2026        let mut wsum = 0.0f32;
2027        for j in 0..k_plus_1 {
2028            let bj = basis[j];
2029            if bj == 0.0 {
2030                continue;
2031            }
2032            let w = if rational { control_weights[j] } else { 1.0 };
2033            let bw = bj * w;
2034            wsum += bw;
2035            acc[0] += bw * control_points[j][0];
2036            acc[1] += bw * control_points[j][1];
2037            acc[2] += bw * control_points[j][2];
2038        }
2039        if rational && wsum.abs() > f32::EPSILON {
2040            out.push([acc[0] / wsum, acc[1] / wsum, acc[2] / wsum]);
2041        } else if !rational && wsum.abs() > f32::EPSILON {
2042            // Non-rational basis functions sum to 1 inside the valid
2043            // window by partition-of-unity (spec note: "basis functions
2044            // sum to 1.0, such as Bezier, Cardinal, and NURB"); no
2045            // division needed in theory, but we still emit `acc` as-is.
2046            out.push(acc);
2047        } else {
2048            // Sample fell outside the support of every basis function —
2049            // emit the running accumulator (which is zero) so the
2050            // polyline length still matches `samples + 1`. In practice
2051            // the clip + nudge above prevents this branch except for
2052            // pathological knot vectors.
2053            out.push(acc);
2054        }
2055    }
2056    out
2057}
2058
2059/// Cox-deBoor recursive basis-function evaluation at parameter `t`
2060/// against the given knot vector. Returns one weight per control point
2061/// (control-point count = knots.len() − degree − 1).
2062///
2063/// Uses the iterative bottom-up formulation: build degree-0 step
2064/// functions, then accumulate higher-degree polynomials in place. This
2065/// is `O(k_plus_1 · (degree + 1))` work per evaluation, which suffices
2066/// for the modest curve sizes typical of OBJ files. The standard
2067/// `0/0 = 0` convention is applied via explicit denominator guards
2068/// (spec §"B-spline" inline note).
2069fn bspline_basis(t: f32, knots: &[f32], degree: usize) -> Vec<f32> {
2070    let m = knots.len();
2071    if m <= degree + 1 {
2072        return Vec::new();
2073    }
2074    let k_plus_1 = m - degree - 1;
2075    // Allocate one row of `m - 1` degree-0 weights (one per knot span);
2076    // we'll fold this down to k_plus_1 weights at the end.
2077    let mut basis: Vec<f32> = Vec::with_capacity(m - 1);
2078    for i in 0..(m - 1) {
2079        // Degree-0: indicator function on the half-open knot span. Use
2080        // the closed-on-the-right convention for the final span so that
2081        // a t exactly at the upper bound still falls inside the last
2082        // non-empty interval (NURBS-evaluator convention).
2083        let inside = if i + 1 == m - 1 {
2084            knots[i] <= t && t <= knots[i + 1]
2085        } else {
2086            knots[i] <= t && t < knots[i + 1]
2087        };
2088        basis.push(if inside { 1.0 } else { 0.0 });
2089    }
2090    // Recursive degree promotion.
2091    for k in 1..=degree {
2092        // After this loop iteration we want length (m - 1 - k); we
2093        // overwrite in place, indexing j and j+1.
2094        let new_len = m - 1 - k;
2095        for j in 0..new_len {
2096            let denom_left = knots[j + k] - knots[j];
2097            let denom_right = knots[j + k + 1] - knots[j + 1];
2098            let left = if denom_left.abs() < f32::EPSILON {
2099                0.0
2100            } else {
2101                (t - knots[j]) / denom_left * basis[j]
2102            };
2103            let right = if denom_right.abs() < f32::EPSILON {
2104                0.0
2105            } else {
2106                (knots[j + k + 1] - t) / denom_right * basis[j + 1]
2107            };
2108            basis[j] = left + right;
2109        }
2110        basis.truncate(new_len);
2111    }
2112    debug_assert_eq!(basis.len(), k_plus_1);
2113    basis
2114}
2115
2116/// Evaluate a cubic Cardinal (Catmull-Rom) curve at `samples + 1`
2117/// uniformly-spaced parameter values from `t = 0` (start of first
2118/// segment) to `t = K - 2` (end of last segment) where `K = control_points.len()`.
2119///
2120/// Spec §"Cardinal": Cardinal splines are cubic only and interpolate all
2121/// but the first and last control points. The conversion to Bezier
2122/// control points for one segment over `c0, c1, c2, c3` is:
2123///
2124///   b0 = c1
2125///   b1 = c1 + (c2 - c0) / 6
2126///   b2 = c2 - (c3 - c1) / 6
2127///   b3 = c2
2128///
2129/// The full curve is the concatenation of `K - 3` such Bezier segments
2130/// produced by sliding a 4-point window across the control polygon —
2131/// segment `i` consumes `c[i..i+4]` and traces from the interpolated
2132/// midpoint `c[i+1]` to `c[i+2]`. This yields a C¹-continuous piecewise
2133/// curve that passes through every interior control point exactly.
2134///
2135/// The result is emitted as one polyline carrying `samples + 1` total
2136/// vertices distributed across all segments in proportion to their share
2137/// of the parameter range. To keep the implementation simple and the
2138/// polyline density uniform along the curve, we evaluate `samples` total
2139/// intervals (`samples + 1` points) globally, mapping each global sample
2140/// to a segment index plus a local `t ∈ [0, 1]` within that segment.
2141///
2142/// Weights / rationality: the spec note says the unit-weight default is
2143/// reasonable for Cardinal because its basis functions sum to 1, so we
2144/// don't differentiate `rat cardinal` from `cardinal` — the per-vertex
2145/// 4th `w` weight is read from `position_weights` but treated as 1 in
2146/// the Bezier-conversion form (where it would otherwise alter the shape
2147/// in a way the spec doesn't explicitly define).
2148fn sample_cardinal(control_points: &[[f32; 3]], samples: u32) -> Vec<[f32; 3]> {
2149    if control_points.len() < 4 || samples == 0 {
2150        return Vec::new();
2151    }
2152    let n_segments = control_points.len() - 3;
2153    let n_samples = samples + 1;
2154    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
2155
2156    for i in 0..n_samples {
2157        // Global `s ∈ [0, n_segments]`; integer part picks the segment,
2158        // fractional part is the local `t ∈ [0, 1]`. Pin the last sample
2159        // to the very end of the last segment so the polyline closes
2160        // exactly on `c[K-2]`.
2161        let s = if i == n_samples - 1 {
2162            n_segments as f32
2163        } else {
2164            i as f32 * n_segments as f32 / (n_samples - 1) as f32
2165        };
2166        let mut seg = s.floor() as usize;
2167        let mut t = s - seg as f32;
2168        if seg >= n_segments {
2169            seg = n_segments - 1;
2170            t = 1.0;
2171        }
2172        // 4 Cardinal control points for this segment.
2173        let c0 = control_points[seg];
2174        let c1 = control_points[seg + 1];
2175        let c2 = control_points[seg + 2];
2176        let c3 = control_points[seg + 3];
2177        // Spec §"Cardinal" Bezier conversion (component-wise per axis):
2178        //   b0 = c1
2179        //   b1 = c1 + (c2 - c0) / 6
2180        //   b2 = c2 - (c3 - c1) / 6
2181        //   b3 = c2
2182        let mut b: [[f32; 3]; 4] = [[0.0; 3]; 4];
2183        for a in 0..3 {
2184            b[0][a] = c1[a];
2185            b[1][a] = c1[a] + (c2[a] - c0[a]) / 6.0;
2186            b[2][a] = c2[a] - (c3[a] - c1[a]) / 6.0;
2187            b[3][a] = c2[a];
2188        }
2189        // Cubic Bezier evaluation (Bernstein form, expanded for n = 3
2190        // since the spec only defines Cardinal for the cubic case):
2191        //   B(t) = (1-t)^3 b0 + 3(1-t)^2 t b1 + 3(1-t) t^2 b2 + t^3 b3
2192        let u = 1.0 - t;
2193        let w0 = u * u * u;
2194        let w1 = 3.0 * u * u * t;
2195        let w2 = 3.0 * u * t * t;
2196        let w3 = t * t * t;
2197        let p = [
2198            w0 * b[0][0] + w1 * b[1][0] + w2 * b[2][0] + w3 * b[3][0],
2199            w0 * b[0][1] + w1 * b[1][1] + w2 * b[2][1] + w3 * b[3][1],
2200            w0 * b[0][2] + w1 * b[1][2] + w2 * b[2][2] + w3 * b[3][2],
2201        ];
2202        out.push(p);
2203    }
2204    out
2205}
2206
2207/// Evaluate a single cubic Cardinal (Catmull-Rom) control polygon at the
2208/// global parameter `s ∈ [0, len − 3]`, where the integer part of `s`
2209/// selects the 4-point segment window and the fractional part is the
2210/// local `t ∈ [0, 1]` inside that segment.
2211///
2212/// Spec §"Cardinal": each segment over `c0, c1, c2, c3` converts to a
2213/// cubic Bezier (`b0 = c1`, `b1 = c1 + (c2 − c0) / 6`,
2214/// `b2 = c2 − (c3 − c1) / 6`, `b3 = c2`) and is then evaluated with the
2215/// Bernstein cubic basis. The curve interpolates every interior control
2216/// point exactly. This is the 1D building block the tensor-product
2217/// surface evaluator reuses in both parametric directions.
2218fn cardinal_eval_1d(points: &[[f32; 3]], s: f32) -> [f32; 3] {
2219    // Caller guarantees `points.len() >= 4`.
2220    let n_segments = points.len() - 3;
2221    let mut seg = s.floor() as isize;
2222    let mut t = s - seg as f32;
2223    if seg < 0 {
2224        seg = 0;
2225        t = 0.0;
2226    } else if seg as usize >= n_segments {
2227        seg = n_segments as isize - 1;
2228        t = 1.0;
2229    }
2230    let seg = seg as usize;
2231    let c0 = points[seg];
2232    let c1 = points[seg + 1];
2233    let c2 = points[seg + 2];
2234    let c3 = points[seg + 3];
2235    // Spec §"Cardinal" Bezier conversion (component-wise per axis).
2236    let mut b: [[f32; 3]; 4] = [[0.0; 3]; 4];
2237    for a in 0..3 {
2238        b[0][a] = c1[a];
2239        b[1][a] = c1[a] + (c2[a] - c0[a]) / 6.0;
2240        b[2][a] = c2[a] - (c3[a] - c1[a]) / 6.0;
2241        b[3][a] = c2[a];
2242    }
2243    let u = 1.0 - t;
2244    let w0 = u * u * u;
2245    let w1 = 3.0 * u * u * t;
2246    let w2 = 3.0 * u * t * t;
2247    let w3 = t * t * t;
2248    [
2249        w0 * b[0][0] + w1 * b[1][0] + w2 * b[2][0] + w3 * b[3][0],
2250        w0 * b[0][1] + w1 * b[1][1] + w2 * b[2][1] + w3 * b[3][1],
2251        w0 * b[0][2] + w1 * b[1][2] + w2 * b[2][2] + w3 * b[3][2],
2252    ]
2253}
2254
2255/// Evaluate a cubic Cardinal (Catmull-Rom) surface patch at a
2256/// `(samples + 1) × (samples + 1)` lattice via the bivariate
2257/// tensor-product Cardinal evaluation (spec §"Cardinal").
2258///
2259/// `grid` is the control mesh in row-major order with the u index varying
2260/// fastest (`cols` control points per v-row, `rows` v-rows; spec
2261/// §"Surface vertex data — control points"). The surface is the tensor
2262/// product of two cubic Cardinal bases:
2263///
2264///   S(u, v) = Σ_i Σ_j C_i(u) · C_j(v) · d_{i,j}
2265///
2266/// where `C_·` are the cubic Cardinal basis functions. We collapse the
2267/// inner u sum first by running the 1D Cardinal evaluator on each v-row,
2268/// then a second 1D Cardinal evaluation in the v direction over the
2269/// `rows` collapsed points (spec §"Cardinal": "For surfaces, all but the
2270/// first and last row and column of control points are interpolated").
2271///
2272/// The global parameter domain is `[0, cols − 3] × [0, rows − 3]` (one
2273/// unit per Cardinal segment); samples are spread uniformly over it. The
2274/// `surf` range scalars are provenance only (Cardinal is segment-
2275/// normalised, like the round-9 curve path), so they are not used to
2276/// re-parameterise the evaluation.
2277///
2278/// Weights / rationality: spec §"Free-form curve/surface body
2279/// statements" notes the unit-weight default is reasonable for Cardinal
2280/// (its basis functions sum to 1), so per-vertex `w` weights are not
2281/// applied — `rat cardinal` routes here too.
2282///
2283/// Output vertices are ordered row-major in the sample lattice: sample
2284/// `(su, sv)` lands at index `sv * (samples + 1) + su`.
2285fn sample_cardinal_surface(
2286    grid: &[[f32; 3]],
2287    cols: usize,
2288    rows: usize,
2289    samples: u32,
2290) -> Vec<[f32; 3]> {
2291    // Cardinal needs at least a 4×4 control window per direction.
2292    if samples == 0 || cols < 4 || rows < 4 || grid.len() != cols * rows {
2293        return Vec::new();
2294    }
2295    let n = samples as usize + 1;
2296    let u_span = (cols - 3) as f32; // number of u-segments.
2297    let v_span = (rows - 3) as f32; // number of v-segments.
2298
2299    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n * n);
2300    for sv in 0..n {
2301        let v = if n == 1 {
2302            0.0
2303        } else {
2304            sv as f32 / (n - 1) as f32 * v_span
2305        };
2306        for su in 0..n {
2307            let u = if n == 1 {
2308                0.0
2309            } else {
2310                su as f32 / (n - 1) as f32 * u_span
2311            };
2312            // Inner pass: evaluate each v-row's 1D Cardinal curve at u,
2313            // leaving one point per row.
2314            let mut col_pts: Vec<[f32; 3]> = Vec::with_capacity(rows);
2315            for r in 0..rows {
2316                let row = &grid[r * cols..r * cols + cols];
2317                col_pts.push(cardinal_eval_1d(row, u));
2318            }
2319            // Outer pass: 1D Cardinal evaluation in v over the collapsed
2320            // points.
2321            out.push(cardinal_eval_1d(&col_pts, v));
2322        }
2323    }
2324    out
2325}
2326
2327/// Integer square root that returns `Some(r)` only when `n == r * r`
2328/// (i.e. `n` is a perfect square). Used to recover the square single-
2329/// patch control-grid dimension for a Cardinal `surf` whose `parm`
2330/// directives carry only the 2-value global parameter range.
2331fn isqrt_exact(n: usize) -> Option<usize> {
2332    if n == 0 {
2333        return None;
2334    }
2335    let mut r = (n as f64).sqrt() as usize;
2336    // Guard against floating-point rounding on either side.
2337    while r * r > n {
2338        r -= 1;
2339    }
2340    while (r + 1) * (r + 1) <= n {
2341        r += 1;
2342    }
2343    if r * r == n { Some(r) } else { None }
2344}
2345
2346/// Evaluate a Taylor polynomial curve at `samples + 1` uniformly-spaced
2347/// parameter values from `u_min` to `u_max`.
2348///
2349/// Spec §"Taylor": "The basis function is simply t^i" with the note
2350/// that the control points are the polynomial coefficients (and have no
2351/// geometric significance). So for `K + 1` control points c_0..c_K
2352/// supplied via `curv`, the curve is:
2353///
2354///   P(t) = c_0 + c_1 · t + c_2 · t^2 + … + c_K · t^K
2355///
2356/// applied component-wise per axis. This is Horner's-rule territory —
2357/// we use the straightforward bottom-up evaluation:
2358///
2359///   P(t) = ((c_K · t + c_{K-1}) · t + c_{K-2}) · t + … + c_0
2360///
2361/// which is numerically well-behaved for the modest degrees typical of
2362/// real Taylor curves (the spec example is degree 4).
2363///
2364/// The `u_min` / `u_max` arguments on the `curv` directive are the
2365/// global parameter clip bounds; Taylor curves evaluate against `t`
2366/// directly (not a normalised `[0, 1]` re-parameterisation) so we
2367/// sample at `t_i = u_min + i / samples · (u_max - u_min)`.
2368fn sample_taylor(
2369    control_points: &[[f32; 3]],
2370    u_min: f32,
2371    u_max: f32,
2372    samples: u32,
2373) -> Vec<[f32; 3]> {
2374    if control_points.is_empty() || samples == 0 {
2375        return Vec::new();
2376    }
2377    let n_samples = samples + 1;
2378    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
2379    let k = control_points.len();
2380    for i in 0..n_samples {
2381        let frac = if n_samples == 1 {
2382            0.0
2383        } else {
2384            i as f32 / (n_samples - 1) as f32
2385        };
2386        let t = u_min + frac * (u_max - u_min);
2387        // Horner's rule on the coefficient vector. Walk from the
2388        // highest-order coefficient down to c_0.
2389        let mut acc = control_points[k - 1];
2390        for j in (0..(k - 1)).rev() {
2391            acc[0] = acc[0] * t + control_points[j][0];
2392            acc[1] = acc[1] * t + control_points[j][1];
2393            acc[2] = acc[2] * t + control_points[j][2];
2394        }
2395        out.push(acc);
2396    }
2397    out
2398}
2399
2400/// Evaluate a basis-matrix curve at `samples + 1` total points.
2401///
2402/// Spec §"Basis matrix": general arbitrary-degree curves whose basis is
2403/// expressed through a user-supplied `(n + 1) × (n + 1)` matrix `B`
2404/// (passed via `bmat u`) and segment stride `s` (passed via `step`).
2405/// Each polynomial segment `i` consumes the control-point window
2406/// `c[i·s .. i·s + n]` (0-based) and evaluates per spec §"Basis matrix":
2407///
2408/// ```text
2409///   P(t) = Σ_{i=0..n} Σ_{j=0..n} B[i][j] · t^j · p_i
2410/// ```
2411///
2412/// where `B[i][j]` is the row-major element of `bmat u` with column
2413/// index `j` varying fastest (per spec §"bmat u/v matrix": "matrix
2414/// lists the contents of the basis matrix with column subscript j
2415/// varying the fastest"). For the spec's cubic-Bezier-as-bmatrix
2416/// example, this produces the standard Bernstein basis.
2417///
2418/// Number of segments per spec §"step": with `K` control points,
2419/// degree `n`, and step `s`, segment `i` uses indices
2420/// `c_{i·s + 1} .. c_{i·s + n + 1}` (1-based) ⇒ the segment count is
2421/// `floor((K - n - 1) / s) + 1` when `K ≥ n + 1`. Samples are
2422/// distributed proportionally across all segments so the polyline
2423/// density is uniform along the global parameter.
2424///
2425/// Rationality: the spec note in §"Free-form curve/surface body
2426/// statements" explicitly says the unit-weight default "may or may
2427/// not make sense for a representation given in basis-matrix form",
2428/// so we don't apply per-vertex weights here — the user's `bmat u`
2429/// is the authoritative basis.
2430fn sample_bmatrix(
2431    control_points: &[[f32; 3]],
2432    bmat_u: &[f32],
2433    degree: u32,
2434    step: u32,
2435    samples: u32,
2436) -> Vec<[f32; 3]> {
2437    let n_plus_1 = degree as usize + 1;
2438    if control_points.len() < n_plus_1
2439        || bmat_u.len() != n_plus_1 * n_plus_1
2440        || step == 0
2441        || samples == 0
2442    {
2443        return Vec::new();
2444    }
2445    // Spec §"step stepu stepv": segment `i` uses control points
2446    // `c_{i·s + 1} .. c_{i·s + n + 1}` (1-based). Solve for the largest
2447    // i with `i·s + n + 1 ≤ K` ⇒ `i ≤ (K - n - 1) / s`.
2448    let s = step as usize;
2449    let n_segments = (control_points.len() - n_plus_1) / s + 1;
2450    let n_samples = samples + 1;
2451    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
2452
2453    for i in 0..n_samples {
2454        // Global `g ∈ [0, n_segments]` with integer part = segment and
2455        // fractional part = local `t ∈ [0, 1]` within that segment. Pin
2456        // the last sample exactly to the end of the final segment so
2457        // the polyline closes on the spec-defined endpoint.
2458        let g = if i == n_samples - 1 {
2459            n_segments as f32
2460        } else {
2461            i as f32 * n_segments as f32 / (n_samples - 1) as f32
2462        };
2463        let mut seg = g.floor() as usize;
2464        let mut t = g - seg as f32;
2465        if seg >= n_segments {
2466            seg = n_segments - 1;
2467            t = 1.0;
2468        }
2469        let base = seg * s;
2470
2471        // Compute t^0 .. t^n once.
2472        let mut t_pow: Vec<f32> = Vec::with_capacity(n_plus_1);
2473        let mut p = 1.0_f32;
2474        for _ in 0..n_plus_1 {
2475            t_pow.push(p);
2476            p *= t;
2477        }
2478
2479        // P(t) = Σ_i p_i · (Σ_j B[i][j] · t^j) summed component-wise.
2480        let mut accum = [0.0_f32; 3];
2481        for ii in 0..n_plus_1 {
2482            // Row `ii` of B, dotted against `[t^0, t^1, …, t^n]`.
2483            let mut coef = 0.0_f32;
2484            for jj in 0..n_plus_1 {
2485                coef += bmat_u[ii * n_plus_1 + jj] * t_pow[jj];
2486            }
2487            let cp = control_points[base + ii];
2488            accum[0] += coef * cp[0];
2489            accum[1] += coef * cp[1];
2490            accum[2] += coef * cp[2];
2491        }
2492        out.push(accum);
2493    }
2494    out
2495}
2496
2497/// `true` when the primitive was synthesised by the curve tessellator
2498/// (see [`tessellate_curves`]). Encoder + serialiser branches use this
2499/// to skip emitting derived geometry as `v` lines — the original
2500/// `cstype` / `curv` / `end` directives carry the source-of-truth
2501/// shape.
2502fn is_tessellated_curve(prim: &Primitive) -> bool {
2503    prim.extras
2504        .get("obj:tessellated_curve")
2505        .and_then(|v| v.as_bool())
2506        .unwrap_or(false)
2507}
2508
2509/// Promote a single-`l`-element primitive to `LineStrip` / `LineLoop`
2510/// when applicable; fall back to `Lines` for multi-element or 2-vertex
2511/// segments. See [`build_primitive`] for the surrounding context.
2512fn single_line_topology(elements: &[Element]) -> Topology {
2513    if elements.len() != 1 {
2514        return Topology::Lines;
2515    }
2516    let Element::Line(verts) = &elements[0] else {
2517        return Topology::Lines;
2518    };
2519    if verts.len() < 2 {
2520        return Topology::Lines;
2521    }
2522    // A 2-vertex `l` is a plain segment — keep it on `Lines` so the
2523    // round-trip stays minimal (one `l v1 v2` line either way).
2524    if verts.len() == 2 {
2525        return Topology::Lines;
2526    }
2527    // Closed polyline: first / last vertex coincide on the position
2528    // index. We don't need to compare uv/normal — `l` references only
2529    // ever populate the position component for the loop-detection
2530    // semantics specified by the spec §"Line elements".
2531    let same_start_end = verts.first().map(|fv| fv.v) == verts.last().map(|fv| fv.v);
2532    if same_start_end {
2533        Topology::LineLoop
2534    } else {
2535        Topology::LineStrip
2536    }
2537}
2538
2539/// Build one [`Primitive`] from an accumulated [`PrimAccum`].
2540///
2541/// Returns the primitive plus a per-element arity vector — one entry
2542/// per face (3 for a triangle, 4 for a quad, ≥5 for an n-gon). Lines
2543/// don't contribute arity entries (the encoder switches on topology
2544/// instead).
2545fn build_primitive(
2546    prim_acc: &PrimAccum,
2547    positions: &[[f32; 3]],
2548    position_weights: &[Option<f32>],
2549    position_colors: &[Option<[f32; 4]>],
2550    texcoords: &[[f32; 2]],
2551    normals: &[[f32; 3]],
2552    material_ids: &HashMap<String, oxideav_mesh3d::MaterialId>,
2553) -> Result<(Primitive, Vec<u32>)> {
2554    // Decide topology + attribute presence by looking at the first
2555    // element. Mixed-element primitives (lines + faces under one
2556    // `usemtl`) aren't representable in mesh3d so we error cleanly.
2557    //
2558    // For a single `l` element we promote to the more specific
2559    // `LineStrip` / `LineLoop` topology so consumers don't have to
2560    // reconstruct the polyline shape from disjoint segment pairs:
2561    //
2562    //   * exactly one `l` element with N ≥ 2 vertices whose last
2563    //     vertex equals its first → `LineLoop` (the redundant
2564    //     closing vertex is dropped from the index buffer).
2565    //   * exactly one `l` element with N ≥ 2 distinct end vertices →
2566    //     `LineStrip`.
2567    //   * multiple `l` elements (or a single 2-vertex `l` that is a
2568    //     plain segment) fall back to `Lines` for the existing
2569    //     contiguous-chain re-emit path on the encoder side.
2570    let first = prim_acc.elements.first();
2571    let topology = match first {
2572        Some(Element::Face(_)) => Topology::Triangles,
2573        Some(Element::Line(_)) => single_line_topology(&prim_acc.elements),
2574        Some(Element::Point(_)) => Topology::Points,
2575        None => Topology::Triangles,
2576    };
2577    for elt in &prim_acc.elements {
2578        let ok = matches!(
2579            (&topology, elt),
2580            (Topology::Triangles, Element::Face(_))
2581                | (Topology::Lines, Element::Line(_))
2582                | (Topology::LineStrip, Element::Line(_))
2583                | (Topology::LineLoop, Element::Line(_))
2584                | (Topology::Points, Element::Point(_))
2585        );
2586        if !ok {
2587            return Err(Error::unsupported(
2588                "OBJ primitive mixes face / line / point elements under one usemtl",
2589            ));
2590        }
2591    }
2592
2593    let has_uv = prim_acc.elements.iter().any(|elt| match elt {
2594        Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
2595            verts.iter().any(|fv| fv.vt != 0)
2596        }
2597    });
2598    let has_normal = prim_acc.elements.iter().any(|elt| match elt {
2599        Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
2600            verts.iter().any(|fv| fv.vn != 0)
2601        }
2602    });
2603    // Per-vertex colour applies to a primitive whenever any of its
2604    // referenced positions carries the `v x y z r g b` extension. We
2605    // promote to a single-channel `colors[0]` set; vertices that
2606    // don't carry RGB fall back to white (the obvious "no colour
2607    // information" sentinel — preserves the standard glTF expectation
2608    // that a colour buffer is fully populated when present). The
2609    // round-trip-aware `obj:vertex_color_present` per-position
2610    // bitmap below guards the encoder against re-emitting a
2611    // synthetic white that the original file didn't spell out.
2612    let has_color = prim_acc.elements.iter().any(|elt| match elt {
2613        Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
2614            verts.iter().any(|fv| {
2615                position_colors
2616                    .get((fv.v - 1) as usize)
2617                    .is_some_and(Option::is_some)
2618            })
2619        }
2620    });
2621
2622    let mut prim = Primitive::new(topology);
2623    if has_uv {
2624        prim.uvs.push(Vec::new());
2625    }
2626    if has_normal {
2627        prim.normals = Some(Vec::new());
2628    }
2629    if has_color {
2630        prim.colors.push(Vec::new());
2631    }
2632    // Track per-interned-vertex "did this position carry RGB / a
2633    // weight in the source file?" so the encoder doesn't fabricate
2634    // colours / weights that the user never wrote. Both vectors are
2635    // parallel to `prim.positions` after interning completes.
2636    let mut color_present: Vec<bool> = Vec::new();
2637    let mut weights_seen: Vec<Option<f32>> = Vec::new();
2638
2639    // De-duplicate face-vertices into a single interleaved buffer.
2640    let mut indexer: HashMap<FaceVert, u32> = HashMap::new();
2641    let mut arities: Vec<u32> = Vec::new();
2642    let mut local_indices: Vec<u32> = Vec::new();
2643
2644    let intern = |fv: FaceVert,
2645                  prim: &mut Primitive,
2646                  indexer: &mut HashMap<FaceVert, u32>,
2647                  color_present: &mut Vec<bool>,
2648                  weights_seen: &mut Vec<Option<f32>>|
2649     -> Result<u32> {
2650        if let Some(&idx) = indexer.get(&fv) {
2651            return Ok(idx);
2652        }
2653        let pos = positions
2654            .get((fv.v - 1) as usize)
2655            .ok_or_else(|| Error::invalid(format!("face references missing position {}", fv.v)))?;
2656        prim.positions.push(*pos);
2657        if has_uv {
2658            let uv = if fv.vt == 0 {
2659                [0.0, 0.0]
2660            } else {
2661                *texcoords.get((fv.vt - 1) as usize).ok_or_else(|| {
2662                    Error::invalid(format!("face references missing texcoord {}", fv.vt))
2663                })?
2664            };
2665            prim.uvs[0].push(uv);
2666        }
2667        if has_normal {
2668            let n = if fv.vn == 0 {
2669                [0.0, 0.0, 0.0]
2670            } else {
2671                *normals.get((fv.vn - 1) as usize).ok_or_else(|| {
2672                    Error::invalid(format!("face references missing normal {}", fv.vn))
2673                })?
2674            };
2675            prim.normals.as_mut().unwrap().push(n);
2676        }
2677        if has_color {
2678            // Either the source file carried RGB for this vertex, or
2679            // we synthesise opaque white so the colour buffer stays
2680            // length-parallel with positions (mesh3d invariant).
2681            let rgba = position_colors
2682                .get((fv.v - 1) as usize)
2683                .copied()
2684                .flatten()
2685                .unwrap_or([1.0, 1.0, 1.0, 1.0]);
2686            prim.colors[0].push(rgba);
2687            color_present.push(
2688                position_colors
2689                    .get((fv.v - 1) as usize)
2690                    .is_some_and(Option::is_some),
2691            );
2692        }
2693        weights_seen.push(position_weights.get((fv.v - 1) as usize).copied().flatten());
2694        let new_idx = (prim.positions.len() - 1) as u32;
2695        indexer.insert(fv, new_idx);
2696        Ok(new_idx)
2697    };
2698
2699    for elt in &prim_acc.elements {
2700        match elt {
2701            Element::Face(verts) => {
2702                let arity = verts.len() as u32;
2703                arities.push(arity);
2704                let resolved: Vec<u32> = verts
2705                    .iter()
2706                    .map(|&fv| {
2707                        intern(
2708                            fv,
2709                            &mut prim,
2710                            &mut indexer,
2711                            &mut color_present,
2712                            &mut weights_seen,
2713                        )
2714                    })
2715                    .collect::<Result<Vec<_>>>()?;
2716                // Fan triangulate: (v0, v1, v2), (v0, v2, v3), …
2717                for i in 1..(resolved.len() - 1) {
2718                    local_indices.push(resolved[0]);
2719                    local_indices.push(resolved[i]);
2720                    local_indices.push(resolved[i + 1]);
2721                }
2722            }
2723            Element::Line(verts) => {
2724                let resolved: Vec<u32> = verts
2725                    .iter()
2726                    .map(|&fv| {
2727                        intern(
2728                            fv,
2729                            &mut prim,
2730                            &mut indexer,
2731                            &mut color_present,
2732                            &mut weights_seen,
2733                        )
2734                    })
2735                    .collect::<Result<Vec<_>>>()?;
2736                match topology {
2737                    Topology::LineStrip => {
2738                        // Emit the polyline as a contiguous index list.
2739                        local_indices.extend_from_slice(&resolved);
2740                    }
2741                    Topology::LineLoop => {
2742                        // Drop the redundant closing vertex; consumers
2743                        // treat the strip as closed at draw time.
2744                        let n = resolved.len().saturating_sub(1);
2745                        local_indices.extend_from_slice(&resolved[..n]);
2746                    }
2747                    _ => {
2748                        // Plain `Lines` — decompose polyline into
2749                        // disjoint segment pairs (encoder rejoins
2750                        // contiguous chains on the way out).
2751                        for w in resolved.windows(2) {
2752                            local_indices.push(w[0]);
2753                            local_indices.push(w[1]);
2754                        }
2755                    }
2756                }
2757            }
2758            Element::Point(verts) => {
2759                // Each `p` line can carry multiple vertex references;
2760                // every reference becomes one element index for
2761                // `Topology::Points`. Original arities aren't tracked
2762                // since a re-emit can pack them on one line freely.
2763                for &fv in verts {
2764                    let idx = intern(
2765                        fv,
2766                        &mut prim,
2767                        &mut indexer,
2768                        &mut color_present,
2769                        &mut weights_seen,
2770                    )?;
2771                    local_indices.push(idx);
2772                }
2773            }
2774        }
2775    }
2776
2777    // Promote to U32 if any index >= 65536; U16 otherwise.
2778    if local_indices.iter().any(|&i| i >= u16::MAX as u32) {
2779        prim.indices = Some(Indices::U32(local_indices));
2780    } else {
2781        prim.indices = Some(Indices::U16(
2782            local_indices.into_iter().map(|i| i as u16).collect(),
2783        ));
2784    }
2785
2786    // Per-vertex extension state — surfaced through `Primitive::extras`
2787    // so the encoder knows which `v` lines to expand to the 4-token
2788    // `xyzw`, 6-token `xyzrgb`, or 7-token `xyzwrgb` form. We only stash
2789    // the bitmaps when at least one vertex used the extension; the
2790    // common no-extension case stays free of decode-time noise.
2791    if has_color && color_present.iter().any(|&b| b) {
2792        prim.extras.insert(
2793            "obj:vertex_color_present".to_string(),
2794            serde_json::to_value(&color_present).unwrap(),
2795        );
2796    }
2797    if weights_seen.iter().any(Option::is_some) {
2798        prim.extras.insert(
2799            "obj:vertex_weight".to_string(),
2800            serde_json::to_value(&weights_seen).unwrap(),
2801        );
2802    }
2803
2804    if let Some(name) = &prim_acc.material {
2805        if let Some(id) = material_ids.get(name) {
2806            prim.material = Some(*id);
2807        }
2808        prim.extras.insert(
2809            "obj:usemtl".to_string(),
2810            serde_json::Value::String(name.clone()),
2811        );
2812    }
2813    if let Some(s) = &prim_acc.smoothing_group {
2814        prim.extras.insert(
2815            "obj:smoothing_group".to_string(),
2816            serde_json::Value::String(s.clone()),
2817        );
2818    }
2819    if let Some(s) = &prim_acc.merging_group {
2820        prim.extras.insert(
2821            "obj:merging_group".to_string(),
2822            serde_json::Value::String(s.clone()),
2823        );
2824    }
2825    if let Some(s) = &prim_acc.bevel {
2826        prim.extras.insert(
2827            "obj:bevel".to_string(),
2828            serde_json::Value::String(s.clone()),
2829        );
2830    }
2831    if let Some(s) = &prim_acc.c_interp {
2832        prim.extras.insert(
2833            "obj:c_interp".to_string(),
2834            serde_json::Value::String(s.clone()),
2835        );
2836    }
2837    if let Some(s) = &prim_acc.d_interp {
2838        prim.extras.insert(
2839            "obj:d_interp".to_string(),
2840            serde_json::Value::String(s.clone()),
2841        );
2842    }
2843    if let Some(s) = &prim_acc.lod {
2844        prim.extras
2845            .insert("obj:lod".to_string(), serde_json::Value::String(s.clone()));
2846    }
2847    if !prim_acc.groups.is_empty() {
2848        prim.extras.insert(
2849            "obj:groups".to_string(),
2850            serde_json::to_value(&prim_acc.groups).unwrap(),
2851        );
2852    }
2853
2854    Ok((prim, arities))
2855}
2856
2857// ---------------------------------------------------------------------------
2858// Public API
2859// ---------------------------------------------------------------------------
2860
2861/// Parser configuration knobs.
2862///
2863/// The default leaves free-form geometry as captured-only extras
2864/// (back-compatible with rounds 1-6). Set
2865/// [`ParseOptions::curve_tessellation_samples`] to a non-zero value
2866/// to enable evaluation of `cstype bezier` / `cstype bspline`
2867/// (rational + non-rational) curves into real `LineStrip` primitives
2868/// (see [`crate::ObjDecoder::with_curve_tessellation`]).
2869#[derive(Clone, Debug, Default)]
2870pub struct ParseOptions {
2871    /// When > 0, every `curv` directive under an active `cstype bezier`
2872    /// / `cstype rat bezier` / `cstype bspline` / `cstype rat bspline`
2873    /// header is evaluated at `curve_tessellation_samples + 1`
2874    /// uniformly-spaced parameter values. The resulting polyline lands
2875    /// on a synthetic mesh named `"obj:curves"` whose primitives carry
2876    /// `Topology::LineStrip`. The directive itself is still preserved
2877    /// in `Scene3D::extras["obj:freeform_directives"]` so a round-trip
2878    /// re-emit produces the same free-form section — downstream
2879    /// consumers can opt out of the synthetic mesh by filtering on
2880    /// `Primitive::extras["obj:tessellated_curve"] == true`.
2881    ///
2882    /// B-spline curves additionally require a valid `parm u` knot
2883    /// vector (length must equal control-point count + degree + 1 per
2884    /// spec §"B-spline" condition 6); curves with an incomplete knot
2885    /// vector are skipped silently.
2886    ///
2887    /// `0` disables tessellation (the default; back-compat with r1-r6).
2888    pub curve_tessellation_samples: u32,
2889}
2890
2891/// Parse an OBJ document (no MTL resolution).
2892///
2893/// `usemtl` directives still create one `Primitive` per switch and the
2894/// material name lands in `Primitive::extras["obj:usemtl"]` even with
2895/// no actual `Material` constructed. Use [`parse_obj_with_resolver`]
2896/// when companion MTL data is available.
2897pub fn parse_obj(text: &str) -> Result<Scene3D> {
2898    parse_obj_with_resolver(text, |_path| Ok(Vec::new()))
2899}
2900
2901/// Parse an OBJ document at `path`, resolving `mtllib` references
2902/// against the OBJ file's parent directory.
2903///
2904/// Convenience wrapper around [`parse_obj_with_resolver`] for the
2905/// overwhelmingly common case of "I have a path, please load it and
2906/// follow the MTL references". Each `mtllib foo.mtl` directive becomes
2907/// a sibling-file read; missing libraries surface the underlying
2908/// [`std::io::Error`] (wrapped in [`Error::invalid`]) rather than
2909/// silently dropping. If you want lenient missing-MTL handling, use
2910/// [`parse_obj_with_resolver`] directly.
2911pub fn parse_obj_from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Scene3D> {
2912    let path = path.as_ref();
2913    let bytes =
2914        std::fs::read(path).map_err(|e| Error::invalid(format!("OBJ read {path:?}: {e}")))?;
2915    let text = std::str::from_utf8(&bytes)
2916        .map_err(|_| Error::invalid(format!("OBJ {path:?} contained non-UTF-8 bytes")))?;
2917    let parent = path.parent().map(std::path::Path::to_path_buf);
2918    parse_obj_with_resolver(text, |libname| {
2919        // Empty / absolute / parent-relative library names are honoured
2920        // verbatim; bare names are resolved against the OBJ's parent
2921        // directory.
2922        let lib_path = match &parent {
2923            Some(dir) => dir.join(libname),
2924            None => std::path::PathBuf::from(libname),
2925        };
2926        std::fs::read(&lib_path)
2927            .map_err(|e| Error::invalid(format!("mtllib read {lib_path:?}: {e}")))
2928    })
2929}
2930
2931/// Parse an OBJ document, calling `resolve` once per `mtllib` entry to
2932/// fetch the bytes of the named material library. Each library is
2933/// parsed via [`parse_mtl`] and its materials merged into the resulting
2934/// scene; references in `usemtl` directives bind to those materials by
2935/// name.
2936///
2937/// The resolver returns `Ok(Vec::new())` to signal "this library
2938/// couldn't be located but skip silently"; any other `Err` aborts the
2939/// parse.
2940pub fn parse_obj_with_resolver<R>(text: &str, resolve: R) -> Result<Scene3D>
2941where
2942    R: FnMut(&str) -> Result<Vec<u8>>,
2943{
2944    parse_obj_with_options(text, &ParseOptions::default(), resolve)
2945}
2946
2947/// Parse an OBJ document with explicit [`ParseOptions`] and a
2948/// caller-supplied `mtllib` resolver. Lifts the option struct out of
2949/// the otherwise-identical [`parse_obj_with_resolver`] signature.
2950pub fn parse_obj_with_options<R>(
2951    text: &str,
2952    options: &ParseOptions,
2953    mut resolve: R,
2954) -> Result<Scene3D>
2955where
2956    R: FnMut(&str) -> Result<Vec<u8>>,
2957{
2958    let mut doc = parse_obj_doc(text)?;
2959
2960    // Resolve material libraries, if any.
2961    for lib in doc.mtllibs.clone() {
2962        let bytes = resolve(&lib)?;
2963        if bytes.is_empty() {
2964            continue;
2965        }
2966        let lib_text = std::str::from_utf8(&bytes)
2967            .map_err(|_| Error::invalid(format!("mtllib {lib:?} contained non-UTF-8 bytes")))?;
2968        let materials = parse_mtl(lib_text)?;
2969        for mat in materials {
2970            if let Some(name) = mat.name.clone() {
2971                doc.resolved_materials.insert(name, mat);
2972            }
2973        }
2974    }
2975
2976    // Curve tessellation pass — captures the curve directives still in
2977    // `doc.freeform_directives` and synthesises `LineStrip` primitives
2978    // on a dedicated mesh. Skipped when samples == 0 (the default).
2979    // Supports `cstype bezier` / `rat bezier` (round 7) and
2980    // `cstype bspline` / `rat bspline` (round 8).
2981    let tessellated = if options.curve_tessellation_samples > 0 {
2982        tessellate_curves(&doc, options.curve_tessellation_samples)
2983    } else {
2984        Vec::new()
2985    };
2986
2987    // Surface tessellation pass — the same sample knob drives Bezier
2988    // `surf` tensor-product evaluation (round 11). Synthesises a
2989    // `Topology::Triangles` mesh; the directives still ride on
2990    // `Scene3D::extras["obj:freeform_directives"]` for round-trip.
2991    let tessellated_surfaces = if options.curve_tessellation_samples > 0 {
2992        tessellate_surfaces(&doc, options.curve_tessellation_samples)
2993    } else {
2994        Vec::new()
2995    };
2996
2997    let mut scene = build_scene(doc)?;
2998
2999    if !tessellated.is_empty() {
3000        let mut mesh = Mesh::new(Some("obj:curves".to_string()));
3001        for prim in tessellated {
3002            mesh.primitives.push(prim);
3003        }
3004        scene.add_mesh(mesh);
3005    }
3006
3007    if !tessellated_surfaces.is_empty() {
3008        let mut mesh = Mesh::new(Some("obj:surfaces".to_string()));
3009        for prim in tessellated_surfaces {
3010            mesh.primitives.push(prim);
3011        }
3012        scene.add_mesh(mesh);
3013    }
3014
3015    Ok(scene)
3016}
3017
3018/// Serialiser configuration. Keeps the public free-function signature
3019/// stable while letting the [`crate::ObjEncoder`] thread richer options
3020/// through.
3021#[derive(Clone, Debug, Default)]
3022pub struct SerializeOptions<'a> {
3023    /// Reference an external MTL file via an `mtllib <basename>.mtl`
3024    /// header line. Equivalent to the `mtl_basename` parameter on
3025    /// [`serialize_obj`].
3026    pub mtl_basename: Option<&'a str>,
3027    /// When `true`, emit face/line vertex indices in the relative
3028    /// negative-index form (`f -1 -2 -3`) instead of absolute 1-based.
3029    /// Round-trips verbatim back through the parser; useful when the
3030    /// caller wants their re-encoded OBJ to mirror an input that used
3031    /// negative indices throughout.
3032    pub negative_indices: bool,
3033}
3034
3035/// Serialise a [`Scene3D`] to OBJ format.
3036///
3037/// `mtl_basename`, when supplied, emits an `mtllib <basename>.mtl`
3038/// directive at the top so a sibling MTL file (written separately via
3039/// [`crate::mtl::serialize_mtl`]) is referenced.
3040pub fn serialize_obj(scene: &Scene3D, mtl_basename: Option<&str>) -> Result<Vec<u8>> {
3041    serialize_obj_with_options(
3042        scene,
3043        &SerializeOptions {
3044            mtl_basename,
3045            ..SerializeOptions::default()
3046        },
3047    )
3048}
3049
3050/// Serialise a [`Scene3D`] to OBJ format with explicit options.
3051///
3052/// See [`SerializeOptions`] for the supported knobs.
3053pub fn serialize_obj_with_options(
3054    scene: &Scene3D,
3055    options: &SerializeOptions<'_>,
3056) -> Result<Vec<u8>> {
3057    let mtl_basename = options.mtl_basename;
3058    let negative = options.negative_indices;
3059    use std::fmt::Write;
3060    let mut out = String::new();
3061    writeln!(out, "# OBJ generated by oxideav-obj").unwrap();
3062    if let Some(base) = mtl_basename {
3063        writeln!(out, "mtllib {base}.mtl").unwrap();
3064    }
3065    // Replay any mtllib refs preserved on the scene itself when no
3066    // explicit basename was supplied.
3067    if mtl_basename.is_none() {
3068        if let Some(serde_json::Value::Array(list)) = scene.extras.get("obj:mtllibs") {
3069            for entry in list {
3070                if let Some(s) = entry.as_str() {
3071                    writeln!(out, "mtllib {s}").unwrap();
3072                }
3073            }
3074        }
3075    }
3076
3077    // Deduplicated global vertex / texcoord / normal pools so emitted
3078    // index references match the canonical 1-based numbering.
3079    let mut positions: Vec<[f32; 3]> = Vec::new();
3080    // Parallel to `positions` — `Some(rgb)` when the source flagged
3081    // this vertex through the `obj:vertex_color_present` extras
3082    // bitmap, `None` otherwise. We *don't* emit synthetic white for a
3083    // `None` entry: the round-trip rule is "only re-emit RGB for
3084    // vertices that originally had it". When at least one position
3085    // carries colour the encoder also sets a flag so the entire
3086    // colour set isn't dropped on a partial-colouring file (mixed
3087    // colored / uncolored vertices in one primitive — re-emit
3088    // standard `v x y z` for the uncolored).
3089    let mut position_colors: Vec<Option<[f32; 4]>> = Vec::new();
3090    // Parallel to `positions` — preserved `v` 4th `w` weight whenever
3091    // the source carried it. `None` re-emits the standard 3-token form.
3092    let mut position_weights: Vec<Option<f32>> = Vec::new();
3093    let mut texcoords: Vec<[f32; 2]> = Vec::new();
3094    let mut normals: Vec<[f32; 3]> = Vec::new();
3095    let mut pos_map: HashMap<KeyVec3, u32> = HashMap::new();
3096    let mut tex_map: HashMap<KeyVec2, u32> = HashMap::new();
3097    let mut nor_map: HashMap<KeyVec3, u32> = HashMap::new();
3098
3099    // Intern a position into the shared global pool, attaching the
3100    // (optional) per-vertex colour + weight derived from the
3101    // `obj:vertex_color_present` / `obj:vertex_weight` extras. When the
3102    // same position appears across primitives, the *first* non-`None`
3103    // colour / weight wins — silently ignoring later overrides keeps
3104    // round-trip determinism without forcing a partition of duplicate
3105    // positions on differing colour metadata (which would force the
3106    // encoder to emit redundant `v` lines and bloat the output).
3107    let intern_pos = |p: [f32; 3],
3108                      colour: Option<[f32; 4]>,
3109                      weight: Option<f32>,
3110                      positions: &mut Vec<[f32; 3]>,
3111                      colours: &mut Vec<Option<[f32; 4]>>,
3112                      weights: &mut Vec<Option<f32>>,
3113                      map: &mut HashMap<KeyVec3, u32>|
3114     -> u32 {
3115        let key = KeyVec3::from(p);
3116        if let Some(&i) = map.get(&key) {
3117            // First-write-wins on extension metadata.
3118            let slot = (i - 1) as usize;
3119            if colours[slot].is_none() {
3120                colours[slot] = colour;
3121            }
3122            if weights[slot].is_none() {
3123                weights[slot] = weight;
3124            }
3125            return i;
3126        }
3127        positions.push(p);
3128        colours.push(colour);
3129        weights.push(weight);
3130        let idx = positions.len() as u32;
3131        map.insert(key, idx);
3132        idx
3133    };
3134    let intern_tex =
3135        |p: [f32; 2], texcoords: &mut Vec<[f32; 2]>, map: &mut HashMap<KeyVec2, u32>| -> u32 {
3136            let key = KeyVec2::from(p);
3137            if let Some(&i) = map.get(&key) {
3138                return i;
3139            }
3140            texcoords.push(p);
3141            let idx = texcoords.len() as u32;
3142            map.insert(key, idx);
3143            idx
3144        };
3145    let intern_nor =
3146        |p: [f32; 3], normals: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
3147            let key = KeyVec3::from(p);
3148            if let Some(&i) = map.get(&key) {
3149                return i;
3150            }
3151            normals.push(p);
3152            let idx = normals.len() as u32;
3153            map.insert(key, idx);
3154            idx
3155        };
3156
3157    // Seed the position pool with `obj:positions` if present — these
3158    // are the source 1-based vertex coordinates captured on decode so
3159    // free-form directives (`curv`, `surf`, etc.) that reference
3160    // positions by absolute index keep resolving correctly across a
3161    // decode → encode → decode round-trip. Without this, the encoder
3162    // would only pool positions referenced by polygonal primitives and
3163    // the free-form directive numbering would silently drift.
3164    if let Some(serde_json::Value::Array(src_positions)) = scene.extras.get("obj:positions") {
3165        let src_weights: Vec<Option<f32>> = scene
3166            .extras
3167            .get("obj:position_weights")
3168            .and_then(serde_json::Value::as_array)
3169            .map(|arr| arr.iter().map(|v| v.as_f64().map(|f| f as f32)).collect())
3170            .unwrap_or_default();
3171        let src_colors: Vec<Option<[f32; 4]>> = scene
3172            .extras
3173            .get("obj:position_colors")
3174            .and_then(serde_json::Value::as_array)
3175            .map(|arr| {
3176                arr.iter()
3177                    .map(|v| {
3178                        v.as_array().map(|c| {
3179                            let mut rgba = [1.0; 4];
3180                            for (i, x) in c.iter().enumerate().take(4) {
3181                                rgba[i] = x.as_f64().map(|f| f as f32).unwrap_or(0.0);
3182                            }
3183                            rgba
3184                        })
3185                    })
3186                    .collect()
3187            })
3188            .unwrap_or_default();
3189
3190        for (i, pv) in src_positions.iter().enumerate() {
3191            let serde_json::Value::Array(coords) = pv else {
3192                continue;
3193            };
3194            let mut p = [0.0_f32; 3];
3195            for (j, c) in coords.iter().enumerate().take(3) {
3196                p[j] = c.as_f64().map(|f| f as f32).unwrap_or(0.0);
3197            }
3198            let weight = src_weights.get(i).copied().flatten();
3199            let colour = src_colors.get(i).copied().flatten();
3200            intern_pos(
3201                p,
3202                colour,
3203                weight,
3204                &mut positions,
3205                &mut position_colors,
3206                &mut position_weights,
3207                &mut pos_map,
3208            );
3209        }
3210    }
3211
3212    // First pass: emit `v` / `vt` / `vn` lists and remember the global
3213    // indices for each (mesh, primitive, vertex) triple.
3214    //
3215    // Primitives flagged `obj:tessellated_curve = true` are synthetic
3216    // (they came out of the Bezier evaluator, not source `v` lines).
3217    // We skip them here so their points don't pollute the `v` pool and
3218    // skip them again in the element-emit pass below — the original
3219    // `cstype` / `curv` / `end` directives still get replayed verbatim
3220    // from `Scene3D::extras["obj:freeform_directives"]`, so the
3221    // round-trip stays bit-stable for the directive section.
3222    type GlobalTriple = (u32, u32, u32); // (v_idx, vt_idx_or_0, vn_idx_or_0)
3223    let mut global_indices: Vec<Vec<Vec<GlobalTriple>>> = Vec::new();
3224    for mesh in &scene.meshes {
3225        let mut mesh_globals: Vec<Vec<GlobalTriple>> = Vec::new();
3226        for prim in &mesh.primitives {
3227            if is_tessellated_curve(prim) {
3228                // Push an empty slot so global_indices[mi][pi] still
3229                // lines up with mesh.primitives[mi][pi] in the second
3230                // pass — we'll just skip the empty slot there.
3231                mesh_globals.push(Vec::new());
3232                continue;
3233            }
3234            let has_uv = !prim.uvs.is_empty();
3235            let has_normal = prim.normals.is_some();
3236            let has_color = !prim.colors.is_empty();
3237            // Per-vertex bitmap saying "did the source spell out RGB on
3238            // this vertex?". Missing extras / no-colors-set means every
3239            // vertex stays in the standard 3-token form.
3240            let color_present: Vec<bool> = prim
3241                .extras
3242                .get("obj:vertex_color_present")
3243                .and_then(serde_json::Value::as_array)
3244                .map(|arr| arr.iter().map(|v| v.as_bool().unwrap_or(false)).collect())
3245                .unwrap_or_else(|| vec![has_color; prim.positions.len()]);
3246            // Per-vertex weight overrides — preserved through extras.
3247            let weight_overrides: Vec<Option<f32>> = prim
3248                .extras
3249                .get("obj:vertex_weight")
3250                .and_then(serde_json::Value::as_array)
3251                .map(|arr| arr.iter().map(|v| v.as_f64().map(|f| f as f32)).collect())
3252                .unwrap_or_default();
3253            let mut prim_globals: Vec<GlobalTriple> = Vec::with_capacity(prim.positions.len());
3254            for vi in 0..prim.positions.len() {
3255                let colour = if has_color && color_present.get(vi).copied().unwrap_or(false) {
3256                    Some(prim.colors[0][vi])
3257                } else {
3258                    None
3259                };
3260                let weight = weight_overrides.get(vi).copied().flatten();
3261                let v_idx = intern_pos(
3262                    prim.positions[vi],
3263                    colour,
3264                    weight,
3265                    &mut positions,
3266                    &mut position_colors,
3267                    &mut position_weights,
3268                    &mut pos_map,
3269                );
3270                let vt_idx = if has_uv {
3271                    intern_tex(prim.uvs[0][vi], &mut texcoords, &mut tex_map)
3272                } else {
3273                    0
3274                };
3275                let vn_idx = if has_normal {
3276                    intern_nor(
3277                        prim.normals.as_ref().unwrap()[vi],
3278                        &mut normals,
3279                        &mut nor_map,
3280                    )
3281                } else {
3282                    0
3283                };
3284                prim_globals.push((v_idx, vt_idx, vn_idx));
3285            }
3286            mesh_globals.push(prim_globals);
3287        }
3288        global_indices.push(mesh_globals);
3289    }
3290
3291    for (i, p) in positions.iter().enumerate() {
3292        // Pick the most-compact `v` form that still carries the
3293        // extension data: `xyz`, `xyzw` (rational weight), `xyzrgb`
3294        // (MeshLab vertex colour), or `xyzwrgb` (both). Each
3295        // extension is silently dropped if it would just spell out
3296        // the spec default (`w == 1.0`, no colour).
3297        let weight = position_weights[i];
3298        let colour = position_colors[i];
3299        let mut s = String::with_capacity(40);
3300        s.push_str("v ");
3301        s.push_str(&fmt_float(p[0]));
3302        s.push(' ');
3303        s.push_str(&fmt_float(p[1]));
3304        s.push(' ');
3305        s.push_str(&fmt_float(p[2]));
3306        if let Some(w) = weight {
3307            s.push(' ');
3308            s.push_str(&fmt_float(w));
3309        }
3310        if let Some(rgb) = colour {
3311            s.push(' ');
3312            s.push_str(&fmt_float(rgb[0]));
3313            s.push(' ');
3314            s.push_str(&fmt_float(rgb[1]));
3315            s.push(' ');
3316            s.push_str(&fmt_float(rgb[2]));
3317        }
3318        writeln!(out, "{s}").unwrap();
3319    }
3320    // Parameter-space vertices for the free-form geometry section. We
3321    // emit these after `v` and before `vt` to mirror the typical layout
3322    // produced by Wavefront-era authoring tools (the spec doesn't
3323    // mandate an ordering, but co-locating `vp` with the other vertex
3324    // pools keeps human diffs tidy).
3325    if let Some(serde_json::Value::Array(vps)) = scene.extras.get("obj:vp") {
3326        for entry in vps {
3327            if let serde_json::Value::Array(coords) = entry {
3328                let parts: Vec<f32> = coords
3329                    .iter()
3330                    .filter_map(|v| v.as_f64().map(|f| f as f32))
3331                    .collect();
3332                if parts.is_empty() {
3333                    continue;
3334                }
3335                // Emit only as many coordinates as carry meaningful
3336                // information. The decoder padded with `0.0`, so a
3337                // trailing `0` is a strong signal "the operator
3338                // didn't supply this component". 1D / 2D / 3D `vp`
3339                // statements are all valid per spec §"vp u v w".
3340                let trim = if parts.len() >= 3 && parts[2] != 0.0 {
3341                    3
3342                } else if parts.len() >= 2 && parts[1] != 0.0 {
3343                    2
3344                } else {
3345                    1
3346                };
3347                let mut s = String::from("vp");
3348                for coord in parts.iter().take(trim) {
3349                    s.push(' ');
3350                    s.push_str(&fmt_float(*coord));
3351                }
3352                writeln!(out, "{s}").unwrap();
3353            }
3354        }
3355    }
3356    for t in &texcoords {
3357        writeln!(out, "vt {} {}", fmt_float(t[0]), fmt_float(t[1])).unwrap();
3358    }
3359    for n in &normals {
3360        writeln!(
3361            out,
3362            "vn {} {} {}",
3363            fmt_float(n[0]),
3364            fmt_float(n[1]),
3365            fmt_float(n[2])
3366        )
3367        .unwrap();
3368    }
3369
3370    // Second pass: per-mesh `o` directive, per-primitive `usemtl` +
3371    // groups + smoothing-group, then face/line elements.
3372    for (mi, mesh) in scene.meshes.iter().enumerate() {
3373        // Synthesised curve mesh — its primitives carry
3374        // `obj:tessellated_curve = true` and were produced by the
3375        // decoder's de-Casteljau pass. Skip the whole `o` block; the
3376        // original `cstype`/`curv`/`end` directives still get replayed
3377        // from `Scene3D::extras["obj:freeform_directives"]`.
3378        if mesh.primitives.iter().all(is_tessellated_curve) && !mesh.primitives.is_empty() {
3379            continue;
3380        }
3381        if let Some(name) = &mesh.name {
3382            writeln!(out, "o {name}").unwrap();
3383        }
3384
3385        for (pi, prim) in mesh.primitives.iter().enumerate() {
3386            if is_tessellated_curve(prim) {
3387                continue;
3388            }
3389            // Per-primitive arity vector for n-gon re-emission, if any.
3390            let arities: Option<Vec<u32>> = prim
3391                .extras
3392                .get("obj:original_face_arities")
3393                .and_then(|v| serde_json::from_value(v.clone()).ok());
3394            // Groups + smoothing first (spec convention: state tokens
3395            // precede the elements they apply to).
3396            if let Some(serde_json::Value::Array(gs)) = prim.extras.get("obj:groups") {
3397                let names: Vec<&str> = gs.iter().filter_map(|v| v.as_str()).collect();
3398                if !names.is_empty() {
3399                    writeln!(out, "g {}", names.join(" ")).unwrap();
3400                }
3401            }
3402            if let Some(s) = prim
3403                .extras
3404                .get("obj:smoothing_group")
3405                .and_then(|v| v.as_str())
3406            {
3407                writeln!(out, "s {s}").unwrap();
3408            }
3409            if let Some(s) = prim
3410                .extras
3411                .get("obj:merging_group")
3412                .and_then(|v| v.as_str())
3413            {
3414                writeln!(out, "mg {s}").unwrap();
3415            }
3416            // Display-attribute state-setters — emitted ahead of the
3417            // elements they apply to. Order is fixed to keep round-trip
3418            // diffs deterministic.
3419            for keyword in ["bevel", "c_interp", "d_interp", "lod"] {
3420                let key = format!("obj:{keyword}");
3421                if let Some(s) = prim.extras.get(&key).and_then(|v| v.as_str()) {
3422                    writeln!(out, "{keyword} {s}").unwrap();
3423                }
3424            }
3425
3426            // usemtl: prefer extras["obj:usemtl"] (loss-tolerant
3427            // round-trip name), fall back to the bound material's name.
3428            let mtl_name: Option<String> = prim
3429                .extras
3430                .get("obj:usemtl")
3431                .and_then(|v| v.as_str())
3432                .map(|s| s.to_string())
3433                .or_else(|| {
3434                    prim.material.and_then(|id| {
3435                        scene
3436                            .materials
3437                            .get(id.0 as usize)
3438                            .and_then(|m| m.name.clone())
3439                    })
3440                });
3441            if let Some(name) = &mtl_name {
3442                writeln!(out, "usemtl {name}").unwrap();
3443            }
3444
3445            let prim_globals = &global_indices[mi][pi];
3446            let has_uv = !prim.uvs.is_empty();
3447            let has_normal = prim.normals.is_some();
3448
3449            // Build the per-element index iterator. For Triangles topology
3450            // re-shape into n-gons via `arities` if present; otherwise emit
3451            // one triangle per 3 indices. For Lines topology emit `l`
3452            // per pair (we don't reverse strips back into polylines —
3453            // that's lossy and the round-trip test doesn't need it).
3454            match prim.topology {
3455                Topology::Triangles => {
3456                    let face_indices: Vec<u32> = match &prim.indices {
3457                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
3458                        Some(Indices::U32(v)) => v.clone(),
3459                        None => {
3460                            // Implicit indices: 0, 1, 2, …
3461                            (0..prim.positions.len() as u32).collect()
3462                        }
3463                    };
3464                    if let Some(per_prim_arities) = arities.as_ref() {
3465                        // Reconstruct n-gons from triangle fans. Each
3466                        // n-gon contributed (n - 2) triangles.
3467                        let mut tri_pos: usize = 0;
3468                        for &arity in per_prim_arities {
3469                            let mut verts: Vec<u32> = Vec::with_capacity(arity as usize);
3470                            // The fan was: (v0, v1, v2), (v0, v2, v3), (v0, v3, v4), …
3471                            let n_tris = (arity as usize).saturating_sub(2);
3472                            // First triangle gives v0, v1, v2.
3473                            verts.push(face_indices[tri_pos * 3]);
3474                            verts.push(face_indices[tri_pos * 3 + 1]);
3475                            verts.push(face_indices[tri_pos * 3 + 2]);
3476                            // Each subsequent triangle adds one new vertex (the third index).
3477                            for k in 1..n_tris {
3478                                verts.push(face_indices[(tri_pos + k) * 3 + 2]);
3479                            }
3480                            tri_pos += n_tris;
3481
3482                            write_face(
3483                                &mut out,
3484                                &verts,
3485                                prim_globals,
3486                                has_uv,
3487                                has_normal,
3488                                negative,
3489                                positions.len() as u32,
3490                                texcoords.len() as u32,
3491                                normals.len() as u32,
3492                            );
3493                        }
3494                        // Any leftover triangles after the recorded arities
3495                        // (e.g. a primitive grew after the arity vector was
3496                        // captured) are emitted as plain triangles.
3497                        let consumed = per_prim_arities
3498                            .iter()
3499                            .map(|&a| (a as usize).saturating_sub(2))
3500                            .sum::<usize>();
3501                        for tri in consumed..(face_indices.len() / 3) {
3502                            let verts = [
3503                                face_indices[tri * 3],
3504                                face_indices[tri * 3 + 1],
3505                                face_indices[tri * 3 + 2],
3506                            ];
3507                            write_face(
3508                                &mut out,
3509                                &verts,
3510                                prim_globals,
3511                                has_uv,
3512                                has_normal,
3513                                negative,
3514                                positions.len() as u32,
3515                                texcoords.len() as u32,
3516                                normals.len() as u32,
3517                            );
3518                        }
3519                    } else {
3520                        for tri in 0..(face_indices.len() / 3) {
3521                            let verts = [
3522                                face_indices[tri * 3],
3523                                face_indices[tri * 3 + 1],
3524                                face_indices[tri * 3 + 2],
3525                            ];
3526                            write_face(
3527                                &mut out,
3528                                &verts,
3529                                prim_globals,
3530                                has_uv,
3531                                has_normal,
3532                                negative,
3533                                positions.len() as u32,
3534                                texcoords.len() as u32,
3535                                normals.len() as u32,
3536                            );
3537                        }
3538                    }
3539                }
3540                Topology::Lines => {
3541                    let line_indices: Vec<u32> = match &prim.indices {
3542                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
3543                        Some(Indices::U32(v)) => v.clone(),
3544                        None => (0..prim.positions.len() as u32).collect(),
3545                    };
3546                    let total_v = positions.len() as u32;
3547                    // Walk segment pairs and join contiguous chains
3548                    // (segment N's end == segment N+1's start) into
3549                    // one polyline before emit. Saves bytes on the
3550                    // common case of a long polyline that round-tripped
3551                    // through `Topology::Lines` decomposition.
3552                    let mut chain: Vec<u32> = Vec::new();
3553                    let flush = |chain: &mut Vec<u32>, out: &mut String| {
3554                        if chain.len() < 2 {
3555                            chain.clear();
3556                            return;
3557                        }
3558                        let parts: Vec<String> = chain
3559                            .iter()
3560                            .map(|&local| {
3561                                fmt_index(prim_globals[local as usize].0, total_v, negative)
3562                            })
3563                            .collect();
3564                        writeln!(out, "l {}", parts.join(" ")).unwrap();
3565                        chain.clear();
3566                    };
3567                    for w in line_indices.chunks_exact(2) {
3568                        let (a, b) = (w[0], w[1]);
3569                        if chain.is_empty() {
3570                            chain.push(a);
3571                            chain.push(b);
3572                        } else if *chain.last().unwrap() == a {
3573                            chain.push(b);
3574                        } else {
3575                            flush(&mut chain, &mut out);
3576                            chain.push(a);
3577                            chain.push(b);
3578                        }
3579                    }
3580                    flush(&mut chain, &mut out);
3581                }
3582                Topology::LineStrip | Topology::LineLoop => {
3583                    // Reconstruct the strip's index list from whichever
3584                    // backing storage the primitive carries; bare
3585                    // positions imply implicit `0..N` indices. For
3586                    // `LineLoop` we re-append the first index so the
3587                    // emitted `l` line spells out the closing edge —
3588                    // the parser then detects start == end and round-
3589                    // trips back to `LineLoop`.
3590                    let mut strip_indices: Vec<u32> = match &prim.indices {
3591                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
3592                        Some(Indices::U32(v)) => v.clone(),
3593                        None => (0..prim.positions.len() as u32).collect(),
3594                    };
3595                    if matches!(prim.topology, Topology::LineLoop)
3596                        && let Some(&first) = strip_indices.first()
3597                    {
3598                        strip_indices.push(first);
3599                    }
3600                    if strip_indices.len() >= 2 {
3601                        let total_v = positions.len() as u32;
3602                        let parts: Vec<String> = strip_indices
3603                            .iter()
3604                            .map(|&local| {
3605                                fmt_index(prim_globals[local as usize].0, total_v, negative)
3606                            })
3607                            .collect();
3608                        writeln!(out, "l {}", parts.join(" ")).unwrap();
3609                    }
3610                }
3611                Topology::Points => {
3612                    let pt_indices: Vec<u32> = match &prim.indices {
3613                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
3614                        Some(Indices::U32(v)) => v.clone(),
3615                        None => (0..prim.positions.len() as u32).collect(),
3616                    };
3617                    let total_v = positions.len() as u32;
3618                    if !pt_indices.is_empty() {
3619                        // Pack every reference onto a single `p` line —
3620                        // the spec explicitly permits the multi-vertex
3621                        // form (`p v1 v2 v3 …`) and it's what most
3622                        // tools emit.
3623                        let parts: Vec<String> = pt_indices
3624                            .iter()
3625                            .map(|&local| {
3626                                fmt_index(prim_globals[local as usize].0, total_v, negative)
3627                            })
3628                            .collect();
3629                        writeln!(out, "p {}", parts.join(" ")).unwrap();
3630                    }
3631                }
3632                other => {
3633                    return Err(Error::unsupported(format!(
3634                        "OBJ encoder: topology {other:?} not representable"
3635                    )));
3636                }
3637            }
3638        }
3639    }
3640
3641    // Free-form geometry section: replay the captured directive
3642    // sequence verbatim. The decoder records every `cstype` / `deg` /
3643    // `curv` / `surf` / `parm` / `trim` / `hole` / `scrv` / `sp` /
3644    // `end` / `bzp` / `bsp` line as `[keyword, arg1, arg2, …]` so the
3645    // encoder is purely textual — no semantic interpretation, which
3646    // means the round-trip is bit-exact for the directive args even
3647    // when the polygonal section sits between `vp` and the free-form
3648    // body.
3649    if let Some(serde_json::Value::Array(directives)) = scene.extras.get("obj:freeform_directives")
3650    {
3651        for entry in directives {
3652            if let serde_json::Value::Array(toks) = entry {
3653                let parts: Vec<&str> = toks.iter().filter_map(|v| v.as_str()).collect();
3654                if parts.is_empty() {
3655                    continue;
3656                }
3657                writeln!(out, "{}", parts.join(" ")).unwrap();
3658            }
3659        }
3660    }
3661
3662    Ok(out.into_bytes())
3663}
3664
3665#[allow(clippy::too_many_arguments)]
3666fn write_face(
3667    out: &mut String,
3668    verts: &[u32],
3669    prim_globals: &[(u32, u32, u32)],
3670    has_uv: bool,
3671    has_normal: bool,
3672    negative: bool,
3673    total_v: u32,
3674    total_vt: u32,
3675    total_vn: u32,
3676) {
3677    use std::fmt::Write;
3678    out.push('f');
3679    for &local in verts {
3680        let (v, vt, vn) = prim_globals[local as usize];
3681        let v_s = fmt_index(v, total_v, negative);
3682        let vt_s = fmt_index(vt, total_vt, negative);
3683        let vn_s = fmt_index(vn, total_vn, negative);
3684        match (has_uv, has_normal) {
3685            (true, true) => write!(out, " {v_s}/{vt_s}/{vn_s}").unwrap(),
3686            (true, false) => write!(out, " {v_s}/{vt_s}").unwrap(),
3687            (false, true) => write!(out, " {v_s}//{vn_s}").unwrap(),
3688            (false, false) => write!(out, " {v_s}").unwrap(),
3689        }
3690    }
3691    out.push('\n');
3692}
3693
3694/// Render a 1-based positive index as either its absolute form
3695/// (`5`) or a negative-from-end form (`-3`, when `total = 7`).
3696/// `idx == 0` means "no index" — we always emit `0` regardless of
3697/// the negative flag so the parser still treats it as absent.
3698fn fmt_index(idx: u32, total: u32, negative: bool) -> String {
3699    if idx == 0 || !negative {
3700        idx.to_string()
3701    } else {
3702        // total = 7, idx = 5  ⇒  -3  (i.e. "third from the end").
3703        // Parser computes: resolved = total + 1 + raw  ⇒  raw = idx - total - 1.
3704        let raw = (idx as i64) - (total as i64) - 1;
3705        raw.to_string()
3706    }
3707}
3708
3709/// Format a float without scientific notation; trims trailing zeros
3710/// while keeping at least one digit after the decimal point. Keeps the
3711/// emitted file human-diffable.
3712fn fmt_float(x: f32) -> String {
3713    if x == 0.0 {
3714        return "0".to_string();
3715    }
3716    let s = format!("{x:.6}");
3717    let trimmed = s.trim_end_matches('0').trim_end_matches('.').to_string();
3718    if trimmed.is_empty() || trimmed == "-" {
3719        "0".to_string()
3720    } else {
3721        trimmed
3722    }
3723}
3724
3725// ---------------------------------------------------------------------------
3726// Float keys for the dedup HashMap (f32 isn't Hash).
3727// ---------------------------------------------------------------------------
3728
3729#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
3730struct KeyVec2 {
3731    a: u32,
3732    b: u32,
3733}
3734impl From<[f32; 2]> for KeyVec2 {
3735    fn from(v: [f32; 2]) -> Self {
3736        Self {
3737            a: v[0].to_bits(),
3738            b: v[1].to_bits(),
3739        }
3740    }
3741}
3742
3743#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
3744struct KeyVec3 {
3745    a: u32,
3746    b: u32,
3747    c: u32,
3748}
3749impl From<[f32; 3]> for KeyVec3 {
3750    fn from(v: [f32; 3]) -> Self {
3751        Self {
3752            a: v[0].to_bits(),
3753            b: v[1].to_bits(),
3754            c: v[2].to_bits(),
3755        }
3756    }
3757}
3758
3759// ---------------------------------------------------------------------------
3760// Tests (unit-level — integration tests live under `tests/`).
3761// ---------------------------------------------------------------------------
3762
3763#[cfg(test)]
3764mod tests {
3765    use super::*;
3766
3767    #[test]
3768    fn preprocess_strips_comments_and_glues_continuations() {
3769        let lines =
3770            preprocess_lines("v 1.0 2.0 \\\n3.0 # comment\nv 4 5 6\n# pure comment\nf 1 2 3");
3771        assert_eq!(lines[0].trim(), "v 1.0 2.0  3.0");
3772        assert_eq!(lines[1].trim(), "v 4 5 6");
3773        // The pure-comment line collapses to an empty preprocessed line.
3774        assert_eq!(lines[2].trim(), "");
3775        assert_eq!(lines[3].trim(), "f 1 2 3");
3776    }
3777
3778    #[test]
3779    fn fmt_float_is_diff_friendly() {
3780        assert_eq!(fmt_float(1.0), "1");
3781        assert_eq!(fmt_float(0.0), "0");
3782        assert_eq!(fmt_float(-0.5), "-0.5");
3783        assert_eq!(fmt_float(1.0 / 3.0), "0.333333");
3784    }
3785}