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