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    /// Active texture-map name from `usemap <name>` or `usemap off`.
78    /// Spec §"usemap map_name/off" — rendering identifier that names
79    /// the texture map for the following elements. `None` means "no
80    /// `usemap` directive has been seen yet" (the spec default is
81    /// `off`); `Some("off")` is the explicit-off form; `Some(name)`
82    /// is an active map binding. State-setting in the same shape as
83    /// `usemtl`: a change mid-stream splits the primitive so each one
84    /// carries one consistent binding.
85    usemap: Option<String>,
86}
87
88/// One open mesh — accumulates primitives while a single `o <name>`
89/// (or default object) is active.
90#[derive(Debug, Default)]
91struct MeshAccum {
92    name: Option<String>,
93    primitives: Vec<PrimAccum>,
94}
95
96impl MeshAccum {
97    fn current_or_new(&mut self) -> &mut PrimAccum {
98        if self.primitives.is_empty() {
99            self.primitives.push(PrimAccum::default());
100        }
101        self.primitives.last_mut().unwrap()
102    }
103}
104
105/// The polygonal data parsed out of an OBJ document.
106///
107/// This intermediate form keeps positions / texcoords / normals in
108/// their original 1-based numbering so the resolution of negative and
109/// 1-based face indices into 0-based primitive-local indices happens
110/// in one well-defined place ([`build_scene`]).
111#[derive(Debug, Default)]
112struct ObjDoc {
113    positions: Vec<[f32; 3]>,
114    /// Per-position rational weight from the optional 4th `w` component
115    /// of `v x y z w`. `None` means "no weight given" (the spec default
116    /// is `1.0`); `Some(w)` is preserved verbatim so a round-trip emits
117    /// the original 4-token form rather than collapsing to 3 tokens.
118    /// Parallel to `positions` (1-based / 0-based index parity).
119    /// Spec §"v x y z w" — w defaults to 1.0 for non-rational geometry.
120    position_weights: Vec<Option<f32>>,
121    /// Per-position vertex colour from the widely-deployed
122    /// `v x y z r g b` extension (MeshLab, libigl, Meshroom, OpenCV).
123    /// `None` for vertices written in the standard 3-token form.
124    /// `Some([r, g, b, 1.0])` carries the linear-space RGB triplet
125    /// (alpha pinned to opaque since the extension only spells out
126    /// three colour channels). Parallel to `positions`.
127    /// Not in the original spec — flagged in `docs/3d/obj/README.md`
128    /// as the canonical "widely used but never standardised" extension.
129    position_colors: Vec<Option<[f32; 4]>>,
130    texcoords: Vec<[f32; 2]>,
131    normals: Vec<[f32; 3]>,
132    /// Parameter-space vertices (`vp u v [w]`) from the free-form
133    /// geometry portion of the spec — 1-based numbering, parallel to
134    /// `positions` / `texcoords` / `normals`. Stored as a 3-tuple
135    /// where missing components default to `0.0` (this matches what
136    /// the spec calls out: `v` defaults to 0 for 1D points, `w`
137    /// defaults to 1.0 for rational trimming curves but we leave the
138    /// raw "what the file said" in extras and let the consumer
139    /// interpret).
140    vp: Vec<[f32; 3]>,
141    /// Material library file names referenced by `mtllib`.
142    mtllibs: Vec<String>,
143    /// Texture-map library file names referenced by `maplib`. Spec
144    /// §"maplib filename1 filename2 ..." — sibling to `mtllib` but for
145    /// texture-map definitions consumed by `usemap`. Captured verbatim
146    /// in document order, with later duplicates suppressed (matching
147    /// the `mtllib` de-duplication policy). Surfaced through
148    /// `Scene3D::extras["obj:maplibs"]` and replayed by the encoder.
149    /// No external IO is performed — the spec mandates that `maplib`
150    /// references resolve at render time, not at decode time, so we
151    /// treat the listed filenames as opaque round-trip metadata.
152    maplibs: Vec<String>,
153    /// All material definitions resolved from `mtllib` references
154    /// supplied via [`ObjDoc::with_resolved_mtllibs`]. Round 1 ships
155    /// no IO so we accept these via an external resolver hook on the
156    /// caller.
157    resolved_materials: HashMap<String, oxideav_mesh3d::Material>,
158    meshes: Vec<MeshAccum>,
159    /// Verbatim sequence of free-form-geometry directives (`cstype`,
160    /// `deg`, `curv`, `surf`, `parm`, `trim`, `hole`, `scrv`, `sp`,
161    /// `end`, `bzp`, the older `bsp`, plus the curve / surface
162    /// approximation-technique directives `ctech` / `stech`). Each entry
163    /// is the keyword followed by its whitespace-separated arguments.
164    /// Round-trip preservation: the encoder replays the sequence verbatim
165    /// after the polygonal section so consumers can carry free-form data
166    /// through us without semantic loss. Body statements (`parm`,
167    /// `trim`, `hole`, `scrv`, `sp`, `end`) are accepted in document
168    /// order; the spec mandates they appear between an element start
169    /// (`curv` / `surf`) and `end`, but we don't enforce that — a
170    /// lenient loader pattern matches what tools in the wild emit.
171    freeform_directives: Vec<Vec<String>>,
172    /// Shadow-casting object filename from a `shadow_obj filename`
173    /// directive (spec §"shadow_obj filename"). Top-level state: the
174    /// spec states "Only one shadow object can be stored in a file. If
175    /// more than one shadow object is specified, the last one specified
176    /// will be used." `None` if no directive appeared. Round-trip path:
177    /// surfaced through `Scene3D::extras["obj:shadow_obj"]` and re-emitted
178    /// before the polygonal section.
179    shadow_obj: Option<String>,
180    /// Ray-tracing reflection object filename from a `trace_obj filename`
181    /// directive (spec §"trace_obj filename"). Mirrors `shadow_obj` —
182    /// last-wins semantics per spec, surfaced through
183    /// `Scene3D::extras["obj:trace_obj"]`.
184    trace_obj: Option<String>,
185    /// Verbatim sequence of "general statement" directives — spec
186    /// §"General statement" lists `call filename.ext arg1 arg2 …`
187    /// (inline file inclusion of a sibling `.obj` / `.mod` file) and
188    /// `csh command` / `csh -command` (shell-execute, with the leading
189    /// `-` flagging "ignore error on non-zero exit"). Both are
190    /// captured verbatim for round-trip but NOT semantically
191    /// interpreted — `call` does not pull the referenced file into the
192    /// scene (would require IO and conflict with the clean-room
193    /// boundary; consumers can re-resolve manually), and `csh` does
194    /// not execute the requested command (would be a sandbox-escape
195    /// trapdoor in any consumer that round-trips untrusted OBJ inputs).
196    /// Surfaces through `Scene3D::extras["obj:general_directives"]` as
197    /// an array of `[keyword, arg1, arg2, …]` arrays in document order.
198    /// The encoder replays them in the preamble (right after `mtllib`
199    /// and the `shadow_obj` / `trace_obj` companion-file block) since
200    /// the spec is silent on placement ("The call statement can be
201    /// inserted into .obj files using a text editor"); source-line
202    /// position relative to the polygonal section is NOT preserved by
203    /// design.
204    general_directives: Vec<Vec<String>>,
205}
206
207/// Glue line-continuation (`\\` + newline) before line splitting and
208/// strip comments (`#…` to end of line). Returns owned strings since
209/// continuation gluing rewrites the input.
210fn preprocess_lines(text: &str) -> Vec<String> {
211    let mut out: Vec<String> = Vec::new();
212    let mut acc = String::new();
213    for raw_line in text.split('\n') {
214        // Strip a trailing CR so CRLF inputs land cleanly.
215        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
216        // Strip comments — `#` past the start of a token introduces
217        // an end-of-line comment per the spec.
218        let no_comment = match line.find('#') {
219            Some(idx) => &line[..idx],
220            None => line,
221        };
222        let trimmed = no_comment.trim_end();
223        if let Some(stripped) = trimmed.strip_suffix('\\') {
224            acc.push_str(stripped);
225            acc.push(' ');
226        } else {
227            acc.push_str(trimmed);
228            out.push(std::mem::take(&mut acc));
229        }
230    }
231    if !acc.is_empty() {
232        out.push(acc);
233    }
234    out
235}
236
237/// Parse a face-vertex token. Accepts `v`, `v/vt`, `v//vn`, `v/vt/vn`.
238/// Each component is a non-zero integer (negative => relative-from-end).
239/// Resolution to 1-based positive indices happens here; 0-based
240/// primitive-local indexing happens in [`build_scene`].
241///
242/// The position component (the part before the first `/`) is mandatory
243/// per spec ("v is the index of the geometric vertex, … required for
244/// every reference"); an empty or missing `v` slot surfaces as
245/// `Err(Error::invalid)` rather than coalescing to `0` and tripping the
246/// downstream `(fv.v - 1) as usize` underflow.
247fn parse_face_vertex(tok: &str, n_pos: i64, n_tex: i64, n_norm: i64) -> Result<FaceVert> {
248    let mut parts = tok.split('/');
249    let v = parts
250        .next()
251        .ok_or_else(|| Error::invalid(format!("face vertex missing position: {tok:?}")))?;
252    if v.is_empty() {
253        return Err(Error::invalid(format!(
254            "face vertex missing position index: {tok:?}"
255        )));
256    }
257    let vt = parts.next().unwrap_or("");
258    let vn = parts.next().unwrap_or("");
259
260    let resolve = |s: &str, n: i64, kind: &str| -> Result<u32> {
261        if s.is_empty() {
262            return Ok(0);
263        }
264        let raw: i64 = s.parse().map_err(|_| {
265            Error::invalid(format!(
266                "invalid {kind} index in face vertex {tok:?}: {s:?}"
267            ))
268        })?;
269        let resolved = if raw < 0 { n + 1 + raw } else { raw };
270        if resolved <= 0 || resolved > n {
271            return Err(Error::invalid(format!(
272                "{kind} index out of range in face vertex {tok:?}: {raw} (have {n})"
273            )));
274        }
275        Ok(resolved as u32)
276    };
277
278    Ok(FaceVert {
279        v: resolve(v, n_pos, "position")?,
280        vt: resolve(vt, n_tex, "texcoord")?,
281        vn: resolve(vn, n_norm, "normal")?,
282    })
283}
284
285/// Parse the geometry part of an OBJ document into the intermediate
286/// [`ObjDoc`] form. No I/O — `mtllib` lines are recorded by name only;
287/// the caller resolves them.
288fn parse_obj_doc(text: &str) -> Result<ObjDoc> {
289    let mut doc = ObjDoc::default();
290    // One implicit mesh until an `o` directive opens a named one.
291    doc.meshes.push(MeshAccum::default());
292
293    let lines = preprocess_lines(text);
294    for line in &lines {
295        let mut tokens = line.split_whitespace();
296        let Some(keyword) = tokens.next() else {
297            continue;
298        };
299        match keyword {
300            "v" => {
301                let coords: Vec<f32> = tokens
302                    .map(str::parse)
303                    .collect::<std::result::Result<Vec<f32>, _>>()
304                    .map_err(|e| Error::invalid(format!("v: bad float ({e})")))?;
305                // Spec §"v x y z w" defines 3 or 4 components (the 4th
306                // is the rational weight, default 1.0). The
307                // widely-deployed MeshLab / libigl / Meshroom extension
308                // adds a per-vertex RGB triplet making 6 (`x y z r g b`)
309                // or 7 (`x y z w r g b`) the supported widths in the
310                // wild. We accept all four shapes and surface the extra
311                // information through parallel `position_weights` /
312                // `position_colors` arrays so the encoder can re-emit
313                // the original token width on round-trip.
314                let (w, rgb) = match coords.len() {
315                    3 => (None, None),
316                    4 => (Some(coords[3]), None),
317                    6 => (None, Some([coords[3], coords[4], coords[5], 1.0])),
318                    7 => (
319                        Some(coords[3]),
320                        Some([coords[4], coords[5], coords[6], 1.0]),
321                    ),
322                    n => {
323                        return Err(Error::invalid(format!(
324                            "v: expected 3, 4, 6, or 7 floats (xyz, xyzw, xyzrgb, or \
325                             xyzwrgb per spec + MeshLab vertex-colour extension), got {n}"
326                        )));
327                    }
328                };
329                doc.positions.push([coords[0], coords[1], coords[2]]);
330                doc.position_weights.push(w);
331                doc.position_colors.push(rgb);
332            }
333            "vt" => {
334                let coords: Vec<f32> = tokens
335                    .map(str::parse)
336                    .collect::<std::result::Result<Vec<f32>, _>>()
337                    .map_err(|e| Error::invalid(format!("vt: bad float ({e})")))?;
338                if coords.is_empty() {
339                    return Err(Error::invalid("vt: expected ≥1 coord"));
340                }
341                let u = coords[0];
342                let v = coords.get(1).copied().unwrap_or(0.0);
343                // Drop optional 3rd `w` — meaningless to glTF UV.
344                doc.texcoords.push([u, v]);
345            }
346            "vn" => {
347                let coords: Vec<f32> = tokens
348                    .map(str::parse)
349                    .collect::<std::result::Result<Vec<f32>, _>>()
350                    .map_err(|e| Error::invalid(format!("vn: bad float ({e})")))?;
351                if coords.len() != 3 {
352                    return Err(Error::invalid(format!(
353                        "vn: expected 3 coords, got {}",
354                        coords.len()
355                    )));
356                }
357                doc.normals.push([coords[0], coords[1], coords[2]]);
358            }
359            "vp" => {
360                // Parameter-space vertex (`vp u v [w]`) — used as the
361                // control-point pool for free-form 2D trimming curves
362                // (`curv2`, referenced by `trim`/`hole`/`scrv`) and
363                // for special points (`sp`). Spec §"vp u v w".
364                //
365                // The number of meaningful coordinates depends on the
366                // usage (1D for 1D special points, 2D for trimming
367                // curves, 3D for rational trimming curves with a
368                // weight). We always store a 3-tuple, padding with
369                // `0.0` so the encoder can emit a faithful
370                // `vp <u> <v> <w>` line for the rational case and a
371                // shorter `vp <u> <v>` / `vp <u>` for the others.
372                let coords: Vec<f32> = tokens
373                    .map(str::parse)
374                    .collect::<std::result::Result<Vec<f32>, _>>()
375                    .map_err(|e| Error::invalid(format!("vp: bad float ({e})")))?;
376                if coords.is_empty() {
377                    return Err(Error::invalid("vp: expected ≥1 coord"));
378                }
379                let u = coords[0];
380                let v = coords.get(1).copied().unwrap_or(0.0);
381                let w = coords.get(2).copied().unwrap_or(0.0);
382                doc.vp.push([u, v, w]);
383            }
384            "cstype" | "deg" | "curv" | "curv2" | "surf" | "parm" | "trim" | "hole" | "scrv"
385            | "sp" | "end" | "bzp" | "bsp" | "cdc" | "cdp" | "res" | "bmat" | "step" | "ctech"
386            | "stech" | "con" => {
387                // Free-form geometry directives. Captured verbatim as
388                // a `(keyword, args)` sequence on the document so the
389                // encoder can replay them after the polygonal section.
390                // No semantic interpretation: the round-trip preserves
391                // the operator's exact token sequence.
392                //
393                // Spec §"Free-form curve/surface attributes" /
394                // §"Specifying free-form curves/surfaces" /
395                // §"Free-form curve/surface body statements" /
396                // §"Superseded statements" (bzp / bsp Bezier/B-spline
397                // patches, cdc Cardinal curve, cdp Cardinal patch, res
398                // segment-count reference/display statement — all read
399                // for round-trip but never written by the source
400                // system: "This release is the last release that will
401                // read these statements. … read in the file and write
402                // it out. The system will convert the data to the new
403                // .obj format." We preserve them verbatim so a
404                // decode → encode cycle keeps the legacy directive
405                // rather than silently dropping it.) /
406                // §"bmat u/v matrix" + §"step stepu stepv" /
407                // §"ctech technique resolution" (cparm / cspace / curv
408                // forms) + §"stech technique resolution" (cparma /
409                // cparmb / cspace / curv forms) — both classified as
410                // free-form geometry statements by the spec and
411                // captured verbatim in source order alongside the
412                // structural directives so the round-trip preserves
413                // the per-block approximation hints.
414                //
415                // `con surf_1 q0_1 q1_1 curv2d_1 surf_2 q0_2 q1_2
416                // curv2d_2` (spec §"Connectivity between free-form
417                // surfaces", §"con surf_1 q0_1 q1_1 curv2d_1 surf_2
418                // q0_2 q1_2 curv2d_2") is a top-level free-form
419                // geometry statement that ties two previously-declared
420                // `surf` blocks together along a shared trimming-curve
421                // segment for edge merging. It sits OUTSIDE any
422                // `cstype … end` block (the worked example in spec
423                // §"Connectivity between free-form surfaces"
424                // §"Example 1" places it after the last surface's
425                // `end`), so capturing it into the same verbatim
426                // sequence keeps source order intact across the
427                // polygonal section / free-form section boundary.
428                // No semantic merging is performed — consumers that
429                // care about connectivity walk the captured directive
430                // sequence themselves; the round-trip is byte-faithful
431                // for the args.
432                let mut entry: Vec<String> = Vec::new();
433                entry.push(keyword.to_string());
434                for tok in tokens {
435                    entry.push(tok.to_string());
436                }
437                doc.freeform_directives.push(entry);
438            }
439            "shadow_obj" => {
440                // Spec §"shadow_obj filename": top-level last-wins
441                // shadow-caster filename. The spec note ("If more than
442                // one shadow object is specified, the last one
443                // specified will be used.") makes the multi-line
444                // collapse behaviour mandatory; we honour it directly
445                // rather than carrying the discarded earlier entries.
446                let v: String = tokens.collect::<Vec<_>>().join(" ");
447                if !v.is_empty() {
448                    doc.shadow_obj = Some(v);
449                }
450            }
451            "trace_obj" => {
452                // Spec §"trace_obj filename": top-level last-wins ray-
453                // tracing reflection-target filename. Same last-wins
454                // semantics as `shadow_obj`; reuses the same join +
455                // last-write-wins pattern so quoted spaces (if any) in
456                // the filename survive the tokenisation.
457                let v: String = tokens.collect::<Vec<_>>().join(" ");
458                if !v.is_empty() {
459                    doc.trace_obj = Some(v);
460                }
461            }
462            "call" | "csh" => {
463                // Spec §"General statement" — `call filename.ext arg1
464                // arg2 …` (inline include of a sibling `.obj` / `.mod`
465                // file with positional argument substitution) and
466                // `csh command` / `csh -command` (shell-execute a UNIX
467                // command, with a leading `-` flagging "ignore error
468                // on non-zero exit"). Both are spec-defined but
469                // semantically expensive / unsafe to interpret here:
470                //
471                //   * `call` would require IO + recursive parser
472                //     re-entry + nested-call depth tracking; consumers
473                //     can re-resolve the included files themselves
474                //     against the captured filename.
475                //   * `csh` is a sandbox-escape trapdoor for any
476                //     consumer that round-trips untrusted OBJ input,
477                //     so the spec-mandated "executes the requested
478                //     UNIX command" behaviour is deliberately NOT
479                //     implemented — consumers can inspect the captured
480                //     command text and decide for themselves.
481                //
482                // Capture verbatim into `general_directives`. Empty
483                // arg lists land as `[keyword]` only (a bare `csh`
484                // line with no command, while ill-formed, still
485                // survives the round-trip rather than getting dropped
486                // — mirrors the lenient-loader pattern used elsewhere).
487                let mut entry: Vec<String> = Vec::new();
488                entry.push(keyword.to_string());
489                for tok in tokens {
490                    entry.push(tok.to_string());
491                }
492                doc.general_directives.push(entry);
493            }
494            "f" => {
495                let n_pos = doc.positions.len() as i64;
496                let n_tex = doc.texcoords.len() as i64;
497                let n_norm = doc.normals.len() as i64;
498                let verts: Vec<FaceVert> = tokens
499                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
500                    .collect::<Result<Vec<_>>>()?;
501                if verts.len() < 3 {
502                    return Err(Error::invalid(format!(
503                        "f: face needs ≥3 vertices, got {}",
504                        verts.len()
505                    )));
506                }
507                let mesh = doc.meshes.last_mut().unwrap();
508                mesh.current_or_new().elements.push(Element::Face(verts));
509            }
510            "l" => {
511                let n_pos = doc.positions.len() as i64;
512                let n_tex = doc.texcoords.len() as i64;
513                let n_norm = doc.normals.len() as i64;
514                let verts: Vec<FaceVert> = tokens
515                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
516                    .collect::<Result<Vec<_>>>()?;
517                if verts.len() < 2 {
518                    return Err(Error::invalid(format!(
519                        "l: line needs ≥2 vertices, got {}",
520                        verts.len()
521                    )));
522                }
523                let mesh = doc.meshes.last_mut().unwrap();
524                mesh.current_or_new().elements.push(Element::Line(verts));
525            }
526            "p" => {
527                // Point elements are state-incompatible with face/line
528                // primitives (different `Topology`); mirror the `usemtl`
529                // pattern and split into a fresh primitive whenever the
530                // current one already holds incompatible elements.
531                let n_pos = doc.positions.len() as i64;
532                let n_tex = doc.texcoords.len() as i64;
533                let n_norm = doc.normals.len() as i64;
534                // `p` only takes vertex references (no `/vt` or `//vn`),
535                // but parse_face_vertex degrades gracefully when the
536                // separators are absent.
537                let verts: Vec<FaceVert> = tokens
538                    .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
539                    .collect::<Result<Vec<_>>>()?;
540                if verts.is_empty() {
541                    return Err(Error::invalid("p: needs ≥1 vertex"));
542                }
543                let mesh = doc.meshes.last_mut().unwrap();
544                let prim = mesh.current_or_new();
545                if prim
546                    .elements
547                    .iter()
548                    .any(|e| !matches!(e, Element::Point(_)))
549                {
550                    // Mixed-kind elements aren't representable; open a
551                    // fresh primitive that inherits material + groups +
552                    // smoothing/merging/display-attr state.
553                    let mat = prim.material.clone();
554                    let groups = prim.groups.clone();
555                    let smoothing = prim.smoothing_group.clone();
556                    let merging = prim.merging_group.clone();
557                    let bevel = prim.bevel.clone();
558                    let c_interp = prim.c_interp.clone();
559                    let d_interp = prim.d_interp.clone();
560                    let lod = prim.lod.clone();
561                    let usemap = prim.usemap.clone();
562                    mesh.primitives.push(PrimAccum {
563                        material: mat,
564                        groups,
565                        smoothing_group: smoothing,
566                        merging_group: merging,
567                        bevel,
568                        c_interp,
569                        d_interp,
570                        lod,
571                        usemap,
572                        elements: vec![Element::Point(verts)],
573                    });
574                } else {
575                    prim.elements.push(Element::Point(verts));
576                }
577            }
578            "bevel" | "c_interp" | "d_interp" | "lod" => {
579                // Display-attribute state-setting — `bevel on/off`,
580                // `c_interp on/off`, `d_interp on/off`, `lod <level>`.
581                // Captured per-primitive; a mid-stream change splits
582                // the primitive so each one carries one consistent
583                // value (mirrors `s`/`mg`).
584                let v: String = tokens.collect::<Vec<_>>().join(" ");
585                if v.is_empty() {
586                    continue;
587                }
588                let mesh = doc.meshes.last_mut().unwrap();
589                let last = mesh.current_or_new();
590                let current: Option<&str> = match keyword {
591                    "bevel" => last.bevel.as_deref(),
592                    "c_interp" => last.c_interp.as_deref(),
593                    "d_interp" => last.d_interp.as_deref(),
594                    "lod" => last.lod.as_deref(),
595                    _ => unreachable!(),
596                };
597                if last.elements.is_empty() {
598                    // Overwrite the pending value.
599                    match keyword {
600                        "bevel" => last.bevel = Some(v),
601                        "c_interp" => last.c_interp = Some(v),
602                        "d_interp" => last.d_interp = Some(v),
603                        "lod" => last.lod = Some(v),
604                        _ => unreachable!(),
605                    }
606                } else if current != Some(v.as_str()) {
607                    let mat = last.material.clone();
608                    let groups = last.groups.clone();
609                    let smoothing = last.smoothing_group.clone();
610                    let merging = last.merging_group.clone();
611                    let mut bevel = last.bevel.clone();
612                    let mut c_interp = last.c_interp.clone();
613                    let mut d_interp = last.d_interp.clone();
614                    let mut lod = last.lod.clone();
615                    let usemap = last.usemap.clone();
616                    match keyword {
617                        "bevel" => bevel = Some(v),
618                        "c_interp" => c_interp = Some(v),
619                        "d_interp" => d_interp = Some(v),
620                        "lod" => lod = Some(v),
621                        _ => unreachable!(),
622                    }
623                    mesh.primitives.push(PrimAccum {
624                        material: mat,
625                        smoothing_group: smoothing,
626                        merging_group: merging,
627                        groups,
628                        bevel,
629                        c_interp,
630                        d_interp,
631                        lod,
632                        usemap,
633                        elements: Vec::new(),
634                    });
635                }
636            }
637            "mg" => {
638                // Merging group — `mg <group_number> [res]` or `mg off`
639                // / `mg 0`. Like `s`, it's state-setting; preserve the
640                // operator's spelling verbatim. The semantic value
641                // (smoothing across surface joins for free-form
642                // surfaces) is meaningless without the free-form
643                // surface support, but the round-trip preservation
644                // matters for tools that round-trip mesh data through
645                // us.
646                let v: String = tokens.collect::<Vec<_>>().join(" ");
647                if v.is_empty() {
648                    continue;
649                }
650                let mesh = doc.meshes.last_mut().unwrap();
651                let last = mesh.current_or_new();
652                if last.elements.is_empty() {
653                    // No elements yet — overwrite the pending value.
654                    last.merging_group = Some(v);
655                } else if last.merging_group.as_deref() != Some(v.as_str()) {
656                    // Merging-group changed mid-stream; split into a
657                    // fresh primitive so each one carries one
658                    // consistent assignment (mirrors smoothing-group
659                    // behaviour).
660                    let mat = last.material.clone();
661                    let groups = last.groups.clone();
662                    let smoothing = last.smoothing_group.clone();
663                    let bevel = last.bevel.clone();
664                    let c_interp = last.c_interp.clone();
665                    let d_interp = last.d_interp.clone();
666                    let lod = last.lod.clone();
667                    let usemap = last.usemap.clone();
668                    mesh.primitives.push(PrimAccum {
669                        material: mat,
670                        smoothing_group: smoothing,
671                        groups,
672                        merging_group: Some(v),
673                        bevel,
674                        c_interp,
675                        d_interp,
676                        lod,
677                        usemap,
678                        elements: Vec::new(),
679                    });
680                }
681            }
682            "o" => {
683                let name: String = tokens.collect::<Vec<_>>().join(" ");
684                // Open a fresh mesh — but if the current mesh is still
685                // empty (no primitives accumulated yet), reuse it so we
686                // don't end up with a leading empty mesh.
687                let last = doc.meshes.last_mut().unwrap();
688                if last.name.is_none() && last.primitives.is_empty() {
689                    last.name = if name.is_empty() { None } else { Some(name) };
690                } else {
691                    doc.meshes.push(MeshAccum {
692                        name: if name.is_empty() { None } else { Some(name) },
693                        primitives: Vec::new(),
694                    });
695                }
696            }
697            "g" => {
698                // The spec (Wavefront *Advanced Visualizer* Appendix B,
699                // §"Grouping") explicitly permits multiple group names
700                // on one line: `g group_name1 group_name2 …`. Each
701                // whitespace-separated token is its own group; the
702                // following elements belong to ALL listed groups.
703                let names: Vec<String> = tokens.map(|t| t.to_string()).collect();
704                if names.is_empty() {
705                    continue;
706                }
707                let mesh = doc.meshes.last_mut().unwrap();
708                let prim = mesh.current_or_new();
709                for name in names {
710                    if !prim.groups.iter().any(|g| g == &name) {
711                        prim.groups.push(name);
712                    }
713                }
714            }
715            "s" => {
716                // `s 0` and `s off` both mean "no smoothing"; preserve
717                // the operator's chosen spelling verbatim for round-trip.
718                let v: String = tokens.collect::<Vec<_>>().join(" ");
719                if v.is_empty() {
720                    continue;
721                }
722                let mesh = doc.meshes.last_mut().unwrap();
723                let last = mesh.current_or_new();
724                if last.elements.is_empty() {
725                    // No elements yet — overwrite the pending value.
726                    last.smoothing_group = Some(v);
727                } else if last.smoothing_group.as_deref() != Some(v.as_str()) {
728                    // Smoothing changed mid-stream; spec says it's
729                    // state-setting and applies to subsequent
730                    // elements, so split into a new primitive that
731                    // inherits the current material + groups +
732                    // merging-group + display attributes.
733                    let mat = last.material.clone();
734                    let groups = last.groups.clone();
735                    let merging = last.merging_group.clone();
736                    let bevel = last.bevel.clone();
737                    let c_interp = last.c_interp.clone();
738                    let d_interp = last.d_interp.clone();
739                    let lod = last.lod.clone();
740                    let usemap = last.usemap.clone();
741                    mesh.primitives.push(PrimAccum {
742                        material: mat,
743                        smoothing_group: Some(v),
744                        groups,
745                        merging_group: merging,
746                        bevel,
747                        c_interp,
748                        d_interp,
749                        lod,
750                        usemap,
751                        elements: Vec::new(),
752                    });
753                }
754            }
755            "usemtl" => {
756                let name: String = tokens.collect::<Vec<_>>().join(" ");
757                let mesh = doc.meshes.last_mut().unwrap();
758                let last = mesh.current_or_new();
759                if last.elements.is_empty() && last.material.is_none() {
760                    // First usemtl in this primitive — adopt directly.
761                    last.material = if name.is_empty() { None } else { Some(name) };
762                } else {
763                    // Subsequent usemtl — start a new primitive that
764                    // inherits the sibling state-setters (groups,
765                    // smoothing/merging/display attrs, plus the active
766                    // `usemap` binding which is independent of
767                    // `usemtl` per spec §"usemap map_name/off").
768                    let groups = last.groups.clone();
769                    let smoothing = last.smoothing_group.clone();
770                    let merging = last.merging_group.clone();
771                    let bevel = last.bevel.clone();
772                    let c_interp = last.c_interp.clone();
773                    let d_interp = last.d_interp.clone();
774                    let lod = last.lod.clone();
775                    let usemap = last.usemap.clone();
776                    mesh.primitives.push(PrimAccum {
777                        material: if name.is_empty() { None } else { Some(name) },
778                        groups,
779                        smoothing_group: smoothing,
780                        merging_group: merging,
781                        bevel,
782                        c_interp,
783                        d_interp,
784                        lod,
785                        usemap,
786                        elements: Vec::new(),
787                    });
788                }
789            }
790            "usemap" => {
791                // Rendering identifier — `usemap <name>` or `usemap off`.
792                // Spec §"usemap map_name/off": state-setting; applies to
793                // the elements that follow until the next `usemap`. The
794                // bind operates independently of `usemtl` (one chooses a
795                // material, the other a texture-map definition), so a
796                // change splits the primitive into a fresh one that
797                // inherits everything but the map binding. An empty
798                // line is treated as no-op rather than an explicit
799                // turn-off (the spec spells out `off` as the keyword,
800                // never an empty token list).
801                let v: String = tokens.collect::<Vec<_>>().join(" ");
802                if v.is_empty() {
803                    continue;
804                }
805                let mesh = doc.meshes.last_mut().unwrap();
806                let last = mesh.current_or_new();
807                if last.elements.is_empty() {
808                    // No elements bound to this primitive yet — overwrite.
809                    last.usemap = Some(v);
810                } else if last.usemap.as_deref() != Some(v.as_str()) {
811                    let mat = last.material.clone();
812                    let groups = last.groups.clone();
813                    let smoothing = last.smoothing_group.clone();
814                    let merging = last.merging_group.clone();
815                    let bevel = last.bevel.clone();
816                    let c_interp = last.c_interp.clone();
817                    let d_interp = last.d_interp.clone();
818                    let lod = last.lod.clone();
819                    mesh.primitives.push(PrimAccum {
820                        material: mat,
821                        smoothing_group: smoothing,
822                        merging_group: merging,
823                        groups,
824                        bevel,
825                        c_interp,
826                        d_interp,
827                        lod,
828                        usemap: Some(v),
829                        elements: Vec::new(),
830                    });
831                }
832            }
833            "mtllib" => {
834                // Each `mtllib` line can list multiple .mtl files.
835                for tok in tokens {
836                    if !doc.mtllibs.iter().any(|m| m == tok) {
837                        doc.mtllibs.push(tok.to_string());
838                    }
839                }
840            }
841            "maplib" => {
842                // Rendering identifier — `maplib filename1 filename2 ...`.
843                // Spec §"maplib filename1 filename2 ...": parallel to
844                // `mtllib` but for the texture-map library that
845                // `usemap` references. Each line can list several
846                // files; later duplicates are suppressed (same policy
847                // as `mtllib`).
848                for tok in tokens {
849                    if !doc.maplibs.iter().any(|m| m == tok) {
850                        doc.maplibs.push(tok.to_string());
851                    }
852                }
853            }
854            // Unhandled keywords (curves/surfaces/display attributes/etc.) are
855            // silently skipped per spec lenient-loader convention.
856            _ => {}
857        }
858    }
859
860    Ok(doc)
861}
862
863// ---------------------------------------------------------------------------
864// Scene assembly
865// ---------------------------------------------------------------------------
866
867/// Convert the intermediate [`ObjDoc`] into a [`Scene3D`].
868///
869/// Indices are de-duplicated per-primitive so the resulting vertex
870/// buffer carries `unique_face_vertices` entries (matching glTF's
871/// per-primitive interleaved-attribute model). Original face arities
872/// are stored in `Mesh::extras["obj:original_face_arities"]` so the
873/// encoder can reconstruct the n-gons.
874fn build_scene(doc: ObjDoc) -> Result<Scene3D> {
875    use oxideav_mesh3d::{Axis, Material, Unit};
876
877    let mut scene = Scene3D::new();
878    // OBJ has no unit metadata; the primer says "Metres is the safe
879    // default" and "Y-up matches the glTF default".
880    scene.up_axis = Axis::PosY;
881    scene.unit = Unit::Metres;
882
883    // Spec §"Special point", §"sp vp1 vp …" typed view: precomputed
884    // here so the doc move into the `doc.meshes` for-loop below doesn't
885    // strand the borrow. Parse-time-only — the encoder still drives
886    // `sp` line emission off `obj:freeform_directives`.
887    let sp_typed = if !doc.freeform_directives.is_empty() {
888        let (typed, _) = collect_special_points(&doc);
889        typed
890    } else {
891        Vec::new()
892    };
893
894    // Spec §"Connectivity between free-form surfaces", §"con surf_1 q0_1
895    // q1_1 curv2d_1 surf_2 q0_2 q1_2 curv2d_2" typed view: precomputed
896    // for the same borrow-stranding reason as `sp_typed` above. Parse-
897    // time-only — the encoder still drives `con` line emission off
898    // `obj:freeform_directives`.
899    let con_typed = if !doc.freeform_directives.is_empty() {
900        collect_connectivity(&doc)
901    } else {
902        Vec::new()
903    };
904
905    // Spec §"parm u/v" typed view: one object per `parm u …` / `parm v …`
906    // body statement, paired with the enclosing element kind
907    // (`curv` / `curv2` / `surf`) and `cstype` slug. Parse-time-only —
908    // the encoder still drives `parm` line emission off
909    // `obj:freeform_directives`.
910    let parms_typed = if !doc.freeform_directives.is_empty() {
911        collect_parms(&doc)
912    } else {
913        Vec::new()
914    };
915
916    // Spec §"ctech technique resolution" / §"stech technique resolution"
917    // typed view: one object per `ctech` / `stech` body statement, paired
918    // with the enclosing element kind (`"curve"` / `"surface"`), the
919    // technique slug (`cparm` / `cspace` / `curv` for curves;
920    // `cparma` / `cparmb` / `cspace` / `curv` for surfaces), the parsed
921    // f64 resolution parameter array, and the `cstype` slug. Parse-time-
922    // only — the encoder still drives `ctech` / `stech` emission off
923    // `obj:freeform_directives`.
924    let approximations_typed = if !doc.freeform_directives.is_empty() {
925        collect_approximation_techniques(&doc)
926    } else {
927        Vec::new()
928    };
929
930    // Spec §"Trimming loops and holes" / §"Special curve" typed view:
931    // one object per `trim` / `hole` / `scrv` body statement, paired
932    // with the enclosing element kind + `cstype` slug and decomposed
933    // into its `(u0, u1, curv2d)` segment triples. Parse-time-only —
934    // the encoder still drives `trim` / `hole` / `scrv` emission off
935    // `obj:freeform_directives`.
936    let trim_loops_typed = if !doc.freeform_directives.is_empty() {
937        collect_trim_loops(&doc)
938    } else {
939        Vec::new()
940    };
941
942    // Materials first so primitives can point at their MaterialId.
943    // Insertion order is preserved (HashMap iteration order is
944    // unspecified, so sort by name to keep round-trip deterministic).
945    let mut material_ids: HashMap<String, oxideav_mesh3d::MaterialId> = HashMap::new();
946    let mut material_names: Vec<String> = doc.resolved_materials.keys().cloned().collect();
947    material_names.sort();
948    for name in &material_names {
949        let mut mat = doc
950            .resolved_materials
951            .get(name)
952            .cloned()
953            .unwrap_or_else(Material::new);
954        if mat.name.is_none() {
955            mat.name = Some(name.clone());
956        }
957        let id = scene.add_material(mat);
958        material_ids.insert(name.clone(), id);
959    }
960
961    for mesh_acc in doc.meshes {
962        // Drop genuinely empty meshes (no primitives that emit anything).
963        let has_anything = mesh_acc.primitives.iter().any(|p| !p.elements.is_empty());
964        if !has_anything {
965            continue;
966        }
967
968        let mut mesh = Mesh::new(mesh_acc.name.clone());
969
970        for prim_acc in mesh_acc.primitives {
971            let (mut primitive, arities) = build_primitive(
972                &prim_acc,
973                &doc.positions,
974                &doc.position_weights,
975                &doc.position_colors,
976                &doc.texcoords,
977                &doc.normals,
978                &material_ids,
979            )?;
980            // Skip primitives that never accumulated any element.
981            if primitive.positions.is_empty() {
982                continue;
983            }
984            // Stash original face arities per-primitive when the primitive
985            // contained at least one non-triangle face. Mesh has no
986            // `extras` field, so the round-trip annotation lives on the
987            // primitive — symmetrical with the smoothing-group / groups /
988            // usemtl extras already populated by `build_primitive`.
989            if arities.iter().any(|&a| a != 3) {
990                primitive.extras.insert(
991                    "obj:original_face_arities".to_string(),
992                    serde_json::to_value(&arities).unwrap(),
993                );
994            }
995            mesh.primitives.push(primitive);
996        }
997
998        scene.add_mesh(mesh);
999    }
1000
1001    // Keep the mtllib references in scene extras so a re-encode that
1002    // wants to point back at a specific MTL file can find them.
1003    if !doc.mtllibs.is_empty() {
1004        scene.extras.insert(
1005            "obj:mtllibs".to_string(),
1006            serde_json::to_value(&doc.mtllibs).unwrap(),
1007        );
1008    }
1009
1010    // Spec §"maplib filename1 filename2 ..." — sibling to `mtllib` but
1011    // for texture-map definitions consumed by `usemap`. Surfaced on
1012    // the scene so a re-encode replays the original library list.
1013    if !doc.maplibs.is_empty() {
1014        scene.extras.insert(
1015            "obj:maplibs".to_string(),
1016            serde_json::to_value(&doc.maplibs).unwrap(),
1017        );
1018    }
1019
1020    // Source-of-truth position pool — kept in 1-based parallel order
1021    // for free-form directives (`curv` / `surf`) that reference
1022    // vertices by index. Without this, an OBJ whose free-form section
1023    // is the *only* consumer of those positions would lose them on
1024    // re-encode (the encoder pools positions only from polygonal
1025    // primitives). The encoder re-emits any `obj:positions` entry not
1026    // already covered by polygonal primitives, in their original
1027    // 1-based order, so `curv 0 1 N M K` directives keep resolving
1028    // to the same coordinates after a decode → encode → decode cycle.
1029    //
1030    // Position colours / weights ride along on the same parallel
1031    // arrays so the `xyzrgb` / `xyzw` extension widths survive.
1032    if !doc.positions.is_empty()
1033        && (doc.freeform_directives.iter().any(|d| {
1034            matches!(
1035                d.first().map(String::as_str),
1036                Some("curv" | "curv2" | "surf" | "bzp" | "bsp" | "cdc" | "cdp")
1037            )
1038        }))
1039    {
1040        scene.extras.insert(
1041            "obj:positions".to_string(),
1042            serde_json::to_value(&doc.positions).unwrap(),
1043        );
1044        if doc.position_weights.iter().any(Option::is_some) {
1045            scene.extras.insert(
1046                "obj:position_weights".to_string(),
1047                serde_json::to_value(&doc.position_weights).unwrap(),
1048            );
1049        }
1050        if doc.position_colors.iter().any(Option::is_some) {
1051            scene.extras.insert(
1052                "obj:position_colors".to_string(),
1053                serde_json::to_value(&doc.position_colors).unwrap(),
1054            );
1055        }
1056    }
1057
1058    // Free-form geometry side-channel: the parameter-space vertex pool
1059    // (`vp`) and the verbatim sequence of `cstype` / `deg` / `curv` /
1060    // `surf` / `parm` / `trim` / `hole` / `scrv` / `sp` / `end` / `bzp`
1061    // / `bsp` directives. The encoder replays these after the
1062    // polygonal section so consumers that don't care about free-form
1063    // geometry simply ignore the keys, while consumers that do can
1064    // walk the directive sequence themselves.
1065    if !doc.vp.is_empty() {
1066        scene
1067            .extras
1068            .insert("obj:vp".to_string(), serde_json::to_value(&doc.vp).unwrap());
1069    }
1070    if !doc.freeform_directives.is_empty() {
1071        scene.extras.insert(
1072            "obj:freeform_directives".to_string(),
1073            serde_json::to_value(&doc.freeform_directives).unwrap(),
1074        );
1075    }
1076    if !sp_typed.is_empty() {
1077        // Spec §"Special point", §"sp vp1 vp …" typed view from the
1078        // precomputed pass above. Skipped when no `sp` resolves cleanly
1079        // (empty pool, all references out of range, or none of the
1080        // directives carry an `sp`).
1081        scene.extras.insert(
1082            "obj:special_points".to_string(),
1083            serde_json::Value::Array(sp_typed),
1084        );
1085    }
1086    if !con_typed.is_empty() {
1087        // Spec §"Connectivity between free-form surfaces" / §"con
1088        // surf_1 q0_1 q1_1 curv2d_1 surf_2 q0_2 q1_2 curv2d_2" typed
1089        // view from the precomputed pass above. Skipped when no `con`
1090        // line parsed cleanly (missing keyword, wrong argument count,
1091        // or any one of the eight slots failed to parse as the
1092        // appropriate i64/f64). The encoder still drives `con`
1093        // emission off `obj:freeform_directives`.
1094        scene.extras.insert(
1095            "obj:connectivity".to_string(),
1096            serde_json::Value::Array(con_typed),
1097        );
1098    }
1099    if !parms_typed.is_empty() {
1100        // Spec §"parm u/v" typed view from the precomputed pass above.
1101        // Skipped when no `parm` line resolved cleanly (no enclosing
1102        // element kind, unrecognised direction token, or every line
1103        // failed to surface any parseable values). The encoder still
1104        // drives `parm` emission off `obj:freeform_directives`.
1105        scene.extras.insert(
1106            "obj:parms".to_string(),
1107            serde_json::Value::Array(parms_typed),
1108        );
1109    }
1110    if !approximations_typed.is_empty() {
1111        // Spec §"ctech technique resolution" / §"stech technique
1112        // resolution" typed view from the precomputed pass above.
1113        // Skipped when no `ctech` / `stech` line resolved cleanly
1114        // (unrecognised technique slug, wrong argument count, or any
1115        // resolution parameter failed to parse as `f64`). The encoder
1116        // still drives line emission off `obj:freeform_directives`.
1117        scene.extras.insert(
1118            "obj:approximations".to_string(),
1119            serde_json::Value::Array(approximations_typed),
1120        );
1121    }
1122    if !trim_loops_typed.is_empty() {
1123        // Spec §"Trimming loops and holes" / §"Special curve" typed
1124        // view from the precomputed pass above. Skipped when no
1125        // `trim` / `hole` / `scrv` line resolved cleanly (argument
1126        // count not a positive multiple of three, or any segment's
1127        // `u0` / `u1` / `curv2d` token failed to parse). The encoder
1128        // still drives line emission off `obj:freeform_directives`.
1129        scene.extras.insert(
1130            "obj:trim_loops".to_string(),
1131            serde_json::Value::Array(trim_loops_typed),
1132        );
1133    }
1134
1135    // Spec §"shadow_obj filename" / §"trace_obj filename": top-level
1136    // last-wins state. Surfaced as plain strings so the encoder can
1137    // replay them in the preamble (before the polygonal section).
1138    if let Some(name) = &doc.shadow_obj {
1139        scene.extras.insert(
1140            "obj:shadow_obj".to_string(),
1141            serde_json::Value::String(name.clone()),
1142        );
1143    }
1144    if let Some(name) = &doc.trace_obj {
1145        scene.extras.insert(
1146            "obj:trace_obj".to_string(),
1147            serde_json::Value::String(name.clone()),
1148        );
1149    }
1150
1151    // Spec §"General statement" — `call` and `csh` directives are
1152    // captured verbatim into a separate side-channel keyed by
1153    // `obj:general_directives`. The encoder replays them in the
1154    // preamble right after the companion-file block. Source position
1155    // relative to the polygonal section is NOT preserved by design
1156    // (see the docstring on `ObjDoc::general_directives`).
1157    if !doc.general_directives.is_empty() {
1158        scene.extras.insert(
1159            "obj:general_directives".to_string(),
1160            serde_json::to_value(&doc.general_directives).unwrap(),
1161        );
1162    }
1163
1164    Ok(scene)
1165}
1166
1167/// Walk the captured free-form directive sequence in [`ObjDoc`] and
1168/// synthesise one [`Primitive`] (Topology::LineStrip, indexed) per
1169/// `curv` directive that sits under a supported `cstype` header.
1170///
1171/// Supported `cstype` values:
1172///   * `bmatrix` — round 10, evaluated via the user-supplied basis
1173///     matrix from `bmat u` and the step size from `step` (spec §"Basis
1174///     matrix"). Each polynomial segment is constructed by walking the
1175///     control-point list at the step size and computing
1176///     `P(t) = Σ_i Σ_j B[i][j] · t^j · p_i` per axis (`bmat u`
1177///     stores `B` in row-major order with column index `j` varying
1178///     fastest, per spec §"bmat u/v matrix").
1179///
1180///   * `bezier` / `rat bezier` — round 7, de Casteljau evaluation on the
1181///     `[0, 1]` basis domain.
1182///   * `bspline` / `rat bspline` — round 8, Cox-deBoor recursive basis
1183///     functions evaluated on `[t_min, t_max]` derived from the curve's
1184///     `u_min` / `u_max` clipped against the active knot vector parsed
1185///     from the most-recent `parm u` body statement.
1186///   * `cardinal` — round 9, cubic Catmull-Rom evaluation via the spec's
1187///     conversion to Bezier control points (`b1 = c1 + (c2 - c0) / 6`,
1188///     `b2 = c2 - (c3 - c1) / 6`, `b0 = c1`, `b3 = c2`). Sliding-window
1189///     piecewise: each segment i uses `c[i..i+4]`. Cardinal is cubic only
1190///     per spec §"Cardinal" — non-cubic `deg` is rejected.
1191///   * `taylor` — round 9, direct polynomial evaluation
1192///     `P(t) = Σ_{i=0..n} c_i · t^i` where each control point IS a
1193///     coefficient vector (spec §"Taylor": "control points are the
1194///     polynomial coefficients"). Sample range `[u_min, u_max]`.
1195///
1196/// Each curve is evaluated at `samples + 1` uniformly-spaced parameter
1197/// values across its evaluation interval. The resulting points become a
1198/// polyline.
1199///
1200/// `cstype` modifiers other than the listed kinds are ignored. This
1201/// function handles only 1D `curv` directives; 2-parameter `surf`
1202/// surfaces are evaluated separately by [`tessellate_surfaces`] (Bezier
1203/// tensor-product, round 11). NURBS surfaces remain captured-only.
1204///
1205/// Per-curve provenance lands on `Primitive::extras`:
1206///
1207///   * `obj:tessellated_curve` — `true` (sentinel for filters).
1208///   * `obj:curve_kind` — `"bezier"` / `"rat_bezier"` / `"bspline"` /
1209///     `"rat_bspline"` / `"cardinal"` / `"taylor"` / `"bmatrix"`.
1210///   * `obj:curve_degree` — basis polynomial degree.
1211///   * `obj:curve_u_range` — `[u_min, u_max]` from the `curv` directive.
1212///   * `obj:curve_samples` — sample count emitted.
1213///
1214/// Spec references: §"Curve and surface type" (cstype), §"Degree"
1215/// (deg), §"Curve" (curv), §"Parameter values and knot vectors"
1216/// (parm), §"B-spline" (Cox-deBoor recursion), §"Cardinal" (Catmull-Rom
1217/// conversion to Bezier), §"Taylor" (polynomial-coefficient basis),
1218/// §"Basis matrix" (general arbitrary-degree user-defined basis,
1219/// `bmat u/v` + `step` body statements),
1220/// §"Free-form curve/surface body statements" (rational weight semantics).
1221fn tessellate_curves(doc: &ObjDoc, samples: u32) -> Vec<Primitive> {
1222    // Spec §"Specifying free-form curves/surfaces": the curve / surface
1223    // header (`curv` / `surf`) lists control points, and the *body*
1224    // statements (`parm`, `trim`, `hole`, `scrv`, `sp`) follow before
1225    // the block-terminating `end`. That means a `curv` directive is
1226    // syntactically ahead of the `parm u …` knot vector it depends on
1227    // — we can't tessellate B-splines on a single linear walk.
1228    //
1229    // Strategy: scan into per-block records (`cstype` opens, `end`
1230    // closes), accumulate the relevant directives, then evaluate every
1231    // pending `curv` once the body is fully visible. The Bezier path
1232    // doesn't need the body but uses the same scaffolding for
1233    // simplicity.
1234    let mut out: Vec<Primitive> = Vec::new();
1235
1236    // Pending state inside the current `cstype` … `end` block.
1237    let mut active_kind: Option<&'static str> = None;
1238    let mut active_degree: Option<u32> = None;
1239    let mut parm_u: Vec<f32> = Vec::new();
1240    // Basis-matrix block state (spec §"Basis matrix"): `bmat u <matrix>`
1241    // supplies the (n+1)×(n+1) basis stored row-major (column j varies
1242    // fastest per spec); `step <stepu>` supplies the integer stride
1243    // between successive segment windows of control points.
1244    let mut bmat_u: Vec<f32> = Vec::new();
1245    let mut step_u: Option<u32> = None;
1246    // `curv` directives queued for this block — evaluated on `end`.
1247    let mut pending_curves: Vec<&Vec<String>> = Vec::new();
1248
1249    for entry in &doc.freeform_directives {
1250        if entry.is_empty() {
1251            continue;
1252        }
1253        match entry[0].as_str() {
1254            "cstype" => {
1255                // Flush the previous block (rare — OBJ usually ends
1256                // each block with `end`, but be defensive).
1257                flush_block(
1258                    &mut out,
1259                    doc,
1260                    active_kind,
1261                    active_degree,
1262                    &parm_u,
1263                    &bmat_u,
1264                    step_u,
1265                    &pending_curves,
1266                    samples,
1267                );
1268                pending_curves.clear();
1269                parm_u.clear();
1270                bmat_u.clear();
1271                step_u = None;
1272                active_degree = None;
1273
1274                // Spec §"Curve and surface type": `cstype [rat] type`.
1275                let mut iter = entry.iter().skip(1);
1276                let first = iter.next().map(String::as_str);
1277                let second = iter.next().map(String::as_str);
1278                active_kind = match (first, second) {
1279                    (Some("bezier"), _) => Some("bezier"),
1280                    (Some("rat"), Some("bezier")) => Some("rat_bezier"),
1281                    (Some("bspline"), _) => Some("bspline"),
1282                    (Some("rat"), Some("bspline")) => Some("rat_bspline"),
1283                    // Spec §"Cardinal": cubic Catmull-Rom. The `rat`
1284                    // qualifier is permitted but the spec note says the
1285                    // unit-weight default is reasonable for Cardinal
1286                    // because its basis functions sum to 1; we don't
1287                    // currently differentiate rat_cardinal from cardinal
1288                    // because the per-vertex weight is rarely populated
1289                    // in real Cardinal data.
1290                    (Some("cardinal"), _) => Some("cardinal"),
1291                    (Some("rat"), Some("cardinal")) => Some("cardinal"),
1292                    // Spec §"Taylor": polynomial-coefficient basis. The
1293                    // spec note explicitly warns that the rational form
1294                    // "does not make sense for Taylor" so we accept the
1295                    // `rat` qualifier but route to the same evaluator.
1296                    (Some("taylor"), _) => Some("taylor"),
1297                    (Some("rat"), Some("taylor")) => Some("taylor"),
1298                    // Spec §"Basis matrix": `cstype bmatrix` — the
1299                    // user supplies the basis via `bmat u <matrix>` and
1300                    // the segment stride via `step <stepu>`. The spec
1301                    // note on rational forms says the unit-weight
1302                    // default "may or may not make sense for a
1303                    // representation given in basis-matrix form", so
1304                    // we accept `rat bmatrix` but don't apply weights
1305                    // (the user's basis is the source of truth).
1306                    (Some("bmatrix"), _) => Some("bmatrix"),
1307                    (Some("rat"), Some("bmatrix")) => Some("bmatrix"),
1308                    _ => None,
1309                };
1310            }
1311            "deg" => {
1312                // Spec §"Degree": `deg degu [degv]`. We only consume
1313                // `degu` for 1D `curv` tessellation; `degv` is captured
1314                // in the directive sequence but unused here.
1315                if let Some(d) = entry.get(1).and_then(|t| t.parse::<u32>().ok()) {
1316                    active_degree = Some(d);
1317                }
1318            }
1319            // Spec §"Parameter values and knot vectors":
1320            // `parm u p1 p2 p3 …` (or `parm v …`). For 1D curves we
1321            // only need the `u` knot vector / parameter vector.
1322            "parm" if entry.get(1).map(String::as_str) == Some("u") => {
1323                parm_u = entry[2..]
1324                    .iter()
1325                    .filter_map(|t| t.parse::<f32>().ok())
1326                    .collect();
1327            }
1328            // Spec §"bmat u/v matrix": `bmat u m_00 m_01 … m_nn` (row-
1329            // major with column index `j` varying fastest). Only the
1330            // u-direction matrix is consumed by 1D `curv` evaluation;
1331            // `bmat v` is captured in the directive sequence but only
1332            // matters for surface tessellation (deferred).
1333            "bmat" if entry.get(1).map(String::as_str) == Some("u") => {
1334                bmat_u = entry[2..]
1335                    .iter()
1336                    .filter_map(|t| t.parse::<f32>().ok())
1337                    .collect();
1338            }
1339            // Spec §"step stepu stepv": `step stepu [stepv]`. `stepu`
1340            // is the integer stride between successive segment windows
1341            // of control points (`stepv` is required only for
1342            // surfaces).
1343            "step" => {
1344                step_u = entry.get(1).and_then(|t| t.parse::<u32>().ok());
1345            }
1346            "curv" => {
1347                // Defer evaluation until `end` — the body statement
1348                // `parm u …` that supplies the B-spline knot vector
1349                // hasn't been seen yet at this point.
1350                pending_curves.push(entry);
1351            }
1352            "end" => {
1353                flush_block(
1354                    &mut out,
1355                    doc,
1356                    active_kind,
1357                    active_degree,
1358                    &parm_u,
1359                    &bmat_u,
1360                    step_u,
1361                    &pending_curves,
1362                    samples,
1363                );
1364                pending_curves.clear();
1365                parm_u.clear();
1366                bmat_u.clear();
1367                step_u = None;
1368                active_kind = None;
1369                active_degree = None;
1370            }
1371            // `surf`, `curv2`, `trim`, `hole`, `scrv`, `sp`, `bzp`,
1372            // `bsp` etc. are tracked through `freeform_directives` but
1373            // don't influence 1D-curve tessellation directly. `surf`
1374            // (a 2-parameter surface) is evaluated by the separate
1375            // `tessellate_surfaces` pass (round 11, Bezier tensor-
1376            // product).
1377            _ => {}
1378        }
1379    }
1380    // Tail flush — a malformed OBJ might omit the closing `end`. Spec
1381    // §"Free-form curve/surface body statements" requires it, but the
1382    // rest of the loader is lenient so we are too.
1383    flush_block(
1384        &mut out,
1385        doc,
1386        active_kind,
1387        active_degree,
1388        &parm_u,
1389        &bmat_u,
1390        step_u,
1391        &pending_curves,
1392        samples,
1393    );
1394    out
1395}
1396
1397/// Evaluate every `curv` entry queued for the current `cstype … end`
1398/// block, appending tessellated primitives to `out`. A block whose
1399/// state is incomplete (missing `cstype`, missing knot vector for
1400/// B-spline, malformed control-point indices, …) is silently dropped —
1401/// the directive sequence already rides on `Scene3D::extras` for
1402/// downstream consumers.
1403#[allow(clippy::too_many_arguments)]
1404fn flush_block(
1405    out: &mut Vec<Primitive>,
1406    doc: &ObjDoc,
1407    active_kind: Option<&'static str>,
1408    active_degree: Option<u32>,
1409    parm_u: &[f32],
1410    bmat_u: &[f32],
1411    step_u: Option<u32>,
1412    pending_curves: &[&Vec<String>],
1413    samples: u32,
1414) {
1415    let Some(kind) = active_kind else {
1416        return;
1417    };
1418    for entry in pending_curves {
1419        // tokens past "curv" — first two are u_min / u_max,
1420        // remaining are 1-based / negative position indices.
1421        if entry.len() < 5 {
1422            // Minimum: keyword + u0 + u1 + at least 2 control points
1423            // (a line / degree-1 curve). Anything shorter is malformed;
1424            // skip rather than abort — the lenient-loader pattern
1425            // matches the rest of the codebase.
1426            continue;
1427        }
1428        let Ok(u_min) = entry[1].parse::<f32>() else {
1429            continue;
1430        };
1431        let Ok(u_max) = entry[2].parse::<f32>() else {
1432            continue;
1433        };
1434        let n_pos = doc.positions.len() as i64;
1435        let mut control_points: Vec<[f32; 3]> = Vec::new();
1436        let mut control_weights: Vec<f32> = Vec::new();
1437        let mut bad = false;
1438        for tok in &entry[3..] {
1439            let Ok(raw) = tok.parse::<i64>() else {
1440                bad = true;
1441                break;
1442            };
1443            let resolved = if raw < 0 { n_pos + 1 + raw } else { raw };
1444            if resolved <= 0 || resolved > n_pos {
1445                bad = true;
1446                break;
1447            }
1448            let pos = doc.positions[(resolved as usize) - 1];
1449            control_points.push(pos);
1450            // For rational forms, take the position's 4th-w weight from
1451            // the parallel `position_weights` pool (`v x y z w`).
1452            // Default 1.0 per spec when absent.
1453            let w = doc.position_weights[(resolved as usize) - 1].unwrap_or(1.0);
1454            control_weights.push(w);
1455        }
1456        if bad || control_points.len() < 2 {
1457            continue;
1458        }
1459
1460        let curve_points = match kind {
1461            "bezier" | "rat_bezier" => sample_bezier(
1462                &control_points,
1463                &control_weights,
1464                kind,
1465                u_min,
1466                u_max,
1467                samples,
1468            ),
1469            "bspline" | "rat_bspline" => {
1470                // B-spline needs a knot vector and a degree. Spec
1471                // §"B-spline" condition 6: K = q - n - 1 ⇒ knot count
1472                // must equal control-point count + degree + 1. Skip
1473                // silently when missing — the source OBJ is incomplete
1474                // in spec terms but we don't want to abort the whole
1475                // decode.
1476                let Some(degree) = active_degree else {
1477                    continue;
1478                };
1479                if parm_u.len() != control_points.len() + degree as usize + 1 {
1480                    continue;
1481                }
1482                sample_bspline(
1483                    &control_points,
1484                    &control_weights,
1485                    kind,
1486                    degree,
1487                    parm_u,
1488                    u_min,
1489                    u_max,
1490                    samples,
1491                )
1492            }
1493            "cardinal" => {
1494                // Spec §"Cardinal": "Cardinal splines are only defined
1495                // for the cubic case." Reject non-cubic `deg`. The
1496                // `parm` count requirement (K - n + 2 values, ⇒ K - 2
1497                // segments) is informational here — we slide a window
1498                // of 4 control points and emit segments directly
1499                // without needing the global parameter vector for the
1500                // basis evaluation itself, since the Catmull-Rom
1501                // tangent definition is purely local (segment i uses
1502                // c[i..i+4]).
1503                if active_degree.is_some_and(|d| d != 3) {
1504                    continue;
1505                }
1506                // Need at least 4 control points for one segment.
1507                if control_points.len() < 4 {
1508                    continue;
1509                }
1510                sample_cardinal(&control_points, samples)
1511            }
1512            "taylor" => {
1513                // Spec §"Taylor": basis function is t^i; control points
1514                // are the polynomial coefficients. `deg n` ⇒ n + 1
1515                // coefficient vectors expected. Reject when the count
1516                // doesn't match (lenient: also accept missing `deg` and
1517                // infer n = K).
1518                let degree = match active_degree {
1519                    Some(d) => d as usize,
1520                    None => control_points.len().saturating_sub(1),
1521                };
1522                if control_points.len() != degree + 1 {
1523                    continue;
1524                }
1525                sample_taylor(&control_points, u_min, u_max, samples)
1526            }
1527            "bmatrix" => {
1528                // Spec §"Basis matrix": needs `deg n` + `bmat u <(n+1)²
1529                // floats>` + `step <stepu>` body statements. Without any
1530                // of those, the block is malformed in spec terms — skip
1531                // silently (lenient-loader pattern). The basis matrix is
1532                // (n + 1) × (n + 1) per spec §"Consistency conditions":
1533                // "the size of the basis matrix is (n + 1) x (n + 1)".
1534                let Some(degree) = active_degree else {
1535                    continue;
1536                };
1537                let Some(step) = step_u else {
1538                    continue;
1539                };
1540                // `checked_add` / `checked_mul` here guard against
1541                // attacker-supplied huge `deg` values whose squared
1542                // basis-matrix size would overflow `usize`; fall through
1543                // to captured-only on overflow.
1544                let Some(n_plus_1) = (degree as usize).checked_add(1) else {
1545                    continue;
1546                };
1547                let Some(expected_bmat) = n_plus_1.checked_mul(n_plus_1) else {
1548                    continue;
1549                };
1550                if bmat_u.len() != expected_bmat {
1551                    continue;
1552                }
1553                if step == 0 {
1554                    continue;
1555                }
1556                // Need at least n + 1 control points for one segment.
1557                if control_points.len() < n_plus_1 {
1558                    continue;
1559                }
1560                sample_bmatrix(&control_points, bmat_u, degree, step, samples)
1561            }
1562            _ => continue,
1563        };
1564        if curve_points.len() < 2 {
1565            continue;
1566        }
1567
1568        let mut prim = Primitive::new(Topology::LineStrip);
1569        let n = curve_points.len() as u32;
1570        prim.positions = curve_points;
1571        // Implicit 0..N strip indices keep the buffer compact and
1572        // match how `LineStrip` consumers normally walk the vertex
1573        // array.
1574        if n > u16::MAX as u32 {
1575            prim.indices = Some(Indices::U32((0..n).collect()));
1576        } else {
1577            prim.indices = Some(Indices::U16((0..n).map(|i| i as u16).collect()));
1578        }
1579
1580        prim.extras.insert(
1581            "obj:tessellated_curve".to_string(),
1582            serde_json::Value::Bool(true),
1583        );
1584        prim.extras.insert(
1585            "obj:curve_kind".to_string(),
1586            serde_json::Value::String(kind.to_string()),
1587        );
1588        // Reported degree: for Bezier the basis degree always equals
1589        // N − 1 (control-point count − 1). For B-spline the basis
1590        // degree is the `deg` value (independent of the control-point
1591        // count). We report whichever is semantically correct for the
1592        // basis.
1593        let reported_degree = match kind {
1594            "bezier" | "rat_bezier" => (control_points.len() - 1) as u64,
1595            "bspline" | "rat_bspline" => active_degree.unwrap_or(0) as u64,
1596            // Spec §"Cardinal": "Cardinal splines are only defined for
1597            // the cubic case." Always 3.
1598            "cardinal" => 3,
1599            // Spec §"Taylor": degree n ⇒ K + 1 = n + 1 coefficients.
1600            "taylor" => active_degree
1601                .map(u64::from)
1602                .unwrap_or_else(|| (control_points.len() - 1) as u64),
1603            // Spec §"Basis matrix": degree comes from `deg n`; the
1604            // basis matrix is (n + 1) × (n + 1).
1605            "bmatrix" => active_degree.map(u64::from).unwrap_or(0),
1606            _ => 0,
1607        };
1608        prim.extras.insert(
1609            "obj:curve_degree".to_string(),
1610            serde_json::Value::Number(serde_json::Number::from(reported_degree)),
1611        );
1612        let range_arr = serde_json::Value::Array(vec![
1613            serde_json::Value::from(u_min as f64),
1614            serde_json::Value::from(u_max as f64),
1615        ]);
1616        prim.extras
1617            .insert("obj:curve_u_range".to_string(), range_arr);
1618        prim.extras.insert(
1619            "obj:curve_samples".to_string(),
1620            serde_json::Value::Number(serde_json::Number::from(samples as u64)),
1621        );
1622
1623        out.push(prim);
1624    }
1625}
1626
1627/// Tessellate every `curv2` 2D trimming / special / connectivity curve
1628/// (spec §"curv2") that sits under a supported `cstype` header into a
1629/// parameter-space polyline ([`Topology::LineStrip`]).
1630///
1631/// Where [`tessellate_curves`] evaluates 3D space curves whose control
1632/// points are geometric `v` vertices, a `curv2` references **parameter
1633/// vertices** (`vp u v [w]`, spec §"vp u v w") and lies in the 2D
1634/// parameter space of the surface it trims. The curve maths is identical
1635/// — same Bezier / B-spline / Cardinal / Taylor / basis-matrix basis as
1636/// the active `cstype` — so we reuse the 1D samplers component-wise by
1637/// lifting each `vp (u, v)` into a `[u, v, 0.0]` control point. The
1638/// sampled `x`/`y` are the parameter-space `(u, v)` coordinates; `z`
1639/// stays `0.0` (the curve is flat in parameter space).
1640///
1641/// Differences from the 3D `curv` path (spec §"curv2"):
1642///   * A `curv2` line carries **no** leading `u0 u1` range — it is just
1643///     `curv2 vp1 vp2 …`. The evaluation range for the B-spline window
1644///     comes from the block's `parm u` knot vector
1645///     (`[parm_u[0], parm_u[last]]`); Bezier / Taylor / Cardinal sample
1646///     uniformly on `[0, 1]` exactly as the 3D path does.
1647///   * Control points are 2D (non-rational) or 2D/3D (rational, the
1648///     optional 3rd `vp` coordinate is the weight, default 1.0). Since
1649///     `vp` storage pads a missing 3rd coordinate with `0.0` and a
1650///     zero rational weight is degenerate, a stored weight of exactly
1651///     `0.0` is read back as the spec default `1.0` for rational
1652///     evaluation.
1653///
1654/// Output primitives carry the same `obj:tessellated_curve` sentinel as
1655/// the 3D path (so the encoder filters them out and replays the original
1656/// `cstype` / `curv2` / `end` block verbatim from
1657/// `Scene3D::extras["obj:freeform_directives"]`) plus a
1658/// `obj:curve2 = true` marker and the
1659/// `obj:curve_kind` / `obj:curve_degree` / `obj:curve_u_range` /
1660/// `obj:curve_samples` provenance.
1661fn tessellate_curve2(doc: &ObjDoc, samples: u32) -> Vec<Primitive> {
1662    let mut out: Vec<Primitive> = Vec::new();
1663
1664    let mut active_kind: Option<&'static str> = None;
1665    let mut active_degree: Option<u32> = None;
1666    let mut parm_u: Vec<f32> = Vec::new();
1667    let mut bmat_u: Vec<f32> = Vec::new();
1668    let mut step_u: Option<u32> = None;
1669    // `curv2` directives queued for this block — evaluated on `end`
1670    // (mirrors the 3D `curv` two-pass deferral so the body `parm u`
1671    // knot vector is visible before B-spline evaluation).
1672    let mut pending: Vec<&Vec<String>> = Vec::new();
1673
1674    let flush = |out: &mut Vec<Primitive>,
1675                 active_kind: Option<&'static str>,
1676                 active_degree: Option<u32>,
1677                 parm_u: &[f32],
1678                 bmat_u: &[f32],
1679                 step_u: Option<u32>,
1680                 pending: &[&Vec<String>]| {
1681        flush_curve2_block(
1682            out,
1683            doc,
1684            active_kind,
1685            active_degree,
1686            parm_u,
1687            bmat_u,
1688            step_u,
1689            pending,
1690            samples,
1691        );
1692    };
1693
1694    for entry in &doc.freeform_directives {
1695        if entry.is_empty() {
1696            continue;
1697        }
1698        match entry[0].as_str() {
1699            "cstype" => {
1700                flush(
1701                    &mut out,
1702                    active_kind,
1703                    active_degree,
1704                    &parm_u,
1705                    &bmat_u,
1706                    step_u,
1707                    &pending,
1708                );
1709                pending.clear();
1710                parm_u.clear();
1711                bmat_u.clear();
1712                step_u = None;
1713                active_degree = None;
1714
1715                let mut iter = entry.iter().skip(1);
1716                let first = iter.next().map(String::as_str);
1717                let second = iter.next().map(String::as_str);
1718                active_kind = match (first, second) {
1719                    (Some("bezier"), _) => Some("bezier"),
1720                    (Some("rat"), Some("bezier")) => Some("rat_bezier"),
1721                    (Some("bspline"), _) => Some("bspline"),
1722                    (Some("rat"), Some("bspline")) => Some("rat_bspline"),
1723                    (Some("cardinal"), _) => Some("cardinal"),
1724                    (Some("rat"), Some("cardinal")) => Some("cardinal"),
1725                    (Some("taylor"), _) => Some("taylor"),
1726                    (Some("rat"), Some("taylor")) => Some("taylor"),
1727                    (Some("bmatrix"), _) => Some("bmatrix"),
1728                    (Some("rat"), Some("bmatrix")) => Some("bmatrix"),
1729                    _ => None,
1730                };
1731            }
1732            "deg" => {
1733                if let Some(d) = entry.get(1).and_then(|t| t.parse::<u32>().ok()) {
1734                    active_degree = Some(d);
1735                }
1736            }
1737            "parm" if entry.get(1).map(String::as_str) == Some("u") => {
1738                parm_u = entry[2..]
1739                    .iter()
1740                    .filter_map(|t| t.parse::<f32>().ok())
1741                    .collect();
1742            }
1743            "bmat" if entry.get(1).map(String::as_str) == Some("u") => {
1744                bmat_u = entry[2..]
1745                    .iter()
1746                    .filter_map(|t| t.parse::<f32>().ok())
1747                    .collect();
1748            }
1749            "step" => {
1750                step_u = entry.get(1).and_then(|t| t.parse::<u32>().ok());
1751            }
1752            "curv2" => {
1753                pending.push(entry);
1754            }
1755            "end" => {
1756                flush(
1757                    &mut out,
1758                    active_kind,
1759                    active_degree,
1760                    &parm_u,
1761                    &bmat_u,
1762                    step_u,
1763                    &pending,
1764                );
1765                pending.clear();
1766                parm_u.clear();
1767                bmat_u.clear();
1768                step_u = None;
1769                active_kind = None;
1770                active_degree = None;
1771            }
1772            _ => {}
1773        }
1774    }
1775    // Tail flush for a malformed block missing its closing `end`.
1776    flush(
1777        &mut out,
1778        active_kind,
1779        active_degree,
1780        &parm_u,
1781        &bmat_u,
1782        step_u,
1783        &pending,
1784    );
1785    out
1786}
1787
1788/// Evaluate one `curv2` entry under an active `cstype` and block-level
1789/// `parm u` / `bmat u` / `step` state. Returns `(u_min, u_max,
1790/// polyline_points)` on success, `None` when the block state is
1791/// incomplete (no `cstype`, B-spline knot-count mismatch, bad bmatrix
1792/// sizing, fewer than two control points, etc.). Shared by
1793/// [`flush_curve2_block`] and [`collect_all_curv2_polylines`] so the
1794/// surface trim/hole clipping pass (spec §"trim u0 u1 curv2d …",
1795/// §"hole u0 u1 curv2d …") sees the same polyline a stand-alone
1796/// `obj:curves2` synthetic primitive does.
1797#[allow(clippy::too_many_arguments)]
1798fn evaluate_curv2_entry(
1799    kind: &'static str,
1800    active_degree: Option<u32>,
1801    parm_u: &[f32],
1802    bmat_u: &[f32],
1803    step_u: Option<u32>,
1804    control_points: &[[f32; 3]],
1805    control_weights: &[f32],
1806    samples: u32,
1807) -> Option<(f32, f32, Vec<[f32; 3]>)> {
1808    // `curv2` carries no inline `u0 u1`; the evaluation range comes from
1809    // the block's `parm u` knot vector when present (needed for the
1810    // B-spline window clip), otherwise the canonical `[0, 1]`. Spec
1811    // §"curv2" + §"parm u/v".
1812    let (u_min, u_max) = match (parm_u.first(), parm_u.last()) {
1813        (Some(&a), Some(&b)) if parm_u.len() >= 2 => (a, b),
1814        _ => (0.0, 1.0),
1815    };
1816
1817    let curve_points = match kind {
1818        "bezier" | "rat_bezier" => {
1819            sample_bezier(control_points, control_weights, kind, u_min, u_max, samples)
1820        }
1821        "bspline" | "rat_bspline" => {
1822            let degree = active_degree?;
1823            if parm_u.len() != control_points.len() + degree as usize + 1 {
1824                return None;
1825            }
1826            sample_bspline(
1827                control_points,
1828                control_weights,
1829                kind,
1830                degree,
1831                parm_u,
1832                u_min,
1833                u_max,
1834                samples,
1835            )
1836        }
1837        "cardinal" => {
1838            if active_degree.is_some_and(|d| d != 3) {
1839                return None;
1840            }
1841            if control_points.len() < 4 {
1842                return None;
1843            }
1844            sample_cardinal(control_points, samples)
1845        }
1846        "taylor" => {
1847            let degree = match active_degree {
1848                Some(d) => d as usize,
1849                None => control_points.len().saturating_sub(1),
1850            };
1851            if control_points.len() != degree + 1 {
1852                return None;
1853            }
1854            sample_taylor(control_points, u_min, u_max, samples)
1855        }
1856        "bmatrix" => {
1857            let degree = active_degree?;
1858            let step = step_u?;
1859            let n_plus_1 = (degree as usize).checked_add(1)?;
1860            let expected_bmat = n_plus_1.checked_mul(n_plus_1)?;
1861            if bmat_u.len() != expected_bmat {
1862                return None;
1863            }
1864            if step == 0 {
1865                return None;
1866            }
1867            if control_points.len() < n_plus_1 {
1868                return None;
1869            }
1870            sample_bmatrix(control_points, bmat_u, degree, step, samples)
1871        }
1872        _ => return None,
1873    };
1874    Some((u_min, u_max, curve_points))
1875}
1876
1877/// `(u_min, u_max, polyline)` triple captured for one `curv2` entry —
1878/// see [`collect_all_curv2_polylines`]. Aliased to keep clippy's
1879/// `type_complexity` lint happy on the `Vec<Option<…>>` table that
1880/// indexes one such triple per global curv2 occurrence.
1881type Curv2Polyline = (f32, f32, Vec<[f32; 2]>);
1882/// `Vec<Option<Curv2Polyline>>` keyed by zero-based global curv2 source
1883/// order (so a `curv2d` 1-based reference reads `table[idx - 1]`).
1884type Curv2PolylineTable = Vec<Option<Curv2Polyline>>;
1885
1886/// Walk `doc.freeform_directives` once and return, for every `curv2`
1887/// encountered (1-based in source order), the tessellated parameter-space
1888/// polyline plus its evaluation `(u_min, u_max)` range. Returns `None` at
1889/// the slot when the enclosing `cstype … end` block is too incomplete to
1890/// evaluate; the slot indices themselves stay aligned with `curv2 N`
1891/// references on `trim` / `hole` / `scrv` statements (spec §"trim u0 u1
1892/// curv2d …", §"hole u0 u1 curv2d …").
1893///
1894/// `samples` is the same per-direction sample knob the user supplied via
1895/// [`ObjDecoder::with_curve_tessellation`]; the resolved polylines feed
1896/// the surface trim/hole clip pass (the polyline is rasterised at
1897/// `samples + 1` (u, v) coordinates per curve segment, then point-in-
1898/// polygon tested against each surface-lattice sample).
1899fn collect_all_curv2_polylines(doc: &ObjDoc, samples: u32) -> Curv2PolylineTable {
1900    let mut out: Curv2PolylineTable = Vec::new();
1901    if samples == 0 {
1902        return out;
1903    }
1904
1905    let n_vp = doc.vp.len() as i64;
1906    let mut active_kind: Option<&'static str> = None;
1907    let mut active_degree: Option<u32> = None;
1908    let mut parm_u: Vec<f32> = Vec::new();
1909    let mut bmat_u: Vec<f32> = Vec::new();
1910    let mut step_u: Option<u32> = None;
1911    // First pass collects per-block state and indexes of every `curv2`
1912    // entry within that block; we evaluate on `end` (or `cstype` / tail)
1913    // so the body `parm u` knot vector is fully visible. This mirrors
1914    // the two-pass deferral used by `tessellate_curve2`.
1915    let mut pending: Vec<(usize, &Vec<String>)> = Vec::new();
1916
1917    let flush = |out: &mut Curv2PolylineTable,
1918                 active_kind: Option<&'static str>,
1919                 active_degree: Option<u32>,
1920                 parm_u: &[f32],
1921                 bmat_u: &[f32],
1922                 step_u: Option<u32>,
1923                 pending: &[(usize, &Vec<String>)]| {
1924        for (idx, entry) in pending {
1925            // Make sure the output Vec is long enough to address `idx`
1926            // (1-based — slot 0 is unused so `curv2 1` lands at index 0
1927            // with a `-1` shift when looked up).
1928            while out.len() <= *idx {
1929                out.push(None);
1930            }
1931            let Some(kind) = active_kind else {
1932                continue;
1933            };
1934            if entry.len() < 3 {
1935                continue;
1936            }
1937            let mut control_points: Vec<[f32; 3]> = Vec::new();
1938            let mut control_weights: Vec<f32> = Vec::new();
1939            let mut bad = false;
1940            for tok in &entry[1..] {
1941                let Ok(raw) = tok.parse::<i64>() else {
1942                    bad = true;
1943                    break;
1944                };
1945                let resolved = if raw < 0 { n_vp + 1 + raw } else { raw };
1946                if resolved <= 0 || resolved > n_vp {
1947                    bad = true;
1948                    break;
1949                }
1950                let vp = doc.vp[(resolved as usize) - 1];
1951                control_points.push([vp[0], vp[1], 0.0]);
1952                let w = if vp[2] == 0.0 { 1.0 } else { vp[2] };
1953                control_weights.push(w);
1954            }
1955            if bad || control_points.len() < 2 {
1956                continue;
1957            }
1958            let Some((u_min, u_max, pts)) = evaluate_curv2_entry(
1959                kind,
1960                active_degree,
1961                parm_u,
1962                bmat_u,
1963                step_u,
1964                &control_points,
1965                &control_weights,
1966                samples,
1967            ) else {
1968                continue;
1969            };
1970            // Lift the curve's (x, y, z) samples down to 2D — the z
1971            // component is always 0 for a `curv2` (we lifted the 2D `vp`
1972            // into a flat 3D control point inside the evaluator), so
1973            // dropping it is lossless.
1974            let polyline: Vec<[f32; 2]> = pts.iter().map(|p| [p[0], p[1]]).collect();
1975            out[*idx] = Some((u_min, u_max, polyline));
1976        }
1977    };
1978
1979    // 0-based global `curv2` counter; the spec's `trim u0 u1 curv2d`
1980    // numbering is 1-based so we look up at `curv2d - 1`.
1981    let mut curv2_counter: usize = 0;
1982
1983    for entry in &doc.freeform_directives {
1984        if entry.is_empty() {
1985            continue;
1986        }
1987        match entry[0].as_str() {
1988            "cstype" => {
1989                flush(
1990                    &mut out,
1991                    active_kind,
1992                    active_degree,
1993                    &parm_u,
1994                    &bmat_u,
1995                    step_u,
1996                    &pending,
1997                );
1998                pending.clear();
1999                parm_u.clear();
2000                bmat_u.clear();
2001                step_u = None;
2002                active_degree = None;
2003
2004                let mut iter = entry.iter().skip(1);
2005                let first = iter.next().map(String::as_str);
2006                let second = iter.next().map(String::as_str);
2007                active_kind = match (first, second) {
2008                    (Some("bezier"), _) => Some("bezier"),
2009                    (Some("rat"), Some("bezier")) => Some("rat_bezier"),
2010                    (Some("bspline"), _) => Some("bspline"),
2011                    (Some("rat"), Some("bspline")) => Some("rat_bspline"),
2012                    (Some("cardinal"), _) => Some("cardinal"),
2013                    (Some("rat"), Some("cardinal")) => Some("cardinal"),
2014                    (Some("taylor"), _) => Some("taylor"),
2015                    (Some("rat"), Some("taylor")) => Some("taylor"),
2016                    (Some("bmatrix"), _) => Some("bmatrix"),
2017                    (Some("rat"), Some("bmatrix")) => Some("bmatrix"),
2018                    _ => None,
2019                };
2020            }
2021            "deg" => {
2022                if let Some(d) = entry.get(1).and_then(|t| t.parse::<u32>().ok()) {
2023                    active_degree = Some(d);
2024                }
2025            }
2026            "parm" if entry.get(1).map(String::as_str) == Some("u") => {
2027                parm_u = entry[2..]
2028                    .iter()
2029                    .filter_map(|t| t.parse::<f32>().ok())
2030                    .collect();
2031            }
2032            "bmat" if entry.get(1).map(String::as_str) == Some("u") => {
2033                bmat_u = entry[2..]
2034                    .iter()
2035                    .filter_map(|t| t.parse::<f32>().ok())
2036                    .collect();
2037            }
2038            "step" => {
2039                step_u = entry.get(1).and_then(|t| t.parse::<u32>().ok());
2040            }
2041            "curv2" => {
2042                pending.push((curv2_counter, entry));
2043                curv2_counter += 1;
2044            }
2045            "end" => {
2046                flush(
2047                    &mut out,
2048                    active_kind,
2049                    active_degree,
2050                    &parm_u,
2051                    &bmat_u,
2052                    step_u,
2053                    &pending,
2054                );
2055                pending.clear();
2056                parm_u.clear();
2057                bmat_u.clear();
2058                step_u = None;
2059                active_kind = None;
2060                active_degree = None;
2061            }
2062            _ => {}
2063        }
2064    }
2065    // Tail flush for blocks missing their closing `end` directive.
2066    flush(
2067        &mut out,
2068        active_kind,
2069        active_degree,
2070        &parm_u,
2071        &bmat_u,
2072        step_u,
2073        &pending,
2074    );
2075    // Pad the output so every globally-numbered curv2 has a slot, even
2076    // when the trailing ones evaluated to `None`.
2077    while out.len() < curv2_counter {
2078        out.push(None);
2079    }
2080    out
2081}
2082
2083/// Slice a tessellated `curv2` polyline to the parameter sub-range
2084/// `[u0, u1]` (spec §"trim u0 u1 curv2d" / §"hole u0 u1 curv2d") and
2085/// append the resulting (u, v) sample sequence onto `loop_out`. The
2086/// polyline was sampled uniformly over the curve's own `(u_min, u_max)`
2087/// range, so we map `[u0, u1]` linearly into that range and pick out
2088/// the matching slice. Reverses the slice when `u0 > u1` so the loop
2089/// orientation can be expressed by either ordering.
2090///
2091/// To avoid duplicate vertices at segment boundaries on a multi-curve
2092/// loop, the first vertex of the second-and-later segment is skipped
2093/// when `loop_out` is non-empty.
2094fn append_curv2_segment(
2095    loop_out: &mut Vec<[f32; 2]>,
2096    polyline: &[[f32; 2]],
2097    curve_u_min: f32,
2098    curve_u_max: f32,
2099    u0: f32,
2100    u1: f32,
2101) {
2102    if polyline.len() < 2 {
2103        return;
2104    }
2105    let n = polyline.len();
2106    let span = curve_u_max - curve_u_min;
2107    let to_idx = |u: f32| -> usize {
2108        if span.abs() < f32::EPSILON {
2109            0
2110        } else {
2111            let t = ((u - curve_u_min) / span).clamp(0.0, 1.0);
2112            (t * (n - 1) as f32).round() as usize
2113        }
2114    };
2115    let i0 = to_idx(u0);
2116    let i1 = to_idx(u1);
2117    let forward = i0 <= i1;
2118    let (lo, hi) = if forward { (i0, i1) } else { (i1, i0) };
2119    if hi <= lo {
2120        return;
2121    }
2122    let segment: Vec<[f32; 2]> = if forward {
2123        polyline[lo..=hi].to_vec()
2124    } else {
2125        polyline[lo..=hi].iter().rev().copied().collect()
2126    };
2127    let start = if loop_out.is_empty() { 0 } else { 1 };
2128    for p in &segment[start..] {
2129        loop_out.push(*p);
2130    }
2131}
2132
2133/// Standard ray-casting point-in-polygon test (Jordan curve theorem).
2134/// The polygon is treated as closed — the implicit edge from the last
2135/// vertex back to the first is included so a `curv2` loop that ends on
2136/// its starting parameter vertex (the typical spec pattern, e.g.
2137/// `curv2 1 2 3 4 1`) is handled correctly. Vertices on the boundary
2138/// can resolve either way depending on the epsilon noise of the
2139/// rasterised polyline; this is fine for the surface-clip use case
2140/// since we keep triangles only when **all three** vertices pass, so a
2141/// single ambiguous boundary point can at most lose one cell.
2142fn point_in_polygon(point: [f32; 2], polygon: &[[f32; 2]]) -> bool {
2143    let n = polygon.len();
2144    if n < 3 {
2145        return false;
2146    }
2147    let [px, py] = point;
2148    let mut inside = false;
2149    let mut j = n - 1;
2150    for i in 0..n {
2151        let [xi, yi] = polygon[i];
2152        let [xj, yj] = polygon[j];
2153        let intersect = (yi > py) != (yj > py) && {
2154            let denom = yj - yi;
2155            if denom.abs() < f32::EPSILON {
2156                false
2157            } else {
2158                px < (xj - xi) * (py - yi) / denom + xi
2159            }
2160        };
2161        if intersect {
2162            inside = !inside;
2163        }
2164        j = i;
2165    }
2166    inside
2167}
2168
2169/// Evaluate every `curv2` entry queued for the current `cstype … end`
2170/// block (helper for [`tessellate_curve2`]). A block whose state is
2171/// incomplete (missing `cstype`, missing knot vector for B-spline,
2172/// malformed `vp` indices, …) is silently dropped, matching the
2173/// lenient-loader pattern used throughout the crate.
2174#[allow(clippy::too_many_arguments)]
2175fn flush_curve2_block(
2176    out: &mut Vec<Primitive>,
2177    doc: &ObjDoc,
2178    active_kind: Option<&'static str>,
2179    active_degree: Option<u32>,
2180    parm_u: &[f32],
2181    bmat_u: &[f32],
2182    step_u: Option<u32>,
2183    pending: &[&Vec<String>],
2184    samples: u32,
2185) {
2186    let Some(kind) = active_kind else {
2187        return;
2188    };
2189    let n_vp = doc.vp.len() as i64;
2190    for entry in pending {
2191        // `curv2 vp1 vp2 …` — keyword + at least two control points.
2192        if entry.len() < 3 {
2193            continue;
2194        }
2195        let mut control_points: Vec<[f32; 3]> = Vec::new();
2196        let mut control_weights: Vec<f32> = Vec::new();
2197        let mut bad = false;
2198        for tok in &entry[1..] {
2199            let Ok(raw) = tok.parse::<i64>() else {
2200                bad = true;
2201                break;
2202            };
2203            // Spec §"curv2": control points are parameter vertices;
2204            // negative values are relative-from-end (spec §"vp").
2205            let resolved = if raw < 0 { n_vp + 1 + raw } else { raw };
2206            if resolved <= 0 || resolved > n_vp {
2207                bad = true;
2208                break;
2209            }
2210            let vp = doc.vp[(resolved as usize) - 1];
2211            // Lift the 2D parameter coordinate into a flat 3D control
2212            // point so the existing 1D samplers (which operate on
2213            // `[f32; 3]` component-wise) evaluate the curve unchanged.
2214            control_points.push([vp[0], vp[1], 0.0]);
2215            // The optional 3rd `vp` coordinate is the rational weight
2216            // (spec §"vp u v w"). `vp` storage pads a missing 3rd
2217            // coordinate with `0.0`; a 0 weight is degenerate, so read
2218            // it back as the spec default 1.0.
2219            let w = if vp[2] == 0.0 { 1.0 } else { vp[2] };
2220            control_weights.push(w);
2221        }
2222        if bad || control_points.len() < 2 {
2223            continue;
2224        }
2225
2226        let Some((u_min, u_max, curve_points)) = evaluate_curv2_entry(
2227            kind,
2228            active_degree,
2229            parm_u,
2230            bmat_u,
2231            step_u,
2232            &control_points,
2233            &control_weights,
2234            samples,
2235        ) else {
2236            continue;
2237        };
2238        if curve_points.len() < 2 {
2239            continue;
2240        }
2241
2242        let mut prim = Primitive::new(Topology::LineStrip);
2243        let n = curve_points.len() as u32;
2244        prim.positions = curve_points;
2245        if n > u16::MAX as u32 {
2246            prim.indices = Some(Indices::U32((0..n).collect()));
2247        } else {
2248            prim.indices = Some(Indices::U16((0..n).map(|i| i as u16).collect()));
2249        }
2250
2251        prim.extras.insert(
2252            "obj:tessellated_curve".to_string(),
2253            serde_json::Value::Bool(true),
2254        );
2255        // 2D-parameter-space marker so consumers can tell a `curv2`
2256        // polyline apart from a 3D `curv` one (the positions are
2257        // `(u, v, 0)` parameter-space coordinates, not model space).
2258        prim.extras
2259            .insert("obj:curve2".to_string(), serde_json::Value::Bool(true));
2260        prim.extras.insert(
2261            "obj:curve_kind".to_string(),
2262            serde_json::Value::String(kind.to_string()),
2263        );
2264        let reported_degree = match kind {
2265            "bezier" | "rat_bezier" => (control_points.len() - 1) as u64,
2266            "bspline" | "rat_bspline" => active_degree.unwrap_or(0) as u64,
2267            "cardinal" => 3,
2268            "taylor" => active_degree
2269                .map(u64::from)
2270                .unwrap_or_else(|| (control_points.len() - 1) as u64),
2271            "bmatrix" => active_degree.map(u64::from).unwrap_or(0),
2272            _ => 0,
2273        };
2274        prim.extras.insert(
2275            "obj:curve_degree".to_string(),
2276            serde_json::Value::Number(serde_json::Number::from(reported_degree)),
2277        );
2278        prim.extras.insert(
2279            "obj:curve_u_range".to_string(),
2280            serde_json::Value::Array(vec![
2281                serde_json::Value::from(u_min as f64),
2282                serde_json::Value::from(u_max as f64),
2283            ]),
2284        );
2285        prim.extras.insert(
2286            "obj:curve_samples".to_string(),
2287            serde_json::Value::Number(serde_json::Number::from(samples as u64)),
2288        );
2289
2290        out.push(prim);
2291    }
2292}
2293
2294/// Tessellate every `scrv` (special-curve) directive that sits inside a
2295/// `cstype … end` block into a single parameter-space polyline
2296/// ([`Topology::LineStrip`]). Spec §"Special curve", §"scrv u0 u1 curv2d
2297/// u0 u1 curv2d …".
2298///
2299/// A `scrv` is structurally identical to `trim` / `hole`: each directive
2300/// is a sequence of `(u0, u1, curv2d)` triples that select sub-ranges of
2301/// previously-defined `curv2` parameter-space curves. Unlike trim/hole,
2302/// the resulting polyline is **not** a closed loop — the spec describes
2303/// it as "a sequence of curves which lie on a given surface to build a
2304/// single special curve" that will appear as triangle edges in the
2305/// surface's final triangulation. Until the surface triangulator
2306/// honours that constraint, this round emits the special curve as a
2307/// stand-alone parameter-space polyline on the synthetic `"obj:scrvs"`
2308/// mesh so consumers that care about the special-curve geometry can
2309/// resolve it without re-walking the directive stream.
2310///
2311/// Per-`scrv` provenance lands on `Primitive::extras`:
2312///   * `obj:tessellated_curve` — `true` (shared encoder-filter sentinel).
2313///   * `obj:scrv` — `true` (special-curve marker).
2314///   * `obj:scrv_segments` — number of `(u0, u1, curv2d)` segments
2315///     concatenated into the polyline.
2316///   * `obj:scrv_curv2_refs` — array of `[curv2d_index_1based, u0, u1]`
2317///     triples in source order (provenance for the segment list).
2318///
2319/// The free-form directive sequence still rides on
2320/// `Scene3D::extras["obj:freeform_directives"]` so a decode → encode
2321/// cycle replays the original `cstype` / `surf` / `scrv` / `end` block
2322/// verbatim — the encoder filters the synthetic polyline out via the
2323/// shared `obj:tessellated_curve` sentinel.
2324///
2325/// `curv2d` references are 1-based global per spec §"scrv u0 u1
2326/// curv2d" — "This curve must have been previously defined with the
2327/// curv2 statement". Segments whose referenced `curv2` failed to
2328/// tessellate (incomplete block state, missing knot vector, …) are
2329/// silently dropped; the surrounding `scrv` may still produce a
2330/// partial polyline if at least two vertices survive across the
2331/// successfully-resolved segments.
2332fn tessellate_scrv(doc: &ObjDoc, samples: u32) -> Vec<Primitive> {
2333    let mut out: Vec<Primitive> = Vec::new();
2334    if samples == 0 {
2335        return out;
2336    }
2337
2338    // Reuse the same pre-resolution pass the surface trim/hole clipper
2339    // uses (spec §"trim u0 u1 curv2d" / §"hole u0 u1 curv2d" /
2340    // §"scrv u0 u1 curv2d" — all three reference `curv2` by 1-based
2341    // global index).
2342    let curv2_polylines = collect_all_curv2_polylines(doc, samples);
2343
2344    for entry in &doc.freeform_directives {
2345        if entry.first().map(String::as_str) != Some("scrv") {
2346            continue;
2347        }
2348        // `scrv u0 u1 curv2d u0 u1 curv2d …` — keyword + N triples.
2349        let toks = &entry[1..];
2350        if toks.len() < 3 || toks.len() % 3 != 0 {
2351            continue;
2352        }
2353
2354        let mut polyline: Vec<[f32; 2]> = Vec::new();
2355        let mut refs: Vec<serde_json::Value> = Vec::new();
2356        let mut segments: u32 = 0;
2357        let mut bad = false;
2358        for chunk in toks.chunks(3) {
2359            let Ok(u0) = chunk[0].parse::<f32>() else {
2360                bad = true;
2361                break;
2362            };
2363            let Ok(u1) = chunk[1].parse::<f32>() else {
2364                bad = true;
2365                break;
2366            };
2367            let Ok(idx) = chunk[2].parse::<i64>() else {
2368                bad = true;
2369                break;
2370            };
2371            // Spec §"scrv u0 u1 curv2d": the curv2 index is 1-based and
2372            // references an earlier definition. The spec doesn't describe
2373            // a negative-from-end form here (those would predate the
2374            // curve being defined), matching the trim/hole semantics.
2375            if idx <= 0 {
2376                continue;
2377            }
2378            let slot = idx as usize - 1;
2379            let Some(entry) = curv2_polylines.get(slot).and_then(|e| e.as_ref()) else {
2380                // The referenced curv2 didn't tessellate (block state
2381                // incomplete, malformed `vp` index, etc.). Skip this
2382                // segment but keep walking the rest of the scrv — the
2383                // spec doesn't require all-or-nothing.
2384                continue;
2385            };
2386            let (curve_u_min, curve_u_max, segment_polyline) = entry;
2387            append_curv2_segment(
2388                &mut polyline,
2389                segment_polyline,
2390                *curve_u_min,
2391                *curve_u_max,
2392                u0,
2393                u1,
2394            );
2395            refs.push(serde_json::Value::Array(vec![
2396                serde_json::Value::from(idx),
2397                serde_json::Value::from(u0 as f64),
2398                serde_json::Value::from(u1 as f64),
2399            ]));
2400            segments += 1;
2401        }
2402        if bad || polyline.len() < 2 {
2403            continue;
2404        }
2405
2406        // Lift the 2D parameter-space samples into a flat 3D position
2407        // list (z = 0). Matches the `curv2` synthetic primitive layout
2408        // so consumers can treat scrv and curv2 polylines uniformly.
2409        let positions: Vec<[f32; 3]> = polyline.iter().map(|p| [p[0], p[1], 0.0]).collect();
2410        let n = positions.len() as u32;
2411        let mut prim = Primitive::new(Topology::LineStrip);
2412        prim.positions = positions;
2413        prim.indices = if n > u16::MAX as u32 {
2414            Some(Indices::U32((0..n).collect()))
2415        } else {
2416            Some(Indices::U16((0..n).map(|i| i as u16).collect()))
2417        };
2418        prim.extras.insert(
2419            "obj:tessellated_curve".to_string(),
2420            serde_json::Value::Bool(true),
2421        );
2422        prim.extras
2423            .insert("obj:scrv".to_string(), serde_json::Value::Bool(true));
2424        prim.extras.insert(
2425            "obj:scrv_segments".to_string(),
2426            serde_json::Value::Number(serde_json::Number::from(segments as u64)),
2427        );
2428        prim.extras.insert(
2429            "obj:scrv_curv2_refs".to_string(),
2430            serde_json::Value::Array(refs),
2431        );
2432        out.push(prim);
2433    }
2434
2435    out
2436}
2437
2438/// Connectivity (`con`) seam tessellation pass — evaluates every `con`
2439/// connectivity statement into a pair of synthetic parameter-space
2440/// `LineStrip` polylines, one per joined surface edge.
2441///
2442/// Spec §"Connectivity between free-form surfaces" / §"con surf_1 q0_1
2443/// q1_1 curv2d_1 surf_2 q0_2 q1_2 curv2d_2": the statement ties two
2444/// previously-declared `surf` blocks together along a shared trimming-
2445/// curve segment so a downstream merger can weld the surfaces' boundary
2446/// vertices without re-deriving the adjacency numerically (spec:
2447/// "This information is useful for edge merging. Without this surface
2448/// and curve data, connectivity must be determined numerically at
2449/// greater expense and with reduced accuracy using the mg statement.").
2450/// The appendix gives the exact correspondence: the seam is the curve
2451/// `S1(T1(t1))` for `t1 ∈ [q0_1, q1_1]` on the first surface and
2452/// `S2(T2(t2))` for `t2 ∈ [q0_2, q1_2]` on the second, with the two
2453/// "identical up to reparameterization" and the endpoints meeting
2454/// exactly — `S1(T1(q0_1)) = S2(T2(q0_2))`, `S1(T1(q1_1)) =
2455/// S2(T2(q1_2))`.
2456///
2457/// Where the round-251 typed view (`obj:connectivity`) surfaces the
2458/// eight raw `con` arguments for a consumer to act on, this pass emits
2459/// the seam itself as drawable geometry: the `curv2d` referenced on
2460/// each side is resolved through the same `collect_all_curv2_polylines`
2461/// table the trim / hole / scrv passes use (spec §"con … curv2d …":
2462/// "This curve must have been previously defined with the curv2
2463/// statement"), and the `[q0, q1]` sub-range is walked with the shared
2464/// `append_curv2_segment` so a connectivity seam is sampled identically
2465/// to a special-curve segment. Each side becomes one `LineStrip`
2466/// primitive in parameter space (`z = 0`, matching the `curv2` / `scrv`
2467/// synthetic layout) on a dedicated `obj:cons` mesh.
2468///
2469/// Each emitted primitive carries:
2470///   * `obj:tessellated_curve` — `true` (shared encoder-filter sentinel
2471///     so the encoder's existing `is_tessellated_curve` filter drops
2472///     the synthetic seam from a re-emit; the original `con` line still
2473///     rides on `obj:freeform_directives` for verbatim round-trip).
2474///   * `obj:con` — `true`.
2475///   * `obj:con_side` — `1` or `2`, which `(surf, q0, q1, curv2d)`
2476///     quadruple of the statement this seam came from.
2477///   * `obj:con_surf` — `i64`, the index of the surface this side joins
2478///     (`surf_1` for side 1, `surf_2` for side 2). Carried as-is; the
2479///     seam geometry comes from the `curv2d` curve, not from resolving
2480///     the surface, so a forward reference to a surface that this pass
2481///     doesn't number is still recorded.
2482///   * `obj:con_peer_surf` — `i64`, the index of the surface on the
2483///     *other* side of the join (so a consumer holding one seam can
2484///     find its mate without re-parsing the statement).
2485///   * `obj:con_curv2d` — `i64`, the 1-based `curv2d` index this seam
2486///     was resolved from.
2487///   * `obj:con_q0` / `obj:con_q1` — `f64`, the start / end parameter
2488///     on `curv2d` for this side.
2489///
2490/// A `con` line that doesn't carry exactly eight arguments, or whose
2491/// integer slots fail to parse as `i64` / float slots fail to parse as
2492/// `f64`, is skipped without failing the parse (mirrors the
2493/// `obj:connectivity` typed-view policy — lossy on malformed input, the
2494/// verbatim channel stays the source of truth for the encoder). A side
2495/// whose referenced `curv2d` didn't tessellate (block state incomplete,
2496/// non-positive index, fewer than two resolved points over the
2497/// requested sub-range) is dropped on its own; the other side of the
2498/// same `con` can still emit. Spec §"con": "Connectivity between
2499/// surfaces in different merging groups is ignored." — merging-group
2500/// filtering is a renderer-side decision over the `mg` state and is NOT
2501/// applied here; the seam is emitted whenever its curve resolves and
2502/// the consumer prunes against the merging-group provenance if it
2503/// chooses.
2504fn tessellate_connectivity(doc: &ObjDoc, samples: u32) -> Vec<Primitive> {
2505    let mut out: Vec<Primitive> = Vec::new();
2506    if samples == 0 {
2507        return out;
2508    }
2509
2510    // Reuse the same pre-resolution pass the surface trim/hole clipper,
2511    // the scrv pass, and the typed views use (spec §"con … curv2d …" —
2512    // the connectivity curves reference `curv2` by 1-based global
2513    // index, exactly like trim / hole / scrv).
2514    let curv2_polylines = collect_all_curv2_polylines(doc, samples);
2515
2516    // Resolve one `(q0, q1, curv2d)` side of a `con` statement into a
2517    // parameter-space polyline. Returns `None` when the side can't be
2518    // realised as a ≥ 2-point seam (non-positive / out-of-range
2519    // curv2d, the referenced curve didn't tessellate, or the sub-range
2520    // collapsed to fewer than two points).
2521    let resolve_side = |q0: f32, q1: f32, idx: i64| -> Option<Vec<[f32; 2]>> {
2522        if idx <= 0 {
2523            return None;
2524        }
2525        let slot = idx as usize - 1;
2526        let entry = curv2_polylines.get(slot).and_then(|e| e.as_ref())?;
2527        let (curve_u_min, curve_u_max, polyline) = entry;
2528        let mut seam: Vec<[f32; 2]> = Vec::new();
2529        append_curv2_segment(&mut seam, polyline, *curve_u_min, *curve_u_max, q0, q1);
2530        if seam.len() < 2 {
2531            return None;
2532        }
2533        Some(seam)
2534    };
2535
2536    let make_prim = |seam: Vec<[f32; 2]>,
2537                     side: u8,
2538                     surf: i64,
2539                     peer_surf: i64,
2540                     curv2d: i64,
2541                     q0: f32,
2542                     q1: f32| {
2543        // Lift the 2D parameter-space seam into a flat 3D position
2544        // list (z = 0), matching the `curv2` / `scrv` synthetic
2545        // primitive layout so consumers can treat all three
2546        // uniformly.
2547        let positions: Vec<[f32; 3]> = seam.iter().map(|p| [p[0], p[1], 0.0]).collect();
2548        let n = positions.len() as u32;
2549        let mut prim = Primitive::new(Topology::LineStrip);
2550        prim.positions = positions;
2551        prim.indices = if n > u16::MAX as u32 {
2552            Some(Indices::U32((0..n).collect()))
2553        } else {
2554            Some(Indices::U16((0..n).map(|i| i as u16).collect()))
2555        };
2556        prim.extras.insert(
2557            "obj:tessellated_curve".to_string(),
2558            serde_json::Value::Bool(true),
2559        );
2560        prim.extras
2561            .insert("obj:con".to_string(), serde_json::Value::Bool(true));
2562        prim.extras.insert(
2563            "obj:con_side".to_string(),
2564            serde_json::Value::Number(serde_json::Number::from(side)),
2565        );
2566        prim.extras.insert(
2567            "obj:con_surf".to_string(),
2568            serde_json::Value::Number(serde_json::Number::from(surf)),
2569        );
2570        prim.extras.insert(
2571            "obj:con_peer_surf".to_string(),
2572            serde_json::Value::Number(serde_json::Number::from(peer_surf)),
2573        );
2574        prim.extras.insert(
2575            "obj:con_curv2d".to_string(),
2576            serde_json::Value::Number(serde_json::Number::from(curv2d)),
2577        );
2578        prim.extras
2579            .insert("obj:con_q0".to_string(), serde_json::Value::from(q0 as f64));
2580        prim.extras
2581            .insert("obj:con_q1".to_string(), serde_json::Value::from(q1 as f64));
2582        prim
2583    };
2584
2585    for entry in &doc.freeform_directives {
2586        if entry.first().map(String::as_str) != Some("con") {
2587            continue;
2588        }
2589        // Spec §"con surf_1 q0_1 q1_1 curv2d_1 surf_2 q0_2 q1_2
2590        // curv2d_2": keyword + exactly eight positional args.
2591        if entry.len() != 9 {
2592            continue;
2593        }
2594        let (
2595            Ok(surf_1),
2596            Ok(q0_1),
2597            Ok(q1_1),
2598            Ok(curv2d_1),
2599            Ok(surf_2),
2600            Ok(q0_2),
2601            Ok(q1_2),
2602            Ok(curv2d_2),
2603        ) = (
2604            entry[1].parse::<i64>(),
2605            entry[2].parse::<f32>(),
2606            entry[3].parse::<f32>(),
2607            entry[4].parse::<i64>(),
2608            entry[5].parse::<i64>(),
2609            entry[6].parse::<f32>(),
2610            entry[7].parse::<f32>(),
2611            entry[8].parse::<i64>(),
2612        )
2613        else {
2614            continue;
2615        };
2616
2617        // Side 1: curve `curv2d_1` over [q0_1, q1_1] on `surf_1`.
2618        if let Some(seam) = resolve_side(q0_1, q1_1, curv2d_1) {
2619            out.push(make_prim(seam, 1, surf_1, surf_2, curv2d_1, q0_1, q1_1));
2620        }
2621        // Side 2: curve `curv2d_2` over [q0_2, q1_2] on `surf_2`.
2622        if let Some(seam) = resolve_side(q0_2, q1_2, curv2d_2) {
2623            out.push(make_prim(seam, 2, surf_2, surf_1, curv2d_2, q0_2, q1_2));
2624        }
2625    }
2626
2627    out
2628}
2629
2630/// Walk every `cstype` … `end` block once and lift every `sp` (special
2631/// point) body statement that appears inside it into a typed
2632/// parameter-space record. Spec §"Special point", §"sp vp1 vp …".
2633///
2634/// A special point is an `sp` body statement (spec §"Free-form
2635/// curve/surface body statements") that lists one or more 1-based
2636/// references into the `vp` parameter-vertex pool. The kind of element
2637/// the body sits under decides how many of each `vp`'s components are
2638/// meaningful:
2639///   * Inside a `curv` (3D space curve) block — the parameter vertices
2640///     are 1D (`vp u`), so only the `u` component of each referenced
2641///     `vp` is meaningful (spec: "For space curves and trimming curves,
2642///     the parameter vertices must be 1D").
2643///   * Inside a `curv2` (2D parameter-space trimming curve) block — also
2644///     1D per spec, but the `vp` line that the curv2 references in turn
2645///     supplies the surface-space `(u, v)` coordinate of the trimming
2646///     curve's control point. The spec treats a special point on a
2647///     trimming curve as "essentially the same as a special point on
2648///     the surface it trims" — both `u` and `v` are meaningful (spec
2649///     §"Special point").
2650///   * Inside a `surf` block — the parameter vertices are 2D (spec:
2651///     "For surfaces, the parameter vertices must be 2D"), so both `u`
2652///     and `v` from each referenced `vp` are meaningful.
2653///
2654/// The element kind is determined by the first `curv` / `curv2` / `surf`
2655/// directive seen inside the open `cstype` block. (The spec doesn't
2656/// allow mixing element kinds inside one `cstype` block — `end` closes
2657/// the block before a fresh `cstype` reopens one.)
2658///
2659/// Typed view: each `sp` directive's resolved references land on
2660/// `Scene3D::extras["obj:special_points"]` as an array of objects with
2661/// the stable keys:
2662///   * `element_kind` — `"curv"` / `"curv2"` / `"surf"`.
2663///   * `vp_index_1based` — the original 1-based reference (after
2664///     negative-from-end normalisation).
2665///   * `u` — `f64`, always present.
2666///   * `v` — `f64` for `curv2` and `surf`; `null` for `curv` (the spec
2667///     says space-curve special points are 1D).
2668///
2669/// Synthetic primitive: per `sp` directive, a [`Topology::Points`]
2670/// primitive lands on a synthetic mesh named `"obj:sps"`. Point
2671/// positions lift each resolved special point into 3D as
2672/// `[u, v_or_0, 0.0]` so consumers can render them alongside the
2673/// surrounding tessellated curve / surface lattice without re-walking
2674/// the directive stream. Each primitive carries provenance extras:
2675///   * `obj:tessellated_curve` — `true` (shared encoder-filter sentinel,
2676///     so the encoder's existing `is_tessellated_curve` filter drops the
2677///     synthetic primitives from a re-emit).
2678///   * `obj:special_point` — `true` (sp marker).
2679///   * `obj:special_point_element_kind` — `"curv"` / `"curv2"` / `"surf"`.
2680///   * `obj:special_point_vp_refs` — array of the resolved 1-based vp
2681///     indices in source order.
2682///
2683/// `vp` references support the spec's negative-from-end shorthand the
2684/// rest of the free-form path uses (§"Special point" example 9: `sp 1`
2685/// after a `curv` that itself referenced `-4 -3 -2 -1`); references that
2686/// land outside the live `vp` pool are silently dropped from both the
2687/// typed view and the synthetic primitive (no fail-loud — the encoder
2688/// still replays the original `sp` line verbatim from
2689/// `Scene3D::extras["obj:freeform_directives"]`).
2690type SpecialPointPrimData = Vec<(SpecialPointKind, Vec<SpecialPointRef>)>;
2691type SpecialPointRef = (i64, f32, Option<f32>);
2692
2693fn collect_special_points(doc: &ObjDoc) -> (Vec<serde_json::Value>, SpecialPointPrimData) {
2694    let mut typed: Vec<serde_json::Value> = Vec::new();
2695    let mut prim_data: SpecialPointPrimData = Vec::new();
2696    let n_vp = doc.vp.len() as i64;
2697    if n_vp == 0 {
2698        return (typed, prim_data);
2699    }
2700
2701    // Per-block state: the active element kind inside the current
2702    // `cstype` … `end` block, set when we see the first `curv` /
2703    // `curv2` / `surf` directive after `cstype` and cleared on `end`.
2704    let mut active_kind: Option<SpecialPointKind> = None;
2705
2706    for entry in &doc.freeform_directives {
2707        let Some(keyword) = entry.first().map(String::as_str) else {
2708            continue;
2709        };
2710        match keyword {
2711            "cstype" => {
2712                // Fresh block opens; clear any leftover kind from a
2713                // previous block that didn't terminate cleanly.
2714                active_kind = None;
2715            }
2716            "end" => {
2717                active_kind = None;
2718            }
2719            "curv" => {
2720                active_kind = Some(SpecialPointKind::Curv);
2721            }
2722            "curv2" => {
2723                active_kind = Some(SpecialPointKind::Curv2);
2724            }
2725            "surf" => {
2726                active_kind = Some(SpecialPointKind::Surf);
2727            }
2728            "sp" => {
2729                let Some(kind) = active_kind else {
2730                    // An `sp` line with no enclosing element. The spec
2731                    // doesn't define behaviour for that — skip but the
2732                    // free-form-directives replay still re-emits it.
2733                    continue;
2734                };
2735                let mut resolved: Vec<SpecialPointRef> = Vec::new();
2736                for tok in &entry[1..] {
2737                    let Ok(raw) = tok.parse::<i64>() else {
2738                        continue;
2739                    };
2740                    if raw == 0 {
2741                        continue;
2742                    }
2743                    let normalised = if raw > 0 { raw } else { n_vp + raw + 1 };
2744                    if normalised <= 0 || normalised > n_vp {
2745                        continue;
2746                    }
2747                    let slot = (normalised - 1) as usize;
2748                    let vp = doc.vp[slot];
2749                    let u = vp[0];
2750                    let v = match kind {
2751                        SpecialPointKind::Curv => None,
2752                        SpecialPointKind::Curv2 | SpecialPointKind::Surf => Some(vp[1]),
2753                    };
2754                    let kind_str = kind.as_str();
2755                    let mut obj = serde_json::Map::new();
2756                    obj.insert(
2757                        "element_kind".to_string(),
2758                        serde_json::Value::String(kind_str.to_string()),
2759                    );
2760                    obj.insert(
2761                        "vp_index_1based".to_string(),
2762                        serde_json::Value::Number(serde_json::Number::from(normalised)),
2763                    );
2764                    obj.insert("u".to_string(), serde_json::Value::from(u as f64));
2765                    obj.insert(
2766                        "v".to_string(),
2767                        match v {
2768                            Some(value) => serde_json::Value::from(value as f64),
2769                            None => serde_json::Value::Null,
2770                        },
2771                    );
2772                    typed.push(serde_json::Value::Object(obj));
2773                    resolved.push((normalised, u, v));
2774                }
2775                if !resolved.is_empty() {
2776                    prim_data.push((kind, resolved));
2777                }
2778            }
2779            _ => {}
2780        }
2781    }
2782
2783    (typed, prim_data)
2784}
2785
2786/// Synthetic-primitive companion to [`collect_special_points`]. Emits
2787/// one [`Topology::Points`] primitive per `sp` directive, with each
2788/// resolved special point lifted into 3D as `[u, v_or_0, 0.0]` (spec
2789/// §"Special point"). The primitives land on the `"obj:sps"` synthetic
2790/// mesh and are filtered out on re-encode via the shared
2791/// `obj:tessellated_curve` sentinel.
2792fn tessellate_special_points(doc: &ObjDoc) -> Vec<Primitive> {
2793    let mut out: Vec<Primitive> = Vec::new();
2794    let (_, prim_data) = collect_special_points(doc);
2795    for (kind, points) in prim_data {
2796        let positions: Vec<[f32; 3]> = points
2797            .iter()
2798            .map(|(_, u, v)| [*u, v.unwrap_or(0.0), 0.0])
2799            .collect();
2800        let n = positions.len() as u32;
2801        let mut prim = Primitive::new(Topology::Points);
2802        prim.positions = positions;
2803        prim.indices = if n > u16::MAX as u32 {
2804            Some(Indices::U32((0..n).collect()))
2805        } else {
2806            Some(Indices::U16((0..n).map(|i| i as u16).collect()))
2807        };
2808        prim.extras.insert(
2809            "obj:tessellated_curve".to_string(),
2810            serde_json::Value::Bool(true),
2811        );
2812        prim.extras.insert(
2813            "obj:special_point".to_string(),
2814            serde_json::Value::Bool(true),
2815        );
2816        prim.extras.insert(
2817            "obj:special_point_element_kind".to_string(),
2818            serde_json::Value::String(kind.as_str().to_string()),
2819        );
2820        let refs: Vec<serde_json::Value> = points
2821            .iter()
2822            .map(|(idx, _, _)| serde_json::Value::Number(serde_json::Number::from(*idx)))
2823            .collect();
2824        prim.extras.insert(
2825            "obj:special_point_vp_refs".to_string(),
2826            serde_json::Value::Array(refs),
2827        );
2828        out.push(prim);
2829    }
2830    out
2831}
2832
2833/// Element-kind classifier for the `sp` body statement (spec §"Special
2834/// point"). Determines how many of each referenced `vp`'s components are
2835/// meaningful when surfaced on the typed view + synthetic primitive.
2836#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2837enum SpecialPointKind {
2838    /// Inside a `curv` (3D space curve) block — `vp` references are 1D.
2839    Curv,
2840    /// Inside a `curv2` (2D parameter-space trimming curve) block —
2841    /// `vp` references are 1D per the curve spec, but the spec
2842    /// describes a trimming-curve special point as "essentially the
2843    /// same as a special point on the surface it trims" so we surface
2844    /// both `u` and `v`.
2845    Curv2,
2846    /// Inside a `surf` block — `vp` references are 2D.
2847    Surf,
2848}
2849
2850impl SpecialPointKind {
2851    fn as_str(self) -> &'static str {
2852        match self {
2853            SpecialPointKind::Curv => "curv",
2854            SpecialPointKind::Curv2 => "curv2",
2855            SpecialPointKind::Surf => "surf",
2856        }
2857    }
2858}
2859
2860/// Walk `doc.freeform_directives` for every `con` connectivity statement
2861/// and return a typed decomposition suitable for surfacing on
2862/// `Scene3D::extras["obj:connectivity"]`.
2863///
2864/// Spec §"Connectivity between free-form surfaces" / §"con surf_1 q0_1
2865/// q1_1 curv2d_1 surf_2 q0_2 q1_2 curv2d_2": the keyword is followed by
2866/// exactly eight positional arguments — two `(surf, q0, q1, curv2d)`
2867/// quadruples — that tie two previously-declared `surf` blocks together
2868/// along a shared trimming-curve segment for edge merging.
2869///
2870/// The returned [`serde_json::Value`] is always an array of objects;
2871/// each object carries the eight stable, lowercase, underscore-separated
2872/// keys:
2873///
2874/// * `surf_1` — `i64`, the 1-based index of the first surface (negative
2875///   values, supported elsewhere in the free-form vocabulary, are kept
2876///   as-is; the typed view does NOT resolve them against the surface
2877///   stream because surfaces aren't numbered in the captured directive
2878///   sequence).
2879/// * `q0_1` — `f64`, starting parameter on `curv2d_1`.
2880/// * `q1_1` — `f64`, ending parameter on `curv2d_1`.
2881/// * `curv2d_1` — `i64`, the 1-based index of the trimming curve on the
2882///   first surface.
2883/// * `surf_2` — `i64`, the second surface's index.
2884/// * `q0_2` — `f64`, starting parameter on `curv2d_2`.
2885/// * `q1_2` — `f64`, ending parameter on `curv2d_2`.
2886/// * `curv2d_2` — `i64`, the second surface's trimming-curve index.
2887///
2888/// A `con` line that doesn't carry exactly eight arguments, or whose
2889/// integer slots fail to parse as `i64` / float slots fail to parse as
2890/// `f64`, is dropped from the typed view without failing the parse
2891/// (the original line still rides on `obj:freeform_directives` for
2892/// verbatim round-trip). This matches the existing `sp` typed-view
2893/// policy: parse-time-only, lossy on malformed input, the verbatim
2894/// channel stays the source of truth for the encoder.
2895fn collect_connectivity(doc: &ObjDoc) -> Vec<serde_json::Value> {
2896    let mut typed: Vec<serde_json::Value> = Vec::new();
2897    for entry in &doc.freeform_directives {
2898        let Some(keyword) = entry.first().map(String::as_str) else {
2899            continue;
2900        };
2901        if keyword != "con" {
2902            continue;
2903        }
2904        // Spec §"con surf_1 q0_1 q1_1 curv2d_1 surf_2 q0_2 q1_2
2905        // curv2d_2": keyword + exactly eight positional args.
2906        if entry.len() != 9 {
2907            continue;
2908        }
2909        let Ok(surf_1) = entry[1].parse::<i64>() else {
2910            continue;
2911        };
2912        let Ok(q0_1) = entry[2].parse::<f64>() else {
2913            continue;
2914        };
2915        let Ok(q1_1) = entry[3].parse::<f64>() else {
2916            continue;
2917        };
2918        let Ok(curv2d_1) = entry[4].parse::<i64>() else {
2919            continue;
2920        };
2921        let Ok(surf_2) = entry[5].parse::<i64>() else {
2922            continue;
2923        };
2924        let Ok(q0_2) = entry[6].parse::<f64>() else {
2925            continue;
2926        };
2927        let Ok(q1_2) = entry[7].parse::<f64>() else {
2928            continue;
2929        };
2930        let Ok(curv2d_2) = entry[8].parse::<i64>() else {
2931            continue;
2932        };
2933        let mut obj = serde_json::Map::new();
2934        obj.insert(
2935            "surf_1".to_string(),
2936            serde_json::Value::Number(serde_json::Number::from(surf_1)),
2937        );
2938        obj.insert("q0_1".to_string(), serde_json::Value::from(q0_1));
2939        obj.insert("q1_1".to_string(), serde_json::Value::from(q1_1));
2940        obj.insert(
2941            "curv2d_1".to_string(),
2942            serde_json::Value::Number(serde_json::Number::from(curv2d_1)),
2943        );
2944        obj.insert(
2945            "surf_2".to_string(),
2946            serde_json::Value::Number(serde_json::Number::from(surf_2)),
2947        );
2948        obj.insert("q0_2".to_string(), serde_json::Value::from(q0_2));
2949        obj.insert("q1_2".to_string(), serde_json::Value::from(q1_2));
2950        obj.insert(
2951            "curv2d_2".to_string(),
2952            serde_json::Value::Number(serde_json::Number::from(curv2d_2)),
2953        );
2954        typed.push(serde_json::Value::Object(obj));
2955    }
2956    typed
2957}
2958
2959/// Walk `doc.freeform_directives` for every `trim` / `hole` / `scrv`
2960/// loop statement and return a typed decomposition suitable for
2961/// surfacing on `Scene3D::extras["obj:trim_loops"]`.
2962///
2963/// Spec §"Trimming loops and holes" / §"trim u0 u1 curv2d u0 u1 curv2d
2964/// …" / §"hole u0 u1 curv2d u0 u1 curv2d …" / §"Special curve" /
2965/// §"scrv u0 u1 curv2d u0 u1 curv2d …": all three share the identical
2966/// repeating-triple body shape — the keyword is followed by one or more
2967/// `(u0, u1, curv2d)` triples, each naming a previously-defined `curv2`
2968/// parameter-space curve plus the `[u0, u1]` sub-range of that curve to
2969/// walk. `trim` assembles an outer trimming loop, `hole` an inner
2970/// (hole) loop, and `scrv` a special curve guaranteed to appear as
2971/// triangle edges in the surface's final triangulation.
2972///
2973/// The returned objects each carry three stable, lowercase,
2974/// underscore-separated keys:
2975///
2976/// * `loop_kind` — exactly `"trim"`, `"hole"`, or `"scrv"` (the
2977///   keyword the line opened with).
2978/// * `element_kind` — the directive that opened the enclosing
2979///   `cstype … end` block (`"surf"` is the only spec-legal host, since
2980///   trimming loops trim a surface; a loop seen inside a `curv` /
2981///   `curv2` block, or outside any block, surfaces `"unknown"` so the
2982///   consumer can still read the segments).
2983/// * `cstype` — the recognised type slug from the enclosing `cstype`
2984///   header (one of `"bezier"` / `"rat_bezier"` / `"bspline"` /
2985///   `"rat_bspline"` / `"cardinal"` / `"taylor"` / `"bmatrix"`), or
2986///   `"unknown"` when the declared type isn't one of those names or no
2987///   `cstype` block is open. Same disambiguation table the `parm` /
2988///   `ctech` / `stech` typed views use.
2989/// * `segments` — an array of `{u0, u1, curv2d}` objects in source
2990///   order. `u0` / `u1` are `f64` (the start / end parameter on the
2991///   referenced curve); `curv2d` is `i64` (the 1-based index of the
2992///   `curv2` trimming curve — negative-from-end references, which the
2993///   spec §"Examples" case 8 special-curve example uses, are echoed
2994///   as-is so the consumer's own resolver can apply relative-from-end
2995///   semantics).
2996///
2997/// A line whose argument count isn't a positive multiple of three, or
2998/// any of whose `u0` / `u1` tokens fail to parse as `f64` or `curv2d`
2999/// token fails to parse as `i64`, is dropped from the typed view
3000/// without failing the parse — the original line still rides on
3001/// `obj:freeform_directives` for verbatim round-trip. Mirrors the
3002/// lossy-on-malformed policy of the existing `sp` / `con` / `parm`
3003/// typed views; the verbatim channel stays the source of truth for the
3004/// encoder.
3005fn collect_trim_loops(doc: &ObjDoc) -> Vec<serde_json::Value> {
3006    let mut typed: Vec<serde_json::Value> = Vec::new();
3007    // Per-block state mirrored from `collect_parms`: a `cstype` opens a
3008    // block and pins the type slug, a `curv` / `curv2` / `surf` pins the
3009    // element kind, and `end` clears both.
3010    let mut active_cstype: Option<&'static str> = None;
3011    let mut active_kind: Option<&'static str> = None;
3012    for entry in &doc.freeform_directives {
3013        let Some(keyword) = entry.first().map(String::as_str) else {
3014            continue;
3015        };
3016        match keyword {
3017            "cstype" => {
3018                let mut iter = entry.iter().skip(1);
3019                let first = iter.next().map(String::as_str);
3020                let second = iter.next().map(String::as_str);
3021                active_cstype = match (first, second) {
3022                    (Some("bezier"), _) => Some("bezier"),
3023                    (Some("rat"), Some("bezier")) => Some("rat_bezier"),
3024                    (Some("bspline"), _) => Some("bspline"),
3025                    (Some("rat"), Some("bspline")) => Some("rat_bspline"),
3026                    (Some("cardinal"), _) => Some("cardinal"),
3027                    (Some("rat"), Some("cardinal")) => Some("cardinal"),
3028                    (Some("taylor"), _) => Some("taylor"),
3029                    (Some("rat"), Some("taylor")) => Some("taylor"),
3030                    (Some("bmatrix"), _) => Some("bmatrix"),
3031                    (Some("rat"), Some("bmatrix")) => Some("bmatrix"),
3032                    _ => None,
3033                };
3034                active_kind = None;
3035            }
3036            "end" => {
3037                active_cstype = None;
3038                active_kind = None;
3039            }
3040            "curv" => active_kind = Some("curv"),
3041            "curv2" => active_kind = Some("curv2"),
3042            "surf" => active_kind = Some("surf"),
3043            "trim" | "hole" | "scrv" => {
3044                // Spec §"trim/hole/scrv u0 u1 curv2d …": keyword + one or
3045                // more `(u0, u1, curv2d)` triples.
3046                let toks = &entry[1..];
3047                if toks.is_empty() || toks.len() % 3 != 0 {
3048                    continue;
3049                }
3050                let mut segments: Vec<serde_json::Value> = Vec::new();
3051                let mut ok = true;
3052                for chunk in toks.chunks(3) {
3053                    let (Ok(u0), Ok(u1), Ok(curv2d)) = (
3054                        chunk[0].parse::<f64>(),
3055                        chunk[1].parse::<f64>(),
3056                        chunk[2].parse::<i64>(),
3057                    ) else {
3058                        ok = false;
3059                        break;
3060                    };
3061                    let mut seg = serde_json::Map::new();
3062                    seg.insert("u0".to_string(), serde_json::Value::from(u0));
3063                    seg.insert("u1".to_string(), serde_json::Value::from(u1));
3064                    seg.insert(
3065                        "curv2d".to_string(),
3066                        serde_json::Value::Number(serde_json::Number::from(curv2d)),
3067                    );
3068                    segments.push(serde_json::Value::Object(seg));
3069                }
3070                if !ok {
3071                    // A single malformed triple drops the whole line from
3072                    // the typed view; the verbatim channel still replays
3073                    // it byte-faithful.
3074                    continue;
3075                }
3076                let mut obj = serde_json::Map::new();
3077                obj.insert(
3078                    "loop_kind".to_string(),
3079                    serde_json::Value::String(keyword.to_string()),
3080                );
3081                obj.insert(
3082                    "element_kind".to_string(),
3083                    serde_json::Value::String(active_kind.unwrap_or("unknown").to_string()),
3084                );
3085                obj.insert(
3086                    "cstype".to_string(),
3087                    serde_json::Value::String(active_cstype.unwrap_or("unknown").to_string()),
3088                );
3089                obj.insert("segments".to_string(), serde_json::Value::Array(segments));
3090                typed.push(serde_json::Value::Object(obj));
3091            }
3092            _ => {}
3093        }
3094    }
3095    typed
3096}
3097
3098/// Walk `doc.freeform_directives` for every `parm u …` / `parm v …` body
3099/// statement and return a typed decomposition suitable for surfacing on
3100/// `Scene3D::extras["obj:parms"]`.
3101///
3102/// Spec §"parm u/v" / §"Free-form curve/surface body statements": a
3103/// `parm` line carries the **global parameters** (for Bezier / Cardinal /
3104/// Taylor / basis-matrix curves and surfaces) or the **knot vector** (for
3105/// B-spline / NURBS curves and surfaces) that fix the evaluation domain
3106/// of the surrounding free-form element. Each line writes one parametric
3107/// direction at a time: `parm u p1 p2 …` for `u`, `parm v p1 p2 …` for
3108/// `v`. A surface block can therefore carry two `parm` lines (one per
3109/// direction); curves and trimming curves only ever write `parm u`.
3110///
3111/// The returned [`serde_json::Value`] is always an array of objects, one
3112/// entry per source `parm` directive in source order; each object carries
3113/// the four stable, lowercase, underscore-separated keys:
3114///
3115/// * `direction` — `String`, exactly `"u"` or `"v"`.
3116/// * `element_kind` — `String`, the kind of the enclosing free-form
3117///   element (`"curv"` / `"curv2"` / `"surf"`) — decided by the most
3118///   recent `curv` / `curv2` / `surf` directive inside the current
3119///   `cstype … end` block. A `parm` line that sits outside any element
3120///   (no `curv` / `curv2` / `surf` seen since the last `cstype`) is
3121///   dropped from the typed view.
3122/// * `cstype` — `String`, the type slug declared by the enclosing
3123///   `cstype` directive — one of `"bezier"` / `"rat_bezier"` /
3124///   `"bspline"` / `"rat_bspline"` / `"cardinal"` / `"taylor"` /
3125///   `"bmatrix"`. A `parm` line that sits outside any `cstype` block
3126///   (i.e. the most-recent `cstype` token wasn't a recognised type)
3127///   surfaces `"unknown"` in this slot so consumers can still see the
3128///   raw values. Per spec the same domain semantics apply: B-spline
3129///   reads the values as the knot vector; Bezier / Cardinal / Taylor
3130///   / basis-matrix reads them as global parameters that define the
3131///   per-segment break points.
3132/// * `values` — array of `f64`, the parsed floating-point values from
3133///   the `parm` line (`p1 p2 p3 …`). Tokens that fail to parse as
3134///   `f64` are dropped — same lenient policy the rest of the typed
3135///   accessors use. The spec requires a minimum of two parameter
3136///   values per line; lines that fall short still surface their
3137///   surviving values without failing the parse.
3138///
3139/// The encoder is still driven by the verbatim
3140/// `obj:freeform_directives` channel; the typed view exists purely so
3141/// consumers don't have to walk the directive sequence themselves to
3142/// pair every `parm` with its enclosing element + `cstype` block.
3143///
3144/// Lines whose `parm` direction token isn't exactly `"u"` or `"v"` (the
3145/// only two values the spec defines) are dropped from the typed view
3146/// without failing the parse; the verbatim channel still replays them
3147/// byte-faithful.
3148fn collect_parms(doc: &ObjDoc) -> Vec<serde_json::Value> {
3149    let mut typed: Vec<serde_json::Value> = Vec::new();
3150    // Per-block state mirrored from the round-11/12/13/14/182 surface
3151    // tessellation pass: a `cstype` directive opens a new block, sets
3152    // the active type slug, and clears any previously-tracked element
3153    // kind; a `curv` / `curv2` / `surf` directive inside the block
3154    // pins the element kind for the following body statements; `end`
3155    // clears both.
3156    let mut active_cstype: Option<&'static str> = None;
3157    let mut active_kind: Option<&'static str> = None;
3158    for entry in &doc.freeform_directives {
3159        let Some(keyword) = entry.first().map(String::as_str) else {
3160            continue;
3161        };
3162        match keyword {
3163            "cstype" => {
3164                // Re-derive the type slug from `cstype [rat] type` per
3165                // spec §"Curve and surface type". Mirrors the matching
3166                // table used by `tessellate_surfaces`.
3167                let mut iter = entry.iter().skip(1);
3168                let first = iter.next().map(String::as_str);
3169                let second = iter.next().map(String::as_str);
3170                active_cstype = match (first, second) {
3171                    (Some("bezier"), _) => Some("bezier"),
3172                    (Some("rat"), Some("bezier")) => Some("rat_bezier"),
3173                    (Some("bspline"), _) => Some("bspline"),
3174                    (Some("rat"), Some("bspline")) => Some("rat_bspline"),
3175                    (Some("cardinal"), _) => Some("cardinal"),
3176                    (Some("rat"), Some("cardinal")) => Some("cardinal"),
3177                    (Some("taylor"), _) => Some("taylor"),
3178                    (Some("rat"), Some("taylor")) => Some("taylor"),
3179                    (Some("bmatrix"), _) => Some("bmatrix"),
3180                    (Some("rat"), Some("bmatrix")) => Some("bmatrix"),
3181                    _ => None,
3182                };
3183                active_kind = None;
3184            }
3185            "end" => {
3186                active_cstype = None;
3187                active_kind = None;
3188            }
3189            "curv" => active_kind = Some("curv"),
3190            "curv2" => active_kind = Some("curv2"),
3191            "surf" => active_kind = Some("surf"),
3192            "parm" => {
3193                // Spec §"parm u/v": exactly `parm u …` or `parm v …`.
3194                // Anything else (no direction token, or a token that
3195                // isn't u/v) drops from the typed view.
3196                let Some(direction) = entry.get(1).map(String::as_str) else {
3197                    continue;
3198                };
3199                if direction != "u" && direction != "v" {
3200                    continue;
3201                }
3202                let Some(kind) = active_kind else {
3203                    // `parm` outside any element. The spec doesn't
3204                    // define this; we drop it from the typed view (the
3205                    // verbatim channel still replays the line).
3206                    continue;
3207                };
3208                let values: Vec<f64> = entry[2..]
3209                    .iter()
3210                    .filter_map(|t| t.parse::<f64>().ok())
3211                    .collect();
3212                let mut obj = serde_json::Map::new();
3213                obj.insert(
3214                    "direction".to_string(),
3215                    serde_json::Value::String(direction.to_string()),
3216                );
3217                obj.insert(
3218                    "element_kind".to_string(),
3219                    serde_json::Value::String(kind.to_string()),
3220                );
3221                obj.insert(
3222                    "cstype".to_string(),
3223                    serde_json::Value::String(active_cstype.unwrap_or("unknown").to_string()),
3224                );
3225                obj.insert(
3226                    "values".to_string(),
3227                    serde_json::Value::Array(
3228                        values.into_iter().map(serde_json::Value::from).collect(),
3229                    ),
3230                );
3231                typed.push(serde_json::Value::Object(obj));
3232            }
3233            _ => {}
3234        }
3235    }
3236    typed
3237}
3238
3239/// Walk `doc.freeform_directives` for every `ctech` curve-approximation
3240/// and `stech` surface-approximation directive, returning a typed
3241/// decomposition suitable for surfacing on
3242/// `Scene3D::extras["obj:approximations"]`.
3243///
3244/// Spec §"ctech technique resolution" — three mutually-exclusive sub-
3245/// forms:
3246///   * `ctech cparm res` — constant parametric subdivision. One f64
3247///     resolution parameter scaling per-segment subdivision count by
3248///     curve degree.
3249///   * `ctech cspace maxlength` — constant spatial subdivision. One f64
3250///     real-space line-segment length cap.
3251///   * `ctech curv maxdist maxangle` — curvature-dependent subdivision.
3252///     Two f64 parameters: object-space chord-to-curve distance and
3253///     curve-normal-angle bound in degrees.
3254///
3255/// Spec §"stech technique resolution" — four mutually-exclusive sub-
3256/// forms:
3257///   * `stech cparma ures vres` — constant parametric subdivision with
3258///     separate u/v resolution parameters (two f64).
3259///   * `stech cparmb uvres` — constant parametric subdivision with one
3260///     unified resolution parameter (one f64).
3261///   * `stech cspace maxlength` — constant spatial subdivision (one
3262///     f64, same shape as the `ctech` sibling).
3263///   * `stech curv maxdist maxangle` — curvature-dependent subdivision
3264///     (two f64, same shape as the `ctech` sibling).
3265///
3266/// The returned [`serde_json::Value`] is always an array of objects, one
3267/// entry per source `ctech` / `stech` directive in source order; each
3268/// object carries the four stable, lowercase, underscore-separated keys:
3269///
3270/// * `element_kind` — `String`, exactly `"curve"` for a `ctech` line and
3271///   `"surface"` for an `stech` line. Pinned per spec text — `ctech`
3272///   "specifies a curve approximation technique", `stech` "specifies a
3273///   surface approximation technique".
3274/// * `technique` — `String`, the spec-defined sub-form slug, one of
3275///   `"cparm"` / `"cspace"` / `"curv"` (curve forms) or `"cparma"` /
3276///   `"cparmb"` / `"cspace"` / `"curv"` (surface forms). Unrecognised
3277///   technique tokens drop the whole line from the typed view (the
3278///   verbatim channel still replays it byte-faithful).
3279/// * `parameters` — array of `f64`, the parsed resolution arguments in
3280///   source order. Length follows the spec's per-form arity:
3281///   `cparm` / `cspace` / `cparmb` are 1; `curv` / `cparma` are 2.
3282///   Tokens that fail to parse as `f64` drop the whole line from the
3283///   typed view (we don't surface partial parameter arrays — a
3284///   resolution argument that doesn't decode would mislead consumers
3285///   into rendering against zero / NaN).
3286/// * `cstype` — `String`, the type slug declared by the enclosing
3287///   `cstype` directive — one of `"bezier"` / `"rat_bezier"` /
3288///   `"bspline"` / `"rat_bspline"` / `"cardinal"` / `"taylor"` /
3289///   `"bmatrix"`, or `"unknown"` when the declared type isn't one of
3290///   those names. Same disambiguation table the `parm` typed view uses.
3291///
3292/// Per spec the `ctech` / `stech` directives sit inside a `cstype` … `end`
3293/// block alongside `curv` / `surf` / `parm`. A line that appears outside
3294/// any block (no `cstype` seen since the last `end`) still surfaces with
3295/// `cstype = "unknown"` so consumers can see the resolution parameters;
3296/// dropping the line entirely would lose data the verbatim channel
3297/// still carries.
3298///
3299/// The encoder is still driven by the verbatim
3300/// `obj:freeform_directives` channel; the typed view exists purely so
3301/// consumers don't have to re-parse the per-technique positional tokens
3302/// to pair every `ctech` / `stech` with its enclosing `cstype` block.
3303///
3304/// Lines whose argument count doesn't match the spec's per-form arity
3305/// (e.g. `ctech curv 0.1` missing the `maxangle` argument) drop from the
3306/// typed view without failing the parse. Mirrors the lossy-on-malformed
3307/// policy of the existing `sp` / `con` / `parm` typed views.
3308fn collect_approximation_techniques(doc: &ObjDoc) -> Vec<serde_json::Value> {
3309    let mut typed: Vec<serde_json::Value> = Vec::new();
3310    // Track the enclosing `cstype` slug so each line carries its block
3311    // context. Mirrors the `collect_parms` state machine.
3312    let mut active_cstype: Option<&'static str> = None;
3313    for entry in &doc.freeform_directives {
3314        let Some(keyword) = entry.first().map(String::as_str) else {
3315            continue;
3316        };
3317        match keyword {
3318            "cstype" => {
3319                let mut iter = entry.iter().skip(1);
3320                let first = iter.next().map(String::as_str);
3321                let second = iter.next().map(String::as_str);
3322                active_cstype = match (first, second) {
3323                    (Some("bezier"), _) => Some("bezier"),
3324                    (Some("rat"), Some("bezier")) => Some("rat_bezier"),
3325                    (Some("bspline"), _) => Some("bspline"),
3326                    (Some("rat"), Some("bspline")) => Some("rat_bspline"),
3327                    (Some("cardinal"), _) => Some("cardinal"),
3328                    (Some("rat"), Some("cardinal")) => Some("cardinal"),
3329                    (Some("taylor"), _) => Some("taylor"),
3330                    (Some("rat"), Some("taylor")) => Some("taylor"),
3331                    (Some("bmatrix"), _) => Some("bmatrix"),
3332                    (Some("rat"), Some("bmatrix")) => Some("bmatrix"),
3333                    _ => None,
3334                };
3335            }
3336            "end" => {
3337                active_cstype = None;
3338            }
3339            "ctech" | "stech" => {
3340                // Spec §"ctech technique resolution" / §"stech technique
3341                // resolution": keyword + technique token + N resolution
3342                // parameters.
3343                let Some(technique) = entry.get(1).map(String::as_str) else {
3344                    continue;
3345                };
3346                let element_kind = if keyword == "ctech" {
3347                    "curve"
3348                } else {
3349                    "surface"
3350                };
3351                // Per-form expected argument arity. See doc-comment table
3352                // above; unrecognised techniques drop the line.
3353                let expected_args: usize = match (keyword, technique) {
3354                    ("ctech", "cparm") => 1,
3355                    ("ctech", "cspace") => 1,
3356                    ("ctech", "curv") => 2,
3357                    ("stech", "cparma") => 2,
3358                    ("stech", "cparmb") => 1,
3359                    ("stech", "cspace") => 1,
3360                    ("stech", "curv") => 2,
3361                    _ => continue,
3362                };
3363                // Keyword + technique slug + expected_args parameters.
3364                if entry.len() != 2 + expected_args {
3365                    continue;
3366                }
3367                // Parse every resolution parameter; bail (without partial
3368                // surfacing) if any token fails — see doc-comment.
3369                let mut params: Vec<f64> = Vec::with_capacity(expected_args);
3370                let mut ok = true;
3371                for raw in &entry[2..] {
3372                    match raw.parse::<f64>() {
3373                        Ok(v) => params.push(v),
3374                        Err(_) => {
3375                            ok = false;
3376                            break;
3377                        }
3378                    }
3379                }
3380                if !ok {
3381                    continue;
3382                }
3383                let mut obj = serde_json::Map::new();
3384                obj.insert(
3385                    "element_kind".to_string(),
3386                    serde_json::Value::String(element_kind.to_string()),
3387                );
3388                obj.insert(
3389                    "technique".to_string(),
3390                    serde_json::Value::String(technique.to_string()),
3391                );
3392                obj.insert(
3393                    "parameters".to_string(),
3394                    serde_json::Value::Array(
3395                        params.into_iter().map(serde_json::Value::from).collect(),
3396                    ),
3397                );
3398                obj.insert(
3399                    "cstype".to_string(),
3400                    serde_json::Value::String(active_cstype.unwrap_or("unknown").to_string()),
3401                );
3402                typed.push(serde_json::Value::Object(obj));
3403            }
3404            _ => {}
3405        }
3406    }
3407    typed
3408}
3409
3410/// Tessellate every `surf` element that sits under a supported `cstype`
3411/// header into a triangulated [`Topology::Triangles`] primitive. Mirrors
3412/// [`tessellate_curves`] but evaluates a bivariate tensor product (spec
3413/// §"Rational and non-rational curves and surfaces", §"Bezier",
3414/// §"B-spline", §"Surface vertex data — control points").
3415///
3416/// Supported `cstype` values:
3417///   * `bezier` / `rat bezier` (round 11) — bivariate tensor-product de
3418///     Casteljau; single patch of `(degu + 1) × (degv + 1)` control
3419///     points.
3420///   * `bspline` / `rat bspline` (round 12) — bivariate tensor-product
3421///     Cox-deBoor evaluation; the `parm u` / `parm v` knot vectors define
3422///     the control-grid extents (`(len(parm u) − degu − 1) ×
3423///     (len(parm v) − degv − 1)` per spec §"B-spline" condition 6).
3424///   * `cardinal` / `rat cardinal` (round 13) — cubic-only bivariate
3425///     tensor-product Cardinal (Catmull-Rom) evaluation via the spec
3426///     §"Cardinal" Cardinal→Bezier conversion applied per parametric
3427///     direction over a sliding 4-point window; the control grid is the
3428///     `parm`-derived extent (`parm_count + 1` per direction) or a
3429///     square single patch when `parm` only carries the 2-value range.
3430///   * `taylor` (round 14) — bivariate tensor-product polynomial
3431///     evaluation `S(u, v) = Σ_i Σ_j c_{i,j} · u^i · v^j` per spec
3432///     §"Taylor" (the control points are the polynomial coefficients).
3433///     Single patch of `(degu + 1) × (degv + 1)` coefficient vectors.
3434///     `rat taylor` routes to the same evaluator without weight
3435///     blending — the spec note in §"Free-form curve/surface body
3436///     statements" explicitly says the rational form "does not make
3437///     sense for Taylor".
3438///   * `bmatrix` / `rat bmatrix` (round 182) — bivariate tensor-product
3439///     basis-matrix evaluation `S(u, v) = Σ_a Σ_b (Σ_p B_u[a][p] u^p)
3440///     (Σ_q B_v[b][q] v^q) · c_{base_u + a, base_v + b}` per spec
3441///     §"Basis matrix". The per-direction control-grid extent is
3442///     `(parm − 2) · s + n + 1` (inverse of spec §"Basis matrix"
3443///     `parm = (K − n) / s + 2`); patch decomposition uses the
3444///     per-direction `step stepu stepv` strides. Multi-patch grids
3445///     are now supported (e.g. the spec §"Examples" cubic Bezier
3446///     basis-matrix surface). The `rat bmatrix` form routes to the
3447///     same evaluator without per-vertex weight blending, matching
3448///     the round-10 1D curve path.
3449///
3450/// `surf` token layout (spec §"surf s0 s1 t0 t1 v1/vt1/vn1 …"):
3451/// `surf s0 s1 t0 t1` followed by `v/vt/vn` control-vertex references.
3452/// Only the leading position index of each `v/vt/vn` token is consumed;
3453/// texture / normal references are interpolation extras the renderer
3454/// would blend with the same basis (spec §"Texture vertices …",
3455/// §"Vertex normals …") but they don't change the surface shape, so the
3456/// position-only evaluation is sufficient for the polyline/triangle
3457/// approximation.
3458///
3459/// Control-point ordering (spec §"Surface vertex data — control
3460/// points"): "listed in the order i = 0 to K1 for j = 0, followed by
3461/// i = 0 to K1 for j = 1, and so on until j = K2." That is row-major
3462/// with the u index (`i`) varying fastest. For a single Bezier patch
3463/// `K1 = degu` and `K2 = degv`, so the control grid is
3464/// `(degu + 1) × (degv + 1)`.
3465///
3466/// Per-surface provenance lands on `Primitive::extras`:
3467///   * `obj:tessellated_curve` — `true` (shared sentinel so the encoder's
3468///     existing filter skips this synthetic geometry).
3469///   * `obj:tessellated_surface` — `true` (surface-specific sentinel).
3470///   * `obj:surface_kind` — `"bezier"` / `"rat_bezier"` / `"bspline"` /
3471///     `"rat_bspline"` / `"cardinal"` / `"taylor"` / `"bmatrix"`.
3472///   * `obj:surface_degree` — `[degu, degv]`.
3473///   * `obj:surface_u_range` / `obj:surface_v_range` — `[s0, s1]` /
3474///     `[t0, t1]` from the `surf` directive.
3475///   * `obj:surface_samples` — sample count per parametric direction.
3476fn tessellate_surfaces(doc: &ObjDoc, samples: u32) -> Vec<Primitive> {
3477    let mut out: Vec<Primitive> = Vec::new();
3478    if samples == 0 {
3479        return out;
3480    }
3481
3482    // Pre-resolve every `curv2` directive in the document into a 2D
3483    // parameter-space polyline keyed by 1-based source order — spec
3484    // §"trim u0 u1 curv2d" / §"hole u0 u1 curv2d" reference these by
3485    // global index so the per-surface trim/hole clip pass needs them all
3486    // available regardless of which `cstype … end` block originally
3487    // declared them.
3488    let curv2_polylines = collect_all_curv2_polylines(doc, samples);
3489
3490    // Block state, accumulated between `cstype` … `end`. Like the curve
3491    // tessellator, a `surf` header is syntactically ahead of the `parm u`
3492    // / `parm v` body statements that supply the B-spline knot vectors,
3493    // so the whole block is buffered and evaluated on `end` (or `cstype`
3494    // / tail flush) once the body is fully visible.
3495    let mut active_kind: Option<&'static str> = None;
3496    let mut deg_u: Option<u32> = None;
3497    let mut deg_v: Option<u32> = None;
3498    // Spec §"parm u/v": for B-spline surfaces these are the u/v knot
3499    // vectors (unused by the Bezier basis but parsed regardless).
3500    let mut parm_u: Vec<f32> = Vec::new();
3501    let mut parm_v: Vec<f32> = Vec::new();
3502    // Spec §"bmat u/v matrix": for `cstype bmatrix` surfaces the per-
3503    // direction basis matrices supply the polynomial coefficients of
3504    // each `(n + 1)`-row in row-major form with column index `j`
3505    // varying fastest (round 10 reuses the same layout for curves).
3506    let mut bmat_u: Vec<f32> = Vec::new();
3507    let mut bmat_v: Vec<f32> = Vec::new();
3508    // Spec §"step stepu stepv": the per-direction segment stride
3509    // controls patch decomposition for both bmatrix curves and bmatrix
3510    // surfaces. `stepu` is mandatory for both; `stepv` is required
3511    // only for surfaces.
3512    let mut step_u: Option<u32> = None;
3513    let mut step_v: Option<u32> = None;
3514    let mut pending_surfs: Vec<&Vec<String>> = Vec::new();
3515    // Trim / hole loops accumulated for the current surface block —
3516    // each is a closed (u, v) polygon assembled from one or more
3517    // `(u0, u1, curv2d)` segments. The spec (§"trim", §"hole" /
3518    // "Trimming Loops") says: "To cut one or more holes in a region,
3519    // use a trim statement followed by one or more hole statements.
3520    // To introduce another trimmed region in the same surface, use
3521    // another trim statement followed by one or more hole statements."
3522    // We therefore keep trims and holes in their source order so the
3523    // clip pass can pair holes with their enclosing trim region.
3524    let mut pending_trims: Vec<Vec<[f32; 2]>> = Vec::new();
3525    let mut pending_holes: Vec<Vec<[f32; 2]>> = Vec::new();
3526    // Special-curve (`scrv`) loops accumulated for the current surface
3527    // block — spec §"Special curve": "A special curve is guaranteed to
3528    // be included in any triangulation of the surface. … the line formed
3529    // by approximating the special curve with a sequence of straight line
3530    // segments will actually appear as a sequence of triangle edges in
3531    // the final triangulation." Resolved here to the same open
3532    // parameter-space polyline shape as `trim` / `hole` (they share the
3533    // identical `(u0, u1, curv2d)` body grammar, spec §"scrv u0 u1
3534    // curv2d …"), but unlike a trim/hole loop a special curve is NOT
3535    // closed — it is a constraint the surface mesh must route triangle
3536    // edges along, not a region boundary.
3537    let mut pending_scrvs: Vec<Vec<[f32; 2]>> = Vec::new();
3538
3539    let resolve_loop = |entry: &Vec<String>| -> Option<Vec<[f32; 2]>> {
3540        // `trim u0 u1 curv2d u0 u1 curv2d …` — one or more (u0, u1,
3541        // curv2d) triples after the keyword. Build the closed loop by
3542        // concatenating each segment in source order.
3543        let toks = &entry[1..];
3544        if toks.len() < 3 || toks.len() % 3 != 0 {
3545            return None;
3546        }
3547        let mut polygon: Vec<[f32; 2]> = Vec::new();
3548        for chunk in toks.chunks(3) {
3549            let u0 = chunk[0].parse::<f32>().ok()?;
3550            let u1 = chunk[1].parse::<f32>().ok()?;
3551            let idx = chunk[2].parse::<i64>().ok()?;
3552            // 1-based, positive only (the spec doesn't define a
3553            // negative `curv2d` here — those references would predate
3554            // the curve being defined and are rejected).
3555            if idx <= 0 {
3556                return None;
3557            }
3558            let slot = idx as usize - 1;
3559            let entry = curv2_polylines.get(slot).and_then(|e| e.as_ref())?;
3560            let (curve_u_min, curve_u_max, polyline) = entry;
3561            append_curv2_segment(&mut polygon, polyline, *curve_u_min, *curve_u_max, u0, u1);
3562        }
3563        if polygon.len() < 3 {
3564            return None;
3565        }
3566        Some(polygon)
3567    };
3568
3569    #[allow(clippy::too_many_arguments)]
3570    let flush = |out: &mut Vec<Primitive>,
3571                 kind: Option<&'static str>,
3572                 deg_u: Option<u32>,
3573                 deg_v: Option<u32>,
3574                 parm_u: &[f32],
3575                 parm_v: &[f32],
3576                 bmat_u: &[f32],
3577                 bmat_v: &[f32],
3578                 step_u: Option<u32>,
3579                 step_v: Option<u32>,
3580                 surfs: &[&Vec<String>],
3581                 trims: &[Vec<[f32; 2]>],
3582                 holes: &[Vec<[f32; 2]>],
3583                 scrvs: &[Vec<[f32; 2]>]| {
3584        let Some(kind) = kind else {
3585            return;
3586        };
3587        for entry in surfs {
3588            if let Some(prim) = flush_surface(
3589                doc, kind, deg_u, deg_v, parm_u, parm_v, bmat_u, bmat_v, step_u, step_v, entry,
3590                samples, trims, holes, scrvs,
3591            ) {
3592                out.push(prim);
3593            }
3594        }
3595    };
3596
3597    for entry in &doc.freeform_directives {
3598        if entry.is_empty() {
3599            continue;
3600        }
3601        match entry[0].as_str() {
3602            "cstype" => {
3603                flush(
3604                    &mut out,
3605                    active_kind,
3606                    deg_u,
3607                    deg_v,
3608                    &parm_u,
3609                    &parm_v,
3610                    &bmat_u,
3611                    &bmat_v,
3612                    step_u,
3613                    step_v,
3614                    &pending_surfs,
3615                    &pending_trims,
3616                    &pending_holes,
3617                    &pending_scrvs,
3618                );
3619                pending_surfs.clear();
3620                pending_trims.clear();
3621                pending_holes.clear();
3622                pending_scrvs.clear();
3623                deg_u = None;
3624                deg_v = None;
3625                parm_u.clear();
3626                parm_v.clear();
3627                bmat_u.clear();
3628                bmat_v.clear();
3629                step_u = None;
3630                step_v = None;
3631                // Spec §"Curve and surface type": `cstype [rat] type`.
3632                let mut iter = entry.iter().skip(1);
3633                let first = iter.next().map(String::as_str);
3634                let second = iter.next().map(String::as_str);
3635                active_kind = match (first, second) {
3636                    (Some("bezier"), _) => Some("bezier"),
3637                    (Some("rat"), Some("bezier")) => Some("rat_bezier"),
3638                    (Some("bspline"), _) => Some("bspline"),
3639                    (Some("rat"), Some("bspline")) => Some("rat_bspline"),
3640                    // Spec §"Cardinal": cubic, first-derivative-continuous
3641                    // surface (round 13). The `rat` qualifier maps to the
3642                    // same kind — the spec note (§"Free-form curve/surface
3643                    // body statements") says the unit-weight default is
3644                    // reasonable for Cardinal because its basis functions
3645                    // sum to 1, so we don't differentiate `rat cardinal`.
3646                    (Some("cardinal"), _) => Some("cardinal"),
3647                    (Some("rat"), Some("cardinal")) => Some("cardinal"),
3648                    // Spec §"Taylor": arbitrary-degree polynomial surface
3649                    // S(u,v) = Σ_i Σ_j c_{i,j} · u^i · v^j (round 14).
3650                    // The spec note in §"Free-form curve/surface body
3651                    // statements" says the unit-weight default "does
3652                    // not make sense for Taylor"; we accept `rat
3653                    // taylor` for syntactic compatibility but evaluate
3654                    // it the same way (no per-vertex weights).
3655                    (Some("taylor"), _) => Some("taylor"),
3656                    (Some("rat"), Some("taylor")) => Some("taylor"),
3657                    // Spec §"Basis matrix" (round 182 surfaces): the
3658                    // user supplies `bmat u` + `bmat v` plus
3659                    // `step stepu stepv` body statements; per spec
3660                    // §"Free-form curve/surface body statements" the
3661                    // `rat` form just signals per-vertex weight
3662                    // blending, which we currently don't apply to the
3663                    // bmatrix path (matches the round-10 curve
3664                    // behaviour), so both forms map to the same kind.
3665                    (Some("bmatrix"), _) => Some("bmatrix"),
3666                    (Some("rat"), Some("bmatrix")) => Some("bmatrix"),
3667                    _ => None,
3668                };
3669            }
3670            "deg" => {
3671                // Spec §"Degree": `deg degu [degv]`. For surfaces both
3672                // are required; `degv` defaults to `degu` only if a
3673                // single value was given (matches the spec note that
3674                // `degv` is "required only for surfaces").
3675                deg_u = entry.get(1).and_then(|t| t.parse::<u32>().ok());
3676                deg_v = entry.get(2).and_then(|t| t.parse::<u32>().ok()).or(deg_u);
3677            }
3678            // Spec §"parm u/v": `parm u p1 p2 …` / `parm v p1 p2 …`. For
3679            // B-spline surfaces these are the knot vectors in each
3680            // parametric direction.
3681            "parm" if entry.get(1).map(String::as_str) == Some("u") => {
3682                parm_u = entry[2..]
3683                    .iter()
3684                    .filter_map(|t| t.parse::<f32>().ok())
3685                    .collect();
3686            }
3687            "parm" if entry.get(1).map(String::as_str) == Some("v") => {
3688                parm_v = entry[2..]
3689                    .iter()
3690                    .filter_map(|t| t.parse::<f32>().ok())
3691                    .collect();
3692            }
3693            // Spec §"bmat u/v matrix": `bmat u m_00 m_01 … m_nn` (and
3694            // `bmat v` for surfaces) supplies the row-major
3695            // `(n + 1) × (n + 1)` basis matrix with the column index
3696            // varying fastest. Captured for the basis-matrix surface
3697            // path; ignored by the other `cstype` branches.
3698            "bmat" if entry.get(1).map(String::as_str) == Some("u") => {
3699                bmat_u = entry[2..]
3700                    .iter()
3701                    .filter_map(|t| t.parse::<f32>().ok())
3702                    .collect();
3703            }
3704            "bmat" if entry.get(1).map(String::as_str) == Some("v") => {
3705                bmat_v = entry[2..]
3706                    .iter()
3707                    .filter_map(|t| t.parse::<f32>().ok())
3708                    .collect();
3709            }
3710            // Spec §"step stepu stepv": `step stepu [stepv]`. `stepu`
3711            // is mandatory; `stepv` is required only for surfaces and
3712            // controls the v-direction patch decomposition.
3713            "step" => {
3714                step_u = entry.get(1).and_then(|t| t.parse::<u32>().ok());
3715                step_v = entry.get(2).and_then(|t| t.parse::<u32>().ok());
3716            }
3717            "surf" => pending_surfs.push(entry),
3718            // Spec §"trim u0 u1 curv2d u0 u1 curv2d …": outer trimming
3719            // loop assembled from one or more curv2 segments. Resolved
3720            // here to a closed (u, v) polygon so the eventual
3721            // `flush_surface` call can point-in-polygon test each
3722            // surface-lattice vertex against it.
3723            "trim" => {
3724                if let Some(loop_uv) = resolve_loop(entry) {
3725                    pending_trims.push(loop_uv);
3726                }
3727            }
3728            // Spec §"hole u0 u1 curv2d u0 u1 curv2d …": inner trimming
3729            // loop ("hole"). Same shape as `trim`, but each surface
3730            // vertex inside any hole loop is excluded.
3731            "hole" => {
3732                if let Some(loop_uv) = resolve_loop(entry) {
3733                    pending_holes.push(loop_uv);
3734                }
3735            }
3736            // Spec §"scrv u0 u1 curv2d u0 u1 curv2d …" / §"Special
3737            // curve": the special curve shares the trim/hole body
3738            // grammar but is an open constraint polyline, not a closed
3739            // region boundary. `resolve_loop` assembles the same
3740            // parameter-space polyline (its `polygon.len() < 3` floor
3741            // still admits a 2-point straight special curve because a
3742            // single curv2 segment rasterises to `samples + 1 ≥ 3`
3743            // points). The eventual `flush_surface` call routes triangle
3744            // edges along it.
3745            "scrv" => {
3746                if let Some(loop_uv) = resolve_loop(entry) {
3747                    pending_scrvs.push(loop_uv);
3748                }
3749            }
3750            "end" => {
3751                flush(
3752                    &mut out,
3753                    active_kind,
3754                    deg_u,
3755                    deg_v,
3756                    &parm_u,
3757                    &parm_v,
3758                    &bmat_u,
3759                    &bmat_v,
3760                    step_u,
3761                    step_v,
3762                    &pending_surfs,
3763                    &pending_trims,
3764                    &pending_holes,
3765                    &pending_scrvs,
3766                );
3767                pending_surfs.clear();
3768                pending_trims.clear();
3769                pending_holes.clear();
3770                pending_scrvs.clear();
3771                active_kind = None;
3772                deg_u = None;
3773                deg_v = None;
3774                parm_u.clear();
3775                parm_v.clear();
3776                bmat_u.clear();
3777                bmat_v.clear();
3778                step_u = None;
3779                step_v = None;
3780            }
3781            _ => {}
3782        }
3783    }
3784    // Tail flush — defensive against a missing closing `end`.
3785    flush(
3786        &mut out,
3787        active_kind,
3788        deg_u,
3789        deg_v,
3790        &parm_u,
3791        &parm_v,
3792        &bmat_u,
3793        &bmat_v,
3794        step_u,
3795        step_v,
3796        &pending_surfs,
3797        &pending_trims,
3798        &pending_holes,
3799        &pending_scrvs,
3800    );
3801    out
3802}
3803
3804/// Sub-cell boundary re-mesher for the surface trim/hole clip
3805/// (spec §"Trimming loops and holes", §"trim u0 u1 curv2d …",
3806/// §"hole u0 u1 curv2d …").
3807///
3808/// The lattice classification pass marks each `(samples + 1)²` sample
3809/// vertex as kept (inside at least one trim loop — or no trim loops at
3810/// all, per spec "If the first trim statement in the sequence is
3811/// omitted, the enclosing outer trimming loop is taken to be the
3812/// parameter range of the surface" — AND outside every hole loop) or
3813/// dropped. Fully-kept triangles emit unchanged and fully-dropped
3814/// triangles vanish, exactly like the conservative clip; the
3815/// previously-dropped **straddling** triangles (1 or 2 corners kept)
3816/// are now clipped against the in/out classification function instead
3817/// of being discarded wholesale:
3818///
3819///   * each lattice edge whose endpoints classify differently is
3820///     bisected in parameter space until the inside/outside frontier
3821///     is pinned to float precision, yielding a boundary vertex on
3822///     the trimming-loop polygon;
3823///   * the kept sub-polygon (a triangle for 1-kept corners, a quad
3824///     split into two triangles for 2-kept corners) is emitted with
3825///     the original winding;
3826///   * crossings are cached per undirected lattice edge so adjacent
3827///     straddling triangles share their boundary vertex and the
3828///     re-meshed rim stays watertight;
3829///   * sub-triangles whose parameter-space area collapses below a
3830///     small fraction of the cell area (loops grazing a lattice line)
3831///     are suppressed rather than emitted as degenerate slivers.
3832///
3833/// The synthesised boundary vertex's 3D position is interpolated
3834/// linearly along the lattice edge at the bisected parameter — the
3835/// same piecewise-linear approximation the triangle lattice itself
3836/// carries, so the re-meshed boundary is exactly as accurate as the
3837/// surrounding mesh. Boundary vertices that end up referenced by no
3838/// surviving sub-triangle (every candidate was a suppressed sliver)
3839/// are garbage-collected by [`TrimRemesh::finish`] so the vertex pool
3840/// only grows where geometry actually appeared.
3841struct TrimRemesh<'a> {
3842    /// `(samples + 1)` — lattice vertices per row.
3843    stride: usize,
3844    samples: u32,
3845    /// Parameter-rectangle origin + spans (`surf s0 s1 t0 t1`).
3846    s0: f32,
3847    span_s: f32,
3848    t0: f32,
3849    span_t: f32,
3850    trims: &'a [Vec<[f32; 2]>],
3851    holes: &'a [Vec<[f32; 2]>],
3852    /// The lattice 3D positions (row-major, `stride²` entries).
3853    lattice: &'a [[f32; 3]],
3854    /// Per-lattice-vertex kept mask from the classification pass.
3855    kept: &'a [bool],
3856    /// Suppress emitted sub-triangles below this parameter-space area.
3857    area_eps: f32,
3858    /// Synthesised boundary vertices (positions + parameter coords),
3859    /// indexed from `lattice.len()` upward.
3860    boundary_positions: Vec<[f32; 3]>,
3861    boundary_uvs: Vec<[f32; 2]>,
3862    /// Undirected lattice edge → synthesised boundary vertex index.
3863    edge_cache: HashMap<(u32, u32), u32>,
3864}
3865
3866impl<'a> TrimRemesh<'a> {
3867    /// Parameter-space coordinate of any vertex index (lattice or
3868    /// synthesised boundary vertex).
3869    fn uv_of(&self, i: u32) -> [f32; 2] {
3870        let lattice_count = self.lattice.len() as u32;
3871        if i < lattice_count {
3872            let su = (i as usize) % self.stride;
3873            let sv = (i as usize) / self.stride;
3874            [
3875                self.s0 + (su as f32 / self.samples as f32) * self.span_s,
3876                self.t0 + (sv as f32 / self.samples as f32) * self.span_t,
3877            ]
3878        } else {
3879            self.boundary_uvs[(i - lattice_count) as usize]
3880        }
3881    }
3882
3883    /// The trim/hole classification function — identical to the
3884    /// per-lattice-vertex pass in [`flush_surface`] so the bisected
3885    /// frontier converges onto the same region boundary.
3886    fn inside(&self, uv: [f32; 2]) -> bool {
3887        let in_trim = self.trims.is_empty()
3888            || self
3889                .trims
3890                .iter()
3891                .any(|loop_uv| point_in_polygon(uv, loop_uv));
3892        let in_hole = self
3893            .holes
3894            .iter()
3895            .any(|loop_uv| point_in_polygon(uv, loop_uv));
3896        in_trim && !in_hole
3897    }
3898
3899    /// Boundary vertex on the lattice edge `inside_idx → outside_idx`,
3900    /// synthesised by bisecting the classification function along the
3901    /// edge in parameter space. Cached per undirected edge so the two
3902    /// triangles sharing the edge agree on the vertex.
3903    fn crossing(&mut self, inside_idx: u32, outside_idx: u32) -> u32 {
3904        let key = if inside_idx < outside_idx {
3905            (inside_idx, outside_idx)
3906        } else {
3907            (outside_idx, inside_idx)
3908        };
3909        if let Some(&idx) = self.edge_cache.get(&key) {
3910            return idx;
3911        }
3912        let uv_in = self.uv_of(inside_idx);
3913        let uv_out = self.uv_of(outside_idx);
3914        // Invariant: `lo` classifies inside, `hi` outside. 24 rounds
3915        // pin the frontier to ~2⁻²⁴ of the edge length — beyond f32
3916        // lattice resolution.
3917        let mut lo = 0.0f32;
3918        let mut hi = 1.0f32;
3919        for _ in 0..24 {
3920            let mid = 0.5 * (lo + hi);
3921            let uv = [
3922                uv_in[0] + (uv_out[0] - uv_in[0]) * mid,
3923                uv_in[1] + (uv_out[1] - uv_in[1]) * mid,
3924            ];
3925            if self.inside(uv) {
3926                lo = mid;
3927            } else {
3928                hi = mid;
3929            }
3930        }
3931        // Land on the last known-inside parameter so the synthesised
3932        // vertex itself still classifies inside the trimmed region.
3933        let t = lo;
3934        let pa = self.lattice[inside_idx as usize];
3935        let pb = self.lattice[outside_idx as usize];
3936        let idx = self.lattice.len() as u32 + self.boundary_positions.len() as u32;
3937        self.boundary_positions.push([
3938            pa[0] + (pb[0] - pa[0]) * t,
3939            pa[1] + (pb[1] - pa[1]) * t,
3940            pa[2] + (pb[2] - pa[2]) * t,
3941        ]);
3942        self.boundary_uvs.push([
3943            uv_in[0] + (uv_out[0] - uv_in[0]) * t,
3944            uv_in[1] + (uv_out[1] - uv_in[1]) * t,
3945        ]);
3946        self.edge_cache.insert(key, idx);
3947        idx
3948    }
3949
3950    /// Push triangle `(a, b, c)` unless its parameter-space area is a
3951    /// degenerate sliver (loop boundary grazing a lattice line).
3952    fn push_triangle(&self, indices: &mut Vec<u32>, a: u32, b: u32, c: u32) {
3953        let p = self.uv_of(a);
3954        let q = self.uv_of(b);
3955        let r = self.uv_of(c);
3956        let area2 = ((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])).abs();
3957        if area2 > self.area_eps {
3958            indices.push(a);
3959            indices.push(b);
3960            indices.push(c);
3961        }
3962    }
3963
3964    /// Clip one CCW lattice triangle against the trim/hole region and
3965    /// append the surviving (sub-)triangles to `indices`.
3966    fn clip_triangle(&mut self, indices: &mut Vec<u32>, a: u32, b: u32, c: u32) {
3967        let ka = self.kept[a as usize];
3968        let kb = self.kept[b as usize];
3969        let kc = self.kept[c as usize];
3970        match (ka, kb, kc) {
3971            (true, true, true) => {
3972                indices.push(a);
3973                indices.push(b);
3974                indices.push(c);
3975            }
3976            (false, false, false) => {}
3977            // Exactly one corner kept: rotate it to the front (winding
3978            // preserved) and keep the corner triangle bounded by the
3979            // two edge crossings.
3980            (true, false, false) => self.clip_one_kept(indices, a, b, c),
3981            (false, true, false) => self.clip_one_kept(indices, b, c, a),
3982            (false, false, true) => self.clip_one_kept(indices, c, a, b),
3983            // Exactly two corners kept: rotate the dropped corner to
3984            // the back and keep the quad `(i1, i2, cross(i2→o),
3985            // cross(i1→o))` split into two triangles.
3986            (true, true, false) => self.clip_two_kept(indices, a, b, c),
3987            (false, true, true) => self.clip_two_kept(indices, b, c, a),
3988            (true, false, true) => self.clip_two_kept(indices, c, a, b),
3989        }
3990    }
3991
3992    /// `(i, o1, o2)` with only `i` kept → triangle
3993    /// `(i, cross(i→o1), cross(i→o2))`.
3994    fn clip_one_kept(&mut self, indices: &mut Vec<u32>, i: u32, o1: u32, o2: u32) {
3995        let p1 = self.crossing(i, o1);
3996        let p2 = self.crossing(i, o2);
3997        self.push_triangle(indices, i, p1, p2);
3998    }
3999
4000    /// `(i1, i2, o)` with `o` dropped → quad
4001    /// `(i1, i2, cross(i2→o), cross(i1→o))` as two triangles.
4002    fn clip_two_kept(&mut self, indices: &mut Vec<u32>, i1: u32, i2: u32, o: u32) {
4003        let p1 = self.crossing(i2, o);
4004        let p2 = self.crossing(i1, o);
4005        self.push_triangle(indices, i1, i2, p1);
4006        self.push_triangle(indices, i1, p1, p2);
4007    }
4008
4009    /// Garbage-collect boundary vertices that no surviving sub-triangle
4010    /// references (every candidate was a suppressed sliver), remap the
4011    /// indices, and return the referenced boundary positions in index
4012    /// order (paired with their parameter-space coordinates) ready to
4013    /// append after the lattice vertices.
4014    fn finish(self, indices: &mut [u32]) -> (Vec<[f32; 3]>, Vec<[f32; 2]>) {
4015        let lattice_count = self.lattice.len() as u32;
4016        let mut remap: HashMap<u32, u32> = HashMap::new();
4017        let mut compacted: Vec<[f32; 3]> = Vec::new();
4018        let mut compacted_uvs: Vec<[f32; 2]> = Vec::new();
4019        for idx in indices.iter_mut() {
4020            let old = *idx;
4021            if old < lattice_count {
4022                continue;
4023            }
4024            let next = lattice_count + remap.len() as u32;
4025            let slot = *remap.entry(old).or_insert_with(|| {
4026                compacted.push(self.boundary_positions[(old - lattice_count) as usize]);
4027                compacted_uvs.push(self.boundary_uvs[(old - lattice_count) as usize]);
4028                next
4029            });
4030            *idx = slot;
4031        }
4032        (compacted, compacted_uvs)
4033    }
4034}
4035
4036/// Special-curve (`scrv`) constraint inserter — spec §"Special curve":
4037/// "A special curve is guaranteed to be included in any triangulation of
4038/// the surface. … the line formed by approximating the special curve
4039/// with a sequence of straight line segments will actually appear as a
4040/// sequence of triangle edges in the final triangulation."
4041///
4042/// The inserter takes the surface triangle soup (index list into a shared
4043/// position / parameter-coordinate vertex pool) and forces every straight
4044/// segment of a `scrv` polyline to coincide with a chain of triangle
4045/// edges. It works directly on the soup — no adjacency structure — by
4046/// repeatedly splitting any triangle whose interior the segment passes
4047/// through:
4048///
4049///   * If a constraint segment crosses a triangle (entering and leaving
4050///     through its boundary, or starting / ending inside it), the
4051///     crossing points are computed in parameter space, registered as
4052///     vertices (deduplicated so adjacent triangles sharing a crossing
4053///     agree on its index), and the triangle is re-triangulated so the
4054///     chord between the two boundary hits becomes an edge.
4055///   * Re-triangulation of one triangle can leave a neighbouring
4056///     triangle still straddling the same segment, so the whole pass
4057///     iterates the triangle list to a fixpoint (bounded — every split
4058///     strictly increases the triangle count toward the finite set of
4059///     lattice-cell / segment intersections).
4060///
4061/// A synthesised vertex's 3D position is the lattice's own
4062/// piecewise-linear interpolation of the triangle it was born in
4063/// (barycentric blend of the triangle's three corners), so the embedded
4064/// special curve is exactly as accurate as the surrounding mesh — no new
4065/// surface evaluation is introduced, matching the trim/hole re-mesh
4066/// policy.
4067struct ScrvConstraint<'a> {
4068    /// Shared 3D position pool (lattice + trim-boundary + scrv vertices).
4069    positions: &'a mut Vec<[f32; 3]>,
4070    /// Parameter-space coordinate of every entry in `positions`.
4071    uvs: &'a mut Vec<[f32; 2]>,
4072    s0: f32,
4073    span_s: f32,
4074    t0: f32,
4075    span_t: f32,
4076    /// Quantised parameter coordinate → vertex index, so two triangles
4077    /// that split at the same crossing reuse one vertex (watertight).
4078    vertex_cache: HashMap<(i64, i64), u32>,
4079}
4080
4081impl ScrvConstraint<'_> {
4082    /// Snap a parameter coordinate to a stable integer grid (≈ 2⁻²⁰ of
4083    /// the parameter span) so crossings computed independently in two
4084    /// adjacent triangles map to the same cached vertex.
4085    fn key(&self, uv: [f32; 2]) -> (i64, i64) {
4086        let qs = if self.span_s.abs() > f32::EPSILON {
4087            ((uv[0] - self.s0) / self.span_s * (1 << 20) as f32).round() as i64
4088        } else {
4089            0
4090        };
4091        let qt = if self.span_t.abs() > f32::EPSILON {
4092            ((uv[1] - self.t0) / self.span_t * (1 << 20) as f32).round() as i64
4093        } else {
4094            0
4095        };
4096        (qs, qt)
4097    }
4098
4099    /// Resolve a parameter coordinate sitting inside triangle `(a, b, c)`
4100    /// to a vertex index, reusing an existing vertex when the coordinate
4101    /// coincides (within the snap grid) with one already in the pool and
4102    /// otherwise appending a fresh vertex whose 3D position is the
4103    /// barycentric blend of the triangle's corners.
4104    fn vertex_for(&mut self, uv: [f32; 2], tri: [u32; 3]) -> u32 {
4105        let k = self.key(uv);
4106        if let Some(&idx) = self.vertex_cache.get(&k) {
4107            return idx;
4108        }
4109        // Barycentric weights of `uv` within the triangle in parameter
4110        // space; the same weights lift to the 3D position so the new
4111        // vertex lies on the existing piecewise-linear surface facet.
4112        let pa = self.uvs[tri[0] as usize];
4113        let pb = self.uvs[tri[1] as usize];
4114        let pc = self.uvs[tri[2] as usize];
4115        let det = (pb[1] - pc[1]) * (pa[0] - pc[0]) + (pc[0] - pb[0]) * (pa[1] - pc[1]);
4116        let pos = if det.abs() < 1e-20 {
4117            // Degenerate parameter triangle — fall back to the corner
4118            // average so the vertex still lands on the facet.
4119            let za = self.positions[tri[0] as usize];
4120            let zb = self.positions[tri[1] as usize];
4121            let zc = self.positions[tri[2] as usize];
4122            [
4123                (za[0] + zb[0] + zc[0]) / 3.0,
4124                (za[1] + zb[1] + zc[1]) / 3.0,
4125                (za[2] + zb[2] + zc[2]) / 3.0,
4126            ]
4127        } else {
4128            let l0 = ((pb[1] - pc[1]) * (uv[0] - pc[0]) + (pc[0] - pb[0]) * (uv[1] - pc[1])) / det;
4129            let l1 = ((pc[1] - pa[1]) * (uv[0] - pc[0]) + (pa[0] - pc[0]) * (uv[1] - pc[1])) / det;
4130            let l2 = 1.0 - l0 - l1;
4131            let za = self.positions[tri[0] as usize];
4132            let zb = self.positions[tri[1] as usize];
4133            let zc = self.positions[tri[2] as usize];
4134            [
4135                l0 * za[0] + l1 * zb[0] + l2 * zc[0],
4136                l0 * za[1] + l1 * zb[1] + l2 * zc[1],
4137                l0 * za[2] + l1 * zb[2] + l2 * zc[2],
4138            ]
4139        };
4140        let idx = self.positions.len() as u32;
4141        self.positions.push(pos);
4142        self.uvs.push(uv);
4143        self.vertex_cache.insert(k, idx);
4144        idx
4145    }
4146
4147    /// Embed one `scrv` polyline into the triangle soup. Returns `true`
4148    /// if the curve overlapped the meshed surface (so it is now present
4149    /// as a chain of triangle edges) — including the case where the
4150    /// curve already lay along existing edges and needed no splits.
4151    fn apply(&mut self, indices: &mut Vec<u32>, polyline: &[[f32; 2]]) -> bool {
4152        let mut any = false;
4153        for seg in polyline.windows(2) {
4154            if self.insert_segment(indices, seg[0], seg[1]) {
4155                any = true;
4156            }
4157        }
4158        any
4159    }
4160
4161    /// Whether the segment `a → b` overlaps the meshed surface at all —
4162    /// i.e. some triangle's interior or boundary carries a non-degenerate
4163    /// portion of it. Used so a special curve that already lies on
4164    /// existing edges still counts as "embedded".
4165    fn overlaps_surface(&self, indices: &[u32], a: [f32; 2], b: [f32; 2]) -> bool {
4166        let mut tri = 0usize;
4167        while tri * 3 < indices.len() {
4168            let t = [indices[tri * 3], indices[tri * 3 + 1], indices[tri * 3 + 2]];
4169            if self.clip_raw_span(t, a, b).is_some() {
4170                return true;
4171            }
4172            tri += 1;
4173        }
4174        false
4175    }
4176
4177    /// Force the straight segment `a → b` (parameter space) to lie along
4178    /// triangle edges by splitting every triangle the open segment
4179    /// crosses. Iterates the triangle list to a fixpoint. Returns `true`
4180    /// if the segment overlapped the surface (embedded), regardless of
4181    /// whether splits were needed.
4182    fn insert_segment(&mut self, indices: &mut Vec<u32>, a: [f32; 2], b: [f32; 2]) -> bool {
4183        let overlapped = self.overlaps_surface(indices, a, b);
4184        // Bound the rework: each split removes one triangle and adds two
4185        // or three, so the soup grows monotonically toward the finite
4186        // arrangement of (segment × triangle) crossings. The cap guards
4187        // against a pathological float cycle.
4188        let max_iters = 64 * (indices.len() / 3).max(1) + 4096;
4189        let mut iters = 0;
4190        loop {
4191            let mut split_at: Option<usize> = None;
4192            let mut tri = 0usize;
4193            while tri * 3 < indices.len() {
4194                let t = [indices[tri * 3], indices[tri * 3 + 1], indices[tri * 3 + 2]];
4195                if self.crosses_interior(t, a, b) {
4196                    split_at = Some(tri);
4197                    break;
4198                }
4199                tri += 1;
4200            }
4201            let Some(tri) = split_at else {
4202                break;
4203            };
4204            let t = [indices[tri * 3], indices[tri * 3 + 1], indices[tri * 3 + 2]];
4205            if !self.split_triangle(indices, tri, t, a, b) {
4206                // No progress possible on this triangle (numeric edge
4207                // case) — stop to avoid spinning.
4208                break;
4209            }
4210            iters += 1;
4211            if iters > max_iters {
4212                break;
4213            }
4214        }
4215        overlapped
4216    }
4217
4218    /// Does the segment `a → b` pass through the open interior of
4219    /// triangle `t` along a portion that is not already one of `t`'s
4220    /// edges? Computes the segment's clipped span inside the triangle and
4221    /// reports whether that span has positive length and at least one of
4222    /// its endpoints is strictly interior to an edge / face (i.e. the
4223    /// triangle genuinely needs splitting).
4224    fn crosses_interior(&self, t: [u32; 3], a: [f32; 2], b: [f32; 2]) -> bool {
4225        self.clip_span(t, a, b).is_some()
4226    }
4227
4228    /// Clip segment `a → b` to triangle `t` (parameter space), returning
4229    /// the in-triangle sub-segment `(p, q)` whenever the segment overlaps
4230    /// the triangle along a non-degenerate span. No edge-coincidence
4231    /// filtering — used by [`Self::overlaps_surface`] to count a special
4232    /// curve that already lies on existing edges as embedded.
4233    fn clip_raw_span(&self, t: [u32; 3], a: [f32; 2], b: [f32; 2]) -> Option<([f32; 2], [f32; 2])> {
4234        let va = self.uvs[t[0] as usize];
4235        let vb = self.uvs[t[1] as usize];
4236        let vc = self.uvs[t[2] as usize];
4237        // Parameter-space area scale of this triangle; used to size the
4238        // geometric epsilon so the test is invariant to lattice density.
4239        let area2 = ((vb[0] - va[0]) * (vc[1] - va[1]) - (vb[1] - va[1]) * (vc[0] - va[0])).abs();
4240        if area2 < 1e-18 {
4241            return None;
4242        }
4243        let eps = (area2.sqrt()) * 1e-4;
4244        // Clip the segment against the three half-planes of the triangle
4245        // (CCW or CW handled by sign normalisation). Parameter t along
4246        // a→b kept in [t_lo, t_hi].
4247        let mut t_lo = 0.0f32;
4248        let mut t_hi = 1.0f32;
4249        let edges = [(va, vb), (vb, vc), (vc, va)];
4250        // Triangle orientation sign.
4251        let orient = (vb[0] - va[0]) * (vc[1] - va[1]) - (vb[1] - va[1]) * (vc[0] - va[0]);
4252        let sgn = if orient >= 0.0 { 1.0 } else { -1.0 };
4253        let dir = [b[0] - a[0], b[1] - a[1]];
4254        for (e0, e1) in edges {
4255            // Inward normal (for CCW): rotate edge by +90°, scaled by
4256            // orientation sign so it points into the triangle.
4257            let ex = e1[0] - e0[0];
4258            let ey = e1[1] - e0[1];
4259            let nx = -ey * sgn;
4260            let ny = ex * sgn;
4261            // Signed inside-distance of a→b endpoints w.r.t. this edge.
4262            let da = nx * (a[0] - e0[0]) + ny * (a[1] - e0[1]);
4263            let db = nx * (b[0] - e0[0]) + ny * (b[1] - e0[1]);
4264            let denom = db - da;
4265            if denom.abs() < 1e-20 {
4266                // Segment parallel to the edge: reject only if fully
4267                // outside.
4268                if da < -eps * (nx * nx + ny * ny).sqrt() {
4269                    return None;
4270                }
4271                continue;
4272            }
4273            // Crossing parameter where the inside-distance hits 0.
4274            let tc = -da / denom;
4275            if denom > 0.0 {
4276                // Entering the half-plane.
4277                if tc > t_lo {
4278                    t_lo = tc;
4279                }
4280            } else if tc < t_hi {
4281                // Leaving the half-plane.
4282                t_hi = tc;
4283            }
4284            if t_lo > t_hi {
4285                return None;
4286            }
4287        }
4288        if t_hi - t_lo <= 1e-6 {
4289            return None;
4290        }
4291        let p = [a[0] + dir[0] * t_lo, a[1] + dir[1] * t_lo];
4292        let q = [a[0] + dir[0] * t_hi, a[1] + dir[1] * t_hi];
4293        Some((p, q))
4294    }
4295
4296    /// Clip segment `a → b` to triangle `t`, returning the in-triangle
4297    /// sub-segment only when the triangle genuinely needs splitting — the
4298    /// span is non-degenerate AND not already coincident with a single
4299    /// triangle edge (in which case the constraint is already satisfied).
4300    fn clip_span(&self, t: [u32; 3], a: [f32; 2], b: [f32; 2]) -> Option<([f32; 2], [f32; 2])> {
4301        let (p, q) = self.clip_raw_span(t, a, b)?;
4302        let va = self.uvs[t[0] as usize];
4303        let vb = self.uvs[t[1] as usize];
4304        let vc = self.uvs[t[2] as usize];
4305        let area2 = ((vb[0] - va[0]) * (vc[1] - va[1]) - (vb[1] - va[1]) * (vc[0] - va[0])).abs();
4306        let eps = area2.sqrt() * 1e-4;
4307        if self.span_on_single_edge(va, vb, vc, p, q, eps) {
4308            return None;
4309        }
4310        Some((p, q))
4311    }
4312
4313    /// True when both `p` and `q` lie (within `eps`) on the same triangle
4314    /// edge line — meaning the chord coincides with an existing edge and
4315    /// no split is required.
4316    fn span_on_single_edge(
4317        &self,
4318        va: [f32; 2],
4319        vb: [f32; 2],
4320        vc: [f32; 2],
4321        p: [f32; 2],
4322        q: [f32; 2],
4323        eps: f32,
4324    ) -> bool {
4325        let on = |e0: [f32; 2], e1: [f32; 2], pt: [f32; 2]| -> bool {
4326            let ex = e1[0] - e0[0];
4327            let ey = e1[1] - e0[1];
4328            let len = (ex * ex + ey * ey).sqrt();
4329            if len < 1e-20 {
4330                return false;
4331            }
4332            let dist = ((pt[0] - e0[0]) * ey - (pt[1] - e0[1]) * ex).abs() / len;
4333            dist <= eps
4334        };
4335        (on(va, vb, p) && on(va, vb, q))
4336            || (on(vb, vc, p) && on(vb, vc, q))
4337            || (on(vc, va, p) && on(vc, va, q))
4338    }
4339
4340    /// Replace triangle `tri` (corners `t`) with a fan that routes the
4341    /// constraint chord `p–q` (the clipped span of `a → b`) along new
4342    /// edges. `p` and `q` are realised as vertices (deduplicated); the
4343    /// triangle is re-meshed as the union of the sub-polygons on either
4344    /// side of the chord, each ear-triangulated. Returns `false` if the
4345    /// span degenerated between detection and split (numeric race).
4346    fn split_triangle(
4347        &mut self,
4348        indices: &mut Vec<u32>,
4349        tri: usize,
4350        t: [u32; 3],
4351        a: [f32; 2],
4352        b: [f32; 2],
4353    ) -> bool {
4354        let Some((p, q)) = self.clip_span(t, a, b) else {
4355            return false;
4356        };
4357        let ip = self.vertex_for(p, t);
4358        let iq = self.vertex_for(q, t);
4359        if ip == iq {
4360            return false;
4361        }
4362        // Re-triangulate the original triangle with the two chord
4363        // endpoints inserted on its boundary / interior. Build the set of
4364        // boundary loop vertices by walking the triangle edges and
4365        // splicing in p / q where they fall, then ear-clip the loop while
4366        // forcing the p–q diagonal. The robust, allocation-light path:
4367        // collect the triangle's 3 corners plus p and q, then emit the
4368        // constrained triangulation via the helper below.
4369        let new_tris = self.constrained_split(t, ip, iq);
4370        if new_tris.is_empty() {
4371            return false;
4372        }
4373        // Overwrite the original slot with the first new triangle and
4374        // append the rest, preserving the soup invariant.
4375        let first = new_tris[0];
4376        indices[tri * 3] = first[0];
4377        indices[tri * 3 + 1] = first[1];
4378        indices[tri * 3 + 2] = first[2];
4379        for nt in &new_tris[1..] {
4380            indices.push(nt[0]);
4381            indices.push(nt[1]);
4382            indices.push(nt[2]);
4383        }
4384        true
4385    }
4386
4387    /// Build a constrained triangulation of the original triangle
4388    /// `(t[0], t[1], t[2])` after inserting the two chord vertices `ip` /
4389    /// `iq`, guaranteeing the edge `ip–iq` is present. Handles the three
4390    /// placements that arise from clipping a straight chord against a
4391    /// triangle: both chord endpoints on the boundary (the usual
4392    /// crossing), or one endpoint strictly interior (a chord that starts
4393    /// or ends inside the triangle). The triangle is decomposed by
4394    /// fanning every region from the chord, which keeps the chord as a
4395    /// shared edge of the surrounding sub-triangles.
4396    fn constrained_split(&self, t: [u32; 3], ip: u32, iq: u32) -> Vec<[u32; 3]> {
4397        // Locate where ip and iq sit relative to the triangle: on an edge
4398        // (return the edge's two corner indices) or interior (None).
4399        let p_edge = self.locate_on_edge(t, ip);
4400        let q_edge = self.locate_on_edge(t, iq);
4401        let mut out: Vec<[u32; 3]> = Vec::new();
4402        match (p_edge, q_edge) {
4403            (Some((pa, pb)), Some((qa, qb))) => {
4404                // Both endpoints on the boundary — the general crossing.
4405                // Re-triangulate the boundary polygon
4406                // [corner sequence with ip and iq spliced in] by walking
4407                // the three corners in order and inserting the chord
4408                // vertices on their edges, then fan from ip.
4409                let loop_verts = self.boundary_loop(t, ip, (pa, pb), iq, (qa, qb));
4410                self.fan_with_chord(&loop_verts, ip, iq, &mut out);
4411            }
4412            (Some((pa, pb)), None) => {
4413                // q is interior: fan the triangle from q, then split the
4414                // wedge that contains the boundary point p along p.
4415                self.split_one_interior(t, iq, ip, (pa, pb), &mut out);
4416            }
4417            (None, Some((qa, qb))) => {
4418                self.split_one_interior(t, ip, iq, (qa, qb), &mut out);
4419            }
4420            (None, None) => {
4421                // Both interior (a chord wholly inside the triangle):
4422                // connect each triangle corner to the nearer chord end so
4423                // the chord becomes a shared interior edge.
4424                out.push([t[0], t[1], ip]);
4425                out.push([t[1], t[2], ip]);
4426                out.push([t[2], t[0], iq]);
4427                out.push([t[0], ip, iq]);
4428                out.push([t[2], iq, ip]);
4429                out.push([t[1], ip, iq]);
4430            }
4431        }
4432        out
4433    }
4434
4435    /// Return the two corner indices of the triangle edge that vertex `v`
4436    /// lies on (within an epsilon), or `None` when `v` is strictly
4437    /// interior. `v` is one of the freshly-inserted chord vertices.
4438    fn locate_on_edge(&self, t: [u32; 3], v: u32) -> Option<(u32, u32)> {
4439        let pt = self.uvs[v as usize];
4440        let corners = [t[0], t[1], t[2]];
4441        let uv = [
4442            self.uvs[t[0] as usize],
4443            self.uvs[t[1] as usize],
4444            self.uvs[t[2] as usize],
4445        ];
4446        let area2 = ((uv[1][0] - uv[0][0]) * (uv[2][1] - uv[0][1])
4447            - (uv[1][1] - uv[0][1]) * (uv[2][0] - uv[0][0]))
4448            .abs();
4449        let eps = area2.sqrt() * 1e-3;
4450        for e in 0..3 {
4451            let e0 = uv[e];
4452            let e1 = uv[(e + 1) % 3];
4453            let ex = e1[0] - e0[0];
4454            let ey = e1[1] - e0[1];
4455            let len = (ex * ex + ey * ey).sqrt();
4456            if len < 1e-20 {
4457                continue;
4458            }
4459            let dist = ((pt[0] - e0[0]) * ey - (pt[1] - e0[1]) * ex).abs() / len;
4460            // Project onto the edge to confirm the point is between the
4461            // endpoints, not on the line beyond them.
4462            let proj = ((pt[0] - e0[0]) * ex + (pt[1] - e0[1]) * ey) / (len * len);
4463            if dist <= eps && (-1e-3..=1.0 + 1e-3).contains(&proj) {
4464                return Some((corners[e], corners[(e + 1) % 3]));
4465            }
4466        }
4467        None
4468    }
4469
4470    /// Walk the triangle's three boundary edges in order, splicing the
4471    /// chord endpoints `ip` / `iq` onto the edges they sit on, to produce
4472    /// the closed boundary vertex loop the chord divides.
4473    fn boundary_loop(
4474        &self,
4475        t: [u32; 3],
4476        ip: u32,
4477        p_edge: (u32, u32),
4478        iq: u32,
4479        q_edge: (u32, u32),
4480    ) -> Vec<u32> {
4481        let mut loop_verts: Vec<u32> = Vec::with_capacity(5);
4482        for e in 0..3 {
4483            let from = t[e];
4484            let to = t[(e + 1) % 3];
4485            loop_verts.push(from);
4486            // Splice any chord vertex that lies on this directed edge,
4487            // ordered by distance from `from`.
4488            let mut on_edge: Vec<(f32, u32)> = Vec::new();
4489            for (cv, ce) in [(ip, p_edge), (iq, q_edge)] {
4490                if (ce.0 == from && ce.1 == to) || (ce.0 == to && ce.1 == from) {
4491                    let pf = self.uvs[from as usize];
4492                    let pt = self.uvs[cv as usize];
4493                    let d = (pt[0] - pf[0]).hypot(pt[1] - pf[1]);
4494                    on_edge.push((d, cv));
4495                }
4496            }
4497            on_edge.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
4498            for (_, cv) in on_edge {
4499                loop_verts.push(cv);
4500            }
4501        }
4502        loop_verts
4503    }
4504
4505    /// Triangulate a convex boundary loop that contains both chord
4506    /// endpoints `ip` and `iq`, forcing the chord `ip–iq` to be an edge.
4507    /// The chord splits the loop into two convex sub-chains; each is
4508    /// fan-triangulated from one chord endpoint, so the chord is the
4509    /// shared base of both fans.
4510    fn fan_with_chord(&self, loop_verts: &[u32], ip: u32, iq: u32, out: &mut Vec<[u32; 3]>) {
4511        let n = loop_verts.len();
4512        if n < 3 {
4513            return;
4514        }
4515        let pos_ip = loop_verts.iter().position(|&v| v == ip);
4516        let pos_iq = loop_verts.iter().position(|&v| v == iq);
4517        let (Some(i), Some(j)) = (pos_ip, pos_iq) else {
4518            // Chord endpoint missing from the loop (shouldn't happen) —
4519            // fall back to a plain fan so no geometry is lost.
4520            for k in 1..n - 1 {
4521                out.push([loop_verts[0], loop_verts[k], loop_verts[k + 1]]);
4522            }
4523            return;
4524        };
4525        // Chain 1: ip → … → iq (forward). Chain 2: iq → … → ip (forward,
4526        // wrapping). Fan each from its first vertex.
4527        let chain = |start: usize, end: usize| -> Vec<u32> {
4528            let mut c = Vec::new();
4529            let mut k = start;
4530            loop {
4531                c.push(loop_verts[k]);
4532                if k == end {
4533                    break;
4534                }
4535                k = (k + 1) % n;
4536            }
4537            c
4538        };
4539        for sub in [chain(i, j), chain(j, i)] {
4540            for k in 1..sub.len().saturating_sub(1) {
4541                out.push([sub[0], sub[k], sub[k + 1]]);
4542            }
4543        }
4544    }
4545
4546    /// One chord endpoint (`inner`) is strictly interior, the other
4547    /// (`bound`) sits on edge `(ea, eb)`. Fan the triangle from `inner`
4548    /// (three wedges), then split the wedge whose far edge is `(ea, eb)`
4549    /// along `bound` so the chord `inner–bound` is an edge.
4550    fn split_one_interior(
4551        &self,
4552        t: [u32; 3],
4553        inner: u32,
4554        bound: u32,
4555        edge: (u32, u32),
4556        out: &mut Vec<[u32; 3]>,
4557    ) {
4558        for e in 0..3 {
4559            let from = t[e];
4560            let to = t[(e + 1) % 3];
4561            if (from == edge.0 && to == edge.1) || (from == edge.1 && to == edge.0) {
4562                // This wedge's outer edge carries `bound`: split into two.
4563                out.push([from, bound, inner]);
4564                out.push([bound, to, inner]);
4565            } else {
4566                out.push([from, to, inner]);
4567            }
4568        }
4569    }
4570}
4571
4572/// Evaluate one `surf` element against an active Bezier / B-spline /
4573/// Cardinal / Taylor `cstype` and return the triangulated primitive,
4574/// or `None` when the directive is incomplete / malformed (lenient-
4575/// loader pattern — the directive still round-trips through
4576/// `obj:freeform_directives`).
4577#[allow(clippy::too_many_arguments)]
4578fn flush_surface(
4579    doc: &ObjDoc,
4580    kind: &'static str,
4581    deg_u: Option<u32>,
4582    deg_v: Option<u32>,
4583    parm_u: &[f32],
4584    parm_v: &[f32],
4585    bmat_u: &[f32],
4586    bmat_v: &[f32],
4587    step_u: Option<u32>,
4588    step_v: Option<u32>,
4589    entry: &[String],
4590    samples: u32,
4591    trims: &[Vec<[f32; 2]>],
4592    holes: &[Vec<[f32; 2]>],
4593    scrvs: &[Vec<[f32; 2]>],
4594) -> Option<Primitive> {
4595    // `surf s0 s1 t0 t1 v1/vt1/vn1 …` — minimum is the keyword + 4
4596    // range scalars + at least one control vertex.
4597    if entry.len() < 6 {
4598        return None;
4599    }
4600    let s0 = entry[1].parse::<f32>().ok()?;
4601    let s1 = entry[2].parse::<f32>().ok()?;
4602    let t0 = entry[3].parse::<f32>().ok()?;
4603    let t1 = entry[4].parse::<f32>().ok()?;
4604
4605    // Spec §"surf": both degu and degv are required for a surface.
4606    let du = deg_u? as usize;
4607    let dv = deg_v? as usize;
4608
4609    let bspline = matches!(kind, "bspline" | "rat_bspline");
4610    let cardinal = kind == "cardinal";
4611    let taylor = kind == "taylor";
4612    let bmatrix = kind == "bmatrix";
4613    // Determine the expected single-patch control grid.
4614    //   * Bezier: a single patch is exactly (degu + 1) × (degv + 1)
4615    //     control points (spec §"Bezier"). Larger grids are multi-patch
4616    //     and need a `step` stride the Bezier basis doesn't carry, so they
4617    //     stay captured-only.
4618    //   * B-spline: the control-point count per direction is fixed by the
4619    //     knot vector — spec §"B-spline" condition 6, `K = q − n − 1`, so
4620    //     there are `len(parm) − deg − 1` control points in that
4621    //     direction. A single `surf` already covers the whole grid (the
4622    //     knot vector internally encodes the piecewise segments), so no
4623    //     patch decomposition is needed.
4624    //   * Cardinal: cubic-only (spec §"Cardinal": "only defined for the
4625    //     cubic case"). The control count per direction relates to the
4626    //     `parm` count by the spec condition `parm = K − n + 2` (n = 3),
4627    //     i.e. `K_dir = parm_count + 1`. When a `parm` directive only
4628    //     spells out the 2-value global parameter range (as the spec
4629    //     Cardinal-surface example does), there is no per-direction split
4630    //     to read, so the grid is taken to be square — `cols = rows =
4631    //     sqrt(total)` — which recovers the canonical single 4×4 patch.
4632    //   * Taylor: the control points are the polynomial coefficients
4633    //     `c_{i,j}` for `S(u,v) = Σ_i Σ_j c_{i,j} · u^i · v^j` (spec
4634    //     §"Taylor"). A single Taylor "patch" of declared degree
4635    //     `deg degu degv` therefore needs exactly
4636    //     `(degu + 1) × (degv + 1)` coefficient vectors, matching the
4637    //     Bezier control-grid extents.
4638    let (cols, rows) = if bspline {
4639        // Need at least `deg + 2` knots per direction for ≥ 1 control
4640        // point. The `du + 2` / `dv + 2` arithmetic guards against
4641        // attacker-supplied `deg` values that would overflow `usize` on
4642        // the subsequent subtraction; an out-of-range degree leaves the
4643        // surface captured-only.
4644        let need_u = du.checked_add(2)?;
4645        let need_v = dv.checked_add(2)?;
4646        if parm_u.len() < need_u || parm_v.len() < need_v {
4647            return None;
4648        }
4649        (parm_u.len() - du - 1, parm_v.len() - dv - 1) // (K1 + 1, K2 + 1)
4650    } else if bmatrix {
4651        // Spec §"Basis matrix" / §"step stepu stepv": the per-direction
4652        // control-vertex count is K = (parm_count − 2) · s + n + 1 (the
4653        // inverse of the spec's `parm = (K − n) / s + 2`). Both `parm u`
4654        // / `parm v` and `step stepu stepv` are required for a surface;
4655        // missing either leaves the surface captured-only.
4656        let su = step_u? as usize;
4657        let sv = step_v? as usize;
4658        if su == 0 || sv == 0 || parm_u.len() < 2 || parm_v.len() < 2 {
4659            return None;
4660        }
4661        let cols = (parm_u.len() - 2)
4662            .checked_mul(su)?
4663            .checked_add(du)?
4664            .checked_add(1)?;
4665        let rows = (parm_v.len() - 2)
4666            .checked_mul(sv)?
4667            .checked_add(dv)?
4668            .checked_add(1)?;
4669        (cols, rows)
4670    } else if cardinal {
4671        // Cardinal must be cubic per spec; reject any other degree (the
4672        // directive still round-trips verbatim through extras).
4673        if du != 3 || dv != 3 {
4674            return None;
4675        }
4676        let total = entry.len() - 5; // control-vertex token count.
4677        // Prefer the per-direction `parm` extents when they carry more
4678        // than just the range endpoints (`parm = K − n + 2`); otherwise
4679        // fall back to a square single-patch grid.
4680        let cols = if parm_u.len() > 2 {
4681            parm_u.len() + 1
4682        } else {
4683            isqrt_exact(total)?
4684        };
4685        let rows = if parm_v.len() > 2 {
4686            parm_v.len() + 1
4687        } else if cols != 0 && total % cols == 0 {
4688            total / cols
4689        } else {
4690            return None;
4691        };
4692        (cols, rows)
4693    } else if taylor {
4694        // Taylor: `(degu + 1) × (degv + 1)` polynomial coefficients per
4695        // single patch (spec §"Taylor"). `checked_add` guards against
4696        // attacker-supplied huge degree values (e.g. `deg 111111`)
4697        // whose `+1` would still fit in `usize` but whose product
4698        // blows past available memory in the `Vec::with_capacity`
4699        // below.
4700        (du.checked_add(1)?, dv.checked_add(1)?)
4701    } else {
4702        // Bezier / rat_bezier — spec §"Bezier": "the number of global
4703        // parameter values given with the parm statement must be
4704        // K/n + 1, where K is the number of control points. For
4705        // surfaces, this requirement applies independently for the u
4706        // and v parametric directions." Inverting that:
4707        // `K = degu × (parm_u_count − 1)`, with adjacent patches
4708        // sharing their boundary control points (spec §"Surface
4709        // vertex data — Control points": "For surfaces made up of
4710        // many patches, …, the control points are ordered as if the
4711        // surface were a single large patch"), so the total
4712        // per-direction grid extent is `K + 1 = degu × patches_u + 1`
4713        // where `patches_u = parm_u_count − 1`. The single-patch case
4714        // (`parm u v0 v1`) collapses to the canonical
4715        // `(degu + 1) × (degv + 1)`. When the `parm` directive is
4716        // missing entirely (some loaders elide it for the default
4717        // single-patch case), fall back to the single-patch grid.
4718        let patches_u = if parm_u.len() >= 2 {
4719            parm_u.len() - 1
4720        } else {
4721            1
4722        };
4723        let patches_v = if parm_v.len() >= 2 {
4724            parm_v.len() - 1
4725        } else {
4726            1
4727        };
4728        let cols = du.checked_mul(patches_u)?.checked_add(1)?;
4729        let rows = dv.checked_mul(patches_v)?.checked_add(1)?;
4730        (cols, rows)
4731    };
4732    // Cap the expected control-grid size: a single `surf` line carries
4733    // `entry.len() - 5` control-vertex tokens, so any `expected` that
4734    // doesn't match that count is captured-only anyway (per the
4735    // `grid.len() != expected` check at the end of the read loop). Bail
4736    // here before the `Vec::with_capacity(expected)` allocation to keep
4737    // attacker `deg` / `parm` values from triggering an
4738    // allocation-size-too-big abort.
4739    let expected = cols.checked_mul(rows)?;
4740    if expected != entry.len().saturating_sub(5) {
4741        return None;
4742    }
4743
4744    let n_pos = doc.positions.len() as i64;
4745    let mut grid: Vec<[f32; 3]> = Vec::with_capacity(expected);
4746    let mut weights: Vec<f32> = Vec::with_capacity(expected);
4747    for tok in &entry[5..] {
4748        // Each control vertex is a `v/vt/vn` reference; we only need the
4749        // leading position index.
4750        let first_field = tok.split('/').next().unwrap_or(tok);
4751        let raw = first_field.parse::<i64>().ok()?;
4752        let resolved = if raw < 0 { n_pos + 1 + raw } else { raw };
4753        if resolved <= 0 || resolved > n_pos {
4754            return None;
4755        }
4756        grid.push(doc.positions[(resolved as usize) - 1]);
4757        let w = doc.position_weights[(resolved as usize) - 1].unwrap_or(1.0);
4758        weights.push(w);
4759    }
4760    if grid.len() != expected {
4761        // Not a single patch of the declared degree (Bezier) or the knot-
4762        // vector-implied grid size (B-spline) — leave it captured-only
4763        // rather than guessing the patch decomposition.
4764        return None;
4765    }
4766
4767    let mut positions = if bspline {
4768        sample_bspline_surface(
4769            &grid, &weights, kind, du as u32, dv as u32, parm_u, parm_v, s0, s1, t0, t1, cols,
4770            rows, samples,
4771        )
4772    } else if cardinal {
4773        sample_cardinal_surface(&grid, cols, rows, samples)
4774    } else if taylor {
4775        sample_taylor_surface(&grid, cols, rows, s0, s1, t0, t1, samples)
4776    } else if bmatrix {
4777        // Spec §"Basis matrix": validate the basis-matrix sizes
4778        // (n + 1)² before evaluating. `flush_surface` already enforced
4779        // the per-direction control-vertex count via the `parm` / `step`
4780        // inverse formula, so a bmat-size mismatch here is the only
4781        // remaining captured-only condition.
4782        let need_u = du.checked_add(1)?.checked_mul(du.checked_add(1)?)?;
4783        let need_v = dv.checked_add(1)?.checked_mul(dv.checked_add(1)?)?;
4784        if bmat_u.len() != need_u || bmat_v.len() != need_v {
4785            return None;
4786        }
4787        let su = step_u?;
4788        let sv = step_v?;
4789        sample_bmatrix_surface(
4790            &grid, bmat_u, bmat_v, du as u32, dv as u32, su, sv, cols, rows, samples,
4791        )
4792    } else {
4793        // Bezier: walk the `patches_u × patches_v` patch grid (each
4794        // patch is `(du + 1) × (dv + 1)` control points, with adjacent
4795        // patches sharing their boundary column / row). Single-patch
4796        // inputs (the common case) route through the same loop with a
4797        // 1×1 patch grid, matching the legacy behaviour.
4798        let patches_u = cols.saturating_sub(1).checked_div(du).unwrap_or(1).max(1);
4799        let patches_v = rows.saturating_sub(1).checked_div(dv).unwrap_or(1).max(1);
4800        sample_bezier_surface_multipatch(
4801            &grid, &weights, kind, cols, rows, du, dv, patches_u, patches_v, samples,
4802        )
4803    };
4804    if positions.is_empty() {
4805        return None;
4806    }
4807
4808    // Per-lattice-vertex parameter-space coordinates, only built when
4809    // there is at least one trim or hole loop to test against. Each
4810    // sample lives at `(s0 + u_frac · (s1 − s0), t0 + v_frac · (t1 − t0))`
4811    // — the same uniform sampling the per-`cstype` evaluators above use.
4812    // Vertex `(su, sv)` is kept iff it lies inside any trim loop (or
4813    // there are no trim loops, in which case "inside the parameter
4814    // rectangle" is assumed — spec §"Trimming Loops": "If no trim or
4815    // hole statements are specified, then the surface is trimmed at
4816    // its parameter range") AND outside every hole loop.
4817    let stride = samples as usize + 1;
4818    let trimming = !trims.is_empty() || !holes.is_empty();
4819    let kept: Vec<bool> = if trimming {
4820        let mut k = Vec::with_capacity(stride * stride);
4821        let span_s = s1 - s0;
4822        let span_t = t1 - t0;
4823        for sv in 0..stride {
4824            for su in 0..stride {
4825                let u_frac = su as f32 / samples as f32;
4826                let v_frac = sv as f32 / samples as f32;
4827                let u = s0 + u_frac * span_s;
4828                let v = t0 + v_frac * span_t;
4829                let in_trim = trims.is_empty()
4830                    || trims
4831                        .iter()
4832                        .any(|loop_uv| point_in_polygon([u, v], loop_uv));
4833                let in_hole = holes
4834                    .iter()
4835                    .any(|loop_uv| point_in_polygon([u, v], loop_uv));
4836                k.push(in_trim && !in_hole);
4837            }
4838        }
4839        k
4840    } else {
4841        Vec::new()
4842    };
4843
4844    // Build a triangle grid over the (samples + 1) × (samples + 1)
4845    // sample lattice. Vertex (su, sv) lives at index sv * stride + su.
4846    // Two CCW triangles per cell (spec §"surf" note: the front of the
4847    // surface is the side where u increases to the right and v
4848    // increases upward). When trim/hole loops are active, straddling
4849    // boundary triangles are sub-cell re-meshed against the loop
4850    // polygon via [`TrimRemesh`] instead of dropped wholesale, so the
4851    // trimmed rim follows the loop boundary rather than staying
4852    // jagged at the lattice grain.
4853    let mut indices: Vec<u32> = Vec::with_capacity((samples as usize) * (samples as usize) * 6);
4854    let mut boundary_vertex_count = 0usize;
4855    // Parameter-space coordinate of every vertex in `positions`, kept in
4856    // lock-step so the special-curve constraint pass (which works in
4857    // parameter space) can address any vertex — lattice or synthesised.
4858    // Only populated when there is special-curve work to do; the common
4859    // (no-scrv) path skips it entirely so existing behaviour is byte-for-
4860    // byte unchanged.
4861    let span_s = s1 - s0;
4862    let span_t = t1 - t0;
4863    let want_scrv = !scrvs.is_empty();
4864    let mut uvs: Vec<[f32; 2]> = if want_scrv {
4865        let mut v = Vec::with_capacity(positions.len());
4866        for sv in 0..stride {
4867            for su in 0..stride {
4868                v.push([
4869                    s0 + (su as f32 / samples as f32) * span_s,
4870                    t0 + (sv as f32 / samples as f32) * span_t,
4871                ]);
4872            }
4873        }
4874        v
4875    } else {
4876        Vec::new()
4877    };
4878    if trimming {
4879        // Sliver threshold: 10⁻⁶ of one lattice cell's parameter-space
4880        // area (×2 because `push_triangle` compares doubled areas).
4881        // Loops that graze a lattice line produce crossings at the
4882        // line itself; the resulting zero-width sub-triangles are
4883        // suppressed instead of emitted as degenerate slivers.
4884        let cell_area2 = (span_s / samples as f32).abs() * (span_t / samples as f32).abs() * 2.0;
4885        let mut remesh = TrimRemesh {
4886            stride,
4887            samples,
4888            s0,
4889            span_s,
4890            t0,
4891            span_t,
4892            trims,
4893            holes,
4894            lattice: &positions,
4895            kept: &kept,
4896            area_eps: cell_area2 * 1e-6,
4897            boundary_positions: Vec::new(),
4898            boundary_uvs: Vec::new(),
4899            edge_cache: HashMap::new(),
4900        };
4901        for sv in 0..samples as usize {
4902            for su in 0..samples as usize {
4903                let i00 = (sv * stride + su) as u32;
4904                let i10 = (sv * stride + su + 1) as u32;
4905                let i01 = ((sv + 1) * stride + su) as u32;
4906                let i11 = ((sv + 1) * stride + su + 1) as u32;
4907                remesh.clip_triangle(&mut indices, i00, i10, i11);
4908                remesh.clip_triangle(&mut indices, i00, i11, i01);
4909            }
4910        }
4911        let (boundary, boundary_uvs) = remesh.finish(&mut indices);
4912        boundary_vertex_count = boundary.len();
4913        positions.extend(boundary);
4914        if want_scrv {
4915            uvs.extend(boundary_uvs);
4916        }
4917    } else {
4918        for sv in 0..samples as usize {
4919            for su in 0..samples as usize {
4920                let i00 = (sv * stride + su) as u32;
4921                let i10 = (sv * stride + su + 1) as u32;
4922                let i01 = ((sv + 1) * stride + su) as u32;
4923                let i11 = ((sv + 1) * stride + su + 1) as u32;
4924                indices.push(i00);
4925                indices.push(i10);
4926                indices.push(i11);
4927                indices.push(i00);
4928                indices.push(i11);
4929                indices.push(i01);
4930            }
4931        }
4932    }
4933
4934    // Special-curve (`scrv`) constraint pass — spec §"Special curve":
4935    // "A special curve is guaranteed to be included in any triangulation
4936    // of the surface. … the line formed by approximating the special
4937    // curve with a sequence of straight line segments will actually
4938    // appear as a sequence of triangle edges in the final
4939    // triangulation." Each `scrv` polyline (resolved in parameter space
4940    // exactly like a trim/hole loop, but left open) is inserted into the
4941    // freshly-built surface triangulation as a constraint: every
4942    // straight segment of the approximated special curve is forced to
4943    // coincide with a chain of triangle edges. Done after the trim/hole
4944    // re-mesh so the constraint operates on the final kept geometry.
4945    let mut scrv_constraint_vertices = 0usize;
4946    let mut scrv_constraint_curves = 0usize;
4947    if want_scrv {
4948        let lattice_count = positions.len();
4949        let mut con = ScrvConstraint {
4950            positions: &mut positions,
4951            uvs: &mut uvs,
4952            s0,
4953            span_s,
4954            t0,
4955            span_t,
4956            vertex_cache: HashMap::new(),
4957        };
4958        for scrv in scrvs {
4959            if con.apply(&mut indices, scrv) {
4960                scrv_constraint_curves += 1;
4961            }
4962        }
4963        scrv_constraint_vertices = positions.len() - lattice_count;
4964    }
4965
4966    let mut prim = Primitive::new(Topology::Triangles);
4967    let n_verts = positions.len() as u32;
4968    prim.positions = positions;
4969    prim.indices = if n_verts > u16::MAX as u32 {
4970        Some(Indices::U32(indices))
4971    } else {
4972        Some(Indices::U16(indices.iter().map(|&i| i as u16).collect()))
4973    };
4974
4975    prim.extras.insert(
4976        "obj:tessellated_curve".to_string(),
4977        serde_json::Value::Bool(true),
4978    );
4979    prim.extras.insert(
4980        "obj:tessellated_surface".to_string(),
4981        serde_json::Value::Bool(true),
4982    );
4983    prim.extras.insert(
4984        "obj:surface_kind".to_string(),
4985        serde_json::Value::String(kind.to_string()),
4986    );
4987    prim.extras.insert(
4988        "obj:surface_degree".to_string(),
4989        serde_json::Value::Array(vec![
4990            serde_json::Value::from(du as u64),
4991            serde_json::Value::from(dv as u64),
4992        ]),
4993    );
4994    prim.extras.insert(
4995        "obj:surface_u_range".to_string(),
4996        serde_json::Value::Array(vec![
4997            serde_json::Value::from(s0 as f64),
4998            serde_json::Value::from(s1 as f64),
4999        ]),
5000    );
5001    prim.extras.insert(
5002        "obj:surface_v_range".to_string(),
5003        serde_json::Value::Array(vec![
5004            serde_json::Value::from(t0 as f64),
5005            serde_json::Value::from(t1 as f64),
5006        ]),
5007    );
5008    prim.extras.insert(
5009        "obj:surface_samples".to_string(),
5010        serde_json::Value::Number(serde_json::Number::from(samples as u64)),
5011    );
5012    // Spec §"Bezier" multi-patch decomposition — when the synthesised
5013    // grid contains more than one patch per direction, surface the
5014    // per-direction patch count so downstream consumers can recognise
5015    // the boundary structure inside the otherwise-uniform triangle
5016    // lattice. Other `cstype` paths already encode their segment count
5017    // through their own provenance (B-spline knot vector, basis-matrix
5018    // step), so the marker is Bezier-specific.
5019    if matches!(kind, "bezier" | "rat_bezier") && du > 0 && dv > 0 {
5020        let patches_u = (cols.saturating_sub(1)) / du;
5021        let patches_v = (rows.saturating_sub(1)) / dv;
5022        if patches_u > 1 || patches_v > 1 {
5023            prim.extras.insert(
5024                "obj:surface_patches".to_string(),
5025                serde_json::Value::Array(vec![
5026                    serde_json::Value::from(patches_u as u64),
5027                    serde_json::Value::from(patches_v as u64),
5028                ]),
5029            );
5030        }
5031    }
5032    if trimming {
5033        // Spec §"Trimming Loops" — record how many outer/inner loops
5034        // contributed to the clip so downstream consumers (or
5035        // round-trip verifiers) can tell the synthetic mesh apart from
5036        // an un-clipped one.
5037        prim.extras.insert(
5038            "obj:surface_trimmed".to_string(),
5039            serde_json::Value::Bool(true),
5040        );
5041        prim.extras.insert(
5042            "obj:surface_trim_loops".to_string(),
5043            serde_json::Value::Number(serde_json::Number::from(trims.len() as u64)),
5044        );
5045        prim.extras.insert(
5046            "obj:surface_hole_loops".to_string(),
5047            serde_json::Value::Number(serde_json::Number::from(holes.len() as u64)),
5048        );
5049        // Sub-cell boundary re-mesh provenance: how many vertices were
5050        // synthesised on the trim/hole loop boundary (0 when every
5051        // straddling cell collapsed to suppressed slivers, e.g. loops
5052        // aligned exactly with lattice lines). Boundary vertices sit
5053        // after the `(samples + 1)²` lattice block in `positions`.
5054        prim.extras.insert(
5055            "obj:surface_trim_boundary_vertices".to_string(),
5056            serde_json::Value::Number(serde_json::Number::from(boundary_vertex_count as u64)),
5057        );
5058    }
5059    if want_scrv {
5060        // Spec §"Special curve" — record how many special curves were
5061        // embedded as triangle edges and how many constraint vertices
5062        // (segment endpoints + lattice-edge crossings) the embedding
5063        // synthesised. The synthesised vertices sit after the lattice
5064        // (and any trim-boundary) block in `positions`. `obj:surface_
5065        // scrv_curves` is 0 when every `scrv` collapsed (e.g. it lay
5066        // wholly outside the parameter rectangle).
5067        prim.extras.insert(
5068            "obj:surface_scrv".to_string(),
5069            serde_json::Value::Bool(true),
5070        );
5071        prim.extras.insert(
5072            "obj:surface_scrv_curves".to_string(),
5073            serde_json::Value::Number(serde_json::Number::from(scrv_constraint_curves as u64)),
5074        );
5075        prim.extras.insert(
5076            "obj:surface_scrv_vertices".to_string(),
5077            serde_json::Value::Number(serde_json::Number::from(scrv_constraint_vertices as u64)),
5078        );
5079    }
5080
5081    Some(prim)
5082}
5083
5084/// Evaluate a Bezier (or rational-Bezier) surface patch at a
5085/// `(samples + 1) × (samples + 1)` lattice via the tensor-product de
5086/// Casteljau algorithm.
5087///
5088/// `grid` is the control mesh in row-major order with the u index
5089/// varying fastest (spec §"Surface vertex data — control points"):
5090/// `cols` control points per v-row, `rows` v-rows. For each `(u, v)`
5091/// sample the surface is `S(u, v) = Σ_i Σ_j B_i(u) · B_j(v) · d_{i,j}`.
5092/// We collapse the inner u sum first by running de Casteljau on each
5093/// v-row, then a second de Casteljau on the resulting `rows` points in
5094/// the v direction.
5095///
5096/// For `kind == "rat_bezier"` each control point is lifted to its
5097/// homogeneous `(w·x, w·y, w·z, w)` form, both de Casteljau passes run
5098/// in 4D, and the result is projected back via `x / w` (spec
5099/// §"Rational and non-rational curves and surfaces").
5100///
5101/// Output vertices are ordered row-major in the sample lattice: sample
5102/// `(su, sv)` lands at index `sv * (samples + 1) + su`.
5103fn sample_bezier_surface(
5104    grid: &[[f32; 3]],
5105    weights: &[f32],
5106    kind: &str,
5107    cols: usize,
5108    rows: usize,
5109    samples: u32,
5110) -> Vec<[f32; 3]> {
5111    if samples == 0 || cols == 0 || rows == 0 || grid.len() != cols * rows {
5112        return Vec::new();
5113    }
5114    let rational = kind == "rat_bezier";
5115    // Lift to homogeneous 4D so a single de Casteljau loop handles both
5116    // forms (non-rational uses w == 1).
5117    let homo: Vec<[f32; 4]> = grid
5118        .iter()
5119        .zip(weights.iter())
5120        .map(|(p, w)| {
5121            let weight = if rational { *w } else { 1.0 };
5122            [p[0] * weight, p[1] * weight, p[2] * weight, weight]
5123        })
5124        .collect();
5125
5126    let n = samples as usize + 1;
5127    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n * n);
5128    for sv in 0..n {
5129        let v = if n == 1 {
5130            0.0
5131        } else {
5132            sv as f32 / (n - 1) as f32
5133        };
5134        for su in 0..n {
5135            let u = if n == 1 {
5136                0.0
5137            } else {
5138                su as f32 / (n - 1) as f32
5139            };
5140            // Inner pass: de Casteljau across each v-row in u, leaving
5141            // one homogeneous point per row.
5142            let mut col_pts: Vec<[f32; 4]> = Vec::with_capacity(rows);
5143            for r in 0..rows {
5144                let row = &homo[r * cols..r * cols + cols];
5145                col_pts.push(de_casteljau_4d(row, u));
5146            }
5147            // Outer pass: de Casteljau in v over the collapsed points.
5148            let pt = de_casteljau_4d(&col_pts, v);
5149            let [x, y, z, w] = pt;
5150            if rational && w.abs() > f32::EPSILON {
5151                out.push([x / w, y / w, z / w]);
5152            } else {
5153                out.push([x, y, z]);
5154            }
5155        }
5156    }
5157    out
5158}
5159
5160/// Evaluate a multi-patch Bezier (or rational-Bezier) surface at a
5161/// `(samples + 1) × (samples + 1)` lattice over the global parameter
5162/// rectangle.
5163///
5164/// Spec §"Bezier" gives the per-direction control count as
5165/// `K = degu × patches_u` (with `parm_u_count = K/degu + 1 = patches_u + 1`),
5166/// and §"Surface vertex data — Control points" arranges the global
5167/// control mesh "as if the surface were a single large patch" with
5168/// adjacent patches sharing their boundary control row / column. The
5169/// total per-direction grid extent is therefore `cols = degu × patches_u + 1`
5170/// (and `rows = degv × patches_v + 1`), with patch `(pu, pv)` owning the
5171/// `(degu + 1) × (degv + 1)` sub-window starting at
5172/// `(pu × degu, pv × degv)`.
5173///
5174/// Each global lattice sample `(su, sv)` maps to a global parameter
5175/// `(u_g, v_g) ∈ [0, patches_u] × [0, patches_v]`; its integer part
5176/// selects the patch, fractional part is the local Bezier parameter
5177/// `t ∈ [0, 1]` for tensor-product de Casteljau (delegated to
5178/// [`sample_bezier_surface`] on a freshly-windowed sub-grid). The single-
5179/// patch case (`patches_u = patches_v = 1`) collapses to the legacy
5180/// behaviour: one lattice over the whole grid, one de Casteljau pass per
5181/// sample.
5182///
5183/// Output vertices are ordered row-major in the sample lattice: sample
5184/// `(su, sv)` lands at index `sv × (samples + 1) + su`.
5185#[allow(clippy::too_many_arguments)]
5186fn sample_bezier_surface_multipatch(
5187    grid: &[[f32; 3]],
5188    weights: &[f32],
5189    kind: &str,
5190    cols: usize,
5191    rows: usize,
5192    deg_u: usize,
5193    deg_v: usize,
5194    patches_u: usize,
5195    patches_v: usize,
5196    samples: u32,
5197) -> Vec<[f32; 3]> {
5198    if samples == 0
5199        || cols == 0
5200        || rows == 0
5201        || deg_u == 0
5202        || deg_v == 0
5203        || patches_u == 0
5204        || patches_v == 0
5205        || grid.len() != cols * rows
5206        || weights.len() != grid.len()
5207    {
5208        return Vec::new();
5209    }
5210    // Single-patch fast path: identical to the original
5211    // `sample_bezier_surface` traversal.
5212    if patches_u == 1 && patches_v == 1 {
5213        return sample_bezier_surface(grid, weights, kind, cols, rows, samples);
5214    }
5215    let rational = kind == "rat_bezier";
5216    let homo: Vec<[f32; 4]> = grid
5217        .iter()
5218        .zip(weights.iter())
5219        .map(|(p, w)| {
5220            let weight = if rational { *w } else { 1.0 };
5221            [p[0] * weight, p[1] * weight, p[2] * weight, weight]
5222        })
5223        .collect();
5224
5225    let n = samples as usize + 1;
5226    let patch_cols = deg_u + 1;
5227    let patch_rows = deg_v + 1;
5228    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n * n);
5229
5230    // Pre-allocated scratch buffers reused per sample.
5231    let mut sub_window: Vec<[f32; 4]> = Vec::with_capacity(patch_cols * patch_rows);
5232    let mut col_pts: Vec<[f32; 4]> = Vec::with_capacity(patch_rows);
5233
5234    for sv in 0..n {
5235        // Global v ∈ [0, patches_v]: integer part = v-patch index,
5236        // fractional part = local Bezier parameter t_v ∈ [0, 1].
5237        let v_global = if n == 1 {
5238            0.0
5239        } else {
5240            sv as f32 * (patches_v as f32) / (n - 1) as f32
5241        };
5242        let mut pv = v_global.floor() as isize;
5243        if pv < 0 {
5244            pv = 0;
5245        }
5246        if pv as usize >= patches_v {
5247            pv = patches_v as isize - 1;
5248        }
5249        let pv = pv as usize;
5250        let t_v = (v_global - pv as f32).clamp(0.0, 1.0);
5251
5252        for su in 0..n {
5253            let u_global = if n == 1 {
5254                0.0
5255            } else {
5256                su as f32 * (patches_u as f32) / (n - 1) as f32
5257            };
5258            let mut pu = u_global.floor() as isize;
5259            if pu < 0 {
5260                pu = 0;
5261            }
5262            if pu as usize >= patches_u {
5263                pu = patches_u as isize - 1;
5264            }
5265            let pu = pu as usize;
5266            let t_u = (u_global - pu as f32).clamp(0.0, 1.0);
5267
5268            // Copy the active patch's `(deg_u + 1) × (deg_v + 1)`
5269            // homogeneous sub-grid. Spec §"Surface vertex data —
5270            // Control points": the active patch starts at
5271            // `(pu · deg_u, pv · deg_v)` and ends at
5272            // `(pu · deg_u + deg_u, pv · deg_v + deg_v)` inclusive,
5273            // sharing its boundary with neighbouring patches.
5274            sub_window.clear();
5275            let base_u = pu * deg_u;
5276            let base_v = pv * deg_v;
5277            for j in 0..patch_rows {
5278                let row_start = (base_v + j) * cols + base_u;
5279                sub_window.extend_from_slice(&homo[row_start..row_start + patch_cols]);
5280            }
5281
5282            col_pts.clear();
5283            for r in 0..patch_rows {
5284                let row = &sub_window[r * patch_cols..(r + 1) * patch_cols];
5285                col_pts.push(de_casteljau_4d(row, t_u));
5286            }
5287            let pt = de_casteljau_4d(&col_pts, t_v);
5288            let [x, y, z, w] = pt;
5289            if rational && w.abs() > f32::EPSILON {
5290                out.push([x / w, y / w, z / w]);
5291            } else {
5292                out.push([x, y, z]);
5293            }
5294        }
5295    }
5296    out
5297}
5298
5299/// Evaluate a basis-matrix surface patch (spec §"Basis matrix",
5300/// §"step stepu stepv") at a `(samples + 1) × (samples + 1)` lattice
5301/// via the bivariate tensor-product polynomial
5302///
5303///   S(u, v) = Σ_a Σ_b ( Σ_p B_u[a][p] · u^p )
5304///                     ( Σ_q B_v[b][q] · v^q )
5305///                     · c_{base_u + a, base_v + b}
5306///
5307/// where `B_u` / `B_v` are the per-direction basis matrices supplied by
5308/// `bmat u` / `bmat v` (row-major, column index `j` varying fastest per
5309/// spec §"bmat u/v matrix"), `deg_u` / `deg_v` are the per-direction
5310/// polynomial degrees from `deg degu degv`, and `step_u` / `step_v` are
5311/// the per-direction segment strides from `step stepu stepv`.
5312///
5313/// `grid` is the control mesh in row-major u-fastest order (spec
5314/// §"Surface vertex data — control points": "i = 0 to K1 for j = 0,
5315/// …"): `cols` control points per v-row, `rows` v-rows. Spec
5316/// §"Basis matrix" gives the per-direction control count as
5317/// `K = (parm − 2) · s + n + 1` (inverse of `parm = (K − n) / s + 2`);
5318/// the caller in [`flush_surface`] enforces that `cols` and `rows`
5319/// match this size before this routine runs.
5320///
5321/// Patch decomposition: each `(seg_u, seg_v)` pair traces a tensor-
5322/// product polynomial segment whose control window starts at
5323/// `(base_u, base_v) = (seg_u · step_u, seg_v · step_v)`. The total
5324/// per-direction segment count is `(K − n − 1) / s + 1`, derived in the
5325/// same way as the round-10 1D curve path (`sample_bmatrix`).
5326///
5327/// Output vertices are ordered row-major in the sample lattice:
5328/// sample `(su, sv)` lands at index `sv · (samples + 1) + su`.
5329///
5330/// Spec §"Free-form curve/surface body statements" notes the rational
5331/// `rat bmatrix` form would blend per-vertex `w` weights; we match the
5332/// round-10 curve path and do not apply them here (the `rat bmatrix`
5333/// kind routes to this same evaluator without weights), which keeps
5334/// the basis-matrix path consistent with the user-authored polynomial
5335/// definition.
5336#[allow(clippy::too_many_arguments)]
5337fn sample_bmatrix_surface(
5338    grid: &[[f32; 3]],
5339    bmat_u: &[f32],
5340    bmat_v: &[f32],
5341    deg_u: u32,
5342    deg_v: u32,
5343    step_u: u32,
5344    step_v: u32,
5345    cols: usize,
5346    rows: usize,
5347    samples: u32,
5348) -> Vec<[f32; 3]> {
5349    let n_plus_1 = match (deg_u as usize).checked_add(1) {
5350        Some(v) => v,
5351        None => return Vec::new(),
5352    };
5353    let m_plus_1 = match (deg_v as usize).checked_add(1) {
5354        Some(v) => v,
5355        None => return Vec::new(),
5356    };
5357    let need_bmat_u = match n_plus_1.checked_mul(n_plus_1) {
5358        Some(v) => v,
5359        None => return Vec::new(),
5360    };
5361    let need_bmat_v = match m_plus_1.checked_mul(m_plus_1) {
5362        Some(v) => v,
5363        None => return Vec::new(),
5364    };
5365    if samples == 0
5366        || cols == 0
5367        || rows == 0
5368        || step_u == 0
5369        || step_v == 0
5370        || grid.len() != cols * rows
5371        || bmat_u.len() != need_bmat_u
5372        || bmat_v.len() != need_bmat_v
5373        || cols < n_plus_1
5374        || rows < m_plus_1
5375    {
5376        return Vec::new();
5377    }
5378    let su_stride = step_u as usize;
5379    let sv_stride = step_v as usize;
5380    // Per-direction segment count: largest `i` with `i · s + n + 1 ≤ K`.
5381    // Matches the round-10 1D derivation, applied independently to u
5382    // and v per spec §"step stepu stepv" ("For surfaces, the above
5383    // description applies independently to each parametric direction.").
5384    let n_seg_u = (cols - n_plus_1) / su_stride + 1;
5385    let n_seg_v = (rows - m_plus_1) / sv_stride + 1;
5386    let n = samples as usize + 1;
5387    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n * n);
5388
5389    for sv_i in 0..n {
5390        // Global v ∈ [0, n_seg_v]: integer part = segment, fractional
5391        // part = local `t ∈ [0, 1]` within that segment. The last sample
5392        // is pinned to the upper endpoint of the final segment so the
5393        // surface closes on the spec-defined boundary.
5394        let gv = if sv_i == n - 1 {
5395            n_seg_v as f32
5396        } else {
5397            sv_i as f32 * n_seg_v as f32 / (n - 1) as f32
5398        };
5399        let mut seg_v = gv.floor() as usize;
5400        let mut tv = gv - seg_v as f32;
5401        if seg_v >= n_seg_v {
5402            seg_v = n_seg_v - 1;
5403            tv = 1.0;
5404        }
5405        let base_v = seg_v * sv_stride;
5406
5407        // tv^0 .. tv^m once per row.
5408        let mut tv_pow: Vec<f32> = Vec::with_capacity(m_plus_1);
5409        let mut pv = 1.0_f32;
5410        for _ in 0..m_plus_1 {
5411            tv_pow.push(pv);
5412            pv *= tv;
5413        }
5414        // Row b's v-basis coefficient: Σ_q B_v[b][q] · tv^q.
5415        let mut v_coef: Vec<f32> = Vec::with_capacity(m_plus_1);
5416        for b in 0..m_plus_1 {
5417            let mut c = 0.0_f32;
5418            for q in 0..m_plus_1 {
5419                c += bmat_v[b * m_plus_1 + q] * tv_pow[q];
5420            }
5421            v_coef.push(c);
5422        }
5423
5424        for su_i in 0..n {
5425            let gu = if su_i == n - 1 {
5426                n_seg_u as f32
5427            } else {
5428                su_i as f32 * n_seg_u as f32 / (n - 1) as f32
5429            };
5430            let mut seg_u = gu.floor() as usize;
5431            let mut tu = gu - seg_u as f32;
5432            if seg_u >= n_seg_u {
5433                seg_u = n_seg_u - 1;
5434                tu = 1.0;
5435            }
5436            let base_u = seg_u * su_stride;
5437
5438            // tu^0 .. tu^n once per (su, sv) sample.
5439            let mut tu_pow: Vec<f32> = Vec::with_capacity(n_plus_1);
5440            let mut pu = 1.0_f32;
5441            for _ in 0..n_plus_1 {
5442                tu_pow.push(pu);
5443                pu *= tu;
5444            }
5445            // Column a's u-basis coefficient: Σ_p B_u[a][p] · tu^p.
5446            let mut u_coef: Vec<f32> = Vec::with_capacity(n_plus_1);
5447            for a in 0..n_plus_1 {
5448                let mut c = 0.0_f32;
5449                for p in 0..n_plus_1 {
5450                    c += bmat_u[a * n_plus_1 + p] * tu_pow[p];
5451                }
5452                u_coef.push(c);
5453            }
5454
5455            // S(u, v) = Σ_a Σ_b u_coef[a] · v_coef[b] · grid[base_v+b][base_u+a].
5456            let mut accum = [0.0_f32; 3];
5457            for (b, vc) in v_coef.iter().enumerate() {
5458                let row = (base_v + b) * cols;
5459                for (a, uc) in u_coef.iter().enumerate() {
5460                    let cp = grid[row + base_u + a];
5461                    let w = uc * vc;
5462                    accum[0] += w * cp[0];
5463                    accum[1] += w * cp[1];
5464                    accum[2] += w * cp[2];
5465                }
5466            }
5467            out.push(accum);
5468        }
5469    }
5470    out
5471}
5472
5473/// de Casteljau evaluation of a homogeneous 4D Bezier control polygon at
5474/// parameter `t ∈ [0, 1]`. Shared by the row and column passes of
5475/// [`sample_bezier_surface`].
5476fn de_casteljau_4d(points: &[[f32; 4]], t: f32) -> [f32; 4] {
5477    if points.is_empty() {
5478        return [0.0, 0.0, 0.0, 1.0];
5479    }
5480    let mut buf: Vec<[f32; 4]> = points.to_vec();
5481    let n = buf.len();
5482    for level in 1..n {
5483        for j in 0..(n - level) {
5484            buf[j] = [
5485                (1.0 - t) * buf[j][0] + t * buf[j + 1][0],
5486                (1.0 - t) * buf[j][1] + t * buf[j + 1][1],
5487                (1.0 - t) * buf[j][2] + t * buf[j + 1][2],
5488                (1.0 - t) * buf[j][3] + t * buf[j + 1][3],
5489            ];
5490        }
5491    }
5492    buf[0]
5493}
5494
5495/// Evaluate a B-spline (or rational B-spline / NURBS) surface patch at a
5496/// `(samples + 1) × (samples + 1)` lattice via the bivariate
5497/// tensor-product Cox-deBoor formula (spec §"B-spline", §"Rational and
5498/// non-rational curves and surfaces", §"Surface vertex data — control
5499/// points").
5500///
5501/// `grid` is the control mesh in row-major order with the u index varying
5502/// fastest (`cols` control points per v-row, `rows` v-rows). The surface
5503/// is
5504///
5505///   S(u, v) = Σ_i Σ_j N_{i,nu}(u) · N_{j,nv}(v) · d_{i,j}
5506///
5507/// for the non-rational case and
5508///
5509///   S(u, v) = Σ_i Σ_j N_{i,nu}(u) · N_{j,nv}(v) · w_{i,j} · d_{i,j}
5510///             ─────────────────────────────────────────────────────
5511///                  Σ_i Σ_j N_{i,nu}(u) · N_{j,nv}(v) · w_{i,j}
5512///
5513/// for the rational (NURBS) case. `nu` / `nv` are the u / v degrees and
5514/// `knots_u` (`parm u`) / `knots_v` (`parm v`) are the per-direction knot
5515/// vectors. The basis functions are evaluated with the same
5516/// [`bspline_basis`] routine the 1D curve path uses.
5517///
5518/// `s0`..`s1` and `t0`..`t1` are the `surf` parameter ranges; each is
5519/// clipped against the spec §"B-spline" condition-5 evaluation window
5520/// `[x_n, x_{K+1}]` of its direction's knot vector. The half-open
5521/// knot-span convention `x_i ≤ t < x_{i+1}` means an endpoint exactly at
5522/// the upper bound would yield an all-zero basis, so the last sample in
5523/// each direction is nudged fractionally below the bound (the same
5524/// standard NURBS-evaluator pattern as [`sample_bspline`]).
5525///
5526/// Output vertices are ordered row-major in the sample lattice: sample
5527/// `(su, sv)` lands at index `sv * (samples + 1) + su`.
5528#[allow(clippy::too_many_arguments)]
5529fn sample_bspline_surface(
5530    grid: &[[f32; 3]],
5531    weights: &[f32],
5532    kind: &str,
5533    deg_u: u32,
5534    deg_v: u32,
5535    knots_u: &[f32],
5536    knots_v: &[f32],
5537    s0: f32,
5538    s1: f32,
5539    t0: f32,
5540    t1: f32,
5541    cols: usize,
5542    rows: usize,
5543    samples: u32,
5544) -> Vec<[f32; 3]> {
5545    if samples == 0 || cols == 0 || rows == 0 || grid.len() != cols * rows {
5546        return Vec::new();
5547    }
5548    let nu = deg_u as usize;
5549    let nv = deg_v as usize;
5550    // Spec §"B-spline" condition 6: q + 1 knots ⇒ K + 1 = q − n control
5551    // points ⇒ knots.len() == control_count + degree + 1.
5552    if knots_u.len() != cols + nu + 1 || knots_v.len() != rows + nv + 1 {
5553        return Vec::new();
5554    }
5555
5556    // Per-direction evaluation windows (spec condition 5:
5557    // x_n ≤ t_min < t_max ≤ x_{K+1}). Clip the `surf` ranges into the
5558    // valid span of each knot vector.
5559    let u_lo_bound = knots_u[nu];
5560    let u_hi_bound = knots_u[cols]; // x_{K1+1}, K1+1 = cols.
5561    let v_lo_bound = knots_v[nv];
5562    let v_hi_bound = knots_v[rows]; // x_{K2+1}, K2+1 = rows.
5563    let u_min = s0.max(u_lo_bound);
5564    let u_max = s1.min(u_hi_bound);
5565    let v_min = t0.max(v_lo_bound);
5566    let v_max = t1.min(v_hi_bound);
5567    if u_min > u_max || v_min > v_max {
5568        return Vec::new();
5569    }
5570
5571    let rational = kind == "rat_bspline";
5572    let n = samples as usize + 1;
5573
5574    // Precompute one row of u-basis values per sample column and one
5575    // column of v-basis values per sample row; the tensor product reuses
5576    // them across the lattice.
5577    let nudge = |t: f32, lo: f32, hi: f32| -> f32 {
5578        // When t lands exactly on the upper bound the half-open spans give
5579        // an all-zero basis; bias it fractionally inside the last span.
5580        if t >= hi {
5581            let biased = hi - (hi - lo).abs() * 1e-7 - f32::EPSILON;
5582            if biased < lo { lo } else { biased }
5583        } else {
5584            t
5585        }
5586    };
5587
5588    let u_basis_rows: Vec<Vec<f32>> = (0..n)
5589        .map(|i| {
5590            let t01 = if n == 1 {
5591                0.0
5592            } else {
5593                i as f32 / (n - 1) as f32
5594            };
5595            let u = nudge(u_min + t01 * (u_max - u_min), u_lo_bound, u_hi_bound);
5596            bspline_basis(u, knots_u, nu)
5597        })
5598        .collect();
5599    let v_basis_rows: Vec<Vec<f32>> = (0..n)
5600        .map(|j| {
5601            let t01 = if n == 1 {
5602                0.0
5603            } else {
5604                j as f32 / (n - 1) as f32
5605            };
5606            let v = nudge(v_min + t01 * (v_max - v_min), v_lo_bound, v_hi_bound);
5607            bspline_basis(v, knots_v, nv)
5608        })
5609        .collect();
5610
5611    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n * n);
5612    for vb in v_basis_rows.iter() {
5613        for ub in u_basis_rows.iter() {
5614            // Tensor product: S = Σ_j vb[j] · Σ_i ub[i] · w_{i,j} · d_{i,j}
5615            // accumulated together with the weighted denominator.
5616            let mut acc = [0.0f32; 3];
5617            let mut wsum = 0.0f32;
5618            for (j, &bv) in vb.iter().enumerate().take(rows) {
5619                if bv == 0.0 {
5620                    continue;
5621                }
5622                for (i, &bu) in ub.iter().enumerate().take(cols) {
5623                    if bu == 0.0 {
5624                        continue;
5625                    }
5626                    let idx = j * cols + i;
5627                    let w = if rational { weights[idx] } else { 1.0 };
5628                    let coeff = bu * bv * w;
5629                    if coeff == 0.0 {
5630                        continue;
5631                    }
5632                    wsum += coeff;
5633                    acc[0] += coeff * grid[idx][0];
5634                    acc[1] += coeff * grid[idx][1];
5635                    acc[2] += coeff * grid[idx][2];
5636                }
5637            }
5638            if wsum.abs() > f32::EPSILON {
5639                // Non-rational basis functions form a partition of unity
5640                // inside the valid window, so the division is a no-op there
5641                // (wsum ≈ 1); the rational form needs it. Dividing in both
5642                // cases keeps a single code path and is numerically safe.
5643                out.push([acc[0] / wsum, acc[1] / wsum, acc[2] / wsum]);
5644            } else {
5645                // Sample fell outside the support of every basis function
5646                // (pathological knot vector); emit the zero accumulator so
5647                // the lattice size still matches (samples + 1)^2.
5648                out.push(acc);
5649            }
5650        }
5651    }
5652    out
5653}
5654
5655/// Evaluate a Bezier (or rational-Bezier) curve at `samples + 1`
5656/// uniformly-spaced parameter values from `u_min` to `u_max` via the
5657/// numerically-stable de Casteljau algorithm.
5658///
5659/// For `kind == "bezier"` weights are ignored and the result is the
5660/// straight 3D control-point combination.
5661///
5662/// For `kind == "rat_bezier"` each control point is treated as a
5663/// homogeneous `(w·x, w·y, w·z, w)` 4-tuple, de Casteljau runs on the
5664/// 4D form, and the final point is projected back to 3D by `x/w`.
5665/// This matches the spec §"Curve" rational form.
5666fn sample_bezier(
5667    control_points: &[[f32; 3]],
5668    control_weights: &[f32],
5669    kind: &str,
5670    _u_min: f32,
5671    _u_max: f32,
5672    samples: u32,
5673) -> Vec<[f32; 3]> {
5674    if control_points.is_empty() || samples == 0 {
5675        return Vec::new();
5676    }
5677    let rational = kind == "rat_bezier";
5678    // Build the working buffer in 4D so the same de Casteljau loop
5679    // covers both rational and non-rational cases (non-rational uses
5680    // w == 1).
5681    let homogeneous: Vec<[f32; 4]> = control_points
5682        .iter()
5683        .zip(control_weights.iter())
5684        .map(|(p, w)| {
5685            let weight = if rational { *w } else { 1.0 };
5686            [p[0] * weight, p[1] * weight, p[2] * weight, weight]
5687        })
5688        .collect();
5689
5690    let n_samples = samples + 1;
5691    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
5692    for i in 0..n_samples {
5693        // Normalise sample index into the curve's parameter range so
5694        // `u_min` and `u_max` aren't mandatorily [0, 1].
5695        let t01 = if n_samples == 1 {
5696            0.0
5697        } else {
5698            i as f32 / (n_samples - 1) as f32
5699        };
5700        // The `u_min` / `u_max` arguments on `curv` are spec-defined
5701        // clip bounds for trimming the basis evaluation, not a
5702        // re-parameterisation of the basis. For a single un-trimmed
5703        // Bezier segment they have no effect on shape — the curve
5704        // domain is `[0, 1]` in basis space. We sample uniformly on
5705        // `t01 ∈ [0, 1]` (so a non-trivial `u_min, u_max` doesn't
5706        // distort the polyline), which is what every other OBJ
5707        // tessellator does.
5708        let t = t01;
5709        let mut buf: Vec<[f32; 4]> = homogeneous.clone();
5710        let n = buf.len();
5711        for level in 1..n {
5712            for j in 0..(n - level) {
5713                buf[j] = [
5714                    (1.0 - t) * buf[j][0] + t * buf[j + 1][0],
5715                    (1.0 - t) * buf[j][1] + t * buf[j + 1][1],
5716                    (1.0 - t) * buf[j][2] + t * buf[j + 1][2],
5717                    (1.0 - t) * buf[j][3] + t * buf[j + 1][3],
5718                ];
5719            }
5720        }
5721        let [x, y, z, w] = buf[0];
5722        if rational && w.abs() > f32::EPSILON {
5723            out.push([x / w, y / w, z / w]);
5724        } else {
5725            out.push([x, y, z]);
5726        }
5727    }
5728    out
5729}
5730
5731/// Evaluate a B-spline (or rational B-spline / NURBS) curve at
5732/// `samples + 1` uniformly-spaced parameter values from `t_min` to
5733/// `t_max`, where the interval is clipped against the spec-required
5734/// `[x_n, x_{K+1}]` evaluation range of the knot vector (spec §"B-spline"
5735/// condition 5: `x_n ≤ t_min < t_max ≤ x_{K+1}`).
5736///
5737/// Mathematics — Cox-deBoor recursion (spec §"B-spline"):
5738///
5739///   N_{i,0}(t) = 1 if x_i ≤ t < x_{i+1} else 0
5740///   N_{i,k}(t) = (t - x_i) / (x_{i+k} - x_i)         · N_{i,k-1}(t)
5741///              + (x_{i+k+1} - t) / (x_{i+k+1} - x_{i+1}) · N_{i+1,k-1}(t)
5742///
5743/// by convention `0/0 = 0`. The curve at parameter t is
5744///
5745///   C(t) = Σ_{i=0..K} N_{i,n}(t) · d_i
5746///
5747/// For the rational form, the weighted homogeneous sum is computed and
5748/// projected back to 3D via `x/w`:
5749///
5750///   C(t) = Σ N_{i,n}(t) · w_i · d_i / Σ N_{i,n}(t) · w_i
5751///
5752/// `kind` selects `"bspline"` (weights ignored, w = 1) or
5753/// `"rat_bspline"` (per-vertex `w` from `v x y z w`).
5754#[allow(clippy::too_many_arguments)]
5755fn sample_bspline(
5756    control_points: &[[f32; 3]],
5757    control_weights: &[f32],
5758    kind: &str,
5759    degree: u32,
5760    knots: &[f32],
5761    u_min: f32,
5762    u_max: f32,
5763    samples: u32,
5764) -> Vec<[f32; 3]> {
5765    if control_points.is_empty() || samples == 0 {
5766        return Vec::new();
5767    }
5768    let n = degree as usize;
5769    let k_plus_1 = control_points.len(); // = K + 1 control points.
5770    // Spec §"B-spline" condition 6: K = q - n - 1 ⇒ knots.len() must
5771    // equal control_points.len() + degree + 1. The caller already
5772    // checks this; double-check defensively.
5773    if knots.len() != k_plus_1 + n + 1 {
5774        return Vec::new();
5775    }
5776    // Spec condition 5: evaluation parameter t must satisfy
5777    //   x_n ≤ t_min < t_max ≤ x_{K+1}
5778    // Clip the caller-supplied u_min / u_max against that window so the
5779    // basis functions evaluate to defined values (any t outside the
5780    // window gives N = 0 across the support and a degenerate sample).
5781    let t_lo_bound = knots[n];
5782    let t_hi_bound = knots[k_plus_1]; // x_{K+1} index = K+1 = k_plus_1.
5783    let t_min = u_min.max(t_lo_bound);
5784    let t_max = u_max.min(t_hi_bound);
5785    if t_min > t_max {
5786        return Vec::new();
5787    }
5788
5789    let rational = kind == "rat_bspline";
5790    let n_samples = samples + 1;
5791    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
5792
5793    for i in 0..n_samples {
5794        let t01 = if n_samples == 1 {
5795            0.0
5796        } else {
5797            i as f32 / (n_samples - 1) as f32
5798        };
5799        let mut t = t_min + t01 * (t_max - t_min);
5800        // Numerical guard — when t == t_hi_bound, the half-open interval
5801        // convention `x_i ≤ t < x_{i+1}` makes N_{i,0} zero everywhere.
5802        // Nudge the last sample fractionally below the upper bound so
5803        // it lies inside the last non-empty knot span (a standard NURBS-
5804        // evaluator pattern; the resulting blend converges to the curve
5805        // endpoint as the bias shrinks).
5806        if t >= t_hi_bound {
5807            t = t_hi_bound - (t_hi_bound - t_lo_bound).abs() * 1e-7 - f32::EPSILON;
5808            if t < t_lo_bound {
5809                t = t_lo_bound;
5810            }
5811        }
5812        let basis = bspline_basis(t, knots, n);
5813        // Σ N_{i,n}(t) · w_i · d_i  (3D positions blended).
5814        // For non-rational, w_i = 1 ⇒ standard polynomial blend.
5815        let mut acc = [0.0f32; 3];
5816        let mut wsum = 0.0f32;
5817        for j in 0..k_plus_1 {
5818            let bj = basis[j];
5819            if bj == 0.0 {
5820                continue;
5821            }
5822            let w = if rational { control_weights[j] } else { 1.0 };
5823            let bw = bj * w;
5824            wsum += bw;
5825            acc[0] += bw * control_points[j][0];
5826            acc[1] += bw * control_points[j][1];
5827            acc[2] += bw * control_points[j][2];
5828        }
5829        if rational && wsum.abs() > f32::EPSILON {
5830            out.push([acc[0] / wsum, acc[1] / wsum, acc[2] / wsum]);
5831        } else if !rational && wsum.abs() > f32::EPSILON {
5832            // Non-rational basis functions sum to 1 inside the valid
5833            // window by partition-of-unity (spec note: "basis functions
5834            // sum to 1.0, such as Bezier, Cardinal, and NURB"); no
5835            // division needed in theory, but we still emit `acc` as-is.
5836            out.push(acc);
5837        } else {
5838            // Sample fell outside the support of every basis function —
5839            // emit the running accumulator (which is zero) so the
5840            // polyline length still matches `samples + 1`. In practice
5841            // the clip + nudge above prevents this branch except for
5842            // pathological knot vectors.
5843            out.push(acc);
5844        }
5845    }
5846    out
5847}
5848
5849/// Cox-deBoor recursive basis-function evaluation at parameter `t`
5850/// against the given knot vector. Returns one weight per control point
5851/// (control-point count = knots.len() − degree − 1).
5852///
5853/// Uses the iterative bottom-up formulation: build degree-0 step
5854/// functions, then accumulate higher-degree polynomials in place. This
5855/// is `O(k_plus_1 · (degree + 1))` work per evaluation, which suffices
5856/// for the modest curve sizes typical of OBJ files. The standard
5857/// `0/0 = 0` convention is applied via explicit denominator guards
5858/// (spec §"B-spline" inline note).
5859fn bspline_basis(t: f32, knots: &[f32], degree: usize) -> Vec<f32> {
5860    let m = knots.len();
5861    if m <= degree + 1 {
5862        return Vec::new();
5863    }
5864    let k_plus_1 = m - degree - 1;
5865    // Allocate one row of `m - 1` degree-0 weights (one per knot span);
5866    // we'll fold this down to k_plus_1 weights at the end.
5867    let mut basis: Vec<f32> = Vec::with_capacity(m - 1);
5868    for i in 0..(m - 1) {
5869        // Degree-0: indicator function on the half-open knot span. Use
5870        // the closed-on-the-right convention for the final span so that
5871        // a t exactly at the upper bound still falls inside the last
5872        // non-empty interval (NURBS-evaluator convention).
5873        let inside = if i + 1 == m - 1 {
5874            knots[i] <= t && t <= knots[i + 1]
5875        } else {
5876            knots[i] <= t && t < knots[i + 1]
5877        };
5878        basis.push(if inside { 1.0 } else { 0.0 });
5879    }
5880    // Recursive degree promotion.
5881    for k in 1..=degree {
5882        // After this loop iteration we want length (m - 1 - k); we
5883        // overwrite in place, indexing j and j+1.
5884        let new_len = m - 1 - k;
5885        for j in 0..new_len {
5886            let denom_left = knots[j + k] - knots[j];
5887            let denom_right = knots[j + k + 1] - knots[j + 1];
5888            let left = if denom_left.abs() < f32::EPSILON {
5889                0.0
5890            } else {
5891                (t - knots[j]) / denom_left * basis[j]
5892            };
5893            let right = if denom_right.abs() < f32::EPSILON {
5894                0.0
5895            } else {
5896                (knots[j + k + 1] - t) / denom_right * basis[j + 1]
5897            };
5898            basis[j] = left + right;
5899        }
5900        basis.truncate(new_len);
5901    }
5902    debug_assert_eq!(basis.len(), k_plus_1);
5903    basis
5904}
5905
5906/// Evaluate a cubic Cardinal (Catmull-Rom) curve at `samples + 1`
5907/// uniformly-spaced parameter values from `t = 0` (start of first
5908/// segment) to `t = K - 2` (end of last segment) where `K = control_points.len()`.
5909///
5910/// Spec §"Cardinal": Cardinal splines are cubic only and interpolate all
5911/// but the first and last control points. The conversion to Bezier
5912/// control points for one segment over `c0, c1, c2, c3` is:
5913///
5914///   b0 = c1
5915///   b1 = c1 + (c2 - c0) / 6
5916///   b2 = c2 - (c3 - c1) / 6
5917///   b3 = c2
5918///
5919/// The full curve is the concatenation of `K - 3` such Bezier segments
5920/// produced by sliding a 4-point window across the control polygon —
5921/// segment `i` consumes `c[i..i+4]` and traces from the interpolated
5922/// midpoint `c[i+1]` to `c[i+2]`. This yields a C¹-continuous piecewise
5923/// curve that passes through every interior control point exactly.
5924///
5925/// The result is emitted as one polyline carrying `samples + 1` total
5926/// vertices distributed across all segments in proportion to their share
5927/// of the parameter range. To keep the implementation simple and the
5928/// polyline density uniform along the curve, we evaluate `samples` total
5929/// intervals (`samples + 1` points) globally, mapping each global sample
5930/// to a segment index plus a local `t ∈ [0, 1]` within that segment.
5931///
5932/// Weights / rationality: the spec note says the unit-weight default is
5933/// reasonable for Cardinal because its basis functions sum to 1, so we
5934/// don't differentiate `rat cardinal` from `cardinal` — the per-vertex
5935/// 4th `w` weight is read from `position_weights` but treated as 1 in
5936/// the Bezier-conversion form (where it would otherwise alter the shape
5937/// in a way the spec doesn't explicitly define).
5938fn sample_cardinal(control_points: &[[f32; 3]], samples: u32) -> Vec<[f32; 3]> {
5939    if control_points.len() < 4 || samples == 0 {
5940        return Vec::new();
5941    }
5942    let n_segments = control_points.len() - 3;
5943    let n_samples = samples + 1;
5944    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
5945
5946    for i in 0..n_samples {
5947        // Global `s ∈ [0, n_segments]`; integer part picks the segment,
5948        // fractional part is the local `t ∈ [0, 1]`. Pin the last sample
5949        // to the very end of the last segment so the polyline closes
5950        // exactly on `c[K-2]`.
5951        let s = if i == n_samples - 1 {
5952            n_segments as f32
5953        } else {
5954            i as f32 * n_segments as f32 / (n_samples - 1) as f32
5955        };
5956        let mut seg = s.floor() as usize;
5957        let mut t = s - seg as f32;
5958        if seg >= n_segments {
5959            seg = n_segments - 1;
5960            t = 1.0;
5961        }
5962        // 4 Cardinal control points for this segment.
5963        let c0 = control_points[seg];
5964        let c1 = control_points[seg + 1];
5965        let c2 = control_points[seg + 2];
5966        let c3 = control_points[seg + 3];
5967        // Spec §"Cardinal" Bezier conversion (component-wise per axis):
5968        //   b0 = c1
5969        //   b1 = c1 + (c2 - c0) / 6
5970        //   b2 = c2 - (c3 - c1) / 6
5971        //   b3 = c2
5972        let mut b: [[f32; 3]; 4] = [[0.0; 3]; 4];
5973        for a in 0..3 {
5974            b[0][a] = c1[a];
5975            b[1][a] = c1[a] + (c2[a] - c0[a]) / 6.0;
5976            b[2][a] = c2[a] - (c3[a] - c1[a]) / 6.0;
5977            b[3][a] = c2[a];
5978        }
5979        // Cubic Bezier evaluation (Bernstein form, expanded for n = 3
5980        // since the spec only defines Cardinal for the cubic case):
5981        //   B(t) = (1-t)^3 b0 + 3(1-t)^2 t b1 + 3(1-t) t^2 b2 + t^3 b3
5982        let u = 1.0 - t;
5983        let w0 = u * u * u;
5984        let w1 = 3.0 * u * u * t;
5985        let w2 = 3.0 * u * t * t;
5986        let w3 = t * t * t;
5987        let p = [
5988            w0 * b[0][0] + w1 * b[1][0] + w2 * b[2][0] + w3 * b[3][0],
5989            w0 * b[0][1] + w1 * b[1][1] + w2 * b[2][1] + w3 * b[3][1],
5990            w0 * b[0][2] + w1 * b[1][2] + w2 * b[2][2] + w3 * b[3][2],
5991        ];
5992        out.push(p);
5993    }
5994    out
5995}
5996
5997/// Evaluate a single cubic Cardinal (Catmull-Rom) control polygon at the
5998/// global parameter `s ∈ [0, len − 3]`, where the integer part of `s`
5999/// selects the 4-point segment window and the fractional part is the
6000/// local `t ∈ [0, 1]` inside that segment.
6001///
6002/// Spec §"Cardinal": each segment over `c0, c1, c2, c3` converts to a
6003/// cubic Bezier (`b0 = c1`, `b1 = c1 + (c2 − c0) / 6`,
6004/// `b2 = c2 − (c3 − c1) / 6`, `b3 = c2`) and is then evaluated with the
6005/// Bernstein cubic basis. The curve interpolates every interior control
6006/// point exactly. This is the 1D building block the tensor-product
6007/// surface evaluator reuses in both parametric directions.
6008fn cardinal_eval_1d(points: &[[f32; 3]], s: f32) -> [f32; 3] {
6009    // Caller guarantees `points.len() >= 4`.
6010    let n_segments = points.len() - 3;
6011    let mut seg = s.floor() as isize;
6012    let mut t = s - seg as f32;
6013    if seg < 0 {
6014        seg = 0;
6015        t = 0.0;
6016    } else if seg as usize >= n_segments {
6017        seg = n_segments as isize - 1;
6018        t = 1.0;
6019    }
6020    let seg = seg as usize;
6021    let c0 = points[seg];
6022    let c1 = points[seg + 1];
6023    let c2 = points[seg + 2];
6024    let c3 = points[seg + 3];
6025    // Spec §"Cardinal" Bezier conversion (component-wise per axis).
6026    let mut b: [[f32; 3]; 4] = [[0.0; 3]; 4];
6027    for a in 0..3 {
6028        b[0][a] = c1[a];
6029        b[1][a] = c1[a] + (c2[a] - c0[a]) / 6.0;
6030        b[2][a] = c2[a] - (c3[a] - c1[a]) / 6.0;
6031        b[3][a] = c2[a];
6032    }
6033    let u = 1.0 - t;
6034    let w0 = u * u * u;
6035    let w1 = 3.0 * u * u * t;
6036    let w2 = 3.0 * u * t * t;
6037    let w3 = t * t * t;
6038    [
6039        w0 * b[0][0] + w1 * b[1][0] + w2 * b[2][0] + w3 * b[3][0],
6040        w0 * b[0][1] + w1 * b[1][1] + w2 * b[2][1] + w3 * b[3][1],
6041        w0 * b[0][2] + w1 * b[1][2] + w2 * b[2][2] + w3 * b[3][2],
6042    ]
6043}
6044
6045/// Evaluate a cubic Cardinal (Catmull-Rom) surface patch at a
6046/// `(samples + 1) × (samples + 1)` lattice via the bivariate
6047/// tensor-product Cardinal evaluation (spec §"Cardinal").
6048///
6049/// `grid` is the control mesh in row-major order with the u index varying
6050/// fastest (`cols` control points per v-row, `rows` v-rows; spec
6051/// §"Surface vertex data — control points"). The surface is the tensor
6052/// product of two cubic Cardinal bases:
6053///
6054///   S(u, v) = Σ_i Σ_j C_i(u) · C_j(v) · d_{i,j}
6055///
6056/// where `C_·` are the cubic Cardinal basis functions. We collapse the
6057/// inner u sum first by running the 1D Cardinal evaluator on each v-row,
6058/// then a second 1D Cardinal evaluation in the v direction over the
6059/// `rows` collapsed points (spec §"Cardinal": "For surfaces, all but the
6060/// first and last row and column of control points are interpolated").
6061///
6062/// The global parameter domain is `[0, cols − 3] × [0, rows − 3]` (one
6063/// unit per Cardinal segment); samples are spread uniformly over it. The
6064/// `surf` range scalars are provenance only (Cardinal is segment-
6065/// normalised, like the round-9 curve path), so they are not used to
6066/// re-parameterise the evaluation.
6067///
6068/// Weights / rationality: spec §"Free-form curve/surface body
6069/// statements" notes the unit-weight default is reasonable for Cardinal
6070/// (its basis functions sum to 1), so per-vertex `w` weights are not
6071/// applied — `rat cardinal` routes here too.
6072///
6073/// Output vertices are ordered row-major in the sample lattice: sample
6074/// `(su, sv)` lands at index `sv * (samples + 1) + su`.
6075fn sample_cardinal_surface(
6076    grid: &[[f32; 3]],
6077    cols: usize,
6078    rows: usize,
6079    samples: u32,
6080) -> Vec<[f32; 3]> {
6081    // Cardinal needs at least a 4×4 control window per direction.
6082    if samples == 0 || cols < 4 || rows < 4 || grid.len() != cols * rows {
6083        return Vec::new();
6084    }
6085    let n = samples as usize + 1;
6086    let u_span = (cols - 3) as f32; // number of u-segments.
6087    let v_span = (rows - 3) as f32; // number of v-segments.
6088
6089    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n * n);
6090    for sv in 0..n {
6091        let v = if n == 1 {
6092            0.0
6093        } else {
6094            sv as f32 / (n - 1) as f32 * v_span
6095        };
6096        for su in 0..n {
6097            let u = if n == 1 {
6098                0.0
6099            } else {
6100                su as f32 / (n - 1) as f32 * u_span
6101            };
6102            // Inner pass: evaluate each v-row's 1D Cardinal curve at u,
6103            // leaving one point per row.
6104            let mut col_pts: Vec<[f32; 3]> = Vec::with_capacity(rows);
6105            for r in 0..rows {
6106                let row = &grid[r * cols..r * cols + cols];
6107                col_pts.push(cardinal_eval_1d(row, u));
6108            }
6109            // Outer pass: 1D Cardinal evaluation in v over the collapsed
6110            // points.
6111            out.push(cardinal_eval_1d(&col_pts, v));
6112        }
6113    }
6114    out
6115}
6116
6117/// Evaluate a Taylor polynomial surface patch at a
6118/// `(samples + 1) × (samples + 1)` lattice via direct bivariate
6119/// polynomial evaluation.
6120///
6121/// Spec §"Taylor": the control points are the polynomial coefficients
6122/// `c_{i,j}` for the bivariate polynomial:
6123///
6124/// ```text
6125///   S(u, v) = Σ_{i=0..degu} Σ_{j=0..degv} c_{i,j} · u^i · v^j
6126/// ```
6127///
6128/// Applied component-wise per axis. Each of the three output channels
6129/// (x, y, z) is an independent polynomial in u and v whose coefficients
6130/// are taken from the corresponding component of the control points.
6131/// The control grid is row-major with the u index varying fastest (spec
6132/// §"Surface vertex data — control points"), so the coefficient
6133/// `c_{i,j}` lives at `grid[j * cols + i]` where `cols = degu + 1` and
6134/// `rows = degv + 1`.
6135///
6136/// The `surf s0 s1 t0 t1` range supplies the global parameter clip
6137/// (spec §"surf": "the [s0, s1] range gives the start/end values for
6138/// the curve in the u direction" — analogous for `[t0, t1]` in v).
6139/// Taylor curves and surfaces evaluate against the raw parameter values
6140/// directly (not a normalised `[0, 1]` re-parameterisation), so we
6141/// sample at `u_i = s0 + i / samples · (s1 - s0)` and similarly for v.
6142///
6143/// Implementation: we collapse the inner u sum first by Horner-rule
6144/// evaluation across each v-row, leaving one point per row, then a
6145/// second Horner-rule pass in v over the collapsed points. The inner-
6146/// loop scratch buffer is heap-allocated once per `(su, sv)` sample at
6147/// modest cost; the total surface sample count is `(samples + 1)²`.
6148///
6149/// Rationality: the spec note in §"Free-form curve/surface body
6150/// statements" explicitly says the rational form "does not make sense
6151/// for Taylor", so `rat taylor` routes here without weight blending.
6152///
6153/// Output vertices are ordered row-major in the sample lattice: sample
6154/// `(su, sv)` lands at index `sv * (samples + 1) + su`.
6155#[allow(clippy::too_many_arguments)]
6156fn sample_taylor_surface(
6157    grid: &[[f32; 3]],
6158    cols: usize,
6159    rows: usize,
6160    s0: f32,
6161    s1: f32,
6162    t0: f32,
6163    t1: f32,
6164    samples: u32,
6165) -> Vec<[f32; 3]> {
6166    if samples == 0 || cols == 0 || rows == 0 || grid.len() != cols * rows {
6167        return Vec::new();
6168    }
6169    let n = samples as usize + 1;
6170    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n * n);
6171    // Scratch for the inner Horner-rule pass: one collapsed point per
6172    // v-row at the current u sample.
6173    let mut col_pts: Vec<[f32; 3]> = Vec::with_capacity(rows);
6174    for sv in 0..n {
6175        let v = if n == 1 {
6176            0.0
6177        } else {
6178            t0 + (sv as f32 / (n - 1) as f32) * (t1 - t0)
6179        };
6180        for su in 0..n {
6181            let u = if n == 1 {
6182                0.0
6183            } else {
6184                s0 + (su as f32 / (n - 1) as f32) * (s1 - s0)
6185            };
6186            // Inner pass: Horner's rule in u across each v-row,
6187            // collapsing each row to a single point at the sample u.
6188            //
6189            //   row(u) = (((c_{degu,j} · u + c_{degu-1,j}) · u + …) · u
6190            //            + c_{0,j})
6191            col_pts.clear();
6192            for r in 0..rows {
6193                let row_start = r * cols;
6194                let mut acc = grid[row_start + cols - 1];
6195                for i in (0..cols - 1).rev() {
6196                    let cij = grid[row_start + i];
6197                    acc[0] = acc[0] * u + cij[0];
6198                    acc[1] = acc[1] * u + cij[1];
6199                    acc[2] = acc[2] * u + cij[2];
6200                }
6201                col_pts.push(acc);
6202            }
6203            // Outer pass: Horner's rule in v over the collapsed points.
6204            let mut acc = col_pts[rows - 1];
6205            for j in (0..rows - 1).rev() {
6206                let cj = col_pts[j];
6207                acc[0] = acc[0] * v + cj[0];
6208                acc[1] = acc[1] * v + cj[1];
6209                acc[2] = acc[2] * v + cj[2];
6210            }
6211            out.push(acc);
6212        }
6213    }
6214    out
6215}
6216
6217/// Integer square root that returns `Some(r)` only when `n == r * r`
6218/// (i.e. `n` is a perfect square). Used to recover the square single-
6219/// patch control-grid dimension for a Cardinal `surf` whose `parm`
6220/// directives carry only the 2-value global parameter range.
6221fn isqrt_exact(n: usize) -> Option<usize> {
6222    if n == 0 {
6223        return None;
6224    }
6225    let mut r = (n as f64).sqrt() as usize;
6226    // Guard against floating-point rounding on either side.
6227    while r * r > n {
6228        r -= 1;
6229    }
6230    while (r + 1) * (r + 1) <= n {
6231        r += 1;
6232    }
6233    if r * r == n { Some(r) } else { None }
6234}
6235
6236/// Evaluate a Taylor polynomial curve at `samples + 1` uniformly-spaced
6237/// parameter values from `u_min` to `u_max`.
6238///
6239/// Spec §"Taylor": "The basis function is simply t^i" with the note
6240/// that the control points are the polynomial coefficients (and have no
6241/// geometric significance). So for `K + 1` control points c_0..c_K
6242/// supplied via `curv`, the curve is:
6243///
6244///   P(t) = c_0 + c_1 · t + c_2 · t^2 + … + c_K · t^K
6245///
6246/// applied component-wise per axis. This is Horner's-rule territory —
6247/// we use the straightforward bottom-up evaluation:
6248///
6249///   P(t) = ((c_K · t + c_{K-1}) · t + c_{K-2}) · t + … + c_0
6250///
6251/// which is numerically well-behaved for the modest degrees typical of
6252/// real Taylor curves (the spec example is degree 4).
6253///
6254/// The `u_min` / `u_max` arguments on the `curv` directive are the
6255/// global parameter clip bounds; Taylor curves evaluate against `t`
6256/// directly (not a normalised `[0, 1]` re-parameterisation) so we
6257/// sample at `t_i = u_min + i / samples · (u_max - u_min)`.
6258fn sample_taylor(
6259    control_points: &[[f32; 3]],
6260    u_min: f32,
6261    u_max: f32,
6262    samples: u32,
6263) -> Vec<[f32; 3]> {
6264    if control_points.is_empty() || samples == 0 {
6265        return Vec::new();
6266    }
6267    let n_samples = samples + 1;
6268    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
6269    let k = control_points.len();
6270    for i in 0..n_samples {
6271        let frac = if n_samples == 1 {
6272            0.0
6273        } else {
6274            i as f32 / (n_samples - 1) as f32
6275        };
6276        let t = u_min + frac * (u_max - u_min);
6277        // Horner's rule on the coefficient vector. Walk from the
6278        // highest-order coefficient down to c_0.
6279        let mut acc = control_points[k - 1];
6280        for j in (0..(k - 1)).rev() {
6281            acc[0] = acc[0] * t + control_points[j][0];
6282            acc[1] = acc[1] * t + control_points[j][1];
6283            acc[2] = acc[2] * t + control_points[j][2];
6284        }
6285        out.push(acc);
6286    }
6287    out
6288}
6289
6290/// Evaluate a basis-matrix curve at `samples + 1` total points.
6291///
6292/// Spec §"Basis matrix": general arbitrary-degree curves whose basis is
6293/// expressed through a user-supplied `(n + 1) × (n + 1)` matrix `B`
6294/// (passed via `bmat u`) and segment stride `s` (passed via `step`).
6295/// Each polynomial segment `i` consumes the control-point window
6296/// `c[i·s .. i·s + n]` (0-based) and evaluates per spec §"Basis matrix":
6297///
6298/// ```text
6299///   P(t) = Σ_{i=0..n} Σ_{j=0..n} B[i][j] · t^j · p_i
6300/// ```
6301///
6302/// where `B[i][j]` is the row-major element of `bmat u` with column
6303/// index `j` varying fastest (per spec §"bmat u/v matrix": "matrix
6304/// lists the contents of the basis matrix with column subscript j
6305/// varying the fastest"). For the spec's cubic-Bezier-as-bmatrix
6306/// example, this produces the standard Bernstein basis.
6307///
6308/// Number of segments per spec §"step": with `K` control points,
6309/// degree `n`, and step `s`, segment `i` uses indices
6310/// `c_{i·s + 1} .. c_{i·s + n + 1}` (1-based) ⇒ the segment count is
6311/// `floor((K - n - 1) / s) + 1` when `K ≥ n + 1`. Samples are
6312/// distributed proportionally across all segments so the polyline
6313/// density is uniform along the global parameter.
6314///
6315/// Rationality: the spec note in §"Free-form curve/surface body
6316/// statements" explicitly says the unit-weight default "may or may
6317/// not make sense for a representation given in basis-matrix form",
6318/// so we don't apply per-vertex weights here — the user's `bmat u`
6319/// is the authoritative basis.
6320fn sample_bmatrix(
6321    control_points: &[[f32; 3]],
6322    bmat_u: &[f32],
6323    degree: u32,
6324    step: u32,
6325    samples: u32,
6326) -> Vec<[f32; 3]> {
6327    // `checked_add` / `checked_mul` are defensive — the public-facing
6328    // `flush_block` caller already filters degrees whose `(n+1)²`
6329    // overflows `usize`, but this helper is also reachable from future
6330    // call sites and the cost of the saturation check is negligible.
6331    let Some(n_plus_1) = (degree as usize).checked_add(1) else {
6332        return Vec::new();
6333    };
6334    let Some(expected_bmat) = n_plus_1.checked_mul(n_plus_1) else {
6335        return Vec::new();
6336    };
6337    if control_points.len() < n_plus_1 || bmat_u.len() != expected_bmat || step == 0 || samples == 0
6338    {
6339        return Vec::new();
6340    }
6341    // Spec §"step stepu stepv": segment `i` uses control points
6342    // `c_{i·s + 1} .. c_{i·s + n + 1}` (1-based). Solve for the largest
6343    // i with `i·s + n + 1 ≤ K` ⇒ `i ≤ (K - n - 1) / s`.
6344    let s = step as usize;
6345    let n_segments = (control_points.len() - n_plus_1) / s + 1;
6346    let n_samples = samples + 1;
6347    let mut out: Vec<[f32; 3]> = Vec::with_capacity(n_samples as usize);
6348
6349    for i in 0..n_samples {
6350        // Global `g ∈ [0, n_segments]` with integer part = segment and
6351        // fractional part = local `t ∈ [0, 1]` within that segment. Pin
6352        // the last sample exactly to the end of the final segment so
6353        // the polyline closes on the spec-defined endpoint.
6354        let g = if i == n_samples - 1 {
6355            n_segments as f32
6356        } else {
6357            i as f32 * n_segments as f32 / (n_samples - 1) as f32
6358        };
6359        let mut seg = g.floor() as usize;
6360        let mut t = g - seg as f32;
6361        if seg >= n_segments {
6362            seg = n_segments - 1;
6363            t = 1.0;
6364        }
6365        let base = seg * s;
6366
6367        // Compute t^0 .. t^n once.
6368        let mut t_pow: Vec<f32> = Vec::with_capacity(n_plus_1);
6369        let mut p = 1.0_f32;
6370        for _ in 0..n_plus_1 {
6371            t_pow.push(p);
6372            p *= t;
6373        }
6374
6375        // P(t) = Σ_i p_i · (Σ_j B[i][j] · t^j) summed component-wise.
6376        let mut accum = [0.0_f32; 3];
6377        for ii in 0..n_plus_1 {
6378            // Row `ii` of B, dotted against `[t^0, t^1, …, t^n]`.
6379            let mut coef = 0.0_f32;
6380            for jj in 0..n_plus_1 {
6381                coef += bmat_u[ii * n_plus_1 + jj] * t_pow[jj];
6382            }
6383            let cp = control_points[base + ii];
6384            accum[0] += coef * cp[0];
6385            accum[1] += coef * cp[1];
6386            accum[2] += coef * cp[2];
6387        }
6388        out.push(accum);
6389    }
6390    out
6391}
6392
6393/// `true` when the primitive was synthesised by the curve tessellator
6394/// (see [`tessellate_curves`]). Encoder + serialiser branches use this
6395/// to skip emitting derived geometry as `v` lines — the original
6396/// `cstype` / `curv` / `end` directives carry the source-of-truth
6397/// shape.
6398fn is_tessellated_curve(prim: &Primitive) -> bool {
6399    prim.extras
6400        .get("obj:tessellated_curve")
6401        .and_then(|v| v.as_bool())
6402        .unwrap_or(false)
6403}
6404
6405/// Promote a single-`l`-element primitive to `LineStrip` / `LineLoop`
6406/// when applicable; fall back to `Lines` for multi-element or 2-vertex
6407/// segments. See [`build_primitive`] for the surrounding context.
6408fn single_line_topology(elements: &[Element]) -> Topology {
6409    if elements.len() != 1 {
6410        return Topology::Lines;
6411    }
6412    let Element::Line(verts) = &elements[0] else {
6413        return Topology::Lines;
6414    };
6415    if verts.len() < 2 {
6416        return Topology::Lines;
6417    }
6418    // A 2-vertex `l` is a plain segment — keep it on `Lines` so the
6419    // round-trip stays minimal (one `l v1 v2` line either way).
6420    if verts.len() == 2 {
6421        return Topology::Lines;
6422    }
6423    // Closed polyline: first / last vertex coincide on the position
6424    // index. We don't need to compare uv/normal — `l` references only
6425    // ever populate the position component for the loop-detection
6426    // semantics specified by the spec §"Line elements".
6427    let same_start_end = verts.first().map(|fv| fv.v) == verts.last().map(|fv| fv.v);
6428    if same_start_end {
6429        Topology::LineLoop
6430    } else {
6431        Topology::LineStrip
6432    }
6433}
6434
6435/// Build one [`Primitive`] from an accumulated [`PrimAccum`].
6436///
6437/// Returns the primitive plus a per-element arity vector — one entry
6438/// per face (3 for a triangle, 4 for a quad, ≥5 for an n-gon). Lines
6439/// don't contribute arity entries (the encoder switches on topology
6440/// instead).
6441fn build_primitive(
6442    prim_acc: &PrimAccum,
6443    positions: &[[f32; 3]],
6444    position_weights: &[Option<f32>],
6445    position_colors: &[Option<[f32; 4]>],
6446    texcoords: &[[f32; 2]],
6447    normals: &[[f32; 3]],
6448    material_ids: &HashMap<String, oxideav_mesh3d::MaterialId>,
6449) -> Result<(Primitive, Vec<u32>)> {
6450    // Decide topology + attribute presence by looking at the first
6451    // element. Mixed-element primitives (lines + faces under one
6452    // `usemtl`) aren't representable in mesh3d so we error cleanly.
6453    //
6454    // For a single `l` element we promote to the more specific
6455    // `LineStrip` / `LineLoop` topology so consumers don't have to
6456    // reconstruct the polyline shape from disjoint segment pairs:
6457    //
6458    //   * exactly one `l` element with N ≥ 2 vertices whose last
6459    //     vertex equals its first → `LineLoop` (the redundant
6460    //     closing vertex is dropped from the index buffer).
6461    //   * exactly one `l` element with N ≥ 2 distinct end vertices →
6462    //     `LineStrip`.
6463    //   * multiple `l` elements (or a single 2-vertex `l` that is a
6464    //     plain segment) fall back to `Lines` for the existing
6465    //     contiguous-chain re-emit path on the encoder side.
6466    let first = prim_acc.elements.first();
6467    let topology = match first {
6468        Some(Element::Face(_)) => Topology::Triangles,
6469        Some(Element::Line(_)) => single_line_topology(&prim_acc.elements),
6470        Some(Element::Point(_)) => Topology::Points,
6471        None => Topology::Triangles,
6472    };
6473    for elt in &prim_acc.elements {
6474        let ok = matches!(
6475            (&topology, elt),
6476            (Topology::Triangles, Element::Face(_))
6477                | (Topology::Lines, Element::Line(_))
6478                | (Topology::LineStrip, Element::Line(_))
6479                | (Topology::LineLoop, Element::Line(_))
6480                | (Topology::Points, Element::Point(_))
6481        );
6482        if !ok {
6483            return Err(Error::unsupported(
6484                "OBJ primitive mixes face / line / point elements under one usemtl",
6485            ));
6486        }
6487    }
6488
6489    let has_uv = prim_acc.elements.iter().any(|elt| match elt {
6490        Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
6491            verts.iter().any(|fv| fv.vt != 0)
6492        }
6493    });
6494    let has_normal = prim_acc.elements.iter().any(|elt| match elt {
6495        Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
6496            verts.iter().any(|fv| fv.vn != 0)
6497        }
6498    });
6499    // Per-vertex colour applies to a primitive whenever any of its
6500    // referenced positions carries the `v x y z r g b` extension. We
6501    // promote to a single-channel `colors[0]` set; vertices that
6502    // don't carry RGB fall back to white (the obvious "no colour
6503    // information" sentinel — preserves the standard glTF expectation
6504    // that a colour buffer is fully populated when present). The
6505    // round-trip-aware `obj:vertex_color_present` per-position
6506    // bitmap below guards the encoder against re-emitting a
6507    // synthetic white that the original file didn't spell out.
6508    let has_color = prim_acc.elements.iter().any(|elt| match elt {
6509        Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
6510            verts.iter().any(|fv| {
6511                position_colors
6512                    .get((fv.v - 1) as usize)
6513                    .is_some_and(Option::is_some)
6514            })
6515        }
6516    });
6517
6518    let mut prim = Primitive::new(topology);
6519    if has_uv {
6520        prim.uvs.push(Vec::new());
6521    }
6522    if has_normal {
6523        prim.normals = Some(Vec::new());
6524    }
6525    if has_color {
6526        prim.colors.push(Vec::new());
6527    }
6528    // Track per-interned-vertex "did this position carry RGB / a
6529    // weight in the source file?" so the encoder doesn't fabricate
6530    // colours / weights that the user never wrote. Both vectors are
6531    // parallel to `prim.positions` after interning completes.
6532    let mut color_present: Vec<bool> = Vec::new();
6533    let mut weights_seen: Vec<Option<f32>> = Vec::new();
6534
6535    // De-duplicate face-vertices into a single interleaved buffer.
6536    let mut indexer: HashMap<FaceVert, u32> = HashMap::new();
6537    let mut arities: Vec<u32> = Vec::new();
6538    let mut local_indices: Vec<u32> = Vec::new();
6539
6540    let intern = |fv: FaceVert,
6541                  prim: &mut Primitive,
6542                  indexer: &mut HashMap<FaceVert, u32>,
6543                  color_present: &mut Vec<bool>,
6544                  weights_seen: &mut Vec<Option<f32>>|
6545     -> Result<u32> {
6546        if let Some(&idx) = indexer.get(&fv) {
6547            return Ok(idx);
6548        }
6549        let pos = positions
6550            .get((fv.v - 1) as usize)
6551            .ok_or_else(|| Error::invalid(format!("face references missing position {}", fv.v)))?;
6552        prim.positions.push(*pos);
6553        if has_uv {
6554            let uv = if fv.vt == 0 {
6555                [0.0, 0.0]
6556            } else {
6557                *texcoords.get((fv.vt - 1) as usize).ok_or_else(|| {
6558                    Error::invalid(format!("face references missing texcoord {}", fv.vt))
6559                })?
6560            };
6561            prim.uvs[0].push(uv);
6562        }
6563        if has_normal {
6564            let n = if fv.vn == 0 {
6565                [0.0, 0.0, 0.0]
6566            } else {
6567                *normals.get((fv.vn - 1) as usize).ok_or_else(|| {
6568                    Error::invalid(format!("face references missing normal {}", fv.vn))
6569                })?
6570            };
6571            prim.normals.as_mut().unwrap().push(n);
6572        }
6573        if has_color {
6574            // Either the source file carried RGB for this vertex, or
6575            // we synthesise opaque white so the colour buffer stays
6576            // length-parallel with positions (mesh3d invariant).
6577            let rgba = position_colors
6578                .get((fv.v - 1) as usize)
6579                .copied()
6580                .flatten()
6581                .unwrap_or([1.0, 1.0, 1.0, 1.0]);
6582            prim.colors[0].push(rgba);
6583            color_present.push(
6584                position_colors
6585                    .get((fv.v - 1) as usize)
6586                    .is_some_and(Option::is_some),
6587            );
6588        }
6589        weights_seen.push(position_weights.get((fv.v - 1) as usize).copied().flatten());
6590        let new_idx = (prim.positions.len() - 1) as u32;
6591        indexer.insert(fv, new_idx);
6592        Ok(new_idx)
6593    };
6594
6595    for elt in &prim_acc.elements {
6596        match elt {
6597            Element::Face(verts) => {
6598                let arity = verts.len() as u32;
6599                arities.push(arity);
6600                let resolved: Vec<u32> = verts
6601                    .iter()
6602                    .map(|&fv| {
6603                        intern(
6604                            fv,
6605                            &mut prim,
6606                            &mut indexer,
6607                            &mut color_present,
6608                            &mut weights_seen,
6609                        )
6610                    })
6611                    .collect::<Result<Vec<_>>>()?;
6612                // Fan triangulate: (v0, v1, v2), (v0, v2, v3), …
6613                for i in 1..(resolved.len() - 1) {
6614                    local_indices.push(resolved[0]);
6615                    local_indices.push(resolved[i]);
6616                    local_indices.push(resolved[i + 1]);
6617                }
6618            }
6619            Element::Line(verts) => {
6620                let resolved: Vec<u32> = verts
6621                    .iter()
6622                    .map(|&fv| {
6623                        intern(
6624                            fv,
6625                            &mut prim,
6626                            &mut indexer,
6627                            &mut color_present,
6628                            &mut weights_seen,
6629                        )
6630                    })
6631                    .collect::<Result<Vec<_>>>()?;
6632                match topology {
6633                    Topology::LineStrip => {
6634                        // Emit the polyline as a contiguous index list.
6635                        local_indices.extend_from_slice(&resolved);
6636                    }
6637                    Topology::LineLoop => {
6638                        // Drop the redundant closing vertex; consumers
6639                        // treat the strip as closed at draw time.
6640                        let n = resolved.len().saturating_sub(1);
6641                        local_indices.extend_from_slice(&resolved[..n]);
6642                    }
6643                    _ => {
6644                        // Plain `Lines` — decompose polyline into
6645                        // disjoint segment pairs (encoder rejoins
6646                        // contiguous chains on the way out).
6647                        for w in resolved.windows(2) {
6648                            local_indices.push(w[0]);
6649                            local_indices.push(w[1]);
6650                        }
6651                    }
6652                }
6653            }
6654            Element::Point(verts) => {
6655                // Each `p` line can carry multiple vertex references;
6656                // every reference becomes one element index for
6657                // `Topology::Points`. Original arities aren't tracked
6658                // since a re-emit can pack them on one line freely.
6659                for &fv in verts {
6660                    let idx = intern(
6661                        fv,
6662                        &mut prim,
6663                        &mut indexer,
6664                        &mut color_present,
6665                        &mut weights_seen,
6666                    )?;
6667                    local_indices.push(idx);
6668                }
6669            }
6670        }
6671    }
6672
6673    // Promote to U32 if any index >= 65536; U16 otherwise.
6674    if local_indices.iter().any(|&i| i >= u16::MAX as u32) {
6675        prim.indices = Some(Indices::U32(local_indices));
6676    } else {
6677        prim.indices = Some(Indices::U16(
6678            local_indices.into_iter().map(|i| i as u16).collect(),
6679        ));
6680    }
6681
6682    // Per-vertex extension state — surfaced through `Primitive::extras`
6683    // so the encoder knows which `v` lines to expand to the 4-token
6684    // `xyzw`, 6-token `xyzrgb`, or 7-token `xyzwrgb` form. We only stash
6685    // the bitmaps when at least one vertex used the extension; the
6686    // common no-extension case stays free of decode-time noise.
6687    if has_color && color_present.iter().any(|&b| b) {
6688        prim.extras.insert(
6689            "obj:vertex_color_present".to_string(),
6690            serde_json::to_value(&color_present).unwrap(),
6691        );
6692    }
6693    if weights_seen.iter().any(Option::is_some) {
6694        prim.extras.insert(
6695            "obj:vertex_weight".to_string(),
6696            serde_json::to_value(&weights_seen).unwrap(),
6697        );
6698    }
6699
6700    if let Some(name) = &prim_acc.material {
6701        if let Some(id) = material_ids.get(name) {
6702            prim.material = Some(*id);
6703        }
6704        prim.extras.insert(
6705            "obj:usemtl".to_string(),
6706            serde_json::Value::String(name.clone()),
6707        );
6708    }
6709    if let Some(s) = &prim_acc.smoothing_group {
6710        prim.extras.insert(
6711            "obj:smoothing_group".to_string(),
6712            serde_json::Value::String(s.clone()),
6713        );
6714    }
6715    if let Some(s) = &prim_acc.merging_group {
6716        prim.extras.insert(
6717            "obj:merging_group".to_string(),
6718            serde_json::Value::String(s.clone()),
6719        );
6720    }
6721    if let Some(s) = &prim_acc.bevel {
6722        prim.extras.insert(
6723            "obj:bevel".to_string(),
6724            serde_json::Value::String(s.clone()),
6725        );
6726    }
6727    if let Some(s) = &prim_acc.c_interp {
6728        prim.extras.insert(
6729            "obj:c_interp".to_string(),
6730            serde_json::Value::String(s.clone()),
6731        );
6732    }
6733    if let Some(s) = &prim_acc.d_interp {
6734        prim.extras.insert(
6735            "obj:d_interp".to_string(),
6736            serde_json::Value::String(s.clone()),
6737        );
6738    }
6739    if let Some(s) = &prim_acc.lod {
6740        prim.extras
6741            .insert("obj:lod".to_string(), serde_json::Value::String(s.clone()));
6742    }
6743    if let Some(s) = &prim_acc.usemap {
6744        prim.extras.insert(
6745            "obj:usemap".to_string(),
6746            serde_json::Value::String(s.clone()),
6747        );
6748    }
6749    if !prim_acc.groups.is_empty() {
6750        prim.extras.insert(
6751            "obj:groups".to_string(),
6752            serde_json::to_value(&prim_acc.groups).unwrap(),
6753        );
6754    }
6755
6756    Ok((prim, arities))
6757}
6758
6759// ---------------------------------------------------------------------------
6760// Public API
6761// ---------------------------------------------------------------------------
6762
6763/// Parser configuration knobs.
6764///
6765/// The default leaves free-form geometry as captured-only extras
6766/// (back-compatible with rounds 1-6). Set
6767/// [`ParseOptions::curve_tessellation_samples`] to a non-zero value
6768/// to enable evaluation of `cstype bezier` / `cstype bspline`
6769/// (rational + non-rational) curves into real `LineStrip` primitives
6770/// (see [`crate::ObjDecoder::with_curve_tessellation`]).
6771#[derive(Clone, Debug, Default)]
6772pub struct ParseOptions {
6773    /// When > 0, every `curv` directive under an active `cstype bezier`
6774    /// / `cstype rat bezier` / `cstype bspline` / `cstype rat bspline`
6775    /// header is evaluated at `curve_tessellation_samples + 1`
6776    /// uniformly-spaced parameter values. The resulting polyline lands
6777    /// on a synthetic mesh named `"obj:curves"` whose primitives carry
6778    /// `Topology::LineStrip`. The directive itself is still preserved
6779    /// in `Scene3D::extras["obj:freeform_directives"]` so a round-trip
6780    /// re-emit produces the same free-form section — downstream
6781    /// consumers can opt out of the synthetic mesh by filtering on
6782    /// `Primitive::extras["obj:tessellated_curve"] == true`.
6783    ///
6784    /// B-spline curves additionally require a valid `parm u` knot
6785    /// vector (length must equal control-point count + degree + 1 per
6786    /// spec §"B-spline" condition 6); curves with an incomplete knot
6787    /// vector are skipped silently.
6788    ///
6789    /// `0` disables tessellation (the default; back-compat with r1-r6).
6790    pub curve_tessellation_samples: u32,
6791}
6792
6793/// Parse an OBJ document (no MTL resolution).
6794///
6795/// `usemtl` directives still create one `Primitive` per switch and the
6796/// material name lands in `Primitive::extras["obj:usemtl"]` even with
6797/// no actual `Material` constructed. Use [`parse_obj_with_resolver`]
6798/// when companion MTL data is available.
6799pub fn parse_obj(text: &str) -> Result<Scene3D> {
6800    parse_obj_with_resolver(text, |_path| Ok(Vec::new()))
6801}
6802
6803/// Parse an OBJ document at `path`, resolving `mtllib` references
6804/// against the OBJ file's parent directory.
6805///
6806/// Convenience wrapper around [`parse_obj_with_resolver`] for the
6807/// overwhelmingly common case of "I have a path, please load it and
6808/// follow the MTL references". Each `mtllib foo.mtl` directive becomes
6809/// a sibling-file read; missing libraries surface the underlying
6810/// [`std::io::Error`] (wrapped in [`Error::invalid`]) rather than
6811/// silently dropping. If you want lenient missing-MTL handling, use
6812/// [`parse_obj_with_resolver`] directly.
6813pub fn parse_obj_from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Scene3D> {
6814    let path = path.as_ref();
6815    let bytes =
6816        std::fs::read(path).map_err(|e| Error::invalid(format!("OBJ read {path:?}: {e}")))?;
6817    let text = std::str::from_utf8(&bytes)
6818        .map_err(|_| Error::invalid(format!("OBJ {path:?} contained non-UTF-8 bytes")))?;
6819    let parent = path.parent().map(std::path::Path::to_path_buf);
6820    parse_obj_with_resolver(text, |libname| {
6821        // Empty / absolute / parent-relative library names are honoured
6822        // verbatim; bare names are resolved against the OBJ's parent
6823        // directory.
6824        let lib_path = match &parent {
6825            Some(dir) => dir.join(libname),
6826            None => std::path::PathBuf::from(libname),
6827        };
6828        std::fs::read(&lib_path)
6829            .map_err(|e| Error::invalid(format!("mtllib read {lib_path:?}: {e}")))
6830    })
6831}
6832
6833/// Parse an OBJ document, calling `resolve` once per `mtllib` entry to
6834/// fetch the bytes of the named material library. Each library is
6835/// parsed via [`parse_mtl`] and its materials merged into the resulting
6836/// scene; references in `usemtl` directives bind to those materials by
6837/// name.
6838///
6839/// The resolver returns `Ok(Vec::new())` to signal "this library
6840/// couldn't be located but skip silently"; any other `Err` aborts the
6841/// parse.
6842pub fn parse_obj_with_resolver<R>(text: &str, resolve: R) -> Result<Scene3D>
6843where
6844    R: FnMut(&str) -> Result<Vec<u8>>,
6845{
6846    parse_obj_with_options(text, &ParseOptions::default(), resolve)
6847}
6848
6849/// Parse an OBJ document with explicit [`ParseOptions`] and a
6850/// caller-supplied `mtllib` resolver. Lifts the option struct out of
6851/// the otherwise-identical [`parse_obj_with_resolver`] signature.
6852pub fn parse_obj_with_options<R>(
6853    text: &str,
6854    options: &ParseOptions,
6855    mut resolve: R,
6856) -> Result<Scene3D>
6857where
6858    R: FnMut(&str) -> Result<Vec<u8>>,
6859{
6860    let mut doc = parse_obj_doc(text)?;
6861
6862    // Resolve material libraries, if any.
6863    for lib in doc.mtllibs.clone() {
6864        let bytes = resolve(&lib)?;
6865        if bytes.is_empty() {
6866            continue;
6867        }
6868        let lib_text = std::str::from_utf8(&bytes)
6869            .map_err(|_| Error::invalid(format!("mtllib {lib:?} contained non-UTF-8 bytes")))?;
6870        let materials = parse_mtl(lib_text)?;
6871        for mat in materials {
6872            if let Some(name) = mat.name.clone() {
6873                doc.resolved_materials.insert(name, mat);
6874            }
6875        }
6876    }
6877
6878    // Curve tessellation pass — captures the curve directives still in
6879    // `doc.freeform_directives` and synthesises `LineStrip` primitives
6880    // on a dedicated mesh. Skipped when samples == 0 (the default).
6881    // Supports `cstype bezier` / `rat bezier` (round 7) and
6882    // `cstype bspline` / `rat bspline` (round 8).
6883    let tessellated = if options.curve_tessellation_samples > 0 {
6884        tessellate_curves(&doc, options.curve_tessellation_samples)
6885    } else {
6886        Vec::new()
6887    };
6888
6889    // 2D trimming-curve (`curv2`) tessellation pass — the same sample
6890    // knob evaluates the parameter-space trimming / special /
6891    // connectivity curves (spec §"curv2") into `LineStrip` polylines on
6892    // a dedicated `obj:curves2` mesh. The directives still ride on
6893    // `Scene3D::extras["obj:freeform_directives"]` for verbatim
6894    // round-trip; the encoder filters the synthetic primitives out.
6895    let tessellated_curve2 = if options.curve_tessellation_samples > 0 {
6896        tessellate_curve2(&doc, options.curve_tessellation_samples)
6897    } else {
6898        Vec::new()
6899    };
6900
6901    // Surface tessellation pass — the same sample knob drives Bezier
6902    // `surf` tensor-product evaluation (round 11). Synthesises a
6903    // `Topology::Triangles` mesh; the directives still ride on
6904    // `Scene3D::extras["obj:freeform_directives"]` for round-trip.
6905    let tessellated_surfaces = if options.curve_tessellation_samples > 0 {
6906        tessellate_surfaces(&doc, options.curve_tessellation_samples)
6907    } else {
6908        Vec::new()
6909    };
6910
6911    // Special-curve (`scrv`) tessellation pass (round 206) — evaluates
6912    // every `scrv` directive into a parameter-space LineStrip polyline
6913    // (spec §"Special curve", §"scrv u0 u1 curv2d u0 u1 curv2d …"). The
6914    // directives still ride on `Scene3D::extras["obj:freeform_directives"]`
6915    // for verbatim round-trip; the encoder filters the synthetic
6916    // primitives out via the shared `obj:tessellated_curve` sentinel.
6917    let tessellated_scrv = if options.curve_tessellation_samples > 0 {
6918        tessellate_scrv(&doc, options.curve_tessellation_samples)
6919    } else {
6920        Vec::new()
6921    };
6922
6923    // Special-point (`sp`) synthetic-primitive pass (round 246) — gated
6924    // on the same `curve_tessellation_samples` knob the curv / scrv /
6925    // surf passes use, but the special-points pass doesn't sample at a
6926    // density; it emits exactly one [`Topology::Points`] primitive per
6927    // `sp` directive (lifted from the resolved `vp` parameter-vertex
6928    // pool, spec §"Special point", §"sp vp1 vp …"). The directives
6929    // still ride on `Scene3D::extras["obj:freeform_directives"]` for
6930    // verbatim round-trip; the encoder filters the synthetic primitives
6931    // out via the shared `obj:tessellated_curve` sentinel.
6932    let tessellated_sp = if options.curve_tessellation_samples > 0 {
6933        tessellate_special_points(&doc)
6934    } else {
6935        Vec::new()
6936    };
6937
6938    // Connectivity (`con`) seam tessellation pass (round 295) —
6939    // evaluates every `con` statement into a pair of parameter-space
6940    // LineStrip seams, one per joined surface edge (spec §"Connectivity
6941    // between free-form surfaces", §"con surf_1 q0_1 q1_1 curv2d_1
6942    // surf_2 q0_2 q1_2 curv2d_2"). Gated on the same
6943    // `curve_tessellation_samples` knob as the curv / curv2 / surf /
6944    // scrv passes. The directives still ride on
6945    // `Scene3D::extras["obj:freeform_directives"]` for verbatim
6946    // round-trip; the encoder filters the synthetic seams out via the
6947    // shared `obj:tessellated_curve` sentinel.
6948    let tessellated_con = if options.curve_tessellation_samples > 0 {
6949        tessellate_connectivity(&doc, options.curve_tessellation_samples)
6950    } else {
6951        Vec::new()
6952    };
6953
6954    let mut scene = build_scene(doc)?;
6955
6956    if !tessellated.is_empty() {
6957        let mut mesh = Mesh::new(Some("obj:curves".to_string()));
6958        for prim in tessellated {
6959            mesh.primitives.push(prim);
6960        }
6961        scene.add_mesh(mesh);
6962    }
6963
6964    if !tessellated_curve2.is_empty() {
6965        let mut mesh = Mesh::new(Some("obj:curves2".to_string()));
6966        for prim in tessellated_curve2 {
6967            mesh.primitives.push(prim);
6968        }
6969        scene.add_mesh(mesh);
6970    }
6971
6972    if !tessellated_surfaces.is_empty() {
6973        let mut mesh = Mesh::new(Some("obj:surfaces".to_string()));
6974        for prim in tessellated_surfaces {
6975            mesh.primitives.push(prim);
6976        }
6977        scene.add_mesh(mesh);
6978    }
6979
6980    if !tessellated_scrv.is_empty() {
6981        let mut mesh = Mesh::new(Some("obj:scrvs".to_string()));
6982        for prim in tessellated_scrv {
6983            mesh.primitives.push(prim);
6984        }
6985        scene.add_mesh(mesh);
6986    }
6987
6988    if !tessellated_sp.is_empty() {
6989        let mut mesh = Mesh::new(Some("obj:sps".to_string()));
6990        for prim in tessellated_sp {
6991            mesh.primitives.push(prim);
6992        }
6993        scene.add_mesh(mesh);
6994    }
6995
6996    if !tessellated_con.is_empty() {
6997        let mut mesh = Mesh::new(Some("obj:cons".to_string()));
6998        for prim in tessellated_con {
6999            mesh.primitives.push(prim);
7000        }
7001        scene.add_mesh(mesh);
7002    }
7003
7004    Ok(scene)
7005}
7006
7007/// Serialiser configuration. Keeps the public free-function signature
7008/// stable while letting the [`crate::ObjEncoder`] thread richer options
7009/// through.
7010#[derive(Clone, Debug, Default)]
7011pub struct SerializeOptions<'a> {
7012    /// Reference an external MTL file via an `mtllib <basename>.mtl`
7013    /// header line. Equivalent to the `mtl_basename` parameter on
7014    /// [`serialize_obj`].
7015    pub mtl_basename: Option<&'a str>,
7016    /// When `true`, emit face/line vertex indices in the relative
7017    /// negative-index form (`f -1 -2 -3`) instead of absolute 1-based.
7018    /// Round-trips verbatim back through the parser; useful when the
7019    /// caller wants their re-encoded OBJ to mirror an input that used
7020    /// negative indices throughout.
7021    pub negative_indices: bool,
7022}
7023
7024/// Serialise a [`Scene3D`] to OBJ format.
7025///
7026/// `mtl_basename`, when supplied, emits an `mtllib <basename>.mtl`
7027/// directive at the top so a sibling MTL file (written separately via
7028/// [`crate::mtl::serialize_mtl`]) is referenced.
7029pub fn serialize_obj(scene: &Scene3D, mtl_basename: Option<&str>) -> Result<Vec<u8>> {
7030    serialize_obj_with_options(
7031        scene,
7032        &SerializeOptions {
7033            mtl_basename,
7034            ..SerializeOptions::default()
7035        },
7036    )
7037}
7038
7039/// Serialise a [`Scene3D`] to OBJ format with explicit options.
7040///
7041/// See [`SerializeOptions`] for the supported knobs.
7042pub fn serialize_obj_with_options(
7043    scene: &Scene3D,
7044    options: &SerializeOptions<'_>,
7045) -> Result<Vec<u8>> {
7046    let mtl_basename = options.mtl_basename;
7047    let negative = options.negative_indices;
7048    use std::fmt::Write;
7049    let mut out = String::new();
7050    writeln!(out, "# OBJ generated by oxideav-obj").unwrap();
7051    if let Some(base) = mtl_basename {
7052        writeln!(out, "mtllib {base}.mtl").unwrap();
7053    }
7054    // Replay any mtllib refs preserved on the scene itself when no
7055    // explicit basename was supplied.
7056    if mtl_basename.is_none() {
7057        if let Some(serde_json::Value::Array(list)) = scene.extras.get("obj:mtllibs") {
7058            for entry in list {
7059                if let Some(s) = entry.as_str() {
7060                    writeln!(out, "mtllib {s}").unwrap();
7061                }
7062            }
7063        }
7064    }
7065    // Spec §"maplib filename1 filename2 ..." — texture-map library
7066    // declarations. Emit each name on its own line (the spec accepts
7067    // a multi-name line but a one-per-line emit keeps re-encoded diffs
7068    // grep-friendly and matches the per-line `mtllib` emit above).
7069    if let Some(serde_json::Value::Array(list)) = scene.extras.get("obj:maplibs") {
7070        for entry in list {
7071            if let Some(s) = entry.as_str() {
7072                writeln!(out, "maplib {s}").unwrap();
7073            }
7074        }
7075    }
7076
7077    // Spec §"shadow_obj filename" / §"trace_obj filename": top-level
7078    // directives that nominate companion files for shadow casting and
7079    // ray-traced reflections. The spec is silent on placement but the
7080    // worked examples in §"Examples" (cases 2 and 3) put them between
7081    // `mtllib` and the vertex pool, so we mirror that. `Scene3D::extras`
7082    // carries plain strings populated by the decoder; absent keys leave
7083    // the preamble unchanged.
7084    if let Some(serde_json::Value::String(name)) = scene.extras.get("obj:shadow_obj") {
7085        if !name.is_empty() {
7086            writeln!(out, "shadow_obj {name}").unwrap();
7087        }
7088    }
7089    if let Some(serde_json::Value::String(name)) = scene.extras.get("obj:trace_obj") {
7090        if !name.is_empty() {
7091            writeln!(out, "trace_obj {name}").unwrap();
7092        }
7093    }
7094
7095    // Spec §"General statement" — replay any captured `call` /
7096    // `csh` lines in document order. Source position relative to the
7097    // polygonal section isn't preserved (see
7098    // `ObjDoc::general_directives` docstring), so we emit them once,
7099    // at the top of the preamble right after the companion-file
7100    // block. Empty arrays / non-string tokens are skipped lenient-loader
7101    // style; absent key leaves the preamble unchanged.
7102    if let Some(serde_json::Value::Array(generals)) = scene.extras.get("obj:general_directives") {
7103        for entry in generals {
7104            if let serde_json::Value::Array(toks) = entry {
7105                let parts: Vec<&str> = toks.iter().filter_map(|v| v.as_str()).collect();
7106                if parts.is_empty() {
7107                    continue;
7108                }
7109                writeln!(out, "{}", parts.join(" ")).unwrap();
7110            }
7111        }
7112    }
7113
7114    // Deduplicated global vertex / texcoord / normal pools so emitted
7115    // index references match the canonical 1-based numbering.
7116    let mut positions: Vec<[f32; 3]> = Vec::new();
7117    // Parallel to `positions` — `Some(rgb)` when the source flagged
7118    // this vertex through the `obj:vertex_color_present` extras
7119    // bitmap, `None` otherwise. We *don't* emit synthetic white for a
7120    // `None` entry: the round-trip rule is "only re-emit RGB for
7121    // vertices that originally had it". When at least one position
7122    // carries colour the encoder also sets a flag so the entire
7123    // colour set isn't dropped on a partial-colouring file (mixed
7124    // colored / uncolored vertices in one primitive — re-emit
7125    // standard `v x y z` for the uncolored).
7126    let mut position_colors: Vec<Option<[f32; 4]>> = Vec::new();
7127    // Parallel to `positions` — preserved `v` 4th `w` weight whenever
7128    // the source carried it. `None` re-emits the standard 3-token form.
7129    let mut position_weights: Vec<Option<f32>> = Vec::new();
7130    let mut texcoords: Vec<[f32; 2]> = Vec::new();
7131    let mut normals: Vec<[f32; 3]> = Vec::new();
7132    let mut pos_map: HashMap<KeyVec3, u32> = HashMap::new();
7133    let mut tex_map: HashMap<KeyVec2, u32> = HashMap::new();
7134    let mut nor_map: HashMap<KeyVec3, u32> = HashMap::new();
7135
7136    // Intern a position into the shared global pool, attaching the
7137    // (optional) per-vertex colour + weight derived from the
7138    // `obj:vertex_color_present` / `obj:vertex_weight` extras. When the
7139    // same position appears across primitives, the *first* non-`None`
7140    // colour / weight wins — silently ignoring later overrides keeps
7141    // round-trip determinism without forcing a partition of duplicate
7142    // positions on differing colour metadata (which would force the
7143    // encoder to emit redundant `v` lines and bloat the output).
7144    let intern_pos = |p: [f32; 3],
7145                      colour: Option<[f32; 4]>,
7146                      weight: Option<f32>,
7147                      positions: &mut Vec<[f32; 3]>,
7148                      colours: &mut Vec<Option<[f32; 4]>>,
7149                      weights: &mut Vec<Option<f32>>,
7150                      map: &mut HashMap<KeyVec3, u32>|
7151     -> u32 {
7152        let key = KeyVec3::from(p);
7153        if let Some(&i) = map.get(&key) {
7154            // First-write-wins on extension metadata.
7155            let slot = (i - 1) as usize;
7156            if colours[slot].is_none() {
7157                colours[slot] = colour;
7158            }
7159            if weights[slot].is_none() {
7160                weights[slot] = weight;
7161            }
7162            return i;
7163        }
7164        positions.push(p);
7165        colours.push(colour);
7166        weights.push(weight);
7167        let idx = positions.len() as u32;
7168        map.insert(key, idx);
7169        idx
7170    };
7171    let intern_tex =
7172        |p: [f32; 2], texcoords: &mut Vec<[f32; 2]>, map: &mut HashMap<KeyVec2, u32>| -> u32 {
7173            let key = KeyVec2::from(p);
7174            if let Some(&i) = map.get(&key) {
7175                return i;
7176            }
7177            texcoords.push(p);
7178            let idx = texcoords.len() as u32;
7179            map.insert(key, idx);
7180            idx
7181        };
7182    let intern_nor =
7183        |p: [f32; 3], normals: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
7184            let key = KeyVec3::from(p);
7185            if let Some(&i) = map.get(&key) {
7186                return i;
7187            }
7188            normals.push(p);
7189            let idx = normals.len() as u32;
7190            map.insert(key, idx);
7191            idx
7192        };
7193
7194    // Seed the position pool with `obj:positions` if present — these
7195    // are the source 1-based vertex coordinates captured on decode so
7196    // free-form directives (`curv`, `surf`, etc.) that reference
7197    // positions by absolute index keep resolving correctly across a
7198    // decode → encode → decode round-trip. Without this, the encoder
7199    // would only pool positions referenced by polygonal primitives and
7200    // the free-form directive numbering would silently drift.
7201    if let Some(serde_json::Value::Array(src_positions)) = scene.extras.get("obj:positions") {
7202        let src_weights: Vec<Option<f32>> = scene
7203            .extras
7204            .get("obj:position_weights")
7205            .and_then(serde_json::Value::as_array)
7206            .map(|arr| arr.iter().map(|v| v.as_f64().map(|f| f as f32)).collect())
7207            .unwrap_or_default();
7208        let src_colors: Vec<Option<[f32; 4]>> = scene
7209            .extras
7210            .get("obj:position_colors")
7211            .and_then(serde_json::Value::as_array)
7212            .map(|arr| {
7213                arr.iter()
7214                    .map(|v| {
7215                        v.as_array().map(|c| {
7216                            let mut rgba = [1.0; 4];
7217                            for (i, x) in c.iter().enumerate().take(4) {
7218                                rgba[i] = x.as_f64().map(|f| f as f32).unwrap_or(0.0);
7219                            }
7220                            rgba
7221                        })
7222                    })
7223                    .collect()
7224            })
7225            .unwrap_or_default();
7226
7227        for (i, pv) in src_positions.iter().enumerate() {
7228            let serde_json::Value::Array(coords) = pv else {
7229                continue;
7230            };
7231            let mut p = [0.0_f32; 3];
7232            for (j, c) in coords.iter().enumerate().take(3) {
7233                p[j] = c.as_f64().map(|f| f as f32).unwrap_or(0.0);
7234            }
7235            let weight = src_weights.get(i).copied().flatten();
7236            let colour = src_colors.get(i).copied().flatten();
7237            intern_pos(
7238                p,
7239                colour,
7240                weight,
7241                &mut positions,
7242                &mut position_colors,
7243                &mut position_weights,
7244                &mut pos_map,
7245            );
7246        }
7247    }
7248
7249    // First pass: emit `v` / `vt` / `vn` lists and remember the global
7250    // indices for each (mesh, primitive, vertex) triple.
7251    //
7252    // Primitives flagged `obj:tessellated_curve = true` are synthetic
7253    // (they came out of the Bezier evaluator, not source `v` lines).
7254    // We skip them here so their points don't pollute the `v` pool and
7255    // skip them again in the element-emit pass below — the original
7256    // `cstype` / `curv` / `end` directives still get replayed verbatim
7257    // from `Scene3D::extras["obj:freeform_directives"]`, so the
7258    // round-trip stays bit-stable for the directive section.
7259    type GlobalTriple = (u32, u32, u32); // (v_idx, vt_idx_or_0, vn_idx_or_0)
7260    let mut global_indices: Vec<Vec<Vec<GlobalTriple>>> = Vec::new();
7261    for mesh in &scene.meshes {
7262        let mut mesh_globals: Vec<Vec<GlobalTriple>> = Vec::new();
7263        for prim in &mesh.primitives {
7264            if is_tessellated_curve(prim) {
7265                // Push an empty slot so global_indices[mi][pi] still
7266                // lines up with mesh.primitives[mi][pi] in the second
7267                // pass — we'll just skip the empty slot there.
7268                mesh_globals.push(Vec::new());
7269                continue;
7270            }
7271            let has_uv = !prim.uvs.is_empty();
7272            let has_normal = prim.normals.is_some();
7273            let has_color = !prim.colors.is_empty();
7274            // Per-vertex bitmap saying "did the source spell out RGB on
7275            // this vertex?". Missing extras / no-colors-set means every
7276            // vertex stays in the standard 3-token form.
7277            let color_present: Vec<bool> = prim
7278                .extras
7279                .get("obj:vertex_color_present")
7280                .and_then(serde_json::Value::as_array)
7281                .map(|arr| arr.iter().map(|v| v.as_bool().unwrap_or(false)).collect())
7282                .unwrap_or_else(|| vec![has_color; prim.positions.len()]);
7283            // Per-vertex weight overrides — preserved through extras.
7284            let weight_overrides: Vec<Option<f32>> = prim
7285                .extras
7286                .get("obj:vertex_weight")
7287                .and_then(serde_json::Value::as_array)
7288                .map(|arr| arr.iter().map(|v| v.as_f64().map(|f| f as f32)).collect())
7289                .unwrap_or_default();
7290            let mut prim_globals: Vec<GlobalTriple> = Vec::with_capacity(prim.positions.len());
7291            for vi in 0..prim.positions.len() {
7292                let colour = if has_color && color_present.get(vi).copied().unwrap_or(false) {
7293                    Some(prim.colors[0][vi])
7294                } else {
7295                    None
7296                };
7297                let weight = weight_overrides.get(vi).copied().flatten();
7298                let v_idx = intern_pos(
7299                    prim.positions[vi],
7300                    colour,
7301                    weight,
7302                    &mut positions,
7303                    &mut position_colors,
7304                    &mut position_weights,
7305                    &mut pos_map,
7306                );
7307                let vt_idx = if has_uv {
7308                    intern_tex(prim.uvs[0][vi], &mut texcoords, &mut tex_map)
7309                } else {
7310                    0
7311                };
7312                let vn_idx = if has_normal {
7313                    intern_nor(
7314                        prim.normals.as_ref().unwrap()[vi],
7315                        &mut normals,
7316                        &mut nor_map,
7317                    )
7318                } else {
7319                    0
7320                };
7321                prim_globals.push((v_idx, vt_idx, vn_idx));
7322            }
7323            mesh_globals.push(prim_globals);
7324        }
7325        global_indices.push(mesh_globals);
7326    }
7327
7328    for (i, p) in positions.iter().enumerate() {
7329        // Pick the most-compact `v` form that still carries the
7330        // extension data: `xyz`, `xyzw` (rational weight), `xyzrgb`
7331        // (MeshLab vertex colour), or `xyzwrgb` (both). Each
7332        // extension is silently dropped if it would just spell out
7333        // the spec default (`w == 1.0`, no colour).
7334        let weight = position_weights[i];
7335        let colour = position_colors[i];
7336        let mut s = String::with_capacity(40);
7337        s.push_str("v ");
7338        s.push_str(&fmt_float(p[0]));
7339        s.push(' ');
7340        s.push_str(&fmt_float(p[1]));
7341        s.push(' ');
7342        s.push_str(&fmt_float(p[2]));
7343        if let Some(w) = weight {
7344            s.push(' ');
7345            s.push_str(&fmt_float(w));
7346        }
7347        if let Some(rgb) = colour {
7348            s.push(' ');
7349            s.push_str(&fmt_float(rgb[0]));
7350            s.push(' ');
7351            s.push_str(&fmt_float(rgb[1]));
7352            s.push(' ');
7353            s.push_str(&fmt_float(rgb[2]));
7354        }
7355        writeln!(out, "{s}").unwrap();
7356    }
7357    // Parameter-space vertices for the free-form geometry section. We
7358    // emit these after `v` and before `vt` to mirror the typical layout
7359    // produced by Wavefront-era authoring tools (the spec doesn't
7360    // mandate an ordering, but co-locating `vp` with the other vertex
7361    // pools keeps human diffs tidy).
7362    if let Some(serde_json::Value::Array(vps)) = scene.extras.get("obj:vp") {
7363        for entry in vps {
7364            if let serde_json::Value::Array(coords) = entry {
7365                let parts: Vec<f32> = coords
7366                    .iter()
7367                    .filter_map(|v| v.as_f64().map(|f| f as f32))
7368                    .collect();
7369                if parts.is_empty() {
7370                    continue;
7371                }
7372                // Emit only as many coordinates as carry meaningful
7373                // information. The decoder padded with `0.0`, so a
7374                // trailing `0` is a strong signal "the operator
7375                // didn't supply this component". 1D / 2D / 3D `vp`
7376                // statements are all valid per spec §"vp u v w".
7377                let trim = if parts.len() >= 3 && parts[2] != 0.0 {
7378                    3
7379                } else if parts.len() >= 2 && parts[1] != 0.0 {
7380                    2
7381                } else {
7382                    1
7383                };
7384                let mut s = String::from("vp");
7385                for coord in parts.iter().take(trim) {
7386                    s.push(' ');
7387                    s.push_str(&fmt_float(*coord));
7388                }
7389                writeln!(out, "{s}").unwrap();
7390            }
7391        }
7392    }
7393    for t in &texcoords {
7394        writeln!(out, "vt {} {}", fmt_float(t[0]), fmt_float(t[1])).unwrap();
7395    }
7396    for n in &normals {
7397        writeln!(
7398            out,
7399            "vn {} {} {}",
7400            fmt_float(n[0]),
7401            fmt_float(n[1]),
7402            fmt_float(n[2])
7403        )
7404        .unwrap();
7405    }
7406
7407    // Second pass: per-mesh `o` directive, per-primitive `usemtl` +
7408    // groups + smoothing-group, then face/line elements.
7409    for (mi, mesh) in scene.meshes.iter().enumerate() {
7410        // Synthesised curve mesh — its primitives carry
7411        // `obj:tessellated_curve = true` and were produced by the
7412        // decoder's de-Casteljau pass. Skip the whole `o` block; the
7413        // original `cstype`/`curv`/`end` directives still get replayed
7414        // from `Scene3D::extras["obj:freeform_directives"]`.
7415        if mesh.primitives.iter().all(is_tessellated_curve) && !mesh.primitives.is_empty() {
7416            continue;
7417        }
7418        if let Some(name) = &mesh.name {
7419            writeln!(out, "o {name}").unwrap();
7420        }
7421
7422        for (pi, prim) in mesh.primitives.iter().enumerate() {
7423            if is_tessellated_curve(prim) {
7424                continue;
7425            }
7426            // Per-primitive arity vector for n-gon re-emission, if any.
7427            let arities: Option<Vec<u32>> = prim
7428                .extras
7429                .get("obj:original_face_arities")
7430                .and_then(|v| serde_json::from_value(v.clone()).ok());
7431            // Groups + smoothing first (spec convention: state tokens
7432            // precede the elements they apply to).
7433            if let Some(serde_json::Value::Array(gs)) = prim.extras.get("obj:groups") {
7434                let names: Vec<&str> = gs.iter().filter_map(|v| v.as_str()).collect();
7435                if !names.is_empty() {
7436                    writeln!(out, "g {}", names.join(" ")).unwrap();
7437                }
7438            }
7439            if let Some(s) = prim
7440                .extras
7441                .get("obj:smoothing_group")
7442                .and_then(|v| v.as_str())
7443            {
7444                writeln!(out, "s {s}").unwrap();
7445            }
7446            if let Some(s) = prim
7447                .extras
7448                .get("obj:merging_group")
7449                .and_then(|v| v.as_str())
7450            {
7451                writeln!(out, "mg {s}").unwrap();
7452            }
7453            // Display-attribute state-setters — emitted ahead of the
7454            // elements they apply to. Order is fixed to keep round-trip
7455            // diffs deterministic.
7456            for keyword in ["bevel", "c_interp", "d_interp", "lod"] {
7457                let key = format!("obj:{keyword}");
7458                if let Some(s) = prim.extras.get(&key).and_then(|v| v.as_str()) {
7459                    writeln!(out, "{keyword} {s}").unwrap();
7460                }
7461            }
7462
7463            // usemtl: prefer extras["obj:usemtl"] (loss-tolerant
7464            // round-trip name), fall back to the bound material's name.
7465            let mtl_name: Option<String> = prim
7466                .extras
7467                .get("obj:usemtl")
7468                .and_then(|v| v.as_str())
7469                .map(|s| s.to_string())
7470                .or_else(|| {
7471                    prim.material.and_then(|id| {
7472                        scene
7473                            .materials
7474                            .get(id.0 as usize)
7475                            .and_then(|m| m.name.clone())
7476                    })
7477                });
7478            if let Some(name) = &mtl_name {
7479                writeln!(out, "usemtl {name}").unwrap();
7480            }
7481
7482            // Spec §"usemap map_name/off" — texture-map rendering
7483            // identifier. Emit verbatim from the round-trip extras
7484            // slot populated by the decoder. The literal string is
7485            // preserved (so `usemap off` re-emits as `usemap off`,
7486            // and `usemap MyTex` re-emits as `usemap MyTex`); the
7487            // same per-primitive emit pattern as `usemtl` above.
7488            if let Some(name) = prim.extras.get("obj:usemap").and_then(|v| v.as_str()) {
7489                writeln!(out, "usemap {name}").unwrap();
7490            }
7491
7492            let prim_globals = &global_indices[mi][pi];
7493            let has_uv = !prim.uvs.is_empty();
7494            let has_normal = prim.normals.is_some();
7495
7496            // Build the per-element index iterator. For Triangles topology
7497            // re-shape into n-gons via `arities` if present; otherwise emit
7498            // one triangle per 3 indices. For Lines topology emit `l`
7499            // per pair (we don't reverse strips back into polylines —
7500            // that's lossy and the round-trip test doesn't need it).
7501            match prim.topology {
7502                Topology::Triangles => {
7503                    let face_indices: Vec<u32> = match &prim.indices {
7504                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
7505                        Some(Indices::U32(v)) => v.clone(),
7506                        None => {
7507                            // Implicit indices: 0, 1, 2, …
7508                            (0..prim.positions.len() as u32).collect()
7509                        }
7510                    };
7511                    if let Some(per_prim_arities) = arities.as_ref() {
7512                        // Reconstruct n-gons from triangle fans. Each
7513                        // n-gon contributed (n - 2) triangles.
7514                        let mut tri_pos: usize = 0;
7515                        for &arity in per_prim_arities {
7516                            let mut verts: Vec<u32> = Vec::with_capacity(arity as usize);
7517                            // The fan was: (v0, v1, v2), (v0, v2, v3), (v0, v3, v4), …
7518                            let n_tris = (arity as usize).saturating_sub(2);
7519                            // First triangle gives v0, v1, v2.
7520                            verts.push(face_indices[tri_pos * 3]);
7521                            verts.push(face_indices[tri_pos * 3 + 1]);
7522                            verts.push(face_indices[tri_pos * 3 + 2]);
7523                            // Each subsequent triangle adds one new vertex (the third index).
7524                            for k in 1..n_tris {
7525                                verts.push(face_indices[(tri_pos + k) * 3 + 2]);
7526                            }
7527                            tri_pos += n_tris;
7528
7529                            write_face(
7530                                &mut out,
7531                                &verts,
7532                                prim_globals,
7533                                has_uv,
7534                                has_normal,
7535                                negative,
7536                                positions.len() as u32,
7537                                texcoords.len() as u32,
7538                                normals.len() as u32,
7539                            );
7540                        }
7541                        // Any leftover triangles after the recorded arities
7542                        // (e.g. a primitive grew after the arity vector was
7543                        // captured) are emitted as plain triangles.
7544                        let consumed = per_prim_arities
7545                            .iter()
7546                            .map(|&a| (a as usize).saturating_sub(2))
7547                            .sum::<usize>();
7548                        for tri in consumed..(face_indices.len() / 3) {
7549                            let verts = [
7550                                face_indices[tri * 3],
7551                                face_indices[tri * 3 + 1],
7552                                face_indices[tri * 3 + 2],
7553                            ];
7554                            write_face(
7555                                &mut out,
7556                                &verts,
7557                                prim_globals,
7558                                has_uv,
7559                                has_normal,
7560                                negative,
7561                                positions.len() as u32,
7562                                texcoords.len() as u32,
7563                                normals.len() as u32,
7564                            );
7565                        }
7566                    } else {
7567                        for tri in 0..(face_indices.len() / 3) {
7568                            let verts = [
7569                                face_indices[tri * 3],
7570                                face_indices[tri * 3 + 1],
7571                                face_indices[tri * 3 + 2],
7572                            ];
7573                            write_face(
7574                                &mut out,
7575                                &verts,
7576                                prim_globals,
7577                                has_uv,
7578                                has_normal,
7579                                negative,
7580                                positions.len() as u32,
7581                                texcoords.len() as u32,
7582                                normals.len() as u32,
7583                            );
7584                        }
7585                    }
7586                }
7587                Topology::Lines => {
7588                    let line_indices: Vec<u32> = match &prim.indices {
7589                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
7590                        Some(Indices::U32(v)) => v.clone(),
7591                        None => (0..prim.positions.len() as u32).collect(),
7592                    };
7593                    let total_v = positions.len() as u32;
7594                    // Walk segment pairs and join contiguous chains
7595                    // (segment N's end == segment N+1's start) into
7596                    // one polyline before emit. Saves bytes on the
7597                    // common case of a long polyline that round-tripped
7598                    // through `Topology::Lines` decomposition.
7599                    let mut chain: Vec<u32> = Vec::new();
7600                    let flush = |chain: &mut Vec<u32>, out: &mut String| {
7601                        if chain.len() < 2 {
7602                            chain.clear();
7603                            return;
7604                        }
7605                        let parts: Vec<String> = chain
7606                            .iter()
7607                            .map(|&local| {
7608                                fmt_index(prim_globals[local as usize].0, total_v, negative)
7609                            })
7610                            .collect();
7611                        writeln!(out, "l {}", parts.join(" ")).unwrap();
7612                        chain.clear();
7613                    };
7614                    for w in line_indices.chunks_exact(2) {
7615                        let (a, b) = (w[0], w[1]);
7616                        if chain.is_empty() {
7617                            chain.push(a);
7618                            chain.push(b);
7619                        } else if *chain.last().unwrap() == a {
7620                            chain.push(b);
7621                        } else {
7622                            flush(&mut chain, &mut out);
7623                            chain.push(a);
7624                            chain.push(b);
7625                        }
7626                    }
7627                    flush(&mut chain, &mut out);
7628                }
7629                Topology::LineStrip | Topology::LineLoop => {
7630                    // Reconstruct the strip's index list from whichever
7631                    // backing storage the primitive carries; bare
7632                    // positions imply implicit `0..N` indices. For
7633                    // `LineLoop` we re-append the first index so the
7634                    // emitted `l` line spells out the closing edge —
7635                    // the parser then detects start == end and round-
7636                    // trips back to `LineLoop`.
7637                    let mut strip_indices: Vec<u32> = match &prim.indices {
7638                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
7639                        Some(Indices::U32(v)) => v.clone(),
7640                        None => (0..prim.positions.len() as u32).collect(),
7641                    };
7642                    if matches!(prim.topology, Topology::LineLoop)
7643                        && let Some(&first) = strip_indices.first()
7644                    {
7645                        strip_indices.push(first);
7646                    }
7647                    if strip_indices.len() >= 2 {
7648                        let total_v = positions.len() as u32;
7649                        let parts: Vec<String> = strip_indices
7650                            .iter()
7651                            .map(|&local| {
7652                                fmt_index(prim_globals[local as usize].0, total_v, negative)
7653                            })
7654                            .collect();
7655                        writeln!(out, "l {}", parts.join(" ")).unwrap();
7656                    }
7657                }
7658                Topology::Points => {
7659                    let pt_indices: Vec<u32> = match &prim.indices {
7660                        Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
7661                        Some(Indices::U32(v)) => v.clone(),
7662                        None => (0..prim.positions.len() as u32).collect(),
7663                    };
7664                    let total_v = positions.len() as u32;
7665                    if !pt_indices.is_empty() {
7666                        // Pack every reference onto a single `p` line —
7667                        // the spec explicitly permits the multi-vertex
7668                        // form (`p v1 v2 v3 …`) and it's what most
7669                        // tools emit.
7670                        let parts: Vec<String> = pt_indices
7671                            .iter()
7672                            .map(|&local| {
7673                                fmt_index(prim_globals[local as usize].0, total_v, negative)
7674                            })
7675                            .collect();
7676                        writeln!(out, "p {}", parts.join(" ")).unwrap();
7677                    }
7678                }
7679                other => {
7680                    return Err(Error::unsupported(format!(
7681                        "OBJ encoder: topology {other:?} not representable"
7682                    )));
7683                }
7684            }
7685        }
7686    }
7687
7688    // Free-form geometry section: replay the captured directive
7689    // sequence verbatim. The decoder records every `cstype` / `deg` /
7690    // `curv` / `surf` / `parm` / `trim` / `hole` / `scrv` / `sp` /
7691    // `end` / `bzp` / `bsp` line as `[keyword, arg1, arg2, …]` so the
7692    // encoder is purely textual — no semantic interpretation, which
7693    // means the round-trip is bit-exact for the directive args even
7694    // when the polygonal section sits between `vp` and the free-form
7695    // body.
7696    if let Some(serde_json::Value::Array(directives)) = scene.extras.get("obj:freeform_directives")
7697    {
7698        for entry in directives {
7699            if let serde_json::Value::Array(toks) = entry {
7700                let parts: Vec<&str> = toks.iter().filter_map(|v| v.as_str()).collect();
7701                if parts.is_empty() {
7702                    continue;
7703                }
7704                writeln!(out, "{}", parts.join(" ")).unwrap();
7705            }
7706        }
7707    }
7708
7709    Ok(out.into_bytes())
7710}
7711
7712#[allow(clippy::too_many_arguments)]
7713fn write_face(
7714    out: &mut String,
7715    verts: &[u32],
7716    prim_globals: &[(u32, u32, u32)],
7717    has_uv: bool,
7718    has_normal: bool,
7719    negative: bool,
7720    total_v: u32,
7721    total_vt: u32,
7722    total_vn: u32,
7723) {
7724    use std::fmt::Write;
7725    out.push('f');
7726    for &local in verts {
7727        let (v, vt, vn) = prim_globals[local as usize];
7728        let v_s = fmt_index(v, total_v, negative);
7729        let vt_s = fmt_index(vt, total_vt, negative);
7730        let vn_s = fmt_index(vn, total_vn, negative);
7731        match (has_uv, has_normal) {
7732            (true, true) => write!(out, " {v_s}/{vt_s}/{vn_s}").unwrap(),
7733            (true, false) => write!(out, " {v_s}/{vt_s}").unwrap(),
7734            (false, true) => write!(out, " {v_s}//{vn_s}").unwrap(),
7735            (false, false) => write!(out, " {v_s}").unwrap(),
7736        }
7737    }
7738    out.push('\n');
7739}
7740
7741/// Render a 1-based positive index as either its absolute form
7742/// (`5`) or a negative-from-end form (`-3`, when `total = 7`).
7743/// `idx == 0` means "no index" — we always emit `0` regardless of
7744/// the negative flag so the parser still treats it as absent.
7745fn fmt_index(idx: u32, total: u32, negative: bool) -> String {
7746    if idx == 0 || !negative {
7747        idx.to_string()
7748    } else {
7749        // total = 7, idx = 5  ⇒  -3  (i.e. "third from the end").
7750        // Parser computes: resolved = total + 1 + raw  ⇒  raw = idx - total - 1.
7751        let raw = (idx as i64) - (total as i64) - 1;
7752        raw.to_string()
7753    }
7754}
7755
7756/// Format a float without scientific notation; trims trailing zeros
7757/// while keeping at least one digit after the decimal point. Keeps the
7758/// emitted file human-diffable.
7759fn fmt_float(x: f32) -> String {
7760    if x == 0.0 {
7761        return "0".to_string();
7762    }
7763    let s = format!("{x:.6}");
7764    let trimmed = s.trim_end_matches('0').trim_end_matches('.').to_string();
7765    if trimmed.is_empty() || trimmed == "-" {
7766        "0".to_string()
7767    } else {
7768        trimmed
7769    }
7770}
7771
7772// ---------------------------------------------------------------------------
7773// Float keys for the dedup HashMap (f32 isn't Hash).
7774// ---------------------------------------------------------------------------
7775
7776#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
7777struct KeyVec2 {
7778    a: u32,
7779    b: u32,
7780}
7781impl From<[f32; 2]> for KeyVec2 {
7782    fn from(v: [f32; 2]) -> Self {
7783        Self {
7784            a: v[0].to_bits(),
7785            b: v[1].to_bits(),
7786        }
7787    }
7788}
7789
7790#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
7791struct KeyVec3 {
7792    a: u32,
7793    b: u32,
7794    c: u32,
7795}
7796impl From<[f32; 3]> for KeyVec3 {
7797    fn from(v: [f32; 3]) -> Self {
7798        Self {
7799            a: v[0].to_bits(),
7800            b: v[1].to_bits(),
7801            c: v[2].to_bits(),
7802        }
7803    }
7804}
7805
7806// ---------------------------------------------------------------------------
7807// Tests (unit-level — integration tests live under `tests/`).
7808// ---------------------------------------------------------------------------
7809
7810#[cfg(test)]
7811mod tests {
7812    use super::*;
7813
7814    #[test]
7815    fn preprocess_strips_comments_and_glues_continuations() {
7816        let lines =
7817            preprocess_lines("v 1.0 2.0 \\\n3.0 # comment\nv 4 5 6\n# pure comment\nf 1 2 3");
7818        assert_eq!(lines[0].trim(), "v 1.0 2.0  3.0");
7819        assert_eq!(lines[1].trim(), "v 4 5 6");
7820        // The pure-comment line collapses to an empty preprocessed line.
7821        assert_eq!(lines[2].trim(), "");
7822        assert_eq!(lines[3].trim(), "f 1 2 3");
7823    }
7824
7825    #[test]
7826    fn fmt_float_is_diff_friendly() {
7827        assert_eq!(fmt_float(1.0), "1");
7828        assert_eq!(fmt_float(0.0), "0");
7829        assert_eq!(fmt_float(-0.5), "-0.5");
7830        assert_eq!(fmt_float(1.0 / 3.0), "0.333333");
7831    }
7832}