oxideav_obj/obj.rs
1//! Wavefront OBJ ASCII parser + serialiser.
2//!
3//! Polygonal subset (vertex / face / line / point / grouping / material
4//! directives) is fully decoded into the typed [`Scene3D`] model. The
5//! free-form curve/surface directives — `vp`, `cstype`, `deg`, `curv`,
6//! `curv2`, `surf`, `parm`, `trim`, `hole`, `scrv`, `sp`, `end`, plus
7//! the superseded `bzp` / `bsp` patches — are captured verbatim into
8//! `Scene3D::extras["obj:vp"]` and
9//! `Scene3D::extras["obj:freeform_directives"]` so a decode → encode
10//! round-trip preserves the directive sequence and arguments without
11//! semantic interpretation. The `.mod` binary form remains out of
12//! scope.
13//!
14//! The grammar is line-oriented; whitespace-separated; `#` introduces
15//! a comment to end of line. Continuation lines (trailing `\\`) are
16//! supported by gluing the next line on before tokenisation.
17
18use std::collections::HashMap;
19
20use oxideav_mesh3d::{Error, Indices, Mesh, Primitive, Result, Scene3D, Topology};
21
22use crate::mtl::parse_mtl;
23
24// ---------------------------------------------------------------------------
25// Parsing
26// ---------------------------------------------------------------------------
27
28/// Per-face-vertex index triple. `0` means "not present".
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
30struct FaceVert {
31 /// 1-based geometric-vertex index (resolved from raw OBJ).
32 v: u32,
33 /// 1-based texture-coord index, or 0 if absent.
34 vt: u32,
35 /// 1-based normal index, or 0 if absent.
36 vn: u32,
37}
38
39/// One face / line / point element captured during the first parse pass.
40///
41/// Different element kinds map to different [`Topology`] variants and
42/// can't share a single [`Primitive`]; the accumulator splits into
43/// fresh primitives whenever the kind changes.
44#[derive(Debug)]
45enum Element {
46 Face(Vec<FaceVert>),
47 Line(Vec<FaceVert>),
48 Point(Vec<FaceVert>),
49}
50
51/// One open primitive — accumulates face/line elements while a single
52/// `usemtl` (or "no material") is active.
53#[derive(Debug, Default)]
54struct PrimAccum {
55 elements: Vec<Element>,
56 material: Option<String>,
57 /// Last seen smoothing group token (`"off"` or an integer string).
58 smoothing_group: Option<String>,
59 /// All distinct group names seen during this primitive.
60 groups: Vec<String>,
61 /// Last seen merging-group token (`"off"` / `"0"` or `"<n> <res>"`).
62 /// Captured as a single state value rather than per-element since
63 /// `mg` is state-setting per spec §"mg group_number res".
64 merging_group: Option<String>,
65 /// Display-attribute state — bevel-interpolation flag (`"on"` /
66 /// `"off"`). Spec §"bevel on/off" — state-setting; default off.
67 bevel: Option<String>,
68 /// Color-interpolation flag (`"on"` / `"off"`). Spec
69 /// §"c_interp on/off" — state-setting; default off.
70 c_interp: Option<String>,
71 /// Dissolve-interpolation flag (`"on"` / `"off"`). Spec
72 /// §"d_interp on/off" — state-setting; default off.
73 d_interp: Option<String>,
74 /// Level-of-detail integer (1..100, or 0 / absent for "all").
75 /// Spec §"lod level" — state-setting.
76 lod: Option<String>,
77}
78
79/// One open mesh — accumulates primitives while a single `o <name>`
80/// (or default object) is active.
81#[derive(Debug, Default)]
82struct MeshAccum {
83 name: Option<String>,
84 primitives: Vec<PrimAccum>,
85}
86
87impl MeshAccum {
88 fn current_or_new(&mut self) -> &mut PrimAccum {
89 if self.primitives.is_empty() {
90 self.primitives.push(PrimAccum::default());
91 }
92 self.primitives.last_mut().unwrap()
93 }
94}
95
96/// The polygonal data parsed out of an OBJ document.
97///
98/// This intermediate form keeps positions / texcoords / normals in
99/// their original 1-based numbering so the resolution of negative and
100/// 1-based face indices into 0-based primitive-local indices happens
101/// in one well-defined place ([`build_scene`]).
102#[derive(Debug, Default)]
103struct ObjDoc {
104 positions: Vec<[f32; 3]>,
105 /// Per-position rational weight from the optional 4th `w` component
106 /// of `v x y z w`. `None` means "no weight given" (the spec default
107 /// is `1.0`); `Some(w)` is preserved verbatim so a round-trip emits
108 /// the original 4-token form rather than collapsing to 3 tokens.
109 /// Parallel to `positions` (1-based / 0-based index parity).
110 /// Spec §"v x y z w" — w defaults to 1.0 for non-rational geometry.
111 position_weights: Vec<Option<f32>>,
112 /// Per-position vertex colour from the widely-deployed
113 /// `v x y z r g b` extension (MeshLab, libigl, Meshroom, OpenCV).
114 /// `None` for vertices written in the standard 3-token form.
115 /// `Some([r, g, b, 1.0])` carries the linear-space RGB triplet
116 /// (alpha pinned to opaque since the extension only spells out
117 /// three colour channels). Parallel to `positions`.
118 /// Not in the original spec — flagged in `docs/3d/obj/README.md`
119 /// as the canonical "widely used but never standardised" extension.
120 position_colors: Vec<Option<[f32; 4]>>,
121 texcoords: Vec<[f32; 2]>,
122 normals: Vec<[f32; 3]>,
123 /// Parameter-space vertices (`vp u v [w]`) from the free-form
124 /// geometry portion of the spec — 1-based numbering, parallel to
125 /// `positions` / `texcoords` / `normals`. Stored as a 3-tuple
126 /// where missing components default to `0.0` (this matches what
127 /// the spec calls out: `v` defaults to 0 for 1D points, `w`
128 /// defaults to 1.0 for rational trimming curves but we leave the
129 /// raw "what the file said" in extras and let the consumer
130 /// interpret).
131 vp: Vec<[f32; 3]>,
132 /// Material library file names referenced by `mtllib`.
133 mtllibs: Vec<String>,
134 /// All material definitions resolved from `mtllib` references
135 /// supplied via [`ObjDoc::with_resolved_mtllibs`]. Round 1 ships
136 /// no IO so we accept these via an external resolver hook on the
137 /// caller.
138 resolved_materials: HashMap<String, oxideav_mesh3d::Material>,
139 meshes: Vec<MeshAccum>,
140 /// Verbatim sequence of free-form-geometry directives (`cstype`,
141 /// `deg`, `curv`, `surf`, `parm`, `trim`, `hole`, `scrv`, `sp`,
142 /// `end`, `bzp`, plus the older `bsp`). Each entry is the keyword
143 /// followed by its whitespace-separated arguments. Round-trip
144 /// preservation: the encoder replays the sequence verbatim after
145 /// the polygonal section so consumers can carry free-form data
146 /// through us without semantic loss. Body statements (`parm`,
147 /// `trim`, `hole`, `scrv`, `sp`, `end`) are accepted in document
148 /// order; the spec mandates they appear between an element start
149 /// (`curv` / `surf`) and `end`, but we don't enforce that — a
150 /// lenient loader pattern matches what tools in the wild emit.
151 freeform_directives: Vec<Vec<String>>,
152}
153
154/// Glue line-continuation (`\\` + newline) before line splitting and
155/// strip comments (`#…` to end of line). Returns owned strings since
156/// continuation gluing rewrites the input.
157fn preprocess_lines(text: &str) -> Vec<String> {
158 let mut out: Vec<String> = Vec::new();
159 let mut acc = String::new();
160 for raw_line in text.split('\n') {
161 // Strip a trailing CR so CRLF inputs land cleanly.
162 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
163 // Strip comments — `#` past the start of a token introduces
164 // an end-of-line comment per the spec.
165 let no_comment = match line.find('#') {
166 Some(idx) => &line[..idx],
167 None => line,
168 };
169 let trimmed = no_comment.trim_end();
170 if let Some(stripped) = trimmed.strip_suffix('\\') {
171 acc.push_str(stripped);
172 acc.push(' ');
173 } else {
174 acc.push_str(trimmed);
175 out.push(std::mem::take(&mut acc));
176 }
177 }
178 if !acc.is_empty() {
179 out.push(acc);
180 }
181 out
182}
183
184/// Parse a face-vertex token. Accepts `v`, `v/vt`, `v//vn`, `v/vt/vn`.
185/// Each component is a non-zero integer (negative => relative-from-end).
186/// Resolution to 1-based positive indices happens here; 0-based
187/// primitive-local indexing happens in [`build_scene`].
188fn parse_face_vertex(tok: &str, n_pos: i64, n_tex: i64, n_norm: i64) -> Result<FaceVert> {
189 let mut parts = tok.split('/');
190 let v = parts
191 .next()
192 .ok_or_else(|| Error::invalid(format!("face vertex missing position: {tok:?}")))?;
193 let vt = parts.next().unwrap_or("");
194 let vn = parts.next().unwrap_or("");
195
196 let resolve = |s: &str, n: i64, kind: &str| -> Result<u32> {
197 if s.is_empty() {
198 return Ok(0);
199 }
200 let raw: i64 = s.parse().map_err(|_| {
201 Error::invalid(format!(
202 "invalid {kind} index in face vertex {tok:?}: {s:?}"
203 ))
204 })?;
205 let resolved = if raw < 0 { n + 1 + raw } else { raw };
206 if resolved <= 0 || resolved > n {
207 return Err(Error::invalid(format!(
208 "{kind} index out of range in face vertex {tok:?}: {raw} (have {n})"
209 )));
210 }
211 Ok(resolved as u32)
212 };
213
214 Ok(FaceVert {
215 v: resolve(v, n_pos, "position")?,
216 vt: resolve(vt, n_tex, "texcoord")?,
217 vn: resolve(vn, n_norm, "normal")?,
218 })
219}
220
221/// Parse the geometry part of an OBJ document into the intermediate
222/// [`ObjDoc`] form. No I/O — `mtllib` lines are recorded by name only;
223/// the caller resolves them.
224fn parse_obj_doc(text: &str) -> Result<ObjDoc> {
225 let mut doc = ObjDoc::default();
226 // One implicit mesh until an `o` directive opens a named one.
227 doc.meshes.push(MeshAccum::default());
228
229 let lines = preprocess_lines(text);
230 for line in &lines {
231 let mut tokens = line.split_whitespace();
232 let Some(keyword) = tokens.next() else {
233 continue;
234 };
235 match keyword {
236 "v" => {
237 let coords: Vec<f32> = tokens
238 .map(str::parse)
239 .collect::<std::result::Result<Vec<f32>, _>>()
240 .map_err(|e| Error::invalid(format!("v: bad float ({e})")))?;
241 // Spec §"v x y z w" defines 3 or 4 components (the 4th
242 // is the rational weight, default 1.0). The
243 // widely-deployed MeshLab / libigl / Meshroom extension
244 // adds a per-vertex RGB triplet making 6 (`x y z r g b`)
245 // or 7 (`x y z w r g b`) the supported widths in the
246 // wild. We accept all four shapes and surface the extra
247 // information through parallel `position_weights` /
248 // `position_colors` arrays so the encoder can re-emit
249 // the original token width on round-trip.
250 let (w, rgb) = match coords.len() {
251 3 => (None, None),
252 4 => (Some(coords[3]), None),
253 6 => (None, Some([coords[3], coords[4], coords[5], 1.0])),
254 7 => (
255 Some(coords[3]),
256 Some([coords[4], coords[5], coords[6], 1.0]),
257 ),
258 n => {
259 return Err(Error::invalid(format!(
260 "v: expected 3, 4, 6, or 7 floats (xyz, xyzw, xyzrgb, or \
261 xyzwrgb per spec + MeshLab vertex-colour extension), got {n}"
262 )));
263 }
264 };
265 doc.positions.push([coords[0], coords[1], coords[2]]);
266 doc.position_weights.push(w);
267 doc.position_colors.push(rgb);
268 }
269 "vt" => {
270 let coords: Vec<f32> = tokens
271 .map(str::parse)
272 .collect::<std::result::Result<Vec<f32>, _>>()
273 .map_err(|e| Error::invalid(format!("vt: bad float ({e})")))?;
274 if coords.is_empty() {
275 return Err(Error::invalid("vt: expected ≥1 coord"));
276 }
277 let u = coords[0];
278 let v = coords.get(1).copied().unwrap_or(0.0);
279 // Drop optional 3rd `w` — meaningless to glTF UV.
280 doc.texcoords.push([u, v]);
281 }
282 "vn" => {
283 let coords: Vec<f32> = tokens
284 .map(str::parse)
285 .collect::<std::result::Result<Vec<f32>, _>>()
286 .map_err(|e| Error::invalid(format!("vn: bad float ({e})")))?;
287 if coords.len() != 3 {
288 return Err(Error::invalid(format!(
289 "vn: expected 3 coords, got {}",
290 coords.len()
291 )));
292 }
293 doc.normals.push([coords[0], coords[1], coords[2]]);
294 }
295 "vp" => {
296 // Parameter-space vertex (`vp u v [w]`) — used as the
297 // control-point pool for free-form 2D trimming curves
298 // (`curv2`, referenced by `trim`/`hole`/`scrv`) and
299 // for special points (`sp`). Spec §"vp u v w".
300 //
301 // The number of meaningful coordinates depends on the
302 // usage (1D for 1D special points, 2D for trimming
303 // curves, 3D for rational trimming curves with a
304 // weight). We always store a 3-tuple, padding with
305 // `0.0` so the encoder can emit a faithful
306 // `vp <u> <v> <w>` line for the rational case and a
307 // shorter `vp <u> <v>` / `vp <u>` for the others.
308 let coords: Vec<f32> = tokens
309 .map(str::parse)
310 .collect::<std::result::Result<Vec<f32>, _>>()
311 .map_err(|e| Error::invalid(format!("vp: bad float ({e})")))?;
312 if coords.is_empty() {
313 return Err(Error::invalid("vp: expected ≥1 coord"));
314 }
315 let u = coords[0];
316 let v = coords.get(1).copied().unwrap_or(0.0);
317 let w = coords.get(2).copied().unwrap_or(0.0);
318 doc.vp.push([u, v, w]);
319 }
320 "cstype" | "deg" | "curv" | "curv2" | "surf" | "parm" | "trim" | "hole" | "scrv"
321 | "sp" | "end" | "bzp" | "bsp" => {
322 // Free-form geometry directives. Captured verbatim as
323 // a `(keyword, args)` sequence on the document so the
324 // encoder can replay them after the polygonal section.
325 // No semantic interpretation: the round-trip preserves
326 // the operator's exact token sequence.
327 //
328 // Spec §"Free-form curve/surface attributes" /
329 // §"Specifying free-form curves/surfaces" /
330 // §"Free-form curve/surface body statements" /
331 // §"Superseded statements (bzp / bsp)".
332 let mut entry: Vec<String> = Vec::new();
333 entry.push(keyword.to_string());
334 for tok in tokens {
335 entry.push(tok.to_string());
336 }
337 doc.freeform_directives.push(entry);
338 }
339 "f" => {
340 let n_pos = doc.positions.len() as i64;
341 let n_tex = doc.texcoords.len() as i64;
342 let n_norm = doc.normals.len() as i64;
343 let verts: Vec<FaceVert> = tokens
344 .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
345 .collect::<Result<Vec<_>>>()?;
346 if verts.len() < 3 {
347 return Err(Error::invalid(format!(
348 "f: face needs ≥3 vertices, got {}",
349 verts.len()
350 )));
351 }
352 let mesh = doc.meshes.last_mut().unwrap();
353 mesh.current_or_new().elements.push(Element::Face(verts));
354 }
355 "l" => {
356 let n_pos = doc.positions.len() as i64;
357 let n_tex = doc.texcoords.len() as i64;
358 let n_norm = doc.normals.len() as i64;
359 let verts: Vec<FaceVert> = tokens
360 .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
361 .collect::<Result<Vec<_>>>()?;
362 if verts.len() < 2 {
363 return Err(Error::invalid(format!(
364 "l: line needs ≥2 vertices, got {}",
365 verts.len()
366 )));
367 }
368 let mesh = doc.meshes.last_mut().unwrap();
369 mesh.current_or_new().elements.push(Element::Line(verts));
370 }
371 "p" => {
372 // Point elements are state-incompatible with face/line
373 // primitives (different `Topology`); mirror the `usemtl`
374 // pattern and split into a fresh primitive whenever the
375 // current one already holds incompatible elements.
376 let n_pos = doc.positions.len() as i64;
377 let n_tex = doc.texcoords.len() as i64;
378 let n_norm = doc.normals.len() as i64;
379 // `p` only takes vertex references (no `/vt` or `//vn`),
380 // but parse_face_vertex degrades gracefully when the
381 // separators are absent.
382 let verts: Vec<FaceVert> = tokens
383 .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
384 .collect::<Result<Vec<_>>>()?;
385 if verts.is_empty() {
386 return Err(Error::invalid("p: needs ≥1 vertex"));
387 }
388 let mesh = doc.meshes.last_mut().unwrap();
389 let prim = mesh.current_or_new();
390 if prim
391 .elements
392 .iter()
393 .any(|e| !matches!(e, Element::Point(_)))
394 {
395 // Mixed-kind elements aren't representable; open a
396 // fresh primitive that inherits material + groups +
397 // smoothing/merging/display-attr state.
398 let mat = prim.material.clone();
399 let groups = prim.groups.clone();
400 let smoothing = prim.smoothing_group.clone();
401 let merging = prim.merging_group.clone();
402 let bevel = prim.bevel.clone();
403 let c_interp = prim.c_interp.clone();
404 let d_interp = prim.d_interp.clone();
405 let lod = prim.lod.clone();
406 mesh.primitives.push(PrimAccum {
407 material: mat,
408 groups,
409 smoothing_group: smoothing,
410 merging_group: merging,
411 bevel,
412 c_interp,
413 d_interp,
414 lod,
415 elements: vec![Element::Point(verts)],
416 });
417 } else {
418 prim.elements.push(Element::Point(verts));
419 }
420 }
421 "bevel" | "c_interp" | "d_interp" | "lod" => {
422 // Display-attribute state-setting — `bevel on/off`,
423 // `c_interp on/off`, `d_interp on/off`, `lod <level>`.
424 // Captured per-primitive; a mid-stream change splits
425 // the primitive so each one carries one consistent
426 // value (mirrors `s`/`mg`).
427 let v: String = tokens.collect::<Vec<_>>().join(" ");
428 if v.is_empty() {
429 continue;
430 }
431 let mesh = doc.meshes.last_mut().unwrap();
432 let last = mesh.current_or_new();
433 let current: Option<&str> = match keyword {
434 "bevel" => last.bevel.as_deref(),
435 "c_interp" => last.c_interp.as_deref(),
436 "d_interp" => last.d_interp.as_deref(),
437 "lod" => last.lod.as_deref(),
438 _ => unreachable!(),
439 };
440 if last.elements.is_empty() {
441 // Overwrite the pending value.
442 match keyword {
443 "bevel" => last.bevel = Some(v),
444 "c_interp" => last.c_interp = Some(v),
445 "d_interp" => last.d_interp = Some(v),
446 "lod" => last.lod = Some(v),
447 _ => unreachable!(),
448 }
449 } else if current != Some(v.as_str()) {
450 let mat = last.material.clone();
451 let groups = last.groups.clone();
452 let smoothing = last.smoothing_group.clone();
453 let merging = last.merging_group.clone();
454 let mut bevel = last.bevel.clone();
455 let mut c_interp = last.c_interp.clone();
456 let mut d_interp = last.d_interp.clone();
457 let mut lod = last.lod.clone();
458 match keyword {
459 "bevel" => bevel = Some(v),
460 "c_interp" => c_interp = Some(v),
461 "d_interp" => d_interp = Some(v),
462 "lod" => lod = Some(v),
463 _ => unreachable!(),
464 }
465 mesh.primitives.push(PrimAccum {
466 material: mat,
467 smoothing_group: smoothing,
468 merging_group: merging,
469 groups,
470 bevel,
471 c_interp,
472 d_interp,
473 lod,
474 elements: Vec::new(),
475 });
476 }
477 }
478 "mg" => {
479 // Merging group — `mg <group_number> [res]` or `mg off`
480 // / `mg 0`. Like `s`, it's state-setting; preserve the
481 // operator's spelling verbatim. The semantic value
482 // (smoothing across surface joins for free-form
483 // surfaces) is meaningless without the free-form
484 // surface support, but the round-trip preservation
485 // matters for tools that round-trip mesh data through
486 // us.
487 let v: String = tokens.collect::<Vec<_>>().join(" ");
488 if v.is_empty() {
489 continue;
490 }
491 let mesh = doc.meshes.last_mut().unwrap();
492 let last = mesh.current_or_new();
493 if last.elements.is_empty() {
494 // No elements yet — overwrite the pending value.
495 last.merging_group = Some(v);
496 } else if last.merging_group.as_deref() != Some(v.as_str()) {
497 // Merging-group changed mid-stream; split into a
498 // fresh primitive so each one carries one
499 // consistent assignment (mirrors smoothing-group
500 // behaviour).
501 let mat = last.material.clone();
502 let groups = last.groups.clone();
503 let smoothing = last.smoothing_group.clone();
504 let bevel = last.bevel.clone();
505 let c_interp = last.c_interp.clone();
506 let d_interp = last.d_interp.clone();
507 let lod = last.lod.clone();
508 mesh.primitives.push(PrimAccum {
509 material: mat,
510 smoothing_group: smoothing,
511 groups,
512 merging_group: Some(v),
513 bevel,
514 c_interp,
515 d_interp,
516 lod,
517 elements: Vec::new(),
518 });
519 }
520 }
521 "o" => {
522 let name: String = tokens.collect::<Vec<_>>().join(" ");
523 // Open a fresh mesh — but if the current mesh is still
524 // empty (no primitives accumulated yet), reuse it so we
525 // don't end up with a leading empty mesh.
526 let last = doc.meshes.last_mut().unwrap();
527 if last.name.is_none() && last.primitives.is_empty() {
528 last.name = if name.is_empty() { None } else { Some(name) };
529 } else {
530 doc.meshes.push(MeshAccum {
531 name: if name.is_empty() { None } else { Some(name) },
532 primitives: Vec::new(),
533 });
534 }
535 }
536 "g" => {
537 // The spec (Wavefront *Advanced Visualizer* Appendix B,
538 // §"Grouping") explicitly permits multiple group names
539 // on one line: `g group_name1 group_name2 …`. Each
540 // whitespace-separated token is its own group; the
541 // following elements belong to ALL listed groups.
542 let names: Vec<String> = tokens.map(|t| t.to_string()).collect();
543 if names.is_empty() {
544 continue;
545 }
546 let mesh = doc.meshes.last_mut().unwrap();
547 let prim = mesh.current_or_new();
548 for name in names {
549 if !prim.groups.iter().any(|g| g == &name) {
550 prim.groups.push(name);
551 }
552 }
553 }
554 "s" => {
555 // `s 0` and `s off` both mean "no smoothing"; preserve
556 // the operator's chosen spelling verbatim for round-trip.
557 let v: String = tokens.collect::<Vec<_>>().join(" ");
558 if v.is_empty() {
559 continue;
560 }
561 let mesh = doc.meshes.last_mut().unwrap();
562 let last = mesh.current_or_new();
563 if last.elements.is_empty() {
564 // No elements yet — overwrite the pending value.
565 last.smoothing_group = Some(v);
566 } else if last.smoothing_group.as_deref() != Some(v.as_str()) {
567 // Smoothing changed mid-stream; spec says it's
568 // state-setting and applies to subsequent
569 // elements, so split into a new primitive that
570 // inherits the current material + groups +
571 // merging-group + display attributes.
572 let mat = last.material.clone();
573 let groups = last.groups.clone();
574 let merging = last.merging_group.clone();
575 let bevel = last.bevel.clone();
576 let c_interp = last.c_interp.clone();
577 let d_interp = last.d_interp.clone();
578 let lod = last.lod.clone();
579 mesh.primitives.push(PrimAccum {
580 material: mat,
581 smoothing_group: Some(v),
582 groups,
583 merging_group: merging,
584 bevel,
585 c_interp,
586 d_interp,
587 lod,
588 elements: Vec::new(),
589 });
590 }
591 }
592 "usemtl" => {
593 let name: String = tokens.collect::<Vec<_>>().join(" ");
594 let mesh = doc.meshes.last_mut().unwrap();
595 let last = mesh.current_or_new();
596 if last.elements.is_empty() && last.material.is_none() {
597 // First usemtl in this primitive — adopt directly.
598 last.material = if name.is_empty() { None } else { Some(name) };
599 } else {
600 // Subsequent usemtl — start a new primitive.
601 mesh.primitives.push(PrimAccum {
602 material: if name.is_empty() { None } else { Some(name) },
603 ..PrimAccum::default()
604 });
605 }
606 }
607 "mtllib" => {
608 // Each `mtllib` line can list multiple .mtl files.
609 for tok in tokens {
610 if !doc.mtllibs.iter().any(|m| m == tok) {
611 doc.mtllibs.push(tok.to_string());
612 }
613 }
614 }
615 // Unhandled keywords (curves/surfaces/display attributes/etc.) are
616 // silently skipped per spec lenient-loader convention.
617 _ => {}
618 }
619 }
620
621 Ok(doc)
622}
623
624// ---------------------------------------------------------------------------
625// Scene assembly
626// ---------------------------------------------------------------------------
627
628/// Convert the intermediate [`ObjDoc`] into a [`Scene3D`].
629///
630/// Indices are de-duplicated per-primitive so the resulting vertex
631/// buffer carries `unique_face_vertices` entries (matching glTF's
632/// per-primitive interleaved-attribute model). Original face arities
633/// are stored in `Mesh::extras["obj:original_face_arities"]` so the
634/// encoder can reconstruct the n-gons.
635fn build_scene(doc: ObjDoc) -> Result<Scene3D> {
636 use oxideav_mesh3d::{Axis, Material, Unit};
637
638 let mut scene = Scene3D::new();
639 // OBJ has no unit metadata; the primer says "Metres is the safe
640 // default" and "Y-up matches the glTF default".
641 scene.up_axis = Axis::PosY;
642 scene.unit = Unit::Metres;
643
644 // Materials first so primitives can point at their MaterialId.
645 // Insertion order is preserved (HashMap iteration order is
646 // unspecified, so sort by name to keep round-trip deterministic).
647 let mut material_ids: HashMap<String, oxideav_mesh3d::MaterialId> = HashMap::new();
648 let mut material_names: Vec<String> = doc.resolved_materials.keys().cloned().collect();
649 material_names.sort();
650 for name in &material_names {
651 let mut mat = doc
652 .resolved_materials
653 .get(name)
654 .cloned()
655 .unwrap_or_else(Material::new);
656 if mat.name.is_none() {
657 mat.name = Some(name.clone());
658 }
659 let id = scene.add_material(mat);
660 material_ids.insert(name.clone(), id);
661 }
662
663 for mesh_acc in doc.meshes {
664 // Drop genuinely empty meshes (no primitives that emit anything).
665 let has_anything = mesh_acc.primitives.iter().any(|p| !p.elements.is_empty());
666 if !has_anything {
667 continue;
668 }
669
670 let mut mesh = Mesh::new(mesh_acc.name.clone());
671
672 for prim_acc in mesh_acc.primitives {
673 let (mut primitive, arities) = build_primitive(
674 &prim_acc,
675 &doc.positions,
676 &doc.position_weights,
677 &doc.position_colors,
678 &doc.texcoords,
679 &doc.normals,
680 &material_ids,
681 )?;
682 // Skip primitives that never accumulated any element.
683 if primitive.positions.is_empty() {
684 continue;
685 }
686 // Stash original face arities per-primitive when the primitive
687 // contained at least one non-triangle face. Mesh has no
688 // `extras` field, so the round-trip annotation lives on the
689 // primitive — symmetrical with the smoothing-group / groups /
690 // usemtl extras already populated by `build_primitive`.
691 if arities.iter().any(|&a| a != 3) {
692 primitive.extras.insert(
693 "obj:original_face_arities".to_string(),
694 serde_json::to_value(&arities).unwrap(),
695 );
696 }
697 mesh.primitives.push(primitive);
698 }
699
700 scene.add_mesh(mesh);
701 }
702
703 // Keep the mtllib references in scene extras so a re-encode that
704 // wants to point back at a specific MTL file can find them.
705 if !doc.mtllibs.is_empty() {
706 scene.extras.insert(
707 "obj:mtllibs".to_string(),
708 serde_json::to_value(&doc.mtllibs).unwrap(),
709 );
710 }
711
712 // Free-form geometry side-channel: the parameter-space vertex pool
713 // (`vp`) and the verbatim sequence of `cstype` / `deg` / `curv` /
714 // `surf` / `parm` / `trim` / `hole` / `scrv` / `sp` / `end` / `bzp`
715 // / `bsp` directives. The encoder replays these after the
716 // polygonal section so consumers that don't care about free-form
717 // geometry simply ignore the keys, while consumers that do can
718 // walk the directive sequence themselves.
719 if !doc.vp.is_empty() {
720 scene
721 .extras
722 .insert("obj:vp".to_string(), serde_json::to_value(&doc.vp).unwrap());
723 }
724 if !doc.freeform_directives.is_empty() {
725 scene.extras.insert(
726 "obj:freeform_directives".to_string(),
727 serde_json::to_value(&doc.freeform_directives).unwrap(),
728 );
729 }
730
731 Ok(scene)
732}
733
734/// Promote a single-`l`-element primitive to `LineStrip` / `LineLoop`
735/// when applicable; fall back to `Lines` for multi-element or 2-vertex
736/// segments. See [`build_primitive`] for the surrounding context.
737fn single_line_topology(elements: &[Element]) -> Topology {
738 if elements.len() != 1 {
739 return Topology::Lines;
740 }
741 let Element::Line(verts) = &elements[0] else {
742 return Topology::Lines;
743 };
744 if verts.len() < 2 {
745 return Topology::Lines;
746 }
747 // A 2-vertex `l` is a plain segment — keep it on `Lines` so the
748 // round-trip stays minimal (one `l v1 v2` line either way).
749 if verts.len() == 2 {
750 return Topology::Lines;
751 }
752 // Closed polyline: first / last vertex coincide on the position
753 // index. We don't need to compare uv/normal — `l` references only
754 // ever populate the position component for the loop-detection
755 // semantics specified by the spec §"Line elements".
756 let same_start_end = verts.first().map(|fv| fv.v) == verts.last().map(|fv| fv.v);
757 if same_start_end {
758 Topology::LineLoop
759 } else {
760 Topology::LineStrip
761 }
762}
763
764/// Build one [`Primitive`] from an accumulated [`PrimAccum`].
765///
766/// Returns the primitive plus a per-element arity vector — one entry
767/// per face (3 for a triangle, 4 for a quad, ≥5 for an n-gon). Lines
768/// don't contribute arity entries (the encoder switches on topology
769/// instead).
770fn build_primitive(
771 prim_acc: &PrimAccum,
772 positions: &[[f32; 3]],
773 position_weights: &[Option<f32>],
774 position_colors: &[Option<[f32; 4]>],
775 texcoords: &[[f32; 2]],
776 normals: &[[f32; 3]],
777 material_ids: &HashMap<String, oxideav_mesh3d::MaterialId>,
778) -> Result<(Primitive, Vec<u32>)> {
779 // Decide topology + attribute presence by looking at the first
780 // element. Mixed-element primitives (lines + faces under one
781 // `usemtl`) aren't representable in mesh3d so we error cleanly.
782 //
783 // For a single `l` element we promote to the more specific
784 // `LineStrip` / `LineLoop` topology so consumers don't have to
785 // reconstruct the polyline shape from disjoint segment pairs:
786 //
787 // * exactly one `l` element with N ≥ 2 vertices whose last
788 // vertex equals its first → `LineLoop` (the redundant
789 // closing vertex is dropped from the index buffer).
790 // * exactly one `l` element with N ≥ 2 distinct end vertices →
791 // `LineStrip`.
792 // * multiple `l` elements (or a single 2-vertex `l` that is a
793 // plain segment) fall back to `Lines` for the existing
794 // contiguous-chain re-emit path on the encoder side.
795 let first = prim_acc.elements.first();
796 let topology = match first {
797 Some(Element::Face(_)) => Topology::Triangles,
798 Some(Element::Line(_)) => single_line_topology(&prim_acc.elements),
799 Some(Element::Point(_)) => Topology::Points,
800 None => Topology::Triangles,
801 };
802 for elt in &prim_acc.elements {
803 let ok = matches!(
804 (&topology, elt),
805 (Topology::Triangles, Element::Face(_))
806 | (Topology::Lines, Element::Line(_))
807 | (Topology::LineStrip, Element::Line(_))
808 | (Topology::LineLoop, Element::Line(_))
809 | (Topology::Points, Element::Point(_))
810 );
811 if !ok {
812 return Err(Error::unsupported(
813 "OBJ primitive mixes face / line / point elements under one usemtl",
814 ));
815 }
816 }
817
818 let has_uv = prim_acc.elements.iter().any(|elt| match elt {
819 Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
820 verts.iter().any(|fv| fv.vt != 0)
821 }
822 });
823 let has_normal = prim_acc.elements.iter().any(|elt| match elt {
824 Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
825 verts.iter().any(|fv| fv.vn != 0)
826 }
827 });
828 // Per-vertex colour applies to a primitive whenever any of its
829 // referenced positions carries the `v x y z r g b` extension. We
830 // promote to a single-channel `colors[0]` set; vertices that
831 // don't carry RGB fall back to white (the obvious "no colour
832 // information" sentinel — preserves the standard glTF expectation
833 // that a colour buffer is fully populated when present). The
834 // round-trip-aware `obj:vertex_color_present` per-position
835 // bitmap below guards the encoder against re-emitting a
836 // synthetic white that the original file didn't spell out.
837 let has_color = prim_acc.elements.iter().any(|elt| match elt {
838 Element::Face(verts) | Element::Line(verts) | Element::Point(verts) => {
839 verts.iter().any(|fv| {
840 position_colors
841 .get((fv.v - 1) as usize)
842 .is_some_and(Option::is_some)
843 })
844 }
845 });
846
847 let mut prim = Primitive::new(topology);
848 if has_uv {
849 prim.uvs.push(Vec::new());
850 }
851 if has_normal {
852 prim.normals = Some(Vec::new());
853 }
854 if has_color {
855 prim.colors.push(Vec::new());
856 }
857 // Track per-interned-vertex "did this position carry RGB / a
858 // weight in the source file?" so the encoder doesn't fabricate
859 // colours / weights that the user never wrote. Both vectors are
860 // parallel to `prim.positions` after interning completes.
861 let mut color_present: Vec<bool> = Vec::new();
862 let mut weights_seen: Vec<Option<f32>> = Vec::new();
863
864 // De-duplicate face-vertices into a single interleaved buffer.
865 let mut indexer: HashMap<FaceVert, u32> = HashMap::new();
866 let mut arities: Vec<u32> = Vec::new();
867 let mut local_indices: Vec<u32> = Vec::new();
868
869 let intern = |fv: FaceVert,
870 prim: &mut Primitive,
871 indexer: &mut HashMap<FaceVert, u32>,
872 color_present: &mut Vec<bool>,
873 weights_seen: &mut Vec<Option<f32>>|
874 -> Result<u32> {
875 if let Some(&idx) = indexer.get(&fv) {
876 return Ok(idx);
877 }
878 let pos = positions
879 .get((fv.v - 1) as usize)
880 .ok_or_else(|| Error::invalid(format!("face references missing position {}", fv.v)))?;
881 prim.positions.push(*pos);
882 if has_uv {
883 let uv = if fv.vt == 0 {
884 [0.0, 0.0]
885 } else {
886 *texcoords.get((fv.vt - 1) as usize).ok_or_else(|| {
887 Error::invalid(format!("face references missing texcoord {}", fv.vt))
888 })?
889 };
890 prim.uvs[0].push(uv);
891 }
892 if has_normal {
893 let n = if fv.vn == 0 {
894 [0.0, 0.0, 0.0]
895 } else {
896 *normals.get((fv.vn - 1) as usize).ok_or_else(|| {
897 Error::invalid(format!("face references missing normal {}", fv.vn))
898 })?
899 };
900 prim.normals.as_mut().unwrap().push(n);
901 }
902 if has_color {
903 // Either the source file carried RGB for this vertex, or
904 // we synthesise opaque white so the colour buffer stays
905 // length-parallel with positions (mesh3d invariant).
906 let rgba = position_colors
907 .get((fv.v - 1) as usize)
908 .copied()
909 .flatten()
910 .unwrap_or([1.0, 1.0, 1.0, 1.0]);
911 prim.colors[0].push(rgba);
912 color_present.push(
913 position_colors
914 .get((fv.v - 1) as usize)
915 .is_some_and(Option::is_some),
916 );
917 }
918 weights_seen.push(position_weights.get((fv.v - 1) as usize).copied().flatten());
919 let new_idx = (prim.positions.len() - 1) as u32;
920 indexer.insert(fv, new_idx);
921 Ok(new_idx)
922 };
923
924 for elt in &prim_acc.elements {
925 match elt {
926 Element::Face(verts) => {
927 let arity = verts.len() as u32;
928 arities.push(arity);
929 let resolved: Vec<u32> = verts
930 .iter()
931 .map(|&fv| {
932 intern(
933 fv,
934 &mut prim,
935 &mut indexer,
936 &mut color_present,
937 &mut weights_seen,
938 )
939 })
940 .collect::<Result<Vec<_>>>()?;
941 // Fan triangulate: (v0, v1, v2), (v0, v2, v3), …
942 for i in 1..(resolved.len() - 1) {
943 local_indices.push(resolved[0]);
944 local_indices.push(resolved[i]);
945 local_indices.push(resolved[i + 1]);
946 }
947 }
948 Element::Line(verts) => {
949 let resolved: Vec<u32> = verts
950 .iter()
951 .map(|&fv| {
952 intern(
953 fv,
954 &mut prim,
955 &mut indexer,
956 &mut color_present,
957 &mut weights_seen,
958 )
959 })
960 .collect::<Result<Vec<_>>>()?;
961 match topology {
962 Topology::LineStrip => {
963 // Emit the polyline as a contiguous index list.
964 local_indices.extend_from_slice(&resolved);
965 }
966 Topology::LineLoop => {
967 // Drop the redundant closing vertex; consumers
968 // treat the strip as closed at draw time.
969 let n = resolved.len().saturating_sub(1);
970 local_indices.extend_from_slice(&resolved[..n]);
971 }
972 _ => {
973 // Plain `Lines` — decompose polyline into
974 // disjoint segment pairs (encoder rejoins
975 // contiguous chains on the way out).
976 for w in resolved.windows(2) {
977 local_indices.push(w[0]);
978 local_indices.push(w[1]);
979 }
980 }
981 }
982 }
983 Element::Point(verts) => {
984 // Each `p` line can carry multiple vertex references;
985 // every reference becomes one element index for
986 // `Topology::Points`. Original arities aren't tracked
987 // since a re-emit can pack them on one line freely.
988 for &fv in verts {
989 let idx = intern(
990 fv,
991 &mut prim,
992 &mut indexer,
993 &mut color_present,
994 &mut weights_seen,
995 )?;
996 local_indices.push(idx);
997 }
998 }
999 }
1000 }
1001
1002 // Promote to U32 if any index >= 65536; U16 otherwise.
1003 if local_indices.iter().any(|&i| i >= u16::MAX as u32) {
1004 prim.indices = Some(Indices::U32(local_indices));
1005 } else {
1006 prim.indices = Some(Indices::U16(
1007 local_indices.into_iter().map(|i| i as u16).collect(),
1008 ));
1009 }
1010
1011 // Per-vertex extension state — surfaced through `Primitive::extras`
1012 // so the encoder knows which `v` lines to expand to the 4-token
1013 // `xyzw`, 6-token `xyzrgb`, or 7-token `xyzwrgb` form. We only stash
1014 // the bitmaps when at least one vertex used the extension; the
1015 // common no-extension case stays free of decode-time noise.
1016 if has_color && color_present.iter().any(|&b| b) {
1017 prim.extras.insert(
1018 "obj:vertex_color_present".to_string(),
1019 serde_json::to_value(&color_present).unwrap(),
1020 );
1021 }
1022 if weights_seen.iter().any(Option::is_some) {
1023 prim.extras.insert(
1024 "obj:vertex_weight".to_string(),
1025 serde_json::to_value(&weights_seen).unwrap(),
1026 );
1027 }
1028
1029 if let Some(name) = &prim_acc.material {
1030 if let Some(id) = material_ids.get(name) {
1031 prim.material = Some(*id);
1032 }
1033 prim.extras.insert(
1034 "obj:usemtl".to_string(),
1035 serde_json::Value::String(name.clone()),
1036 );
1037 }
1038 if let Some(s) = &prim_acc.smoothing_group {
1039 prim.extras.insert(
1040 "obj:smoothing_group".to_string(),
1041 serde_json::Value::String(s.clone()),
1042 );
1043 }
1044 if let Some(s) = &prim_acc.merging_group {
1045 prim.extras.insert(
1046 "obj:merging_group".to_string(),
1047 serde_json::Value::String(s.clone()),
1048 );
1049 }
1050 if let Some(s) = &prim_acc.bevel {
1051 prim.extras.insert(
1052 "obj:bevel".to_string(),
1053 serde_json::Value::String(s.clone()),
1054 );
1055 }
1056 if let Some(s) = &prim_acc.c_interp {
1057 prim.extras.insert(
1058 "obj:c_interp".to_string(),
1059 serde_json::Value::String(s.clone()),
1060 );
1061 }
1062 if let Some(s) = &prim_acc.d_interp {
1063 prim.extras.insert(
1064 "obj:d_interp".to_string(),
1065 serde_json::Value::String(s.clone()),
1066 );
1067 }
1068 if let Some(s) = &prim_acc.lod {
1069 prim.extras
1070 .insert("obj:lod".to_string(), serde_json::Value::String(s.clone()));
1071 }
1072 if !prim_acc.groups.is_empty() {
1073 prim.extras.insert(
1074 "obj:groups".to_string(),
1075 serde_json::to_value(&prim_acc.groups).unwrap(),
1076 );
1077 }
1078
1079 Ok((prim, arities))
1080}
1081
1082// ---------------------------------------------------------------------------
1083// Public API
1084// ---------------------------------------------------------------------------
1085
1086/// Parse an OBJ document (no MTL resolution).
1087///
1088/// `usemtl` directives still create one `Primitive` per switch and the
1089/// material name lands in `Primitive::extras["obj:usemtl"]` even with
1090/// no actual `Material` constructed. Use [`parse_obj_with_resolver`]
1091/// when companion MTL data is available.
1092pub fn parse_obj(text: &str) -> Result<Scene3D> {
1093 parse_obj_with_resolver(text, |_path| Ok(Vec::new()))
1094}
1095
1096/// Parse an OBJ document at `path`, resolving `mtllib` references
1097/// against the OBJ file's parent directory.
1098///
1099/// Convenience wrapper around [`parse_obj_with_resolver`] for the
1100/// overwhelmingly common case of "I have a path, please load it and
1101/// follow the MTL references". Each `mtllib foo.mtl` directive becomes
1102/// a sibling-file read; missing libraries surface the underlying
1103/// [`std::io::Error`] (wrapped in [`Error::invalid`]) rather than
1104/// silently dropping. If you want lenient missing-MTL handling, use
1105/// [`parse_obj_with_resolver`] directly.
1106pub fn parse_obj_from_path<P: AsRef<std::path::Path>>(path: P) -> Result<Scene3D> {
1107 let path = path.as_ref();
1108 let bytes =
1109 std::fs::read(path).map_err(|e| Error::invalid(format!("OBJ read {path:?}: {e}")))?;
1110 let text = std::str::from_utf8(&bytes)
1111 .map_err(|_| Error::invalid(format!("OBJ {path:?} contained non-UTF-8 bytes")))?;
1112 let parent = path.parent().map(std::path::Path::to_path_buf);
1113 parse_obj_with_resolver(text, |libname| {
1114 // Empty / absolute / parent-relative library names are honoured
1115 // verbatim; bare names are resolved against the OBJ's parent
1116 // directory.
1117 let lib_path = match &parent {
1118 Some(dir) => dir.join(libname),
1119 None => std::path::PathBuf::from(libname),
1120 };
1121 std::fs::read(&lib_path)
1122 .map_err(|e| Error::invalid(format!("mtllib read {lib_path:?}: {e}")))
1123 })
1124}
1125
1126/// Parse an OBJ document, calling `resolve` once per `mtllib` entry to
1127/// fetch the bytes of the named material library. Each library is
1128/// parsed via [`parse_mtl`] and its materials merged into the resulting
1129/// scene; references in `usemtl` directives bind to those materials by
1130/// name.
1131///
1132/// The resolver returns `Ok(Vec::new())` to signal "this library
1133/// couldn't be located but skip silently"; any other `Err` aborts the
1134/// parse.
1135pub fn parse_obj_with_resolver<R>(text: &str, mut resolve: R) -> Result<Scene3D>
1136where
1137 R: FnMut(&str) -> Result<Vec<u8>>,
1138{
1139 let mut doc = parse_obj_doc(text)?;
1140
1141 // Resolve material libraries, if any.
1142 for lib in doc.mtllibs.clone() {
1143 let bytes = resolve(&lib)?;
1144 if bytes.is_empty() {
1145 continue;
1146 }
1147 let lib_text = std::str::from_utf8(&bytes)
1148 .map_err(|_| Error::invalid(format!("mtllib {lib:?} contained non-UTF-8 bytes")))?;
1149 let materials = parse_mtl(lib_text)?;
1150 for mat in materials {
1151 if let Some(name) = mat.name.clone() {
1152 doc.resolved_materials.insert(name, mat);
1153 }
1154 }
1155 }
1156
1157 build_scene(doc)
1158}
1159
1160/// Serialiser configuration. Keeps the public free-function signature
1161/// stable while letting the [`crate::ObjEncoder`] thread richer options
1162/// through.
1163#[derive(Clone, Debug, Default)]
1164pub struct SerializeOptions<'a> {
1165 /// Reference an external MTL file via an `mtllib <basename>.mtl`
1166 /// header line. Equivalent to the `mtl_basename` parameter on
1167 /// [`serialize_obj`].
1168 pub mtl_basename: Option<&'a str>,
1169 /// When `true`, emit face/line vertex indices in the relative
1170 /// negative-index form (`f -1 -2 -3`) instead of absolute 1-based.
1171 /// Round-trips verbatim back through the parser; useful when the
1172 /// caller wants their re-encoded OBJ to mirror an input that used
1173 /// negative indices throughout.
1174 pub negative_indices: bool,
1175}
1176
1177/// Serialise a [`Scene3D`] to OBJ format.
1178///
1179/// `mtl_basename`, when supplied, emits an `mtllib <basename>.mtl`
1180/// directive at the top so a sibling MTL file (written separately via
1181/// [`crate::mtl::serialize_mtl`]) is referenced.
1182pub fn serialize_obj(scene: &Scene3D, mtl_basename: Option<&str>) -> Result<Vec<u8>> {
1183 serialize_obj_with_options(
1184 scene,
1185 &SerializeOptions {
1186 mtl_basename,
1187 ..SerializeOptions::default()
1188 },
1189 )
1190}
1191
1192/// Serialise a [`Scene3D`] to OBJ format with explicit options.
1193///
1194/// See [`SerializeOptions`] for the supported knobs.
1195pub fn serialize_obj_with_options(
1196 scene: &Scene3D,
1197 options: &SerializeOptions<'_>,
1198) -> Result<Vec<u8>> {
1199 let mtl_basename = options.mtl_basename;
1200 let negative = options.negative_indices;
1201 use std::fmt::Write;
1202 let mut out = String::new();
1203 writeln!(out, "# OBJ generated by oxideav-obj").unwrap();
1204 if let Some(base) = mtl_basename {
1205 writeln!(out, "mtllib {base}.mtl").unwrap();
1206 }
1207 // Replay any mtllib refs preserved on the scene itself when no
1208 // explicit basename was supplied.
1209 if mtl_basename.is_none() {
1210 if let Some(serde_json::Value::Array(list)) = scene.extras.get("obj:mtllibs") {
1211 for entry in list {
1212 if let Some(s) = entry.as_str() {
1213 writeln!(out, "mtllib {s}").unwrap();
1214 }
1215 }
1216 }
1217 }
1218
1219 // Deduplicated global vertex / texcoord / normal pools so emitted
1220 // index references match the canonical 1-based numbering.
1221 let mut positions: Vec<[f32; 3]> = Vec::new();
1222 // Parallel to `positions` — `Some(rgb)` when the source flagged
1223 // this vertex through the `obj:vertex_color_present` extras
1224 // bitmap, `None` otherwise. We *don't* emit synthetic white for a
1225 // `None` entry: the round-trip rule is "only re-emit RGB for
1226 // vertices that originally had it". When at least one position
1227 // carries colour the encoder also sets a flag so the entire
1228 // colour set isn't dropped on a partial-colouring file (mixed
1229 // colored / uncolored vertices in one primitive — re-emit
1230 // standard `v x y z` for the uncolored).
1231 let mut position_colors: Vec<Option<[f32; 4]>> = Vec::new();
1232 // Parallel to `positions` — preserved `v` 4th `w` weight whenever
1233 // the source carried it. `None` re-emits the standard 3-token form.
1234 let mut position_weights: Vec<Option<f32>> = Vec::new();
1235 let mut texcoords: Vec<[f32; 2]> = Vec::new();
1236 let mut normals: Vec<[f32; 3]> = Vec::new();
1237 let mut pos_map: HashMap<KeyVec3, u32> = HashMap::new();
1238 let mut tex_map: HashMap<KeyVec2, u32> = HashMap::new();
1239 let mut nor_map: HashMap<KeyVec3, u32> = HashMap::new();
1240
1241 // Intern a position into the shared global pool, attaching the
1242 // (optional) per-vertex colour + weight derived from the
1243 // `obj:vertex_color_present` / `obj:vertex_weight` extras. When the
1244 // same position appears across primitives, the *first* non-`None`
1245 // colour / weight wins — silently ignoring later overrides keeps
1246 // round-trip determinism without forcing a partition of duplicate
1247 // positions on differing colour metadata (which would force the
1248 // encoder to emit redundant `v` lines and bloat the output).
1249 let intern_pos = |p: [f32; 3],
1250 colour: Option<[f32; 4]>,
1251 weight: Option<f32>,
1252 positions: &mut Vec<[f32; 3]>,
1253 colours: &mut Vec<Option<[f32; 4]>>,
1254 weights: &mut Vec<Option<f32>>,
1255 map: &mut HashMap<KeyVec3, u32>|
1256 -> u32 {
1257 let key = KeyVec3::from(p);
1258 if let Some(&i) = map.get(&key) {
1259 // First-write-wins on extension metadata.
1260 let slot = (i - 1) as usize;
1261 if colours[slot].is_none() {
1262 colours[slot] = colour;
1263 }
1264 if weights[slot].is_none() {
1265 weights[slot] = weight;
1266 }
1267 return i;
1268 }
1269 positions.push(p);
1270 colours.push(colour);
1271 weights.push(weight);
1272 let idx = positions.len() as u32;
1273 map.insert(key, idx);
1274 idx
1275 };
1276 let intern_tex =
1277 |p: [f32; 2], texcoords: &mut Vec<[f32; 2]>, map: &mut HashMap<KeyVec2, u32>| -> u32 {
1278 let key = KeyVec2::from(p);
1279 if let Some(&i) = map.get(&key) {
1280 return i;
1281 }
1282 texcoords.push(p);
1283 let idx = texcoords.len() as u32;
1284 map.insert(key, idx);
1285 idx
1286 };
1287 let intern_nor =
1288 |p: [f32; 3], normals: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
1289 let key = KeyVec3::from(p);
1290 if let Some(&i) = map.get(&key) {
1291 return i;
1292 }
1293 normals.push(p);
1294 let idx = normals.len() as u32;
1295 map.insert(key, idx);
1296 idx
1297 };
1298
1299 // First pass: emit `v` / `vt` / `vn` lists and remember the global
1300 // indices for each (mesh, primitive, vertex) triple.
1301 type GlobalTriple = (u32, u32, u32); // (v_idx, vt_idx_or_0, vn_idx_or_0)
1302 let mut global_indices: Vec<Vec<Vec<GlobalTriple>>> = Vec::new();
1303 for mesh in &scene.meshes {
1304 let mut mesh_globals: Vec<Vec<GlobalTriple>> = Vec::new();
1305 for prim in &mesh.primitives {
1306 let has_uv = !prim.uvs.is_empty();
1307 let has_normal = prim.normals.is_some();
1308 let has_color = !prim.colors.is_empty();
1309 // Per-vertex bitmap saying "did the source spell out RGB on
1310 // this vertex?". Missing extras / no-colors-set means every
1311 // vertex stays in the standard 3-token form.
1312 let color_present: Vec<bool> = prim
1313 .extras
1314 .get("obj:vertex_color_present")
1315 .and_then(serde_json::Value::as_array)
1316 .map(|arr| arr.iter().map(|v| v.as_bool().unwrap_or(false)).collect())
1317 .unwrap_or_else(|| vec![has_color; prim.positions.len()]);
1318 // Per-vertex weight overrides — preserved through extras.
1319 let weight_overrides: Vec<Option<f32>> = prim
1320 .extras
1321 .get("obj:vertex_weight")
1322 .and_then(serde_json::Value::as_array)
1323 .map(|arr| arr.iter().map(|v| v.as_f64().map(|f| f as f32)).collect())
1324 .unwrap_or_default();
1325 let mut prim_globals: Vec<GlobalTriple> = Vec::with_capacity(prim.positions.len());
1326 for vi in 0..prim.positions.len() {
1327 let colour = if has_color && color_present.get(vi).copied().unwrap_or(false) {
1328 Some(prim.colors[0][vi])
1329 } else {
1330 None
1331 };
1332 let weight = weight_overrides.get(vi).copied().flatten();
1333 let v_idx = intern_pos(
1334 prim.positions[vi],
1335 colour,
1336 weight,
1337 &mut positions,
1338 &mut position_colors,
1339 &mut position_weights,
1340 &mut pos_map,
1341 );
1342 let vt_idx = if has_uv {
1343 intern_tex(prim.uvs[0][vi], &mut texcoords, &mut tex_map)
1344 } else {
1345 0
1346 };
1347 let vn_idx = if has_normal {
1348 intern_nor(
1349 prim.normals.as_ref().unwrap()[vi],
1350 &mut normals,
1351 &mut nor_map,
1352 )
1353 } else {
1354 0
1355 };
1356 prim_globals.push((v_idx, vt_idx, vn_idx));
1357 }
1358 mesh_globals.push(prim_globals);
1359 }
1360 global_indices.push(mesh_globals);
1361 }
1362
1363 for (i, p) in positions.iter().enumerate() {
1364 // Pick the most-compact `v` form that still carries the
1365 // extension data: `xyz`, `xyzw` (rational weight), `xyzrgb`
1366 // (MeshLab vertex colour), or `xyzwrgb` (both). Each
1367 // extension is silently dropped if it would just spell out
1368 // the spec default (`w == 1.0`, no colour).
1369 let weight = position_weights[i];
1370 let colour = position_colors[i];
1371 let mut s = String::with_capacity(40);
1372 s.push_str("v ");
1373 s.push_str(&fmt_float(p[0]));
1374 s.push(' ');
1375 s.push_str(&fmt_float(p[1]));
1376 s.push(' ');
1377 s.push_str(&fmt_float(p[2]));
1378 if let Some(w) = weight {
1379 s.push(' ');
1380 s.push_str(&fmt_float(w));
1381 }
1382 if let Some(rgb) = colour {
1383 s.push(' ');
1384 s.push_str(&fmt_float(rgb[0]));
1385 s.push(' ');
1386 s.push_str(&fmt_float(rgb[1]));
1387 s.push(' ');
1388 s.push_str(&fmt_float(rgb[2]));
1389 }
1390 writeln!(out, "{s}").unwrap();
1391 }
1392 // Parameter-space vertices for the free-form geometry section. We
1393 // emit these after `v` and before `vt` to mirror the typical layout
1394 // produced by Wavefront-era authoring tools (the spec doesn't
1395 // mandate an ordering, but co-locating `vp` with the other vertex
1396 // pools keeps human diffs tidy).
1397 if let Some(serde_json::Value::Array(vps)) = scene.extras.get("obj:vp") {
1398 for entry in vps {
1399 if let serde_json::Value::Array(coords) = entry {
1400 let parts: Vec<f32> = coords
1401 .iter()
1402 .filter_map(|v| v.as_f64().map(|f| f as f32))
1403 .collect();
1404 if parts.is_empty() {
1405 continue;
1406 }
1407 // Emit only as many coordinates as carry meaningful
1408 // information. The decoder padded with `0.0`, so a
1409 // trailing `0` is a strong signal "the operator
1410 // didn't supply this component". 1D / 2D / 3D `vp`
1411 // statements are all valid per spec §"vp u v w".
1412 let trim = if parts.len() >= 3 && parts[2] != 0.0 {
1413 3
1414 } else if parts.len() >= 2 && parts[1] != 0.0 {
1415 2
1416 } else {
1417 1
1418 };
1419 let mut s = String::from("vp");
1420 for coord in parts.iter().take(trim) {
1421 s.push(' ');
1422 s.push_str(&fmt_float(*coord));
1423 }
1424 writeln!(out, "{s}").unwrap();
1425 }
1426 }
1427 }
1428 for t in &texcoords {
1429 writeln!(out, "vt {} {}", fmt_float(t[0]), fmt_float(t[1])).unwrap();
1430 }
1431 for n in &normals {
1432 writeln!(
1433 out,
1434 "vn {} {} {}",
1435 fmt_float(n[0]),
1436 fmt_float(n[1]),
1437 fmt_float(n[2])
1438 )
1439 .unwrap();
1440 }
1441
1442 // Second pass: per-mesh `o` directive, per-primitive `usemtl` +
1443 // groups + smoothing-group, then face/line elements.
1444 for (mi, mesh) in scene.meshes.iter().enumerate() {
1445 if let Some(name) = &mesh.name {
1446 writeln!(out, "o {name}").unwrap();
1447 }
1448
1449 for (pi, prim) in mesh.primitives.iter().enumerate() {
1450 // Per-primitive arity vector for n-gon re-emission, if any.
1451 let arities: Option<Vec<u32>> = prim
1452 .extras
1453 .get("obj:original_face_arities")
1454 .and_then(|v| serde_json::from_value(v.clone()).ok());
1455 // Groups + smoothing first (spec convention: state tokens
1456 // precede the elements they apply to).
1457 if let Some(serde_json::Value::Array(gs)) = prim.extras.get("obj:groups") {
1458 let names: Vec<&str> = gs.iter().filter_map(|v| v.as_str()).collect();
1459 if !names.is_empty() {
1460 writeln!(out, "g {}", names.join(" ")).unwrap();
1461 }
1462 }
1463 if let Some(s) = prim
1464 .extras
1465 .get("obj:smoothing_group")
1466 .and_then(|v| v.as_str())
1467 {
1468 writeln!(out, "s {s}").unwrap();
1469 }
1470 if let Some(s) = prim
1471 .extras
1472 .get("obj:merging_group")
1473 .and_then(|v| v.as_str())
1474 {
1475 writeln!(out, "mg {s}").unwrap();
1476 }
1477 // Display-attribute state-setters — emitted ahead of the
1478 // elements they apply to. Order is fixed to keep round-trip
1479 // diffs deterministic.
1480 for keyword in ["bevel", "c_interp", "d_interp", "lod"] {
1481 let key = format!("obj:{keyword}");
1482 if let Some(s) = prim.extras.get(&key).and_then(|v| v.as_str()) {
1483 writeln!(out, "{keyword} {s}").unwrap();
1484 }
1485 }
1486
1487 // usemtl: prefer extras["obj:usemtl"] (loss-tolerant
1488 // round-trip name), fall back to the bound material's name.
1489 let mtl_name: Option<String> = prim
1490 .extras
1491 .get("obj:usemtl")
1492 .and_then(|v| v.as_str())
1493 .map(|s| s.to_string())
1494 .or_else(|| {
1495 prim.material.and_then(|id| {
1496 scene
1497 .materials
1498 .get(id.0 as usize)
1499 .and_then(|m| m.name.clone())
1500 })
1501 });
1502 if let Some(name) = &mtl_name {
1503 writeln!(out, "usemtl {name}").unwrap();
1504 }
1505
1506 let prim_globals = &global_indices[mi][pi];
1507 let has_uv = !prim.uvs.is_empty();
1508 let has_normal = prim.normals.is_some();
1509
1510 // Build the per-element index iterator. For Triangles topology
1511 // re-shape into n-gons via `arities` if present; otherwise emit
1512 // one triangle per 3 indices. For Lines topology emit `l`
1513 // per pair (we don't reverse strips back into polylines —
1514 // that's lossy and the round-trip test doesn't need it).
1515 match prim.topology {
1516 Topology::Triangles => {
1517 let face_indices: Vec<u32> = match &prim.indices {
1518 Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
1519 Some(Indices::U32(v)) => v.clone(),
1520 None => {
1521 // Implicit indices: 0, 1, 2, …
1522 (0..prim.positions.len() as u32).collect()
1523 }
1524 };
1525 if let Some(per_prim_arities) = arities.as_ref() {
1526 // Reconstruct n-gons from triangle fans. Each
1527 // n-gon contributed (n - 2) triangles.
1528 let mut tri_pos: usize = 0;
1529 for &arity in per_prim_arities {
1530 let mut verts: Vec<u32> = Vec::with_capacity(arity as usize);
1531 // The fan was: (v0, v1, v2), (v0, v2, v3), (v0, v3, v4), …
1532 let n_tris = (arity as usize).saturating_sub(2);
1533 // First triangle gives v0, v1, v2.
1534 verts.push(face_indices[tri_pos * 3]);
1535 verts.push(face_indices[tri_pos * 3 + 1]);
1536 verts.push(face_indices[tri_pos * 3 + 2]);
1537 // Each subsequent triangle adds one new vertex (the third index).
1538 for k in 1..n_tris {
1539 verts.push(face_indices[(tri_pos + k) * 3 + 2]);
1540 }
1541 tri_pos += n_tris;
1542
1543 write_face(
1544 &mut out,
1545 &verts,
1546 prim_globals,
1547 has_uv,
1548 has_normal,
1549 negative,
1550 positions.len() as u32,
1551 texcoords.len() as u32,
1552 normals.len() as u32,
1553 );
1554 }
1555 // Any leftover triangles after the recorded arities
1556 // (e.g. a primitive grew after the arity vector was
1557 // captured) are emitted as plain triangles.
1558 let consumed = per_prim_arities
1559 .iter()
1560 .map(|&a| (a as usize).saturating_sub(2))
1561 .sum::<usize>();
1562 for tri in consumed..(face_indices.len() / 3) {
1563 let verts = [
1564 face_indices[tri * 3],
1565 face_indices[tri * 3 + 1],
1566 face_indices[tri * 3 + 2],
1567 ];
1568 write_face(
1569 &mut out,
1570 &verts,
1571 prim_globals,
1572 has_uv,
1573 has_normal,
1574 negative,
1575 positions.len() as u32,
1576 texcoords.len() as u32,
1577 normals.len() as u32,
1578 );
1579 }
1580 } else {
1581 for tri in 0..(face_indices.len() / 3) {
1582 let verts = [
1583 face_indices[tri * 3],
1584 face_indices[tri * 3 + 1],
1585 face_indices[tri * 3 + 2],
1586 ];
1587 write_face(
1588 &mut out,
1589 &verts,
1590 prim_globals,
1591 has_uv,
1592 has_normal,
1593 negative,
1594 positions.len() as u32,
1595 texcoords.len() as u32,
1596 normals.len() as u32,
1597 );
1598 }
1599 }
1600 }
1601 Topology::Lines => {
1602 let line_indices: Vec<u32> = match &prim.indices {
1603 Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
1604 Some(Indices::U32(v)) => v.clone(),
1605 None => (0..prim.positions.len() as u32).collect(),
1606 };
1607 let total_v = positions.len() as u32;
1608 // Walk segment pairs and join contiguous chains
1609 // (segment N's end == segment N+1's start) into
1610 // one polyline before emit. Saves bytes on the
1611 // common case of a long polyline that round-tripped
1612 // through `Topology::Lines` decomposition.
1613 let mut chain: Vec<u32> = Vec::new();
1614 let flush = |chain: &mut Vec<u32>, out: &mut String| {
1615 if chain.len() < 2 {
1616 chain.clear();
1617 return;
1618 }
1619 let parts: Vec<String> = chain
1620 .iter()
1621 .map(|&local| {
1622 fmt_index(prim_globals[local as usize].0, total_v, negative)
1623 })
1624 .collect();
1625 writeln!(out, "l {}", parts.join(" ")).unwrap();
1626 chain.clear();
1627 };
1628 for w in line_indices.chunks_exact(2) {
1629 let (a, b) = (w[0], w[1]);
1630 if chain.is_empty() {
1631 chain.push(a);
1632 chain.push(b);
1633 } else if *chain.last().unwrap() == a {
1634 chain.push(b);
1635 } else {
1636 flush(&mut chain, &mut out);
1637 chain.push(a);
1638 chain.push(b);
1639 }
1640 }
1641 flush(&mut chain, &mut out);
1642 }
1643 Topology::LineStrip | Topology::LineLoop => {
1644 // Reconstruct the strip's index list from whichever
1645 // backing storage the primitive carries; bare
1646 // positions imply implicit `0..N` indices. For
1647 // `LineLoop` we re-append the first index so the
1648 // emitted `l` line spells out the closing edge —
1649 // the parser then detects start == end and round-
1650 // trips back to `LineLoop`.
1651 let mut strip_indices: Vec<u32> = match &prim.indices {
1652 Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
1653 Some(Indices::U32(v)) => v.clone(),
1654 None => (0..prim.positions.len() as u32).collect(),
1655 };
1656 if matches!(prim.topology, Topology::LineLoop)
1657 && let Some(&first) = strip_indices.first()
1658 {
1659 strip_indices.push(first);
1660 }
1661 if strip_indices.len() >= 2 {
1662 let total_v = positions.len() as u32;
1663 let parts: Vec<String> = strip_indices
1664 .iter()
1665 .map(|&local| {
1666 fmt_index(prim_globals[local as usize].0, total_v, negative)
1667 })
1668 .collect();
1669 writeln!(out, "l {}", parts.join(" ")).unwrap();
1670 }
1671 }
1672 Topology::Points => {
1673 let pt_indices: Vec<u32> = match &prim.indices {
1674 Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
1675 Some(Indices::U32(v)) => v.clone(),
1676 None => (0..prim.positions.len() as u32).collect(),
1677 };
1678 let total_v = positions.len() as u32;
1679 if !pt_indices.is_empty() {
1680 // Pack every reference onto a single `p` line —
1681 // the spec explicitly permits the multi-vertex
1682 // form (`p v1 v2 v3 …`) and it's what most
1683 // tools emit.
1684 let parts: Vec<String> = pt_indices
1685 .iter()
1686 .map(|&local| {
1687 fmt_index(prim_globals[local as usize].0, total_v, negative)
1688 })
1689 .collect();
1690 writeln!(out, "p {}", parts.join(" ")).unwrap();
1691 }
1692 }
1693 other => {
1694 return Err(Error::unsupported(format!(
1695 "OBJ encoder: topology {other:?} not representable"
1696 )));
1697 }
1698 }
1699 }
1700 }
1701
1702 // Free-form geometry section: replay the captured directive
1703 // sequence verbatim. The decoder records every `cstype` / `deg` /
1704 // `curv` / `surf` / `parm` / `trim` / `hole` / `scrv` / `sp` /
1705 // `end` / `bzp` / `bsp` line as `[keyword, arg1, arg2, …]` so the
1706 // encoder is purely textual — no semantic interpretation, which
1707 // means the round-trip is bit-exact for the directive args even
1708 // when the polygonal section sits between `vp` and the free-form
1709 // body.
1710 if let Some(serde_json::Value::Array(directives)) = scene.extras.get("obj:freeform_directives")
1711 {
1712 for entry in directives {
1713 if let serde_json::Value::Array(toks) = entry {
1714 let parts: Vec<&str> = toks.iter().filter_map(|v| v.as_str()).collect();
1715 if parts.is_empty() {
1716 continue;
1717 }
1718 writeln!(out, "{}", parts.join(" ")).unwrap();
1719 }
1720 }
1721 }
1722
1723 Ok(out.into_bytes())
1724}
1725
1726#[allow(clippy::too_many_arguments)]
1727fn write_face(
1728 out: &mut String,
1729 verts: &[u32],
1730 prim_globals: &[(u32, u32, u32)],
1731 has_uv: bool,
1732 has_normal: bool,
1733 negative: bool,
1734 total_v: u32,
1735 total_vt: u32,
1736 total_vn: u32,
1737) {
1738 use std::fmt::Write;
1739 out.push('f');
1740 for &local in verts {
1741 let (v, vt, vn) = prim_globals[local as usize];
1742 let v_s = fmt_index(v, total_v, negative);
1743 let vt_s = fmt_index(vt, total_vt, negative);
1744 let vn_s = fmt_index(vn, total_vn, negative);
1745 match (has_uv, has_normal) {
1746 (true, true) => write!(out, " {v_s}/{vt_s}/{vn_s}").unwrap(),
1747 (true, false) => write!(out, " {v_s}/{vt_s}").unwrap(),
1748 (false, true) => write!(out, " {v_s}//{vn_s}").unwrap(),
1749 (false, false) => write!(out, " {v_s}").unwrap(),
1750 }
1751 }
1752 out.push('\n');
1753}
1754
1755/// Render a 1-based positive index as either its absolute form
1756/// (`5`) or a negative-from-end form (`-3`, when `total = 7`).
1757/// `idx == 0` means "no index" — we always emit `0` regardless of
1758/// the negative flag so the parser still treats it as absent.
1759fn fmt_index(idx: u32, total: u32, negative: bool) -> String {
1760 if idx == 0 || !negative {
1761 idx.to_string()
1762 } else {
1763 // total = 7, idx = 5 ⇒ -3 (i.e. "third from the end").
1764 // Parser computes: resolved = total + 1 + raw ⇒ raw = idx - total - 1.
1765 let raw = (idx as i64) - (total as i64) - 1;
1766 raw.to_string()
1767 }
1768}
1769
1770/// Format a float without scientific notation; trims trailing zeros
1771/// while keeping at least one digit after the decimal point. Keeps the
1772/// emitted file human-diffable.
1773fn fmt_float(x: f32) -> String {
1774 if x == 0.0 {
1775 return "0".to_string();
1776 }
1777 let s = format!("{x:.6}");
1778 let trimmed = s.trim_end_matches('0').trim_end_matches('.').to_string();
1779 if trimmed.is_empty() || trimmed == "-" {
1780 "0".to_string()
1781 } else {
1782 trimmed
1783 }
1784}
1785
1786// ---------------------------------------------------------------------------
1787// Float keys for the dedup HashMap (f32 isn't Hash).
1788// ---------------------------------------------------------------------------
1789
1790#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1791struct KeyVec2 {
1792 a: u32,
1793 b: u32,
1794}
1795impl From<[f32; 2]> for KeyVec2 {
1796 fn from(v: [f32; 2]) -> Self {
1797 Self {
1798 a: v[0].to_bits(),
1799 b: v[1].to_bits(),
1800 }
1801 }
1802}
1803
1804#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1805struct KeyVec3 {
1806 a: u32,
1807 b: u32,
1808 c: u32,
1809}
1810impl From<[f32; 3]> for KeyVec3 {
1811 fn from(v: [f32; 3]) -> Self {
1812 Self {
1813 a: v[0].to_bits(),
1814 b: v[1].to_bits(),
1815 c: v[2].to_bits(),
1816 }
1817 }
1818}
1819
1820// ---------------------------------------------------------------------------
1821// Tests (unit-level — integration tests live under `tests/`).
1822// ---------------------------------------------------------------------------
1823
1824#[cfg(test)]
1825mod tests {
1826 use super::*;
1827
1828 #[test]
1829 fn preprocess_strips_comments_and_glues_continuations() {
1830 let lines =
1831 preprocess_lines("v 1.0 2.0 \\\n3.0 # comment\nv 4 5 6\n# pure comment\nf 1 2 3");
1832 assert_eq!(lines[0].trim(), "v 1.0 2.0 3.0");
1833 assert_eq!(lines[1].trim(), "v 4 5 6");
1834 // The pure-comment line collapses to an empty preprocessed line.
1835 assert_eq!(lines[2].trim(), "");
1836 assert_eq!(lines[3].trim(), "f 1 2 3");
1837 }
1838
1839 #[test]
1840 fn fmt_float_is_diff_friendly() {
1841 assert_eq!(fmt_float(1.0), "1");
1842 assert_eq!(fmt_float(0.0), "0");
1843 assert_eq!(fmt_float(-0.5), "-0.5");
1844 assert_eq!(fmt_float(1.0 / 3.0), "0.333333");
1845 }
1846}