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