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