use std::collections::HashMap;
use oxideav_mesh3d::{Error, Indices, Mesh, Primitive, Result, Scene3D, Topology};
use crate::mtl::parse_mtl;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
struct FaceVert {
v: u32,
vt: u32,
vn: u32,
}
#[derive(Debug)]
enum Element {
Face(Vec<FaceVert>),
Line(Vec<FaceVert>),
}
#[derive(Debug, Default)]
struct PrimAccum {
elements: Vec<Element>,
material: Option<String>,
smoothing_group: Option<String>,
groups: Vec<String>,
}
#[derive(Debug, Default)]
struct MeshAccum {
name: Option<String>,
primitives: Vec<PrimAccum>,
}
impl MeshAccum {
fn current_or_new(&mut self) -> &mut PrimAccum {
if self.primitives.is_empty() {
self.primitives.push(PrimAccum::default());
}
self.primitives.last_mut().unwrap()
}
}
#[derive(Debug, Default)]
struct ObjDoc {
positions: Vec<[f32; 3]>,
texcoords: Vec<[f32; 2]>,
normals: Vec<[f32; 3]>,
mtllibs: Vec<String>,
resolved_materials: HashMap<String, oxideav_mesh3d::Material>,
meshes: Vec<MeshAccum>,
}
fn preprocess_lines(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut acc = String::new();
for raw_line in text.split('\n') {
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
let no_comment = match line.find('#') {
Some(idx) => &line[..idx],
None => line,
};
let trimmed = no_comment.trim_end();
if let Some(stripped) = trimmed.strip_suffix('\\') {
acc.push_str(stripped);
acc.push(' ');
} else {
acc.push_str(trimmed);
out.push(std::mem::take(&mut acc));
}
}
if !acc.is_empty() {
out.push(acc);
}
out
}
fn parse_face_vertex(tok: &str, n_pos: i64, n_tex: i64, n_norm: i64) -> Result<FaceVert> {
let mut parts = tok.split('/');
let v = parts
.next()
.ok_or_else(|| Error::invalid(format!("face vertex missing position: {tok:?}")))?;
let vt = parts.next().unwrap_or("");
let vn = parts.next().unwrap_or("");
let resolve = |s: &str, n: i64, kind: &str| -> Result<u32> {
if s.is_empty() {
return Ok(0);
}
let raw: i64 = s.parse().map_err(|_| {
Error::invalid(format!(
"invalid {kind} index in face vertex {tok:?}: {s:?}"
))
})?;
let resolved = if raw < 0 { n + 1 + raw } else { raw };
if resolved <= 0 || resolved > n {
return Err(Error::invalid(format!(
"{kind} index out of range in face vertex {tok:?}: {raw} (have {n})"
)));
}
Ok(resolved as u32)
};
Ok(FaceVert {
v: resolve(v, n_pos, "position")?,
vt: resolve(vt, n_tex, "texcoord")?,
vn: resolve(vn, n_norm, "normal")?,
})
}
fn parse_obj_doc(text: &str) -> Result<ObjDoc> {
let mut doc = ObjDoc::default();
doc.meshes.push(MeshAccum::default());
let lines = preprocess_lines(text);
for line in &lines {
let mut tokens = line.split_whitespace();
let Some(keyword) = tokens.next() else {
continue;
};
match keyword {
"v" => {
let coords: Vec<f32> = tokens
.map(str::parse)
.collect::<std::result::Result<Vec<f32>, _>>()
.map_err(|e| Error::invalid(format!("v: bad float ({e})")))?;
if coords.len() < 3 {
return Err(Error::invalid(format!(
"v: expected ≥3 coords, got {}",
coords.len()
)));
}
doc.positions.push([coords[0], coords[1], coords[2]]);
}
"vt" => {
let coords: Vec<f32> = tokens
.map(str::parse)
.collect::<std::result::Result<Vec<f32>, _>>()
.map_err(|e| Error::invalid(format!("vt: bad float ({e})")))?;
if coords.is_empty() {
return Err(Error::invalid("vt: expected ≥1 coord"));
}
let u = coords[0];
let v = coords.get(1).copied().unwrap_or(0.0);
doc.texcoords.push([u, v]);
}
"vn" => {
let coords: Vec<f32> = tokens
.map(str::parse)
.collect::<std::result::Result<Vec<f32>, _>>()
.map_err(|e| Error::invalid(format!("vn: bad float ({e})")))?;
if coords.len() != 3 {
return Err(Error::invalid(format!(
"vn: expected 3 coords, got {}",
coords.len()
)));
}
doc.normals.push([coords[0], coords[1], coords[2]]);
}
"vp" => {
}
"f" => {
let n_pos = doc.positions.len() as i64;
let n_tex = doc.texcoords.len() as i64;
let n_norm = doc.normals.len() as i64;
let verts: Vec<FaceVert> = tokens
.map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
.collect::<Result<Vec<_>>>()?;
if verts.len() < 3 {
return Err(Error::invalid(format!(
"f: face needs ≥3 vertices, got {}",
verts.len()
)));
}
let mesh = doc.meshes.last_mut().unwrap();
mesh.current_or_new().elements.push(Element::Face(verts));
}
"l" => {
let n_pos = doc.positions.len() as i64;
let n_tex = doc.texcoords.len() as i64;
let n_norm = doc.normals.len() as i64;
let verts: Vec<FaceVert> = tokens
.map(|t| parse_face_vertex(t, n_pos, n_tex, n_norm))
.collect::<Result<Vec<_>>>()?;
if verts.len() < 2 {
return Err(Error::invalid(format!(
"l: line needs ≥2 vertices, got {}",
verts.len()
)));
}
let mesh = doc.meshes.last_mut().unwrap();
mesh.current_or_new().elements.push(Element::Line(verts));
}
"p" => {
}
"o" => {
let name: String = tokens.collect::<Vec<_>>().join(" ");
let last = doc.meshes.last_mut().unwrap();
if last.name.is_none() && last.primitives.is_empty() {
last.name = if name.is_empty() { None } else { Some(name) };
} else {
doc.meshes.push(MeshAccum {
name: if name.is_empty() { None } else { Some(name) },
primitives: Vec::new(),
});
}
}
"g" => {
let name: String = tokens.collect::<Vec<_>>().join(" ");
if name.is_empty() {
continue;
}
let mesh = doc.meshes.last_mut().unwrap();
let prim = mesh.current_or_new();
if !prim.groups.iter().any(|g| g == &name) {
prim.groups.push(name);
}
}
"s" => {
let v: String = tokens.collect::<Vec<_>>().join(" ");
if v.is_empty() {
continue;
}
let mesh = doc.meshes.last_mut().unwrap();
mesh.current_or_new().smoothing_group = Some(v);
}
"usemtl" => {
let name: String = tokens.collect::<Vec<_>>().join(" ");
let mesh = doc.meshes.last_mut().unwrap();
let last = mesh.current_or_new();
if last.elements.is_empty() && last.material.is_none() {
last.material = if name.is_empty() { None } else { Some(name) };
} else {
mesh.primitives.push(PrimAccum {
material: if name.is_empty() { None } else { Some(name) },
..PrimAccum::default()
});
}
}
"mtllib" => {
for tok in tokens {
if !doc.mtllibs.iter().any(|m| m == tok) {
doc.mtllibs.push(tok.to_string());
}
}
}
_ => {}
}
}
Ok(doc)
}
fn build_scene(doc: ObjDoc) -> Result<Scene3D> {
use oxideav_mesh3d::{Axis, Material, Unit};
let mut scene = Scene3D::new();
scene.up_axis = Axis::PosY;
scene.unit = Unit::Metres;
let mut material_ids: HashMap<String, oxideav_mesh3d::MaterialId> = HashMap::new();
let mut material_names: Vec<String> = doc.resolved_materials.keys().cloned().collect();
material_names.sort();
for name in &material_names {
let mut mat = doc
.resolved_materials
.get(name)
.cloned()
.unwrap_or_else(Material::new);
if mat.name.is_none() {
mat.name = Some(name.clone());
}
let id = scene.add_material(mat);
material_ids.insert(name.clone(), id);
}
for mesh_acc in doc.meshes {
let has_anything = mesh_acc.primitives.iter().any(|p| !p.elements.is_empty());
if !has_anything {
continue;
}
let mut mesh = Mesh::new(mesh_acc.name.clone());
for prim_acc in mesh_acc.primitives {
let (mut primitive, arities) = build_primitive(
&prim_acc,
&doc.positions,
&doc.texcoords,
&doc.normals,
&material_ids,
)?;
if primitive.positions.is_empty() {
continue;
}
if arities.iter().any(|&a| a != 3) {
primitive.extras.insert(
"obj:original_face_arities".to_string(),
serde_json::to_value(&arities).unwrap(),
);
}
mesh.primitives.push(primitive);
}
scene.add_mesh(mesh);
}
if !doc.mtllibs.is_empty() {
scene.extras.insert(
"obj:mtllibs".to_string(),
serde_json::to_value(&doc.mtllibs).unwrap(),
);
}
Ok(scene)
}
fn build_primitive(
prim_acc: &PrimAccum,
positions: &[[f32; 3]],
texcoords: &[[f32; 2]],
normals: &[[f32; 3]],
material_ids: &HashMap<String, oxideav_mesh3d::MaterialId>,
) -> Result<(Primitive, Vec<u32>)> {
let first = prim_acc.elements.first();
let topology = match first {
Some(Element::Face(_)) => Topology::Triangles,
Some(Element::Line(_)) => Topology::Lines,
None => Topology::Triangles,
};
for elt in &prim_acc.elements {
let ok = matches!(
(&topology, elt),
(Topology::Triangles, Element::Face(_)) | (Topology::Lines, Element::Line(_))
);
if !ok {
return Err(Error::unsupported(
"OBJ primitive mixes face and line elements under one usemtl",
));
}
}
let has_uv = prim_acc.elements.iter().any(|elt| match elt {
Element::Face(verts) | Element::Line(verts) => verts.iter().any(|fv| fv.vt != 0),
});
let has_normal = prim_acc.elements.iter().any(|elt| match elt {
Element::Face(verts) | Element::Line(verts) => verts.iter().any(|fv| fv.vn != 0),
});
let mut prim = Primitive::new(topology);
if has_uv {
prim.uvs.push(Vec::new());
}
if has_normal {
prim.normals = Some(Vec::new());
}
let mut indexer: HashMap<FaceVert, u32> = HashMap::new();
let mut arities: Vec<u32> = Vec::new();
let mut local_indices: Vec<u32> = Vec::new();
let intern =
|fv: FaceVert, prim: &mut Primitive, indexer: &mut HashMap<FaceVert, u32>| -> Result<u32> {
if let Some(&idx) = indexer.get(&fv) {
return Ok(idx);
}
let pos = positions.get((fv.v - 1) as usize).ok_or_else(|| {
Error::invalid(format!("face references missing position {}", fv.v))
})?;
prim.positions.push(*pos);
if has_uv {
let uv = if fv.vt == 0 {
[0.0, 0.0]
} else {
*texcoords.get((fv.vt - 1) as usize).ok_or_else(|| {
Error::invalid(format!("face references missing texcoord {}", fv.vt))
})?
};
prim.uvs[0].push(uv);
}
if has_normal {
let n = if fv.vn == 0 {
[0.0, 0.0, 0.0]
} else {
*normals.get((fv.vn - 1) as usize).ok_or_else(|| {
Error::invalid(format!("face references missing normal {}", fv.vn))
})?
};
prim.normals.as_mut().unwrap().push(n);
}
let new_idx = (prim.positions.len() - 1) as u32;
indexer.insert(fv, new_idx);
Ok(new_idx)
};
for elt in &prim_acc.elements {
match elt {
Element::Face(verts) => {
let arity = verts.len() as u32;
arities.push(arity);
let resolved: Vec<u32> = verts
.iter()
.map(|&fv| intern(fv, &mut prim, &mut indexer))
.collect::<Result<Vec<_>>>()?;
for i in 1..(resolved.len() - 1) {
local_indices.push(resolved[0]);
local_indices.push(resolved[i]);
local_indices.push(resolved[i + 1]);
}
}
Element::Line(verts) => {
let resolved: Vec<u32> = verts
.iter()
.map(|&fv| intern(fv, &mut prim, &mut indexer))
.collect::<Result<Vec<_>>>()?;
for w in resolved.windows(2) {
local_indices.push(w[0]);
local_indices.push(w[1]);
}
}
}
}
if local_indices.iter().any(|&i| i >= u16::MAX as u32) {
prim.indices = Some(Indices::U32(local_indices));
} else {
prim.indices = Some(Indices::U16(
local_indices.into_iter().map(|i| i as u16).collect(),
));
}
if let Some(name) = &prim_acc.material {
if let Some(id) = material_ids.get(name) {
prim.material = Some(*id);
}
prim.extras.insert(
"obj:usemtl".to_string(),
serde_json::Value::String(name.clone()),
);
}
if let Some(s) = &prim_acc.smoothing_group {
prim.extras.insert(
"obj:smoothing_group".to_string(),
serde_json::Value::String(s.clone()),
);
}
if !prim_acc.groups.is_empty() {
prim.extras.insert(
"obj:groups".to_string(),
serde_json::to_value(&prim_acc.groups).unwrap(),
);
}
Ok((prim, arities))
}
pub fn parse_obj(text: &str) -> Result<Scene3D> {
parse_obj_with_resolver(text, |_path| Ok(Vec::new()))
}
pub fn parse_obj_with_resolver<R>(text: &str, mut resolve: R) -> Result<Scene3D>
where
R: FnMut(&str) -> Result<Vec<u8>>,
{
let mut doc = parse_obj_doc(text)?;
for lib in doc.mtllibs.clone() {
let bytes = resolve(&lib)?;
if bytes.is_empty() {
continue;
}
let lib_text = std::str::from_utf8(&bytes)
.map_err(|_| Error::invalid(format!("mtllib {lib:?} contained non-UTF-8 bytes")))?;
let materials = parse_mtl(lib_text)?;
for mat in materials {
if let Some(name) = mat.name.clone() {
doc.resolved_materials.insert(name, mat);
}
}
}
build_scene(doc)
}
pub fn serialize_obj(scene: &Scene3D, mtl_basename: Option<&str>) -> Result<Vec<u8>> {
use std::fmt::Write;
let mut out = String::new();
writeln!(out, "# OBJ generated by oxideav-obj").unwrap();
if let Some(base) = mtl_basename {
writeln!(out, "mtllib {base}.mtl").unwrap();
}
if mtl_basename.is_none() {
if let Some(serde_json::Value::Array(list)) = scene.extras.get("obj:mtllibs") {
for entry in list {
if let Some(s) = entry.as_str() {
writeln!(out, "mtllib {s}").unwrap();
}
}
}
}
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut texcoords: Vec<[f32; 2]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut pos_map: HashMap<KeyVec3, u32> = HashMap::new();
let mut tex_map: HashMap<KeyVec2, u32> = HashMap::new();
let mut nor_map: HashMap<KeyVec3, u32> = HashMap::new();
let intern_pos =
|p: [f32; 3], positions: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
let key = KeyVec3::from(p);
if let Some(&i) = map.get(&key) {
return i;
}
positions.push(p);
let idx = positions.len() as u32;
map.insert(key, idx);
idx
};
let intern_tex =
|p: [f32; 2], texcoords: &mut Vec<[f32; 2]>, map: &mut HashMap<KeyVec2, u32>| -> u32 {
let key = KeyVec2::from(p);
if let Some(&i) = map.get(&key) {
return i;
}
texcoords.push(p);
let idx = texcoords.len() as u32;
map.insert(key, idx);
idx
};
let intern_nor =
|p: [f32; 3], normals: &mut Vec<[f32; 3]>, map: &mut HashMap<KeyVec3, u32>| -> u32 {
let key = KeyVec3::from(p);
if let Some(&i) = map.get(&key) {
return i;
}
normals.push(p);
let idx = normals.len() as u32;
map.insert(key, idx);
idx
};
type GlobalTriple = (u32, u32, u32); let mut global_indices: Vec<Vec<Vec<GlobalTriple>>> = Vec::new();
for mesh in &scene.meshes {
let mut mesh_globals: Vec<Vec<GlobalTriple>> = Vec::new();
for prim in &mesh.primitives {
let has_uv = !prim.uvs.is_empty();
let has_normal = prim.normals.is_some();
let mut prim_globals: Vec<GlobalTriple> = Vec::with_capacity(prim.positions.len());
for vi in 0..prim.positions.len() {
let v_idx = intern_pos(prim.positions[vi], &mut positions, &mut pos_map);
let vt_idx = if has_uv {
intern_tex(prim.uvs[0][vi], &mut texcoords, &mut tex_map)
} else {
0
};
let vn_idx = if has_normal {
intern_nor(
prim.normals.as_ref().unwrap()[vi],
&mut normals,
&mut nor_map,
)
} else {
0
};
prim_globals.push((v_idx, vt_idx, vn_idx));
}
mesh_globals.push(prim_globals);
}
global_indices.push(mesh_globals);
}
for p in &positions {
writeln!(
out,
"v {} {} {}",
fmt_float(p[0]),
fmt_float(p[1]),
fmt_float(p[2])
)
.unwrap();
}
for t in &texcoords {
writeln!(out, "vt {} {}", fmt_float(t[0]), fmt_float(t[1])).unwrap();
}
for n in &normals {
writeln!(
out,
"vn {} {} {}",
fmt_float(n[0]),
fmt_float(n[1]),
fmt_float(n[2])
)
.unwrap();
}
for (mi, mesh) in scene.meshes.iter().enumerate() {
if let Some(name) = &mesh.name {
writeln!(out, "o {name}").unwrap();
}
for (pi, prim) in mesh.primitives.iter().enumerate() {
let arities: Option<Vec<u32>> = prim
.extras
.get("obj:original_face_arities")
.and_then(|v| serde_json::from_value(v.clone()).ok());
if let Some(serde_json::Value::Array(gs)) = prim.extras.get("obj:groups") {
let names: Vec<&str> = gs.iter().filter_map(|v| v.as_str()).collect();
if !names.is_empty() {
writeln!(out, "g {}", names.join(" ")).unwrap();
}
}
if let Some(s) = prim
.extras
.get("obj:smoothing_group")
.and_then(|v| v.as_str())
{
writeln!(out, "s {s}").unwrap();
}
let mtl_name: Option<String> = prim
.extras
.get("obj:usemtl")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
prim.material.and_then(|id| {
scene
.materials
.get(id.0 as usize)
.and_then(|m| m.name.clone())
})
});
if let Some(name) = &mtl_name {
writeln!(out, "usemtl {name}").unwrap();
}
let prim_globals = &global_indices[mi][pi];
let has_uv = !prim.uvs.is_empty();
let has_normal = prim.normals.is_some();
match prim.topology {
Topology::Triangles => {
let face_indices: Vec<u32> = match &prim.indices {
Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
Some(Indices::U32(v)) => v.clone(),
None => {
(0..prim.positions.len() as u32).collect()
}
};
if let Some(per_prim_arities) = arities.as_ref() {
let mut tri_pos: usize = 0;
for &arity in per_prim_arities {
let mut verts: Vec<u32> = Vec::with_capacity(arity as usize);
let n_tris = (arity as usize).saturating_sub(2);
verts.push(face_indices[tri_pos * 3]);
verts.push(face_indices[tri_pos * 3 + 1]);
verts.push(face_indices[tri_pos * 3 + 2]);
for k in 1..n_tris {
verts.push(face_indices[(tri_pos + k) * 3 + 2]);
}
tri_pos += n_tris;
write_face(&mut out, &verts, prim_globals, has_uv, has_normal);
}
let consumed = per_prim_arities
.iter()
.map(|&a| (a as usize).saturating_sub(2))
.sum::<usize>();
for tri in consumed..(face_indices.len() / 3) {
let verts = [
face_indices[tri * 3],
face_indices[tri * 3 + 1],
face_indices[tri * 3 + 2],
];
write_face(&mut out, &verts, prim_globals, has_uv, has_normal);
}
} else {
for tri in 0..(face_indices.len() / 3) {
let verts = [
face_indices[tri * 3],
face_indices[tri * 3 + 1],
face_indices[tri * 3 + 2],
];
write_face(&mut out, &verts, prim_globals, has_uv, has_normal);
}
}
}
Topology::Lines => {
let line_indices: Vec<u32> = match &prim.indices {
Some(Indices::U16(v)) => v.iter().map(|&x| x as u32).collect(),
Some(Indices::U32(v)) => v.clone(),
None => (0..prim.positions.len() as u32).collect(),
};
for w in line_indices.chunks_exact(2) {
let a = prim_globals[w[0] as usize];
let b = prim_globals[w[1] as usize];
writeln!(out, "l {} {}", a.0, b.0).unwrap();
}
}
other => {
return Err(Error::unsupported(format!(
"OBJ encoder: topology {other:?} not representable"
)));
}
}
}
}
Ok(out.into_bytes())
}
fn write_face(
out: &mut String,
verts: &[u32],
prim_globals: &[(u32, u32, u32)],
has_uv: bool,
has_normal: bool,
) {
use std::fmt::Write;
out.push('f');
for &local in verts {
let (v, vt, vn) = prim_globals[local as usize];
match (has_uv, has_normal) {
(true, true) => write!(out, " {v}/{vt}/{vn}").unwrap(),
(true, false) => write!(out, " {v}/{vt}").unwrap(),
(false, true) => write!(out, " {v}//{vn}").unwrap(),
(false, false) => write!(out, " {v}").unwrap(),
}
}
out.push('\n');
}
fn fmt_float(x: f32) -> String {
if x == 0.0 {
return "0".to_string();
}
let s = format!("{x:.6}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.').to_string();
if trimmed.is_empty() || trimmed == "-" {
"0".to_string()
} else {
trimmed
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct KeyVec2 {
a: u32,
b: u32,
}
impl From<[f32; 2]> for KeyVec2 {
fn from(v: [f32; 2]) -> Self {
Self {
a: v[0].to_bits(),
b: v[1].to_bits(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct KeyVec3 {
a: u32,
b: u32,
c: u32,
}
impl From<[f32; 3]> for KeyVec3 {
fn from(v: [f32; 3]) -> Self {
Self {
a: v[0].to_bits(),
b: v[1].to_bits(),
c: v[2].to_bits(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preprocess_strips_comments_and_glues_continuations() {
let lines =
preprocess_lines("v 1.0 2.0 \\\n3.0 # comment\nv 4 5 6\n# pure comment\nf 1 2 3");
assert_eq!(lines[0].trim(), "v 1.0 2.0 3.0");
assert_eq!(lines[1].trim(), "v 4 5 6");
assert_eq!(lines[2].trim(), "");
assert_eq!(lines[3].trim(), "f 1 2 3");
}
#[test]
fn fmt_float_is_diff_friendly() {
assert_eq!(fmt_float(1.0), "1");
assert_eq!(fmt_float(0.0), "0");
assert_eq!(fmt_float(-0.5), "-0.5");
assert_eq!(fmt_float(1.0 / 3.0), "0.333333");
}
}