//! OBJ ingestion: parse in-memory bytes into a [`ModelMesh`] via `three-d-asset`. //! //! `three-d-asset` parses Wavefront OBJ through `wavefront_obj`, which //! fan-triangulates convex polygons at parse time and drops Line/Point primitives; //! concave polygons are out of scope. OBJ carries no unit, so the loader assumes //! millimetres. There is no filesystem or network access — an `.mtl` material sidecar is //! not fetched (out of scope) — so this is safe to call from a `wasm32-unknown-unknown` //! build. use three_d_asset::io; use crate::mesh::contract::{MeshFormat, MeshLoadError, ModelMesh, Units}; /// Parse OBJ bytes into a [`ModelMesh`]. /// /// Convex polygons are fan-triangulated; Line and Point primitives are dropped. OBJ is /// unitless, so the result assumes millimetres. Positions are normalized to F32 by the /// shared validator (OBJ arrives as F64). pub fn load_obj(bytes: &[u8]) -> Result { if bytes.is_empty() { return Err(MeshLoadError::Empty); } let model: three_d_asset::Model = io::deserialize("asset.obj", bytes.to_vec()) .map_err(|e| MeshLoadError::Decode(e.to_string()))?; let mut trimesh = crate::mesh::flatten_model(model); crate::mesh::validate(&mut trimesh)?; Ok(ModelMesh { trimesh, format: MeshFormat::Obj, units: Units::unitless_assumed_mm(), }) } #[cfg(test)] mod tests { use super::load_obj; use crate::mesh::contract::{LengthUnit, MeshFormat, MeshLoadError, Units}; use three_d_asset::Positions; fn fixture(name: &str) -> Vec { let path = format!("{}/public/models/{name}", env!("CARGO_MANIFEST_DIR")); std::fs::read(&path) .unwrap_or_else(|_| panic!("OBJ fixture {name} should be committed at {path}")) } #[test] fn parses_quad_cube_fixture() { let model = load_obj(&fixture("cube.obj")).expect("cube.obj should parse"); assert_eq!(model.format, MeshFormat::Obj); assert_eq!( model.units, Units { source: LengthUnit::Unknown, assumed: Some(LengthUnit::Millimeter), } ); // 6 convex quads fan-triangulate to 12 triangles. assert_eq!(model.trimesh.triangle_count(), 12); // OBJ's F64 positions are normalized to F32. assert!(matches!(model.trimesh.positions, Positions::F32(_))); let normals = model .trimesh .normals .as_ref() .expect("geometry normals should be populated"); assert_eq!(normals.len(), model.trimesh.positions.len()); assert!( normals .iter() .all(|n| n.x.is_finite() && n.y.is_finite() && n.z.is_finite()), "normals must be finite" ); } #[test] fn error_empty() { assert!(matches!(load_obj(&[]), Err(MeshLoadError::Empty))); } #[test] fn error_parse_garbage() { let garbage = b"this is definitely not a valid OBJ file !@#$"; assert!(matches!(load_obj(garbage), Err(MeshLoadError::Decode(_)))); } #[test] fn error_zero_triangles() { // Vertices but no faces: parses to an empty mesh. let verts_only = b"v 0 0 0\nv 1 0 0\nv 0 1 0\n"; assert!(matches!( load_obj(verts_only), Err(MeshLoadError::Degenerate) )); } #[test] fn error_point_sized_aabb() { // A face collapsed to a single point has a point-sized AABB. let point = b"v 5 5 5\nf 1 1 1\n"; assert!(matches!(load_obj(point), Err(MeshLoadError::Degenerate))); } }