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}