1use std::collections::HashMap;
12
13use oxideav_mesh3d::{Error, Indices, Mesh, Primitive, Result, Scene3D, Topology};
14
15use crate::mtl::parse_mtl;
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
23struct FaceVert {
24 v: u32,
26 vt: u32,
28 vn: u32,
30}
31
32#[derive(Debug)]
34enum Element {
35 Face(Vec<FaceVert>),
36 Line(Vec<FaceVert>),
37}
38
39#[derive(Debug, Default)]
42struct PrimAccum {
43 elements: Vec<Element>,
44 material: Option<String>,
45 smoothing_group: Option<String>,
47 groups: Vec<String>,
49}
50
51#[derive(Debug, Default)]
54struct MeshAccum {
55 name: Option<String>,
56 primitives: Vec<PrimAccum>,
57}
58
59impl MeshAccum {
60 fn current_or_new(&mut self) -> &mut PrimAccum {
61 if self.primitives.is_empty() {
62 self.primitives.push(PrimAccum::default());
63 }
64 self.primitives.last_mut().unwrap()
65 }
66}
67
68#[derive(Debug, Default)]
75struct ObjDoc {
76 positions: Vec<[f32; 3]>,
77 texcoords: Vec<[f32; 2]>,
78 normals: Vec<[f32; 3]>,
79 mtllibs: Vec<String>,
81 resolved_materials: HashMap<String, oxideav_mesh3d::Material>,
86 meshes: Vec<MeshAccum>,
87}
88
89fn preprocess_lines(text: &str) -> Vec<String> {
93 let mut out: Vec<String> = Vec::new();
94 let mut acc = String::new();
95 for raw_line in text.split('\n') {
96 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
98 let no_comment = match line.find('#') {
101 Some(idx) => &line[..idx],
102 None => line,
103 };
104 let trimmed = no_comment.trim_end();
105 if let Some(stripped) = trimmed.strip_suffix('\\') {
106 acc.push_str(stripped);
107 acc.push(' ');
108 } else {
109 acc.push_str(trimmed);
110 out.push(std::mem::take(&mut acc));
111 }
112 }
113 if !acc.is_empty() {
114 out.push(acc);
115 }
116 out
117}
118
119fn parse_face_vertex(tok: &str, n_pos: i64, n_tex: i64, n_norm: i64) -> Result<FaceVert> {
124 let mut parts = tok.split('/');
125 let v = parts
126 .next()
127 .ok_or_else(|| Error::invalid(format!("face vertex missing position: {tok:?}")))?;
128 let vt = parts.next().unwrap_or("");
129 let vn = parts.next().unwrap_or("");
130
131 let resolve = |s: &str, n: i64, kind: &str| -> Result<u32> {
132 if s.is_empty() {
133 return Ok(0);
134 }
135 let raw: i64 = s.parse().map_err(|_| {
136 Error::invalid(format!(
137 "invalid {kind} index in face vertex {tok:?}: {s:?}"
138 ))
139 })?;
140 let resolved = if raw < 0 { n + 1 + raw } else { raw };
141 if resolved <= 0 || resolved > n {
142 return Err(Error::invalid(format!(
143 "{kind} index out of range in face vertex {tok:?}: {raw} (have {n})"
144 )));
145 }
146 Ok(resolved as u32)
147 };
148
149 Ok(FaceVert {
150 v: resolve(v, n_pos, "position")?,
151 vt: resolve(vt, n_tex, "texcoord")?,
152 vn: resolve(vn, n_norm, "normal")?,
153 })
154}
155
156fn parse_obj_doc(text: &str) -> Result<ObjDoc> {
160 let mut doc = ObjDoc::default();
161 doc.meshes.push(MeshAccum::default());
163
164 let lines = preprocess_lines(text);
165 for line in &lines {
166 let mut tokens = line.split_whitespace();
167 let Some(keyword) = tokens.next() else {
168 continue;
169 };
170 match keyword {
171 "v" => {
172 let coords: Vec<f32> = tokens
173 .map(str::parse)
174 .collect::<std::result::Result<Vec<f32>, _>>()
175 .map_err(|e| Error::invalid(format!("v: bad float ({e})")))?;
176 if coords.len() < 3 {
177 return Err(Error::invalid(format!(
178 "v: expected ≥3 coords, got {}",
179 coords.len()
180 )));
181 }
182 doc.positions.push([coords[0], coords[1], coords[2]]);
184 }
185 "vt" => {
186 let coords: Vec<f32> = tokens
187 .map(str::parse)
188 .collect::<std::result::Result<Vec<f32>, _>>()
189 .map_err(|e| Error::invalid(format!("vt: bad float ({e})")))?;
190 if coords.is_empty() {
191 return Err(Error::invalid("vt: expected ≥1 coord"));
192 }
193 let u = coords[0];
194 let v = coords.get(1).copied().unwrap_or(0.0);
195 doc.texcoords.push([u, v]);
197 }
198 "vn" => {
199 let coords: Vec<f32> = tokens
200 .map(str::parse)
201 .collect::<std::result::Result<Vec<f32>, _>>()
202 .map_err(|e| Error::invalid(format!("vn: bad float ({e})")))?;
203 if coords.len() != 3 {
204 return Err(Error::invalid(format!(
205 "vn: expected 3 coords, got {}",
206 coords.len()
207 )));
208 }
209 doc.normals.push([coords[0], coords[1], coords[2]]);
210 }
211 "vp" => {
212 }
215 "f" => {
216 let n_pos = doc.positions.len() as i64;
217 let n_tex = doc.texcoords.len() as i64;
218 let n_norm = doc.normals.len() as i64;
219 let verts: Vec<FaceVert> = tokens
220 .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
221 .collect::<Result<Vec<_>>>()?;
222 if verts.len() < 3 {
223 return Err(Error::invalid(format!(
224 "f: face needs ≥3 vertices, got {}",
225 verts.len()
226 )));
227 }
228 let mesh = doc.meshes.last_mut().unwrap();
229 mesh.current_or_new().elements.push(Element::Face(verts));
230 }
231 "l" => {
232 let n_pos = doc.positions.len() as i64;
233 let n_tex = doc.texcoords.len() as i64;
234 let n_norm = doc.normals.len() as i64;
235 let verts: Vec<FaceVert> = tokens
236 .map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
237 .collect::<Result<Vec<_>>>()?;
238 if verts.len() < 2 {
239 return Err(Error::invalid(format!(
240 "l: line needs ≥2 vertices, got {}",
241 verts.len()
242 )));
243 }
244 let mesh = doc.meshes.last_mut().unwrap();
245 mesh.current_or_new().elements.push(Element::Line(verts));
246 }
247 "p" => {
248 }
252 "o" => {
253 let name: String = tokens.collect::<Vec<_>>().join(" ");
254 let last = doc.meshes.last_mut().unwrap();
258 if last.name.is_none() && last.primitives.is_empty() {
259 last.name = if name.is_empty() { None } else { Some(name) };
260 } else {
261 doc.meshes.push(MeshAccum {
262 name: if name.is_empty() { None } else { Some(name) },
263 primitives: Vec::new(),
264 });
265 }
266 }
267 "g" => {
268 let name: String = tokens.collect::<Vec<_>>().join(" ");
269 if name.is_empty() {
270 continue;
271 }
272 let mesh = doc.meshes.last_mut().unwrap();
273 let prim = mesh.current_or_new();
274 if !prim.groups.iter().any(|g| g == &name) {
275 prim.groups.push(name);
276 }
277 }
278 "s" => {
279 let v: String = tokens.collect::<Vec<_>>().join(" ");
280 if v.is_empty() {
281 continue;
282 }
283 let mesh = doc.meshes.last_mut().unwrap();
284 mesh.current_or_new().smoothing_group = Some(v);
285 }
286 "usemtl" => {
287 let name: String = tokens.collect::<Vec<_>>().join(" ");
288 let mesh = doc.meshes.last_mut().unwrap();
289 let last = mesh.current_or_new();
290 if last.elements.is_empty() && last.material.is_none() {
291 last.material = if name.is_empty() { None } else { Some(name) };
293 } else {
294 mesh.primitives.push(PrimAccum {
296 material: if name.is_empty() { None } else { Some(name) },
297 ..PrimAccum::default()
298 });
299 }
300 }
301 "mtllib" => {
302 for tok in tokens {
304 if !doc.mtllibs.iter().any(|m| m == tok) {
305 doc.mtllibs.push(tok.to_string());
306 }
307 }
308 }
309 _ => {}
312 }
313 }
314
315 Ok(doc)
316}
317
318fn build_scene(doc: ObjDoc) -> Result<Scene3D> {
330 use oxideav_mesh3d::{Axis, Material, Unit};
331
332 let mut scene = Scene3D::new();
333 scene.up_axis = Axis::PosY;
336 scene.unit = Unit::Metres;
337
338 let mut material_ids: HashMap<String, oxideav_mesh3d::MaterialId> = HashMap::new();
342 let mut material_names: Vec<String> = doc.resolved_materials.keys().cloned().collect();
343 material_names.sort();
344 for name in &material_names {
345 let mut mat = doc
346 .resolved_materials
347 .get(name)
348 .cloned()
349 .unwrap_or_else(Material::new);
350 if mat.name.is_none() {
351 mat.name = Some(name.clone());
352 }
353 let id = scene.add_material(mat);
354 material_ids.insert(name.clone(), id);
355 }
356
357 for mesh_acc in doc.meshes {
358 let has_anything = mesh_acc.primitives.iter().any(|p| !p.elements.is_empty());
360 if !has_anything {
361 continue;
362 }
363
364 let mut mesh = Mesh::new(mesh_acc.name.clone());
365
366 for prim_acc in mesh_acc.primitives {
367 let (mut primitive, arities) = build_primitive(
368 &prim_acc,
369 &doc.positions,
370 &doc.texcoords,
371 &doc.normals,
372 &material_ids,
373 )?;
374 if primitive.positions.is_empty() {
376 continue;
377 }
378 if arities.iter().any(|&a| a != 3) {
384 primitive.extras.insert(
385 "obj:original_face_arities".to_string(),
386 serde_json::to_value(&arities).unwrap(),
387 );
388 }
389 mesh.primitives.push(primitive);
390 }
391
392 scene.add_mesh(mesh);
393 }
394
395 if !doc.mtllibs.is_empty() {
398 scene.extras.insert(
399 "obj:mtllibs".to_string(),
400 serde_json::to_value(&doc.mtllibs).unwrap(),
401 );
402 }
403
404 Ok(scene)
405}
406
407fn build_primitive(
414 prim_acc: &PrimAccum,
415 positions: &[[f32; 3]],
416 texcoords: &[[f32; 2]],
417 normals: &[[f32; 3]],
418 material_ids: &HashMap<String, oxideav_mesh3d::MaterialId>,
419) -> Result<(Primitive, Vec<u32>)> {
420 let first = prim_acc.elements.first();
424 let topology = match first {
425 Some(Element::Face(_)) => Topology::Triangles,
426 Some(Element::Line(_)) => Topology::Lines,
427 None => Topology::Triangles,
428 };
429 for elt in &prim_acc.elements {
430 let ok = matches!(
431 (&topology, elt),
432 (Topology::Triangles, Element::Face(_)) | (Topology::Lines, Element::Line(_))
433 );
434 if !ok {
435 return Err(Error::unsupported(
436 "OBJ primitive mixes face and line elements under one usemtl",
437 ));
438 }
439 }
440
441 let has_uv = prim_acc.elements.iter().any(|elt| match elt {
442 Element::Face(verts) | Element::Line(verts) => verts.iter().any(|fv| fv.vt != 0),
443 });
444 let has_normal = prim_acc.elements.iter().any(|elt| match elt {
445 Element::Face(verts) | Element::Line(verts) => verts.iter().any(|fv| fv.vn != 0),
446 });
447
448 let mut prim = Primitive::new(topology);
449 if has_uv {
450 prim.uvs.push(Vec::new());
451 }
452 if has_normal {
453 prim.normals = Some(Vec::new());
454 }
455
456 let mut indexer: HashMap<FaceVert, u32> = HashMap::new();
458 let mut arities: Vec<u32> = Vec::new();
459 let mut local_indices: Vec<u32> = Vec::new();
460
461 let intern =
462 |fv: FaceVert, prim: &mut Primitive, indexer: &mut HashMap<FaceVert, u32>| -> Result<u32> {
463 if let Some(&idx) = indexer.get(&fv) {
464 return Ok(idx);
465 }
466 let pos = positions.get((fv.v - 1) as usize).ok_or_else(|| {
467 Error::invalid(format!("face references missing position {}", fv.v))
468 })?;
469 prim.positions.push(*pos);
470 if has_uv {
471 let uv = if fv.vt == 0 {
472 [0.0, 0.0]
473 } else {
474 *texcoords.get((fv.vt - 1) as usize).ok_or_else(|| {
475 Error::invalid(format!("face references missing texcoord {}", fv.vt))
476 })?
477 };
478 prim.uvs[0].push(uv);
479 }
480 if has_normal {
481 let n = if fv.vn == 0 {
482 [0.0, 0.0, 0.0]
483 } else {
484 *normals.get((fv.vn - 1) as usize).ok_or_else(|| {
485 Error::invalid(format!("face references missing normal {}", fv.vn))
486 })?
487 };
488 prim.normals.as_mut().unwrap().push(n);
489 }
490 let new_idx = (prim.positions.len() - 1) as u32;
491 indexer.insert(fv, new_idx);
492 Ok(new_idx)
493 };
494
495 for elt in &prim_acc.elements {
496 match elt {
497 Element::Face(verts) => {
498 let arity = verts.len() as u32;
499 arities.push(arity);
500 let resolved: Vec<u32> = verts
501 .iter()
502 .map(|&fv| intern(fv, &mut prim, &mut indexer))
503 .collect::<Result<Vec<_>>>()?;
504 for i in 1..(resolved.len() - 1) {
506 local_indices.push(resolved[0]);
507 local_indices.push(resolved[i]);
508 local_indices.push(resolved[i + 1]);
509 }
510 }
511 Element::Line(verts) => {
512 let resolved: Vec<u32> = verts
513 .iter()
514 .map(|&fv| intern(fv, &mut prim, &mut indexer))
515 .collect::<Result<Vec<_>>>()?;
516 for w in resolved.windows(2) {
518 local_indices.push(w[0]);
519 local_indices.push(w[1]);
520 }
521 }
522 }
523 }
524
525 if local_indices.iter().any(|&i| i >= u16::MAX as u32) {
527 prim.indices = Some(Indices::U32(local_indices));
528 } else {
529 prim.indices = Some(Indices::U16(
530 local_indices.into_iter().map(|i| i as u16).collect(),
531 ));
532 }
533
534 if let Some(name) = &prim_acc.material {
535 if let Some(id) = material_ids.get(name) {
536 prim.material = Some(*id);
537 }
538 prim.extras.insert(
539 "obj:usemtl".to_string(),
540 serde_json::Value::String(name.clone()),
541 );
542 }
543 if let Some(s) = &prim_acc.smoothing_group {
544 prim.extras.insert(
545 "obj:smoothing_group".to_string(),
546 serde_json::Value::String(s.clone()),
547 );
548 }
549 if !prim_acc.groups.is_empty() {
550 prim.extras.insert(
551 "obj:groups".to_string(),
552 serde_json::to_value(&prim_acc.groups).unwrap(),
553 );
554 }
555
556 Ok((prim, arities))
557}
558
559pub fn parse_obj(text: &str) -> Result<Scene3D> {
570 parse_obj_with_resolver(text, |_path| Ok(Vec::new()))
571}
572
573pub fn parse_obj_with_resolver<R>(text: &str, mut resolve: R) -> Result<Scene3D>
583where
584 R: FnMut(&str) -> Result<Vec<u8>>,
585{
586 let mut doc = parse_obj_doc(text)?;
587
588 for lib in doc.mtllibs.clone() {
590 let bytes = resolve(&lib)?;
591 if bytes.is_empty() {
592 continue;
593 }
594 let lib_text = std::str::from_utf8(&bytes)
595 .map_err(|_| Error::invalid(format!("mtllib {lib:?} contained non-UTF-8 bytes")))?;
596 let materials = parse_mtl(lib_text)?;
597 for mat in materials {
598 if let Some(name) = mat.name.clone() {
599 doc.resolved_materials.insert(name, mat);
600 }
601 }
602 }
603
604 build_scene(doc)
605}
606
607pub fn serialize_obj(scene: &Scene3D, mtl_basename: Option<&str>) -> Result<Vec<u8>> {
613 use std::fmt::Write;
614 let mut out = String::new();
615 writeln!(out, "# OBJ generated by oxideav-obj").unwrap();
616 if let Some(base) = mtl_basename {
617 writeln!(out, "mtllib {base}.mtl").unwrap();
618 }
619 if mtl_basename.is_none() {
622 if let Some(serde_json::Value::Array(list)) = scene.extras.get("obj:mtllibs") {
623 for entry in list {
624 if let Some(s) = entry.as_str() {
625 writeln!(out, "mtllib {s}").unwrap();
626 }
627 }
628 }
629 }
630
631 let mut positions: Vec<[f32; 3]> = Vec::new();
634 let mut texcoords: Vec<[f32; 2]> = Vec::new();
635 let mut normals: Vec<[f32; 3]> = Vec::new();
636 let mut pos_map: HashMap<KeyVec3, u32> = HashMap::new();
637 let mut tex_map: HashMap<KeyVec2, u32> = HashMap::new();
638 let mut nor_map: HashMap<KeyVec3, u32> = HashMap::new();
639
640 let intern_pos =
641 |p: [f32; 3], positions: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
642 let key = KeyVec3::from(p);
643 if let Some(&i) = map.get(&key) {
644 return i;
645 }
646 positions.push(p);
647 let idx = positions.len() as u32;
648 map.insert(key, idx);
649 idx
650 };
651 let intern_tex =
652 |p: [f32; 2], texcoords: &mut Vec<[f32; 2]>, map: &mut HashMap<KeyVec2, u32>| -> u32 {
653 let key = KeyVec2::from(p);
654 if let Some(&i) = map.get(&key) {
655 return i;
656 }
657 texcoords.push(p);
658 let idx = texcoords.len() as u32;
659 map.insert(key, idx);
660 idx
661 };
662 let intern_nor =
663 |p: [f32; 3], normals: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
664 let key = KeyVec3::from(p);
665 if let Some(&i) = map.get(&key) {
666 return i;
667 }
668 normals.push(p);
669 let idx = normals.len() as u32;
670 map.insert(key, idx);
671 idx
672 };
673
674 type GlobalTriple = (u32, u32, u32); let mut global_indices: Vec<Vec<Vec<GlobalTriple>>> = Vec::new();
678 for mesh in &scene.meshes {
679 let mut mesh_globals: Vec<Vec<GlobalTriple>> = Vec::new();
680 for prim in &mesh.primitives {
681 let has_uv = !prim.uvs.is_empty();
682 let has_normal = prim.normals.is_some();
683 let mut prim_globals: Vec<GlobalTriple> = Vec::with_capacity(prim.positions.len());
684 for vi in 0..prim.positions.len() {
685 let v_idx = intern_pos(prim.positions[vi], &mut positions, &mut pos_map);
686 let vt_idx = if has_uv {
687 intern_tex(prim.uvs[0][vi], &mut texcoords, &mut tex_map)
688 } else {
689 0
690 };
691 let vn_idx = if has_normal {
692 intern_nor(
693 prim.normals.as_ref().unwrap()[vi],
694 &mut normals,
695 &mut nor_map,
696 )
697 } else {
698 0
699 };
700 prim_globals.push((v_idx, vt_idx, vn_idx));
701 }
702 mesh_globals.push(prim_globals);
703 }
704 global_indices.push(mesh_globals);
705 }
706
707 for p in &positions {
708 writeln!(
709 out,
710 "v {} {} {}",
711 fmt_float(p[0]),
712 fmt_float(p[1]),
713 fmt_float(p[2])
714 )
715 .unwrap();
716 }
717 for t in &texcoords {
718 writeln!(out, "vt {} {}", fmt_float(t[0]), fmt_float(t[1])).unwrap();
719 }
720 for n in &normals {
721 writeln!(
722 out,
723 "vn {} {} {}",
724 fmt_float(n[0]),
725 fmt_float(n[1]),
726 fmt_float(n[2])
727 )
728 .unwrap();
729 }
730
731 for (mi, mesh) in scene.meshes.iter().enumerate() {
734 if let Some(name) = &mesh.name {
735 writeln!(out, "o {name}").unwrap();
736 }
737
738 for (pi, prim) in mesh.primitives.iter().enumerate() {
739 let arities: Option<Vec<u32>> = prim
741 .extras
742 .get("obj:original_face_arities")
743 .and_then(|v| serde_json::from_value(v.clone()).ok());
744 if let Some(serde_json::Value::Array(gs)) = prim.extras.get("obj:groups") {
747 let names: Vec<&str> = gs.iter().filter_map(|v| v.as_str()).collect();
748 if !names.is_empty() {
749 writeln!(out, "g {}", names.join(" ")).unwrap();
750 }
751 }
752 if let Some(s) = prim
753 .extras
754 .get("obj:smoothing_group")
755 .and_then(|v| v.as_str())
756 {
757 writeln!(out, "s {s}").unwrap();
758 }
759
760 let mtl_name: Option<String> = prim
763 .extras
764 .get("obj:usemtl")
765 .and_then(|v| v.as_str())
766 .map(|s| s.to_string())
767 .or_else(|| {
768 prim.material.and_then(|id| {
769 scene
770 .materials
771 .get(id.0 as usize)
772 .and_then(|m| m.name.clone())
773 })
774 });
775 if let Some(name) = &mtl_name {
776 writeln!(out, "usemtl {name}").unwrap();
777 }
778
779 let prim_globals = &global_indices[mi][pi];
780 let has_uv = !prim.uvs.is_empty();
781 let has_normal = prim.normals.is_some();
782
783 match prim.topology {
789 Topology::Triangles => {
790 let face_indices: Vec<u32> = match &prim.indices {
791 Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
792 Some(Indices::U32(v)) => v.clone(),
793 None => {
794 (0..prim.positions.len() as u32).collect()
796 }
797 };
798 if let Some(per_prim_arities) = arities.as_ref() {
799 let mut tri_pos: usize = 0;
802 for &arity in per_prim_arities {
803 let mut verts: Vec<u32> = Vec::with_capacity(arity as usize);
804 let n_tris = (arity as usize).saturating_sub(2);
806 verts.push(face_indices[tri_pos * 3]);
808 verts.push(face_indices[tri_pos * 3 + 1]);
809 verts.push(face_indices[tri_pos * 3 + 2]);
810 for k in 1..n_tris {
812 verts.push(face_indices[(tri_pos + k) * 3 + 2]);
813 }
814 tri_pos += n_tris;
815
816 write_face(&mut out, &verts, prim_globals, has_uv, has_normal);
817 }
818 let consumed = per_prim_arities
822 .iter()
823 .map(|&a| (a as usize).saturating_sub(2))
824 .sum::<usize>();
825 for tri in consumed..(face_indices.len() / 3) {
826 let verts = [
827 face_indices[tri * 3],
828 face_indices[tri * 3 + 1],
829 face_indices[tri * 3 + 2],
830 ];
831 write_face(&mut out, &verts, prim_globals, has_uv, has_normal);
832 }
833 } else {
834 for tri in 0..(face_indices.len() / 3) {
835 let verts = [
836 face_indices[tri * 3],
837 face_indices[tri * 3 + 1],
838 face_indices[tri * 3 + 2],
839 ];
840 write_face(&mut out, &verts, prim_globals, has_uv, has_normal);
841 }
842 }
843 }
844 Topology::Lines => {
845 let line_indices: Vec<u32> = match &prim.indices {
846 Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
847 Some(Indices::U32(v)) => v.clone(),
848 None => (0..prim.positions.len() as u32).collect(),
849 };
850 for w in line_indices.chunks_exact(2) {
851 let a = prim_globals[w[0] as usize];
852 let b = prim_globals[w[1] as usize];
853 writeln!(out, "l {} {}", a.0, b.0).unwrap();
854 }
855 }
856 other => {
857 return Err(Error::unsupported(format!(
858 "OBJ encoder: topology {other:?} not representable"
859 )));
860 }
861 }
862 }
863 }
864
865 Ok(out.into_bytes())
866}
867
868fn write_face(
869 out: &mut String,
870 verts: &[u32],
871 prim_globals: &[(u32, u32, u32)],
872 has_uv: bool,
873 has_normal: bool,
874) {
875 use std::fmt::Write;
876 out.push('f');
877 for &local in verts {
878 let (v, vt, vn) = prim_globals[local as usize];
879 match (has_uv, has_normal) {
880 (true, true) => write!(out, " {v}/{vt}/{vn}").unwrap(),
881 (true, false) => write!(out, " {v}/{vt}").unwrap(),
882 (false, true) => write!(out, " {v}//{vn}").unwrap(),
883 (false, false) => write!(out, " {v}").unwrap(),
884 }
885 }
886 out.push('\n');
887}
888
889fn fmt_float(x: f32) -> String {
893 if x == 0.0 {
894 return "0".to_string();
895 }
896 let s = format!("{x:.6}");
897 let trimmed = s.trim_end_matches('0').trim_end_matches('.').to_string();
898 if trimmed.is_empty() || trimmed == "-" {
899 "0".to_string()
900 } else {
901 trimmed
902 }
903}
904
905#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
910struct KeyVec2 {
911 a: u32,
912 b: u32,
913}
914impl From<[f32; 2]> for KeyVec2 {
915 fn from(v: [f32; 2]) -> Self {
916 Self {
917 a: v[0].to_bits(),
918 b: v[1].to_bits(),
919 }
920 }
921}
922
923#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
924struct KeyVec3 {
925 a: u32,
926 b: u32,
927 c: u32,
928}
929impl From<[f32; 3]> for KeyVec3 {
930 fn from(v: [f32; 3]) -> Self {
931 Self {
932 a: v[0].to_bits(),
933 b: v[1].to_bits(),
934 c: v[2].to_bits(),
935 }
936 }
937}
938
939#[cfg(test)]
944mod tests {
945 use super::*;
946
947 #[test]
948 fn preprocess_strips_comments_and_glues_continuations() {
949 let lines =
950 preprocess_lines("v 1.0 2.0 \\\n3.0 # comment\nv 4 5 6\n# pure comment\nf 1 2 3");
951 assert_eq!(lines[0].trim(), "v 1.0 2.0 3.0");
952 assert_eq!(lines[1].trim(), "v 4 5 6");
953 assert_eq!(lines[2].trim(), "");
955 assert_eq!(lines[3].trim(), "f 1 2 3");
956 }
957
958 #[test]
959 fn fmt_float_is_diff_friendly() {
960 assert_eq!(fmt_float(1.0), "1");
961 assert_eq!(fmt_float(0.0), "0");
962 assert_eq!(fmt_float(-0.5), "-0.5");
963 assert_eq!(fmt_float(1.0 / 3.0), "0.333333");
964 }
965}