From 0e9c4172382d62669b2f315674c3d370c3a53d7e Mon Sep 17 00:00:00 2001 From: Taiki Endo Date: Mon, 22 Jan 2024 14:32:30 +0900 Subject: [PATCH] Implement OBJ parser --- .github/.cspell/project-dictionary.txt | 12 + Cargo.toml | 11 +- examples/kiss3d/src/main.rs | 72 +- fuzz/Cargo.toml | 12 + fuzz/mtl.rs | 43 + fuzz/obj.rs | 41 + fuzz/seeds/mtl/seed.mtl | 9 + fuzz/seeds/obj/seed.obj | 13 + src/collada/iter.rs | 4 +- src/collada/mod.rs | 16 +- src/common.rs | 36 +- src/error.rs | 16 +- src/lib.rs | 10 +- src/loader.rs | 98 +- src/obj/mod.rs | 1334 ++++++++++++++++++++++++ src/stl/mod.rs | 54 +- src/utils/bytes.rs | 72 +- src/utils/mod.rs | 88 +- tests/assimp.rs | 227 +++- 19 files changed, 2020 insertions(+), 148 deletions(-) create mode 100644 fuzz/mtl.rs create mode 100644 fuzz/obj.rs create mode 100644 fuzz/seeds/mtl/seed.mtl create mode 100644 fuzz/seeds/obj/seed.obj create mode 100644 src/obj/mod.rs diff --git a/.github/.cspell/project-dictionary.txt b/.github/.cspell/project-dictionary.txt index 01b71d8..8eabd54 100644 --- a/.github/.cspell/project-dictionary.txt +++ b/.github/.cspell/project-dictionary.txt @@ -3,8 +3,10 @@ binormal bitangent brep bytecount +clearcoat collada ctypes +disp Eisel elems emin @@ -14,6 +16,7 @@ endsolid gltf idents IDREF +illum instancenodes kwxport Lemire @@ -21,7 +24,9 @@ linestrips memchr memrchr mmap +mtllib nalgebra +newmtl NMTOKEN polylist powerset @@ -32,13 +37,20 @@ rustflags rustup SIDREF significand +specularity splitn +testline +testmixed +testpoints texbinormal texcoord texcoords textangent trifans tristrips +usemtl vcolors vcount +vertexcolors +wasi xmlspecialchars diff --git a/Cargo.toml b/Cargo.toml index 47d9648..731648b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" rust-version = "1.60" license = "Apache-2.0" repository = "https://github.com/openrr/mesh-loader" -keywords = ["asset", "mesh", "stl", "collada"] # TODO: "obj" +keywords = ["asset", "mesh", "stl", "collada", "obj"] categories = ["parser-implementations", "graphics"] exclude = ["/.*", "/assets"] description = """ @@ -13,7 +13,7 @@ Fast parser for 3D-model-formats. """ [features] -default = ["stl", "collada"] +default = ["stl", "collada", "obj"] # STL (.stl) # https://en.wikipedia.org/wiki/STL_(file_format) @@ -21,10 +21,9 @@ stl = [] # COLLADA (.dae) # https://en.wikipedia.org/wiki/COLLADA collada = ["roxmltree"] -# TODO -# # Wavefront OBJ (.obj) -# # https://en.wikipedia.org/wiki/Wavefront_.obj_file -# obj = [] +# Wavefront OBJ (.obj) +# https://en.wikipedia.org/wiki/Wavefront_.obj_file +obj = [] [dependencies] # Used in COLLADA parsing. diff --git a/examples/kiss3d/src/main.rs b/examples/kiss3d/src/main.rs index fc77c65..485f951 100644 --- a/examples/kiss3d/src/main.rs +++ b/examples/kiss3d/src/main.rs @@ -77,38 +77,42 @@ fn main() -> Result<()> { } fn add_mesh(window: &mut Window, path: &Path, scale: na::Vector3) -> Result { - let loader = mesh_loader::Loader::default().merge_meshes(true); - let mut scene = loader.load(path)?; - assert_eq!(scene.meshes.len(), 1); // merge_meshes guarantees this. - let mesh = scene.meshes.pop().unwrap(); - eprintln!("mesh={mesh:?}"); - let coords = mesh.vertices.into_iter().map(Into::into).collect(); - let faces = mesh - .faces - .into_iter() - .map(|f| na::Point3::new(f[0], f[1], f[2])) - .collect(); - let normals = if mesh.normals.is_empty() { - None - } else { - Some(mesh.normals.into_iter().map(Into::into).collect()) - }; - let uvs = if mesh.texcoords[0].is_empty() { - None - } else { - Some(mesh.texcoords[0].iter().copied().map(Into::into).collect()) - }; - let kiss3d_mesh = Rc::new(RefCell::new(kiss3d::resource::Mesh::new( - coords, faces, normals, uvs, false, - ))); - let kiss3d_scene = window.add_mesh(kiss3d_mesh, scale); - // TODO(material) - // if let Some(color) = material.diffuse_color() { - // kiss3d_scene.set_color(color[0], color[1], color[2]); - // } - // if let Some(path) = materials.get(0) { - // kiss3d_scene.set_texture_from_file(path, path.to_str().unwrap()); - // } - - Ok(kiss3d_scene) + let mut base = window.add_group(); + let loader = mesh_loader::Loader::default(); + let scene = loader.load(path)?; + assert_eq!(scene.meshes.len(), scene.materials.len()); + for (mesh, material) in scene.meshes.into_iter().zip(scene.materials) { + eprintln!("mesh={mesh:?}"); + eprintln!("material={material:?}"); + let coords = mesh.vertices.into_iter().map(Into::into).collect(); + let faces = mesh + .faces + .into_iter() + .map(|f| na::Point3::new(f[0], f[1], f[2])) + .collect(); + let normals = if mesh.normals.is_empty() { + None + } else { + Some(mesh.normals.into_iter().map(Into::into).collect()) + }; + let uvs = if mesh.texcoords[0].is_empty() { + None + } else { + Some(mesh.texcoords[0].iter().copied().map(Into::into).collect()) + }; + let kiss3d_mesh = Rc::new(RefCell::new(kiss3d::resource::Mesh::new( + coords, faces, normals, uvs, false, + ))); + let mut kiss3d_scene = base.add_mesh(kiss3d_mesh, scale); + if let Some(color) = material.color.diffuse { + kiss3d_scene.set_color(color[0], color[1], color[2]); + } + if let Some(path) = &material.texture.diffuse { + kiss3d_scene.set_texture_from_file(path, path.to_str().unwrap()); + } + if let Some(path) = &material.texture.ambient { + kiss3d_scene.set_texture_from_file(path, path.to_str().unwrap()); + } + } + Ok(base) } diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index b643ec0..3f6ac74 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -14,6 +14,18 @@ mesh-loader = { path = ".." } libfuzzer-sys = { version = "0.4", optional = true } afl = { version = "0.15", optional = true } +[[bin]] +name = "mtl" +path = "mtl.rs" +test = false +doc = false + +[[bin]] +name = "obj" +path = "obj.rs" +test = false +doc = false + [[bin]] name = "stl" path = "stl.rs" diff --git a/fuzz/mtl.rs b/fuzz/mtl.rs new file mode 100644 index 0000000..8730516 --- /dev/null +++ b/fuzz/mtl.rs @@ -0,0 +1,43 @@ +/* +Run with libFuzzer: + +```sh +cargo fuzz run --release --features libfuzzer mtl +``` + +Run with AFL++: + +```sh +cd fuzz +cargo afl build --release --features afl +cargo afl fuzz -i seeds/mtl -o out target/release/mtl +``` +*/ + +#![cfg_attr(feature = "libfuzzer", no_main)] + +use std::collections::HashMap; + +use mesh_loader::obj::read_mtl; + +#[cfg(any( + not(any(feature = "libfuzzer", feature = "afl")), + all(feature = "libfuzzer", feature = "afl"), +))] +compile_error!("exactly one of 'libfuzzer' or 'afl' feature must be enabled"); + +#[cfg(feature = "libfuzzer")] +libfuzzer_sys::fuzz_target!(|bytes: &[u8]| { + run(bytes); +}); + +#[cfg(feature = "afl")] +fn main() { + afl::fuzz!(|bytes: &[u8]| { + run(bytes); + }); +} + +fn run(bytes: &[u8]) { + let _result = read_mtl(bytes, None, &mut vec![], &mut HashMap::new()); +} diff --git a/fuzz/obj.rs b/fuzz/obj.rs new file mode 100644 index 0000000..b49a26a --- /dev/null +++ b/fuzz/obj.rs @@ -0,0 +1,41 @@ +/* +Run with libFuzzer: + +```sh +cargo fuzz run --release --features libfuzzer obj +``` + +Run with AFL++: + +```sh +cd fuzz +cargo afl build --release --features afl +cargo afl fuzz -i seeds/obj -o out target/release/obj +``` +*/ + +#![cfg_attr(feature = "libfuzzer", no_main)] + +use mesh_loader::obj::from_slice; + +#[cfg(any( + not(any(feature = "libfuzzer", feature = "afl")), + all(feature = "libfuzzer", feature = "afl"), +))] +compile_error!("exactly one of 'libfuzzer' or 'afl' feature must be enabled"); + +#[cfg(feature = "libfuzzer")] +libfuzzer_sys::fuzz_target!(|bytes: &[u8]| { + run(bytes); +}); + +#[cfg(feature = "afl")] +fn main() { + afl::fuzz!(|bytes: &[u8]| { + run(bytes); + }); +} + +fn run(bytes: &[u8]) { + let _result = from_slice::, _>(bytes, None, |_| panic!()); +} diff --git a/fuzz/seeds/mtl/seed.mtl b/fuzz/seeds/mtl/seed.mtl new file mode 100644 index 0000000..0bc515a --- /dev/null +++ b/fuzz/seeds/mtl/seed.mtl @@ -0,0 +1,9 @@ +# +newmtl name +Ka 0.1 0.2 1.0 +Kd 0.9 0.8 0.0 +Ks 0.3 0.5 0.7 +d 1.0 +Ns 0.0 +illum 2 +map_Kd .\texture.jpg diff --git a/fuzz/seeds/obj/seed.obj b/fuzz/seeds/obj/seed.obj new file mode 100644 index 0000000..e4649fa --- /dev/null +++ b/fuzz/seeds/obj/seed.obj @@ -0,0 +1,13 @@ +# +mtllib usemtl.mtl + +g group + +v 0.0 -0.0 1.0 + +vt -0.1 0.5 -1.0 + +vn 1.0 0.0 -0.6 + +usemtl mtl +f 1/1/1 1/1/1 1/1/1 diff --git a/src/collada/iter.rs b/src/collada/iter.rs index 9a5ce2a..0a7ee23 100644 --- a/src/collada/iter.rs +++ b/src/collada/iter.rs @@ -4,7 +4,7 @@ use std::{ slice, }; -use crate::{collada as ast, Vec3}; +use crate::{collada as ast, Vec2, Vec3}; impl ast::Document { pub(super) fn meshes(&self) -> Meshes<'_> { @@ -334,7 +334,7 @@ struct TexcoordsInner<'a> { } impl Iterator for Texcoords<'_> { - type Item = [f32; 2]; + type Item = Vec2; fn next(&mut self) -> Option { let inner = self.0.as_mut()?; diff --git a/src/collada/mod.rs b/src/collada/mod.rs index 1feb9ff..709f2b8 100644 --- a/src/collada/mod.rs +++ b/src/collada/mod.rs @@ -13,14 +13,18 @@ use std::{ use self::geometry::*; use crate::{ - utils::xml::{self, XmlNodeExt}, + utils::{ + utf16::decode_string, + xml::{self, XmlNodeExt}, + }, Scene, }; /// Parses meshes from bytes of COLLADA text. #[inline] pub fn from_slice(bytes: &[u8]) -> io::Result { - from_str(str::from_utf8(bytes).map_err(crate::error::invalid_data)?) + let bytes = &decode_string(bytes)?; + from_str(bytes) } /// Parses meshes from a string of COLLADA text. @@ -28,9 +32,11 @@ pub fn from_slice(bytes: &[u8]) -> io::Result { pub fn from_str(s: &str) -> io::Result { let xml = xml::Document::parse(s).map_err(crate::error::invalid_data)?; let collada = Document::parse(&xml)?; - Ok(Scene { - meshes: instance::build_meshes(&collada), - }) + let meshes = instance::build_meshes(&collada); + let materials = (0..meshes.len()) + .map(|_| crate::Material::default()) + .collect(); // TODO + Ok(Scene { materials, meshes }) } // Inspired by gltf-json's `Get` trait. diff --git a/src/common.rs b/src/common.rs index 554c227..9c73dae 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,4 +1,4 @@ -use std::fmt; +use std::{fmt, path::PathBuf}; pub(crate) type Vec2 = [f32; 2]; pub(crate) type Vec3 = [f32; 3]; @@ -13,6 +13,7 @@ pub(crate) const MAX_NUMBER_OF_COLOR_SETS: usize = 2; #[derive(Debug, Default)] #[non_exhaustive] pub struct Scene { + pub materials: Vec, pub meshes: Vec, } @@ -26,6 +27,8 @@ pub struct Mesh { pub normals: Vec, pub faces: Vec, pub colors: [Vec; MAX_NUMBER_OF_COLOR_SETS], + #[cfg(feature = "obj")] + pub(crate) material_index: u32, } impl Mesh { @@ -75,6 +78,8 @@ impl Mesh { normals, faces, colors: [colors0, colors1], + #[cfg(feature = "obj")] + material_index: u32::MAX, } } } @@ -90,6 +95,33 @@ impl fmt::Debug for Mesh { .field("num_faces", &self.faces.len()) .field("num_colors0", &self.colors[0].len()) .field("num_colors1", &self.colors[1].len()) - .finish() + .finish_non_exhaustive() } } + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct Material { + pub name: String, + pub color: Colors, + pub texture: Textures, +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct Colors { + pub ambient: Option, + pub diffuse: Option, + pub specular: Option, + pub emissive: Option, +} + +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct Textures { + pub ambient: Option, + pub diffuse: Option, + pub specular: Option, + pub emissive: Option, + pub normal: Option, +} diff --git a/src/error.rs b/src/error.rs index d64d38e..a0165cf 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,11 +1,11 @@ use std::io; -#[cfg(feature = "stl")] +#[cfg(any(feature = "obj", feature = "stl"))] use std::{fmt, path::Path}; -#[cfg(feature = "stl")] +#[cfg(any(feature = "obj", feature = "stl"))] use crate::utils::bytes::{bytecount_naive, memrchr_naive}; -#[cfg(feature = "collada")] +#[cfg(any(feature = "collada", feature = "obj"))] macro_rules! format_err { ($msg:expr $(,)?) => { crate::error::invalid_data($msg) @@ -15,7 +15,7 @@ macro_rules! format_err { }; } -#[cfg(feature = "collada")] +#[cfg(any(feature = "collada", feature = "obj"))] macro_rules! bail { ($($tt:tt)*) => { return Err(format_err!($($tt)*)) @@ -31,20 +31,20 @@ pub(crate) fn invalid_data(e: impl Into io::Error::new(kind, e) } -#[cfg(feature = "stl")] +#[cfg(any(feature = "obj", feature = "stl"))] #[cold] pub(crate) fn with_location(e: &io::Error, location: &Location<'_>) -> io::Error { io::Error::new(e.kind(), format!("{e} ({location})")) } -#[cfg(feature = "stl")] +#[cfg(any(feature = "obj", feature = "stl"))] pub(crate) struct Location<'a> { file: Option<&'a Path>, line: usize, column: usize, } -#[cfg(feature = "stl")] +#[cfg(any(feature = "obj", feature = "stl"))] impl<'a> Location<'a> { #[cold] #[inline(never)] @@ -60,7 +60,7 @@ impl<'a> Location<'a> { } } -#[cfg(feature = "stl")] +#[cfg(any(feature = "obj", feature = "stl"))] impl fmt::Display for Location<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if let Some(file) = self.file { diff --git a/src/lib.rs b/src/lib.rs index 7302951..cf540fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +19,7 @@ clippy::wildcard_imports, // TODO )] -#[cfg(any(feature = "collada", feature = "stl"))] +#[cfg(any(feature = "collada", feature = "obj", feature = "stl"))] #[macro_use] mod error; @@ -32,16 +32,16 @@ pub use common::*; #[cfg(feature = "collada")] pub mod collada; -// #[cfg(feature = "obj")] -// pub mod obj; +#[cfg(feature = "obj")] +pub mod obj; #[cfg(feature = "stl")] pub mod stl; // Not public API. (exposed for benchmarks) #[doc(hidden)] -#[cfg(any(feature = "collada", feature = "stl"))] +#[cfg(any(feature = "collada", feature = "obj", feature = "stl"))] pub mod __private { pub use crate::utils::float; - #[cfg(feature = "collada")] + #[cfg(any(feature = "collada", feature = "obj"))] pub use crate::utils::int; } diff --git a/src/loader.rs b/src/loader.rs index b0927a9..fad5c9c 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -97,41 +97,48 @@ impl> Loader { } pub fn load>(&self, path: P) -> io::Result { - self.load_(path.as_ref()) + self.load_with_reader(path.as_ref(), self.reader) } - fn load_(&self, path: &Path) -> io::Result { - self.load_from_slice_((self.reader)(path)?.as_ref(), path.as_ref()) + pub fn load_with_reader, F: FnMut(&Path) -> io::Result>( + &self, + path: P, + mut reader: F, + ) -> io::Result { + let path = path.as_ref(); + self.load_from_slice_with_reader(reader(path)?.as_ref(), path, reader) } pub fn load_from_slice>(&self, bytes: &[u8], path: P) -> io::Result { - self.load_from_slice_(bytes, path.as_ref()) + self.load_from_slice_with_reader(bytes, path.as_ref(), self.reader) } - fn load_from_slice_( + pub fn load_from_slice_with_reader, F: FnMut(&Path) -> io::Result>( &self, #[allow(unused_variables)] bytes: &[u8], - path: &Path, + path: P, + #[allow(unused_variables)] reader: F, ) -> io::Result { + let path = path.as_ref(); match detect_file_type(path, bytes) { #[cfg(feature = "stl")] - FileType::Stl => self.load_stl_from_slice_(bytes, path), + FileType::Stl => self.load_stl_from_slice(bytes, path), #[cfg(not(feature = "stl"))] FileType::Stl => Err(io::Error::new( io::ErrorKind::Unsupported, "'stl' feature of mesh-loader must be enabled to parse STL file ({path:?})", )), #[cfg(feature = "collada")] - FileType::Collada => self.load_collada_from_slice_(bytes, path), + FileType::Collada => self.load_collada_from_slice(bytes, path), #[cfg(not(feature = "collada"))] FileType::Collada => Err(io::Error::new( io::ErrorKind::Unsupported, "'collada' feature of mesh-loader must be enabled to parse COLLADA file ({path:?})", )), - // #[cfg(feature = "obj")] - // FileType::Obj => self.load_obj_from_slice_(path), - // #[cfg(not(feature = "obj"))] - // FileType::Obj => Err(io::Error::new( - // io::ErrorKind::Unsupported, - // "'obj' feature of mesh-loader must be enabled to parse OBJ file ({path:?})", - // )), + #[cfg(feature = "obj")] + FileType::Obj => self.load_obj_from_slice_with_reader(bytes, path, reader), + #[cfg(not(feature = "obj"))] + FileType::Obj => Err(io::Error::new( + io::ErrorKind::Unsupported, + "'obj' feature of mesh-loader must be enabled to parse OBJ file ({path:?})", + )), FileType::Unknown => Err(io::Error::new( io::ErrorKind::Unsupported, "unsupported or unrecognized file type {path:?}", @@ -141,19 +148,13 @@ impl> Loader { #[cfg(feature = "stl")] pub fn load_stl>(&self, path: P) -> io::Result { - self.load_stl_(path.as_ref()) - } - #[cfg(feature = "stl")] - fn load_stl_(&self, path: &Path) -> io::Result { - self.load_stl_from_slice_((self.reader)(path)?.as_ref(), path) + let path = path.as_ref(); + self.load_stl_from_slice((self.reader)(path)?.as_ref(), path) } #[cfg(feature = "stl")] pub fn load_stl_from_slice>(&self, bytes: &[u8], path: P) -> io::Result { - self.load_stl_from_slice_(bytes, path.as_ref()) - } - #[cfg(feature = "stl")] - fn load_stl_from_slice_(&self, bytes: &[u8], path: &Path) -> io::Result { - let scene = crate::stl::from_slice_internal(bytes, Some(path), self.stl_parse_color)?; + let scene = + crate::stl::from_slice_internal(bytes, Some(path.as_ref()), self.stl_parse_color)?; Ok(self.post_process(scene)) } #[cfg(feature = "stl")] @@ -165,30 +166,53 @@ impl> Loader { #[cfg(feature = "collada")] pub fn load_collada>(&self, path: P) -> io::Result { - self.load_collada_(path.as_ref()) - } - #[cfg(feature = "collada")] - fn load_collada_(&self, path: &Path) -> io::Result { - self.load_collada_from_slice_((self.reader)(path)?.as_ref(), path) + let path = path.as_ref(); + self.load_collada_from_slice((self.reader)(path)?.as_ref(), path) } #[cfg(feature = "collada")] pub fn load_collada_from_slice>( &self, bytes: &[u8], + _path: P, + ) -> io::Result { + let scene = crate::collada::from_slice(bytes)?; + Ok(self.post_process(scene)) + } + + #[cfg(feature = "obj")] + pub fn load_obj>(&self, path: P) -> io::Result { + self.load_obj_with_reader(path.as_ref(), self.reader) + } + #[cfg(feature = "obj")] + pub fn load_obj_from_slice>(&self, bytes: &[u8], path: P) -> io::Result { + self.load_obj_from_slice_with_reader(bytes, path.as_ref(), self.reader) + } + #[cfg(feature = "obj")] + pub fn load_obj_with_reader, F: FnMut(&Path) -> io::Result>( + &self, path: P, + mut reader: F, ) -> io::Result { - self.load_collada_from_slice_(bytes, path.as_ref()) + let path = path.as_ref(); + self.load_obj_from_slice_with_reader(reader(path)?.as_ref(), path, reader) } - #[cfg(feature = "collada")] - fn load_collada_from_slice_(&self, bytes: &[u8], _path: &Path) -> io::Result { - let scene = crate::collada::from_slice(bytes)?; + #[cfg(feature = "obj")] + pub fn load_obj_from_slice_with_reader, F: FnMut(&Path) -> io::Result>( + &self, + bytes: &[u8], + path: P, + reader: F, + ) -> io::Result { + let scene = crate::obj::from_slice(bytes, Some(path.as_ref()), reader)?; Ok(self.post_process(scene)) } - #[cfg(any(feature = "collada", feature = "stl"))] + #[cfg(any(feature = "collada", feature = "obj", feature = "stl"))] fn post_process(&self, mut scene: Scene) -> Scene { if self.merge_meshes && scene.meshes.len() != 1 { scene.meshes = vec![crate::Mesh::merge(scene.meshes)]; + // TODO + scene.materials = vec![crate::Material::default()]; } scene } @@ -207,7 +231,7 @@ impl fmt::Debug for Loader { enum FileType { Stl, Collada, - // Obj, + Obj, Unknown, } @@ -215,7 +239,7 @@ fn detect_file_type(path: &Path, bytes: &[u8]) -> FileType { match path.extension().and_then(OsStr::to_str) { Some("stl" | "STL") => return FileType::Stl, Some("dae" | "DAE") => return FileType::Collada, - // Some("obj" | "OBJ") => return FileType::Obj, + Some("obj" | "OBJ") => return FileType::Obj, _ => {} } // Fallback: If failed to detect file type from extension, diff --git a/src/obj/mod.rs b/src/obj/mod.rs new file mode 100644 index 0000000..0ba8aaf --- /dev/null +++ b/src/obj/mod.rs @@ -0,0 +1,1334 @@ +//! [Wavefront OBJ] (.obj) parser. +//! +//! [Wavefront OBJ]: https://en.wikipedia.org/wiki/Wavefront_.obj_file + +#![allow(clippy::collapsible_if, clippy::many_single_char_names)] + +use std::{ + collections::HashMap, + io, mem, + path::{Path, PathBuf}, + str, +}; + +use self::error::ErrorKind; +use crate::{ + common, + utils::{ + bytes::{from_utf8_lossy, memchr_naive, memchr_naive_table, path_from_bytes, starts_with}, + float, int, + utf16::decode_bytes, + }, + Color4, Mesh, Scene, Vec2, Vec3, +}; + +/// Parses meshes from bytes of Wavefront OBJ text. +pub fn from_slice, F: FnMut(&Path) -> io::Result>( + bytes: &[u8], + path: Option<&Path>, + mut reader: F, +) -> io::Result { + // If it is UTF-16 with BOM, it is converted to UTF-8, otherwise it is parsed as bytes. + // We don't require UTF-8 here, as we want to support files that are partially non-UTF-8 like: + // https://github.com/assimp/assimp/blob/ac29847d5679c243d7649fe8a5d5e48f0f57c297/test/models/OBJ/regr01.mtl#L67 + let bytes = &decode_bytes(bytes)?; + match read_obj(bytes, path, &mut |path, materials, material_map| { + match reader(path) { + Ok(bytes) => read_mtl(bytes.as_ref(), Some(path), materials, material_map), + // ignore reader error for now + // TODO: logging? + Err(_e) => Ok(()), + } + }) { + Ok((meshes, materials)) => { + let materials = meshes + .iter() + .map(|m| { + materials + .get(m.material_index as usize) + .cloned() + .unwrap_or_default() + }) + .collect(); + Ok(Scene { materials, meshes }) + } + Err(e) => Err(e.into_io_error(bytes, path)), + } +} + +fn read_obj( + mut s: &[u8], + obj_path: Option<&Path>, + reader: &mut dyn FnMut( + &Path, + &mut Vec, + &mut HashMap, u32>, + ) -> io::Result<()>, +) -> Result<(Vec, Vec), ErrorKind> { + let mut meshes = Vec::with_capacity(1); // TODO: right default capacity? + + // TODO: use with_capacity + let mut vertices = vec![]; + let mut normals = vec![]; + let mut texcoords = vec![]; + let mut colors = vec![]; + let mut face = Vec::with_capacity(3); + let mut faces: Vec = vec![]; + let mut current_group: &[u8] = b"default"; + let mut current_material: &[u8] = &[]; + let mut materials = vec![]; + let mut material_map = HashMap::new(); + + while let Some((&c, s_next)) = s.split_first() { + match c { + b'v' => { + s = s_next; + match s.first() { + Some(b' ' | b'\t') => { + skip_spaces(&mut s); + read_v(&mut s, &mut vertices, &mut colors)?; + if !colors.is_empty() && colors.len() < vertices.len() { + colors.resize(vertices.len(), [0.; 3]); + } + continue; + } + Some(b'n') => { + s = &s[1..]; + if skip_spaces(&mut s) { + read_vn(&mut s, &mut normals)?; + continue; + } + } + Some(b't') => { + s = &s[1..]; + if skip_spaces(&mut s) { + read_vt(&mut s, &mut texcoords)?; + continue; + } + } + // ignore vp or other unknown + _ => {} + } + } + b'f' => { + s = s_next; + if skip_spaces(&mut s) { + read_f( + &mut s, &mut faces, &mut face, &vertices, &texcoords, &normals, + )?; + continue; + } + } + b'u' => { + s = s_next; + if token(&mut s, &"usemtl".as_bytes()[1..]) { + if skip_spaces(&mut s) { + let (name, s_next) = name(s); + if name != current_material { + let material_index = material_map.get(current_material).copied(); + push_mesh( + &mut meshes, + &mut faces, + &vertices, + &texcoords, + &normals, + &colors, + current_group, + material_index, + )?; + current_material = name; + } + s = s_next; + continue; + } + } + } + b'm' => { + s = s_next; + if token(&mut s, &"mtllib".as_bytes()[1..]) { + if skip_spaces(&mut s) { + let (path, s_next) = name(s); + let path = if path.is_empty() { + None + } else { + path_from_bytes(path).ok() + }; + if let Some(path) = path { + match obj_path.and_then(Path::parent) { + Some(parent) => { + reader(&parent.join(path), &mut materials, &mut material_map) + .map_err(ErrorKind::Io)?; + } + None => {} // ignored + } + } + s = s_next; + continue; + } + } + // ignore mg or other unknown + } + b'g' => { + s = s_next; + if skip_spaces(&mut s) { + let (mut name, s_next) = name(s); + if name.is_empty() { + name = b"default"; + } + if name != current_group { + let material_index = material_map.get(current_material).copied(); + push_mesh( + &mut meshes, + &mut faces, + &vertices, + &texcoords, + &normals, + &colors, + current_group, + material_index, + )?; + current_material = &[]; + current_group = name; + } + s = s_next; + continue; + } + } + _ => {} + } + // ignore comment, p, l, s, mg, o, or other unknown + skip_any_until_line(&mut s); + } + + let material_index = material_map.get(current_material).copied(); + push_mesh( + &mut meshes, + &mut faces, + &vertices, + &texcoords, + &normals, + &colors, + current_group, + material_index, + )?; + + Ok((meshes, materials)) +} + +#[inline(always)] +fn read_v( + s: &mut &[u8], + vertices: &mut Vec, + colors: &mut Vec, +) -> Result<(), ErrorKind> { + // v ([w] | [ ]) + let vertex = read_float3(s, "v")?; + let has_space = skip_spaces(s); + match s.first() { + Some(b'\n' | b'\r') | None => { + vertices.push(vertex); + *s = s.get(1..).unwrap_or_default(); + return Ok(()); + } + _ if !has_space => return Err(ErrorKind::ExpectedSpace("v", s.len())), + _ => {} + } + // [w] or [r] + let w = match float::parse_partial::(s) { + Some((f, n)) => { + *s = &s[n..]; + f + } + None => return Err(ErrorKind::Float(s.len())), + }; + let has_space = skip_spaces(s); + match s.first() { + Some(b'\n' | b'\r') | None => { + // is homogeneous vector + if w == 0. { + return Err(ErrorKind::InvalidW(s.len())); + } + vertices.push([vertex[0] / w, vertex[1] / w, vertex[2] / w]); + *s = s.get(1..).unwrap_or_default(); + return Ok(()); + } + _ if !has_space => return Err(ErrorKind::ExpectedSpace("v", s.len())), + _ => {} + } + vertices.push(vertex); + // is vertex color + let r = w; + let g = match float::parse_partial::(s) { + Some((f, n)) => { + *s = &s[n..]; + f + } + None => return Err(ErrorKind::Float(s.len())), + }; + if !skip_spaces(s) { + return Err(ErrorKind::ExpectedSpace("v", s.len())); + } + let b = match float::parse_partial::(s) { + Some((f, n)) => { + *s = &s[n..]; + f + } + None => return Err(ErrorKind::Float(s.len())), + }; + colors.push([r, g, b]); + if !skip_spaces_until_line(s) { + return Err(ErrorKind::ExpectedNewline("v", s.len())); + } + Ok(()) +} + +fn read_vn(s: &mut &[u8], normals: &mut Vec) -> Result<(), ErrorKind> { + // vn + let normal = read_float3(s, "vn")?; + normals.push(normal); + if !skip_spaces_until_line(s) { + return Err(ErrorKind::ExpectedNewline("vn", s.len())); + } + Ok(()) +} + +fn read_vt(s: &mut &[u8], texcoords: &mut Vec) -> Result<(), ErrorKind> { + // vt [v=0] [w=0] + let mut texcoord = [0.; 2]; + // + match float::parse_partial::(s) { + Some((f, n)) => { + texcoord[0] = f; + *s = &s[n..]; + } + None => return Err(ErrorKind::Float(s.len())), + } + let has_space = skip_spaces(s); + match s.first() { + Some(b'\n' | b'\r') | None => { + texcoords.push(texcoord); + *s = s.get(1..).unwrap_or_default(); + return Ok(()); + } + _ if !has_space => return Err(ErrorKind::ExpectedSpace("vt", s.len())), + _ => {} + } + // [v=0] + match float::parse_partial::(s) { + Some((f, n)) => { + texcoord[1] = f; + *s = &s[n..]; + } + None => return Err(ErrorKind::Float(s.len())), + } + texcoords.push(texcoord); + let has_space = skip_spaces(s); + match s.first() { + Some(b'\n' | b'\r') | None => { + *s = s.get(1..).unwrap_or_default(); + return Ok(()); + } + _ if !has_space => return Err(ErrorKind::ExpectedSpace("vt", s.len())), + _ => {} + } + // [w=0] + match float::parse_partial::(s) { + Some((_f, n)) => { + // ignored + *s = &s[n..]; + } + None => return Err(ErrorKind::Float(s.len())), + } + if !skip_spaces_until_line(s) { + return Err(ErrorKind::ExpectedNewline("vt", s.len())); + } + Ok(()) +} + +fn read_f( + s: &mut &[u8], + faces: &mut Vec, + face: &mut Vec<[u32; 3]>, + vertices: &[Vec3], + texcoords: &[Vec2], + normals: &[Vec3], +) -> Result<(), ErrorKind> { + // f /[vt1]/[vn1] /[vt2]/[vn2] /[vt3]/[vn3] ... + let mut f; + match memchr_naive_table(LINE, &TABLE, s) { + Some(n) => { + f = &s[..n]; + *s = &s[n + 1..]; + } + None => { + f = s; + *s = &[]; + } + }; + while !f.is_empty() { + let mut w; + let f_next = match memchr_naive_table(WS_NO_LINE, &TABLE, f) { + Some(n) => { + w = &f[..n]; + &f[n + 1..] + } + None => { + w = f; + &[] + } + }; + let mut idx = [u32::MAX; 3]; + let mut i; + match memchr_naive(b'/', w) { + Some(n) => { + i = &w[..n]; + w = &w[n + 1..]; + } + None => { + i = w; + w = &[]; + } + }; + match int::parse::(i) { + #[allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_sign_loss + )] + Some(i) => { + idx[0] = if i < 0 { + (vertices.len() as isize + i as isize) as _ + } else { + (i - 1) as _ + } + } + None => return Err(ErrorKind::Int(s.len() + !s.is_empty() as usize + f.len())), + } + match memchr_naive(b'/', w) { + Some(n) => { + i = &w[..n]; + w = &w[n + 1..]; + } + None => { + i = w; + w = &[]; + } + }; + if !i.is_empty() { + match int::parse::(i) { + #[allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_sign_loss + )] + Some(i) => { + idx[1] = if i < 0 { + (texcoords.len() as isize + i as isize) as _ + } else { + (i - 1) as _ + } + } + None => return Err(ErrorKind::Int(s.len() + !s.is_empty() as usize + f.len())), + } + } + i = w; + if !i.is_empty() { + match int::parse::(i) { + #[allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_sign_loss + )] + Some(i) => { + idx[2] = if i < 0 { + (normals.len() as isize + i as isize) as _ + } else { + (i - 1) as _ + } + } + None => return Err(ErrorKind::Int(s.len() + !s.is_empty() as usize + f.len())), + } + } + f = f_next; + skip_spaces(&mut f); + face.push(idx); + } + match face.len() { + 1 => { + faces.push(Face::Point([face[0]])); + face.clear(); + } + 2 => { + faces.push(Face::Line([face[0], face[1]])); + face.clear(); + } + 3 => { + faces.push(Face::Triangle([face[0], face[1], face[2]])); + face.clear(); + } + 0 => return Err(ErrorKind::Expected("f", s.len())), + // TODO: triangulate in place here? + _ => faces.push(Face::Polygon(mem::take(face))), + } + Ok(()) +} + +fn read_float3(s: &mut &[u8], expected: &'static str) -> Result<[f32; 3], ErrorKind> { + let mut floats = [0.; 3]; + match float::parse_partial::(s) { + Some((f, n)) => { + floats[0] = f; + *s = &s[n..]; + } + None => return Err(ErrorKind::Float(s.len())), + } + if !skip_spaces(s) { + return Err(ErrorKind::ExpectedSpace(expected, s.len())); + } + match float::parse_partial::(s) { + Some((f, n)) => { + floats[1] = f; + *s = &s[n..]; + } + None => return Err(ErrorKind::Float(s.len())), + } + if !skip_spaces(s) { + return Err(ErrorKind::ExpectedSpace(expected, s.len())); + } + match float::parse_partial::(s) { + Some((f, n)) => { + floats[2] = f; + *s = &s[n..]; + } + None => return Err(ErrorKind::Float(s.len())), + } + Ok(floats) +} + +fn read_color(s: &mut &[u8], expected: &'static str) -> Result<[f32; 3], ErrorKind> { + let mut floats = [0.; 3]; + // r + match float::parse_partial::(s) { + Some((f, n)) => { + floats[0] = f; + *s = &s[n..]; + } + None => return Err(ErrorKind::Float(s.len())), + } + let has_space = skip_spaces(s); + match s.first() { + Some(b'\n' | b'\r') | None => { + *s = s.get(1..).unwrap_or_default(); + return Ok(floats); + } + _ if !has_space => return Err(ErrorKind::ExpectedSpace(expected, s.len())), + _ => {} + } + // g + match float::parse_partial::(s) { + Some((f, n)) => { + floats[1] = f; + *s = &s[n..]; + } + None => return Err(ErrorKind::Float(s.len())), + } + if !skip_spaces(s) { + return Err(ErrorKind::ExpectedSpace(expected, s.len())); + } + // b + match float::parse_partial::(s) { + Some((f, n)) => { + floats[2] = f; + *s = &s[n..]; + } + None => return Err(ErrorKind::Float(s.len())), + } + if !skip_spaces_until_line(s) { + return Err(ErrorKind::ExpectedNewline(expected, s.len())); + } + Ok(floats) +} + +fn read_float1(s: &mut &[u8], expected: &'static str) -> Result { + match float::parse_partial::(s) { + Some((f, n)) => { + *s = &s[n..]; + if !skip_spaces_until_line(s) { + return Err(ErrorKind::ExpectedNewline(expected, s.len())); + } + Ok(f) + } + None => Err(ErrorKind::Float(s.len())), + } +} + +#[inline(always)] +fn push_vertex( + mesh: &mut Mesh, + vert: [u32; 3], + vertices: &[Vec3], + colors: &[Vec3], + texcoords: &[Vec2], + normals: &[Vec3], +) -> Result<(), ErrorKind> { + let v = vert[0] as usize; + mesh.vertices + .push(*vertices.get(v).ok_or(ErrorKind::Oob(v, 0))?); + if !texcoords.is_empty() && vert[1] != u32::MAX { + let vt = vert[1] as usize; + mesh.texcoords[0].push(*texcoords.get(vt).ok_or(ErrorKind::Oob(vt, 0))?); + } + if !normals.is_empty() && vert[2] != u32::MAX { + let vn = vert[2] as usize; + mesh.normals + .push(*normals.get(vn).ok_or(ErrorKind::Oob(vn, 0))?); + } + if !colors.is_empty() { + let color = colors.get(v).ok_or(ErrorKind::Oob(v, 0))?; + mesh.colors[0].push([color[0], color[1], color[2], 1.0]); + } + Ok(()) +} + +#[inline(always)] +fn push_mesh( + meshes: &mut Vec, + faces: &mut Vec, + vertices: &[Vec3], + texcoords: &[Vec2], + normals: &[Vec3], + colors: &[Vec3], + current_group: &[u8], + material_index: Option, +) -> Result<(), ErrorKind> { + if !faces.is_empty() { + let mut mesh = Mesh { + name: from_utf8_lossy(current_group).into_owned(), + material_index: material_index.unwrap_or(u32::MAX), + ..Default::default() + }; + // TODO + // mesh.faces.reserve(faces.len()); + // mesh.vertices.reserve(faces.len() * 3); + // if !texcoords.is_empty() { + // mesh.texcoords[0].reserve(faces.len() * 3); + // } + // if !normals.is_empty() { + // mesh.normals.reserve(faces.len() * 3); + // } + // if !colors.is_empty() { + // mesh.colors[0].reserve(faces.len() * 3); + // } + for face in &*faces { + match face { + Face::Point(_) | Face::Line(_) => {} // ignored + Face::Triangle(face) => { + #[allow(clippy::cast_possible_truncation)] + let vertices_indices = [ + mesh.vertices.len() as u32, + (mesh.vertices.len() + 1) as u32, + (mesh.vertices.len() + 2) as u32, + ]; + push_vertex(&mut mesh, face[0], vertices, colors, texcoords, normals)?; + push_vertex(&mut mesh, face[1], vertices, colors, texcoords, normals)?; + push_vertex(&mut mesh, face[2], vertices, colors, texcoords, normals)?; + mesh.faces.push(vertices_indices); + } + Face::Polygon(face) => { + let a = face[0]; + let mut b = face[1]; + for &c in &face[2..] { + #[allow(clippy::cast_possible_truncation)] + let vertices_indices = [ + mesh.vertices.len() as u32, + (mesh.vertices.len() + 1) as u32, + (mesh.vertices.len() + 2) as u32, + ]; + push_vertex(&mut mesh, a, vertices, colors, texcoords, normals)?; + push_vertex(&mut mesh, b, vertices, colors, texcoords, normals)?; + push_vertex(&mut mesh, c, vertices, colors, texcoords, normals)?; + mesh.faces.push(vertices_indices); + b = c; + } + } + } + } + if !mesh.colors[0].is_empty() && mesh.vertices.len() != mesh.colors[0].len() { + // TODO: do not use (0) + return Err(ErrorKind::InvalidFaceIndex(0)); + } + if !mesh.texcoords[0].is_empty() && mesh.vertices.len() != mesh.texcoords[0].len() { + return Err(ErrorKind::InvalidFaceIndex(0)); + } + if !mesh.normals.is_empty() && mesh.vertices.len() != mesh.normals.len() { + return Err(ErrorKind::InvalidFaceIndex(0)); + } + meshes.push(mesh); + faces.clear(); + } + Ok(()) +} + +// Not public API. (Used for fuzzing.) +#[doc(hidden)] +#[allow(clippy::implicit_hasher)] +pub fn read_mtl( + bytes: &[u8], + path: Option<&Path>, + materials: &mut Vec, + material_map: &mut HashMap, u32>, +) -> io::Result<()> { + let bytes = &decode_bytes(bytes)?; + match read_mtl_internal(bytes, path.and_then(Path::parent), materials, material_map) { + Ok(()) => Ok(()), + Err(e) => Err(e.into_io_error(bytes, path)), + } +} + +fn read_mtl_internal( + mut s: &[u8], + mtl_dir: Option<&Path>, + materials: &mut Vec, + material_map: &mut HashMap, u32>, +) -> Result<(), ErrorKind> { + let mut mat: Option> = None; + let mut current_name: &[u8] = b""; + + while let Some((&c, s_next)) = s.split_first() { + match c { + b'K' | b'k' => { + s = s_next; + match s.first() { + Some(b'a') => { + s = &s[1..]; + if skip_spaces(&mut s) { + let color = read_color(&mut s, "Ka")?; + if let Some(mat) = &mut mat { + mat.ambient = Some(color); + } + continue; + } + } + Some(b'd') => { + s = &s[1..]; + if skip_spaces(&mut s) { + let color = read_color(&mut s, "Kd")?; + if let Some(mat) = &mut mat { + mat.diffuse = Some(color); + } + continue; + } + } + Some(b's') => { + s = &s[1..]; + if skip_spaces(&mut s) { + let color = read_color(&mut s, "Ks")?; + if let Some(mat) = &mut mat { + mat.specular = Some(color); + } + continue; + } + } + Some(b'e') => { + s = &s[1..]; + if skip_spaces(&mut s) { + let color = read_color(&mut s, "Ke")?; + if let Some(mat) = &mut mat { + mat.emissive = Some(color); + } + continue; + } + } + _ => {} + } + } + b'T' => { + s = s_next; + match s.first() { + Some(b'f') => { + s = &s[1..]; + if skip_spaces(&mut s) { + let color = read_color(&mut s, "Tf")?; + if let Some(mat) = &mut mat { + mat.transparent = Some(color); + } + continue; + } + } + Some(b'r') => { + s = &s[1..]; + if skip_spaces(&mut s) { + let f = read_float1(&mut s, "Tr")?; + if let Some(mat) = &mut mat { + mat.alpha = Some(1.0 - f); + } + continue; + } + } + _ => {} + } + } + b'd' => { + match s.get(1) { + Some(b' ' | b'\t') => { + s = &s[2..]; + skip_spaces(&mut s); + let f = read_float1(&mut s, "d")?; + if let Some(mat) = &mut mat { + mat.alpha = Some(f); + } + continue; + } + Some(b'i') => { + if read_texture(&mut s, &mut mat) { + // disp + continue; + } + } + _ => {} + } + s = s_next; + } + b'N' | b'n' => match s.get(1) { + Some(b's') => { + s = &s[2..]; + if skip_spaces(&mut s) { + let f = read_float1(&mut s, "Ns")?; + if let Some(mat) = &mut mat { + mat.shininess = Some(f); + } + continue; + } + } + Some(b'i') => { + s = &s[2..]; + if skip_spaces(&mut s) { + let f = read_float1(&mut s, "Ni")?; + if let Some(mat) = &mut mat { + mat.ior = Some(f); + } + continue; + } + } + Some(b'e') => { + s = &s[2..]; + if token(&mut s, &b"newmtl"[2..]) { + if skip_spaces(&mut s) { + let (name, s_next) = name(s); + if let Some(mat) = mat.replace(Material::default()) { + fn color4(color3: Option<[f32; 3]>) -> Option { + let c = color3?; + Some([c[0], c[1], c[2], 1.0]) + } + fn texture_path( + texture: Option<&[u8]>, + mtl_dir: Option<&Path>, + ) -> Option { + let mut p = texture?; + if p.is_empty() + || p.len() == 2 + && (p.starts_with(b".\\") || p.starts_with(b"./")) + { + return None; + } + match mtl_dir { + Some(mtl_dir) => { + p = p.strip_prefix(b".\\").unwrap_or(p); + p = p.strip_prefix(b"./").unwrap_or(p); + let p = path_from_bytes(p).ok()?; + Some(mtl_dir.join(p)) + } + None => { + let p = path_from_bytes(p).ok()?; + Some(p.to_owned()) + } + } + } + #[allow(clippy::cast_possible_truncation)] + let material_index = materials.len() as u32; + materials.push(common::Material { + name: from_utf8_lossy(current_name).into_owned(), + color: crate::Colors { + ambient: color4(mat.ambient), + diffuse: color4(mat.diffuse), + specular: color4(mat.specular), + emissive: color4(mat.emissive), + }, + texture: crate::Textures { + ambient: texture_path(mat.ambient_texture, mtl_dir), + diffuse: texture_path(mat.diffuse_texture, mtl_dir), + specular: texture_path(mat.specular_texture, mtl_dir), + emissive: texture_path(mat.emissive_texture, mtl_dir), + normal: texture_path(mat.normal_texture, mtl_dir), + }, + }); + material_map.insert(current_name.to_owned(), material_index); + } + current_name = name; + s = s_next; + continue; + } + } + } + Some(b'o') => { + if read_texture(&mut s, &mut mat) { + // norm + continue; + } + } + _ => {} + }, + b'P' => { + s = s_next; + match s.first() { + Some(b'r') => { + s = &s[1..]; + if skip_spaces(&mut s) { + let f = read_float1(&mut s, "Pr")?; + if let Some(mat) = &mut mat { + mat.roughness = Some(f); + } + continue; + } + } + Some(b'm') => { + s = &s[1..]; + if skip_spaces(&mut s) { + let f = read_float1(&mut s, "Pm")?; + if let Some(mat) = &mut mat { + mat.metallic = Some(f); + } + continue; + } + } + Some(b's') => { + s = &s[1..]; + if skip_spaces(&mut s) { + let color = read_color(&mut s, "Ps")?; + if let Some(mat) = &mut mat { + mat.sheen = Some(color); + } + continue; + } + } + Some(b'c') => { + s = &s[1..]; + if s.first() == Some(&b'r') { + if skip_spaces(&mut s) { + let f = read_float1(&mut s, "Pcr")?; + if let Some(mat) = &mut mat { + mat.clearcoat_roughness = Some(f); + } + continue; + } + } else if skip_spaces(&mut s) { + let f = read_float1(&mut s, "Pc")?; + if let Some(mat) = &mut mat { + mat.clearcoat_thickness = Some(f); + } + continue; + } + } + _ => {} + } + } + b'm' | b'b' | b'r' => { + if read_texture(&mut s, &mut mat) { + continue; + } + } + b'i' => { + s = s_next; + if token(&mut s, &b"illum"[1..]) { + if skip_spaces(&mut s) { + match int::parse_partial::(s) { + Some((i, n)) => { + s = &s[n..]; + if !skip_spaces_until_line(&mut s) { + return Err(ErrorKind::ExpectedNewline("illum", s.len())); + } + if let Some(mat) = &mut mat { + mat.illumination_model = Some(i); + } + } + None => return Err(ErrorKind::Int(s.len())), + } + continue; + } + } + } + b'a' => { + s = s_next; + if skip_spaces(&mut s) { + let f = read_float1(&mut s, "a")?; + if let Some(mat) = &mut mat { + mat.anisotropy = Some(f); + } + continue; + } + } + _ => {} + } + // ignore comment or other unknown + skip_any_until_line(&mut s); + } + + Ok(()) +} + +fn read_texture<'a>(s: &mut &'a [u8], mat: &mut Option>) -> bool { + // Empty name cases are processed later in texture_path. + // TODO: handle texture options + if token(s, b"map_Kd") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.diffuse_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_Ka") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.ambient_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_Ks") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.specular_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_disp") || token(s, b"disp") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.displacement_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_d") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.opacity_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_emissive") || token(s, b"map_Ke") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.emissive_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_Bump") || token(s, b"map_bump") || token(s, b"bump") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.bump_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_Kn") || token(s, b"norm") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.normal_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"refl") { + if skip_spaces(s) { + let (_name, s_next) = name(s); + // ignore https://github.com/assimp/assimp/blob/ac29847d5679c243d7649fe8a5d5e48f0f57c297/code/AssetLib/Obj/ObjFileMtlImporter.cpp#L415 + *s = s_next; + return true; + } + } else if token(s, b"map_Ns") || token(s, b"map_ns") || token(s, b"map_NS") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.specularity_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_Pr") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.roughness_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_Pm") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.metallic_texture = Some(name); + } + *s = s_next; + return true; + } + } else if token(s, b"map_Ps") { + if skip_spaces(s) { + let (name, s_next) = name(s); + if let Some(mat) = mat { + mat.sheen_texture = Some(name); + } + *s = s_next; + return true; + } + } + false +} + +enum Face { + Point(#[allow(dead_code)] [[u32; 3]; 1]), + Line(#[allow(dead_code)] [[u32; 3]; 2]), + Triangle([[u32; 3]; 3]), + Polygon(Vec<[u32; 3]>), +} + +#[derive(Default)] +struct Material<'a> { + // Textures + diffuse_texture: Option<&'a [u8]>, + specular_texture: Option<&'a [u8]>, + ambient_texture: Option<&'a [u8]>, + emissive_texture: Option<&'a [u8]>, + bump_texture: Option<&'a [u8]>, + normal_texture: Option<&'a [u8]>, + specularity_texture: Option<&'a [u8]>, + opacity_texture: Option<&'a [u8]>, + displacement_texture: Option<&'a [u8]>, + roughness_texture: Option<&'a [u8]>, + metallic_texture: Option<&'a [u8]>, + sheen_texture: Option<&'a [u8]>, + + // Colors + ambient: Option<[f32; 3]>, + diffuse: Option<[f32; 3]>, + specular: Option<[f32; 3]>, + emissive: Option<[f32; 3]>, + alpha: Option, + shininess: Option, + illumination_model: Option, + ior: Option, + transparent: Option<[f32; 3]>, + + roughness: Option, + metallic: Option, + sheen: Option<[f32; 3]>, + clearcoat_thickness: Option, + clearcoat_roughness: Option, + anisotropy: Option, +} + +const __: u8 = 0; +// [ \r\n\t] +const WS: u8 = 1 << 0; +// [ \t] +const WS_NO_LINE: u8 = 1 << 1; +// [\r\n] +const LINE: u8 = 1 << 2; +const LN: u8 = WS | LINE; +const NL: u8 = WS | WS_NO_LINE; + +static TABLE: [u8; 256] = [ + // 1 2 3 4 5 6 7 8 9 A B C D E F + __, __, __, __, __, __, __, __, __, NL, LN, __, __, LN, __, __, // 0 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 1 + NL, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 2 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 3 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 4 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 5 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 6 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 7 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 8 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // 9 + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // A + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // B + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // C + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // D + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // E + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, // F +]; + +#[inline] +fn skip_whitespace_until_byte_or_eof(s: &mut &[u8], byte_mask: u8, whitespace_mask: u8) -> bool { + while let Some((&b, s_next)) = s.split_first() { + let b = TABLE[b as usize]; + if b & byte_mask != 0 { + *s = s_next; + break; + } + if b & whitespace_mask != 0 { + *s = s_next; + continue; + } + if b == b'\\' && matches!(s_next.first(), Some(b'\n' | b'\r')) { + if s_next.starts_with(b"\r\n") { + *s = &s_next[2..]; + } else { + *s = &s_next[1..]; + } + continue; + } + return false; + } + true +} + +#[inline] +fn skip_spaces_until_line(s: &mut &[u8]) -> bool { + skip_whitespace_until_byte_or_eof(s, LINE, WS_NO_LINE) +} + +#[inline] +fn skip_spaces(s: &mut &[u8]) -> bool { + let start = *s; + while let Some((&b, s_next)) = s.split_first() { + let b = TABLE[b as usize]; + if b & WS_NO_LINE != 0 { + *s = s_next; + continue; + } + if b == b'\\' && matches!(s_next.first(), Some(b'\n' | b'\r')) { + if s_next.starts_with(b"\r\n") { + *s = &s_next[2..]; + } else { + *s = &s_next[1..]; + } + continue; + } + break; + } + start.len() != s.len() +} + +#[inline] +fn skip_any_until_line(s: &mut &[u8]) { + loop { + match memchr_naive_table(LINE, &TABLE, s) { + Some(n) => { + if s.get(n.wrapping_sub(1)) == Some(&b'\\') { + if s[n..].starts_with(b"\r\n") { + *s = &s[n + 2..]; + } else { + *s = &s[n + 1..]; + } + continue; + } + *s = &s[n + 1..]; + } + None => *s = &[], + } + break; + } +} + +#[inline] +fn token(s: &mut &[u8], token: &'static [u8]) -> bool { + if starts_with(s, token) { + *s = &s[token.len()..]; + true + } else { + false + } +} + +fn name(s: &[u8]) -> (&[u8], &[u8]) { + let mut name; + let s_next = match memchr_naive_table(LINE, &TABLE, s) { + Some(n) => { + name = &s[..n]; + &s[n + 1..] + } + None => { + name = s; + &[] + } + }; + // Allow spaces in middle, trim end + // https://github.com/assimp/assimp/commit/c84a14a7a8ae4329114269a0ffc1921c838eda9e + while let Some((&b, name_next)) = name.split_last() { + if TABLE[b as usize] & WS != 0 { + name = name_next; + continue; + } + break; + } + (name, s_next) +} + +mod error { + use std::{fmt, io, path::Path, str}; + + #[cfg_attr(test, derive(Debug))] + pub(super) enum ErrorKind { + ExpectedSpace(&'static str, usize), + ExpectedNewline(&'static str, usize), + Expected(&'static str, usize), + Float(usize), + Int(usize), + InvalidW(usize), + InvalidFaceIndex(usize), + Oob(usize, usize), + Io(io::Error), + } + + impl ErrorKind { + #[cold] + #[inline(never)] + pub(super) fn into_io_error(self, start: &[u8], path: Option<&Path>) -> io::Error { + let remaining = match self { + Self::Expected(.., n) + | Self::ExpectedNewline(.., n) + | Self::ExpectedSpace(.., n) + | Self::Float(n) + | Self::Int(n) + | Self::InvalidW(n) + | Self::InvalidFaceIndex(n) + | Self::Oob(.., n) => n, + Self::Io(e) => return e, + }; + crate::error::with_location( + &crate::error::invalid_data(self.to_string()), + &crate::error::Location::find(remaining, start, path), + ) + } + } + + impl fmt::Display for ErrorKind { + #[cold] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::ExpectedSpace(msg, ..) => write!(f, "expected space after {msg}"), + Self::ExpectedNewline(msg, ..) => write!(f, "expected newline after {msg}"), + Self::Expected(msg, ..) => write!(f, "expected {msg}"), + Self::InvalidW(..) => write!(f, "w in homogeneous vector must not zero"), + Self::InvalidFaceIndex(..) => write!(f, "invalid face index"), + Self::Float(..) => write!(f, "error while parsing a float"), + Self::Int(..) => write!(f, "error while parsing an integer"), + Self::Oob(i, ..) => write!(f, "face index out of bounds ({i})"), + Self::Io(ref e) => write!(f, "{e}"), + } + } + } +} diff --git a/src/stl/mod.rs b/src/stl/mod.rs index 7fc43b7..1e674f6 100644 --- a/src/stl/mod.rs +++ b/src/stl/mod.rs @@ -10,7 +10,7 @@ use crate::{ bytes::{memchr_naive_table, starts_with}, float, }, - Color4, Mesh, Scene, Vec3, + Color4, Material, Mesh, Scene, Vec3, }; /// Parses meshes from bytes of binary or ASCII STL. @@ -27,7 +27,10 @@ pub(crate) fn from_slice_internal( let mut meshes = Vec::with_capacity(1); if is_ascii_stl(bytes) { match read_ascii_stl(bytes, &mut meshes) { - Ok(()) => return Ok(Scene { meshes }), + Ok(()) => { + let materials = (0..meshes.len()).map(|_| Material::default()).collect(); + return Ok(Scene { materials, meshes }); + } // If there is solid but no space or line break after solid or no // facet normal, even valid ASCII text may be binary STL. Err( @@ -39,10 +42,20 @@ pub(crate) fn from_slice_internal( Err(e) => return Err(e.into_io_error(bytes, path)), } } - match read_binary_stl(bytes, parse_color) { - Ok(mesh) => { + match read_binary_header(bytes, parse_color) { + Ok(header) => { + let mesh = read_binary_triangles(&header); + let mut material = Material::default(); + if header.reverse_color && mesh.colors[0].is_empty() { + let color = header.default_color; + material.color.diffuse = Some(color); + material.color.specular = Some(color); + } meshes.push(mesh); - Ok(Scene { meshes }) + Ok(Scene { + materials: vec![material], + meshes, + }) } Err(e) => Err(e.into_io_error(bytes, path)), } @@ -95,11 +108,6 @@ struct BinaryHeader<'a> { triangle_bytes: &'a [u8], } -fn read_binary_stl(bytes: &[u8], parse_color: bool) -> Result { - let header = read_binary_header(bytes, parse_color)?; - Ok(read_binary_triangles(&header)) -} - fn read_binary_header(bytes: &[u8], parse_color: bool) -> Result, ErrorKind> { if bytes.len() < TRIANGLE_START { return Err(ErrorKind::TooSmall); @@ -292,7 +300,7 @@ fn read_ascii_stl(mut s: &[u8], meshes: &mut Vec) -> Result<(), ErrorKind> if !name.is_ascii() { return Err(ErrorKind::NotAscii(expected, s.len())); } - if let Some(n) = memchr_naive_table(WS_NO_LINE, &TABLE, name) { + if let Some(n) = memchr_naive_table(SPACE, &TABLE, name) { // Ignore contents after the name. // https://en.wikipedia.org/wiki/STL_(file_format)#ASCII // > The remainder of the line is ignored and is sometimes used to @@ -443,23 +451,23 @@ const __: u8 = 0; // Note: Unlike is_ascii_whitespace, FORM FEED ('\x0C') is not included. // https://en.wikipedia.org/wiki/STL_(file_format)#ASCII // > Whitespace (spaces, tabs, newlines) may be used anywhere in the file except within numbers or words. -const WS: u8 = 1 << 0; +const WS: u8 = SPACE | LINE; // [ \t] -const WS_NO_LINE: u8 = 1 << 1; +const SPACE: u8 = 1 << 0; // [\r\n] -const LINE: u8 = 1 << 2; -const LN: u8 = WS | LINE; -const NL: u8 = WS | WS_NO_LINE; +const LINE: u8 = 1 << 1; +const LN: u8 = LINE; +const NL: u8 = SPACE; // [s] -const S_: u8 = 1 << 3; +const S_: u8 = 1 << 2; // [e] -const E_: u8 = 1 << 4; +const E_: u8 = 1 << 3; // [f] -const F_: u8 = 1 << 5; +const F_: u8 = 1 << 4; // [o] -const O_: u8 = 1 << 6; +const O_: u8 = 1 << 5; // [v] -const V_: u8 = 1 << 7; +const V_: u8 = 1 << 6; static TABLE: [u8; 256] = [ // 1 2 3 4 5 6 7 8 9 A B C D E F @@ -500,7 +508,7 @@ fn skip_whitespace_until_byte(s: &mut &[u8], byte_mask: u8, whitespace_mask: u8) #[inline] fn skip_spaces_until_line(s: &mut &[u8]) -> bool { - skip_whitespace_until_byte(s, LINE, WS_NO_LINE) + skip_whitespace_until_byte(s, LINE, SPACE) } #[inline] @@ -508,7 +516,7 @@ fn skip_spaces(s: &mut &[u8]) -> bool { let start = *s; while let Some((&b, s_next)) = s.split_first() { let b = TABLE[b as usize]; - if b & WS_NO_LINE != 0 { + if b & SPACE != 0 { *s = s_next; continue; } diff --git a/src/utils/bytes.rs b/src/utils/bytes.rs index 48b60ac..a9bf3dd 100644 --- a/src/utils/bytes.rs +++ b/src/utils/bytes.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "obj")] +use std::{borrow::Cow, ffi::OsStr, path::Path, str}; + // This is the same as s.starts_with(needle), but faster if the length of the // needle is known at compile time. #[inline(always)] // Ensure the code getting the length of the needle is inlined. @@ -41,7 +44,20 @@ pub(crate) fn starts_with(mut s: &[u8], mut needle: &'static [u8]) -> bool { s.starts_with(needle) } -#[cfg(feature = "stl")] +#[cfg(feature = "obj")] +#[inline] +pub(crate) const fn memchr_naive(needle: u8, mut s: &[u8]) -> Option { + let start = s; + while let Some((&b, s_next)) = s.split_first() { + if b == needle { + return Some(start.len() - s.len()); + } + s = s_next; + } + None +} + +#[cfg(any(feature = "obj", feature = "stl"))] #[inline] pub(crate) const fn memchr_naive_table( needle_mask: u8, @@ -58,7 +74,7 @@ pub(crate) const fn memchr_naive_table( None } -#[cfg(feature = "stl")] +#[cfg(any(feature = "obj", feature = "stl"))] #[inline] pub(crate) const fn memrchr_naive(needle: u8, mut s: &[u8]) -> Option { let start = s; @@ -71,7 +87,7 @@ pub(crate) const fn memrchr_naive(needle: u8, mut s: &[u8]) -> Option { None } -#[cfg(feature = "stl")] +#[cfg(any(feature = "obj", feature = "stl"))] #[inline] pub(crate) const fn bytecount_naive(needle: u8, mut s: &[u8]) -> usize { let mut n = 0; @@ -81,3 +97,53 @@ pub(crate) const fn bytecount_naive(needle: u8, mut s: &[u8]) -> usize { } n } + +#[cfg(feature = "obj")] +#[allow(clippy::unnecessary_wraps)] // clippy bug: this lint doesn't consider cfg attribute +pub(crate) fn os_str_from_bytes(bytes: &[u8]) -> Result<&OsStr, std::str::Utf8Error> { + #[cfg(any(unix, target_os = "wasi"))] + { + #[cfg(unix)] + use std::os::unix::ffi::OsStrExt as _; + #[cfg(target_os = "wasi")] + use std::os::wasi::ffi::OsStrExt as _; + Ok(OsStr::from_bytes(bytes)) + } + #[cfg(not(any(unix, target_os = "wasi")))] + { + std::str::from_utf8(bytes).map(OsStr::new) + } +} +#[cfg(feature = "obj")] +pub(crate) fn path_from_bytes(bytes: &[u8]) -> Result<&Path, std::str::Utf8Error> { + os_str_from_bytes(bytes).map(Path::new) +} + +// Ideally, we want to use Utf8Chunks here, but it is unstable. +#[cfg(feature = "obj")] +#[inline] +pub(crate) fn from_utf8_lossy(mut bytes: &[u8]) -> Cow<'_, str> { + let mut base = String::new(); + loop { + match str::from_utf8(bytes) { + Ok(s) => { + if base.is_empty() { + return s.into(); + } + base.push_str(s); + return base.into(); + } + Err(e) => { + let valid_up_to = e.valid_up_to(); + let s = str::from_utf8(&bytes[..valid_up_to]).unwrap(); + base.push_str(s); + base.push(char::REPLACEMENT_CHARACTER); + if let Some(error_len) = e.error_len() { + bytes = &bytes[valid_up_to + error_len..]; + } else { + return base.into(); + } + } + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 3b94d90..f215d28 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,7 +1,91 @@ pub(crate) mod bytes; -#[cfg(any(feature = "collada", feature = "stl"))] +#[cfg(any(feature = "collada", feature = "obj", feature = "stl"))] pub mod float; -#[cfg(feature = "collada")] +#[cfg(any(feature = "collada", feature = "obj"))] pub mod int; #[cfg(feature = "collada")] pub(crate) mod xml; + +#[cfg(any(feature = "collada", feature = "obj"))] +pub(crate) mod utf16 { + use std::{borrow::Cow, io}; + + const UTF32BE_BOM: &[u8] = &[0xFF, 0xFE, 00, 00]; + const UTF32LE_BOM: &[u8] = &[00, 00, 0xFE, 0xFF]; + const UTF16BE_BOM: &[u8] = &[0xFE, 0xFF]; + const UTF16LE_BOM: &[u8] = &[0xFF, 0xFE]; + const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF]; + + /// Converts bytes to a string. Converts to UTF-8 if bytes are UTF-16 and have BOM. + #[cfg(feature = "collada")] + pub(crate) fn decode_string(bytes: &[u8]) -> io::Result> { + if bytes.starts_with(UTF8_BOM) { + std::str::from_utf8(&bytes[UTF8_BOM.len()..]) + .map(Cow::Borrowed) + .map_err(crate::error::invalid_data) + } else if bytes.starts_with(UTF32BE_BOM) || bytes.starts_with(UTF32LE_BOM) { + bail!("utf-32 is not supported") + } else if bytes.starts_with(UTF16BE_BOM) { + from_utf16be(&bytes[UTF16BE_BOM.len()..]).map(Into::into) + } else if bytes.starts_with(UTF16LE_BOM) { + from_utf16le(&bytes[UTF16BE_BOM.len()..]).map(Into::into) + } else { + // UTF-16/UTF-32 without BOM will get an error here. + std::str::from_utf8(bytes) + .map(Cow::Borrowed) + .map_err(crate::error::invalid_data) + } + } + + /// Converts to UTF-8 if bytes are UTF-16 and have BOM. + /// This does not handle UTF-16 without BOM or other UTF-8 incompatible encodings, + /// so the resulting bytes must not be trusted as a valid UTF-8. + #[cfg(feature = "obj")] + pub(crate) fn decode_bytes(bytes: &[u8]) -> io::Result> { + if bytes.starts_with(UTF8_BOM) { + Ok(Cow::Borrowed(&bytes[UTF8_BOM.len()..])) + } else if bytes.starts_with(UTF32BE_BOM) || bytes.starts_with(UTF32LE_BOM) { + bail!("utf-32 is not supported") + } else if bytes.starts_with(UTF16BE_BOM) { + from_utf16be(&bytes[UTF16BE_BOM.len()..]) + .map(String::into_bytes) + .map(Into::into) + } else if bytes.starts_with(UTF16LE_BOM) { + from_utf16le(&bytes[UTF16BE_BOM.len()..]) + .map(String::into_bytes) + .map(Into::into) + } else { + Ok(Cow::Borrowed(bytes)) + } + } + + #[cold] + #[inline(never)] + fn from_utf16be(bytes: &[u8]) -> io::Result { + if bytes.len() % 2 != 0 { + bail!("invalid utf-16: lone surrogate found"); + } + char::decode_utf16( + bytes + .chunks_exact(2) + .map(|b| u16::from_be_bytes(b.try_into().unwrap())), + ) + .collect::>() + .map_err(crate::error::invalid_data) + } + + #[cold] + #[inline(never)] + fn from_utf16le(bytes: &[u8]) -> io::Result { + if bytes.len() % 2 != 0 { + bail!("invalid utf-16: lone surrogate found"); + } + char::decode_utf16( + bytes + .chunks_exact(2) + .map(|b| u16::from_le_bytes(b.try_into().unwrap())), + ) + .collect::>() + .map_err(crate::error::invalid_data) + } +} diff --git a/tests/assimp.rs b/tests/assimp.rs index 80c865f..44d27f3 100644 --- a/tests/assimp.rs +++ b/tests/assimp.rs @@ -1,3 +1,7 @@ +#![allow( + clippy::match_same_arms, // https://github.com/rust-lang/rust-clippy/issues/12044 +)] + use std::{ collections::BTreeSet, ffi::OsStr, @@ -19,19 +23,13 @@ fn test() { let models = &download_dir.join("assimp/assimp/test/models"); let mut collada_models = BTreeSet::new(); - // let mut obj_models = BTreeSet::new(); + let mut obj_models = BTreeSet::new(); let mut stl_models = BTreeSet::new(); for e in WalkDir::new(models).into_iter().filter_map(Result::ok) { let path = e.path(); - if let Some(filename) = path.file_name().and_then(OsStr::to_str) { - if filename.contains("UTF16") { - // Skip non-UTF-8 text files. - continue; - } - } match path.extension().and_then(OsStr::to_str) { Some("dae" | "DAE") => collada_models.insert(path.to_owned()), - // Some("obj" | "OBJ") => obj_models.insert(path.to_owned()), + Some("obj" | "OBJ") => obj_models.insert(path.to_owned()), Some("stl" | "STL") => stl_models.insert(path.to_owned()), ext => match path.parent().unwrap().file_stem().and_then(OsStr::to_str) { Some("Collada") if ext == Some("xml") => collada_models.insert(path.to_owned()), @@ -40,8 +38,8 @@ fn test() { }, }; } - assert_eq!(collada_models.len(), 25); - // assert_eq!(obj_models.len(), 26); + assert_eq!(collada_models.len(), 26); + assert_eq!(obj_models.len(), 26); assert_eq!(stl_models.len(), 9); let mesh_loader = mesh_loader::Loader::default().stl_parse_color(true); @@ -63,6 +61,7 @@ fn test() { } let ml = mesh_loader::Mesh::merge(ml.meshes); eprintln!("merge(ml.meshes)={ml:?}"); + // assert_ne!(ml.vertices.len(), 0); assert_eq!(ml.vertices.len(), ml.faces.len() * 3); if ml.normals.is_empty() { assert_eq!(ml.normals.capacity(), 0); @@ -89,7 +88,7 @@ fn test() { // assimp parse error: Cannot parse string \" 0.0 0.0 0.0 1.0 \" as a real number: does not start with digit or decimal point followed by digit. "library_animation_clips.dae" => continue, // assimp error: "Collada: File came out empty. Something is wrong here." - "cube_tristrips.dae" if option_env!("CI").is_some() => continue, + "cube_tristrips.dae" | "cube_UTF16LE.dae" if option_env!("CI").is_some() => continue, _ => {} } let ai = assimp_importer.read_file(path.to_str().unwrap()).unwrap(); @@ -133,11 +132,12 @@ fn test() { if !matches!( filename, "AsXML.xml" - | "COLLADA.dae" - | "Cinema4D.dae" | "anims_with_full_rotations_between_keys.DAE" - | "cube_UTF8BOM.dae" + | "Cinema4D.dae" + | "COLLADA.dae" | "cube_emptyTags.dae" + | "cube_UTF16LE.dae" + | "cube_UTF8BOM.dae" | "cube_xmlspecialchars.dae" | "duck.dae" | "sphere.dae" @@ -152,12 +152,13 @@ fn test() { if !matches!( filename, "AsXML.xml" - | "COLLADA.dae" - | "ConcavePolygon.dae" | "anims_with_full_rotations_between_keys.DAE" | "cameras.dae" - | "cube_UTF8BOM.dae" + | "COLLADA.dae" + | "ConcavePolygon.dae" | "cube_emptyTags.dae" + | "cube_UTF16LE.dae" + | "cube_UTF8BOM.dae" | "cube_xmlspecialchars.dae" | "duck.dae" | "lights.dae" @@ -168,8 +169,8 @@ fn test() { // TODO if !matches!( filename, - "Cinema4D.dae" - | "box_nested_animation.dae" + "box_nested_animation.dae" + | "Cinema4D.dae" | "cube_tristrips.dae" | "cube_with_2UVs.DAE" | "earthCylindrical.DAE" @@ -207,6 +208,189 @@ fn test() { } } + // OBJ + for path in &obj_models { + eprintln!(); + eprintln!("parsing {:?}", path.strip_prefix(manifest_dir).unwrap()); + let filename = path.file_name().unwrap().to_str().unwrap(); + match filename { + // no mesh + "point_cloud.obj" + // no face + | "testline.obj" | "testpoints.obj" + => continue, + _ => {} + } + + // mesh-loader + match filename { + // number parsing issue + "number_formats.obj" + // TODO: should not be allowed + | "empty.obj" | "malformed2.obj" => continue, + _ => {} + } + if path.parent().unwrap().file_name().unwrap() == "invalid" { + let _e = mesh_loader.load(path).unwrap_err(); + let _e = assimp_importer + .read_file(path.to_str().unwrap()) + .map(drop) + .unwrap_err(); + continue; + } + let ml = mesh_loader.load(path).unwrap(); + for (i, m) in ml.meshes.iter().enumerate() { + eprintln!("ml.meshes[{i}]={m:?}"); + } + let ml = mesh_loader::Mesh::merge(ml.meshes); + eprintln!("merge(ml.meshes)={ml:?}"); + assert_ne!(ml.vertices.len(), 0); + assert_eq!(ml.vertices.len(), ml.faces.len() * 3); + if ml.normals.is_empty() { + // assert_eq!(ml.normals.capacity(), 0); + } else { + assert_eq!(ml.vertices.len(), ml.normals.len()); + } + for texcoords in &ml.texcoords { + if texcoords.is_empty() { + assert_eq!(texcoords.capacity(), 0); + } else { + assert_eq!(ml.vertices.len(), texcoords.len()); + } + } + for colors in &ml.colors { + if colors.is_empty() { + assert_eq!(colors.capacity(), 0); + } else { + assert_eq!(ml.vertices.len(), colors.len()); + } + } + + // assimp + match filename { + // segmentation fault... + "box.obj" + | "box_longline.obj" + | "box_mat_with_spaces.obj" + | "box_without_lineending.obj" + | "multiple_spaces.obj" + | "only_a_part_of_vertexcolors.obj" + | "regr_3429812.obj" + | "regr01.obj" + | "testmixed.obj" => continue, + // no mesh... + "box_UTF16BE.obj" => continue, + // less number of faces loaded... + "cube_with_vertexcolors.obj" | "cube_with_vertexcolors_uni.obj" + if option_env!("CI").is_some() => + { + continue + } + _ => {} + } + let ai = assimp_importer.read_file(path.to_str().unwrap()).unwrap(); + // assert_eq!(ai.num_meshes, 1); + // assert_eq!(ai.num_meshes, ai.num_materials); + let ai = ai.mesh(0).unwrap(); + // assert_eq!(ai.num_vertices, ai.num_faces * 3); + assert_eq!(ai.num_vertices as usize, ai.vertex_iter().count()); + assert_eq!(ai.num_vertices as usize, ai.normal_iter().count()); + if ai.has_texture_coords(0) { + assert_eq!(ai.num_vertices as usize, ai.texture_coords_iter(0).count()); + } + if ai.has_vertex_colors(0) { + assert_eq!(ai.num_vertices as usize, ai.vertex_color_iter(0).count()); + } + assert!(!ai.has_texture_coords(1)); + + // TODO + if !matches!( + filename, + "concave_polygon.obj" | "space_in_material_name.obj" | "spider.obj" | "cube_usemtl.obj" + ) { + assert_eq!(ml.faces.len(), ai.num_faces as usize); + for (ml, ai) in ml + .faces + .iter() + .copied() + .zip(ai.face_iter().map(|f| [f[0], f[1], f[2]])) + { + assert_eq!(ml, ai); + } + } + if !matches!( + filename, + "concave_polygon.obj" | "space_in_material_name.obj" | "spider.obj" | "cube_usemtl.obj" + ) { + assert_eq!(ml.vertices.len(), ai.num_vertices as usize); + assert_eq!(ml.normals.len(), ai.num_vertices as usize); + if !matches!(filename, "cube_usemtl.obj") { + for (j, (ml, ai)) in ml + .vertices + .iter() + .copied() + .zip(ai.vertex_iter().map(|f| [f.x, f.y, f.z])) + .enumerate() + { + let eps = f32::EPSILON * 10.; + for i in 0..ml.len() { + let (a, b) = (ml[i], ai[i]); + assert!( + (a - b).abs() < eps, + "assertion failed: `(left !== right)` \ + (left: `{a:?}`, right: `{b:?}`, expect diff: `{eps:?}`, \ + real diff: `{:?}`) at vertices[{j}][{i}]", + (a - b).abs() + ); + } + } + for (j, (ml, ai)) in ml + .normals + .iter() + .copied() + .zip(ai.normal_iter().map(|f| [f.x, f.y, f.z])) + .enumerate() + { + let eps = f32::EPSILON; + for i in 0..ml.len() { + let (a, b) = (ml[i], ai[i]); + assert!( + (a - b).abs() < eps, + "assertion failed: `(left !== right)` \ + (left: `{a:?}`, right: `{b:?}`, expect diff: `{eps:?}`, \ + real diff: `{:?}`) at normals[{j}][{i}]", + (a - b).abs() + ); + } + } + } + if ai.has_vertex_colors(0) { + assert_eq!(ml.colors[0].len(), ai.num_vertices as usize); + for (j, (ml, ai)) in ml.colors[0] + .iter() + .copied() + .zip(ai.vertex_color_iter(0).map(|f| [f.r, f.g, f.b, f.a])) + .enumerate() + { + let eps = f32::EPSILON; + for i in 0..ml.len() { + let (a, b) = (ml[i], ai[i]); + assert!( + (a - b).abs() < eps, + "assertion failed: `(left !== right)` \ + (left: `{a:?}`, right: `{b:?}`, expect diff: `{eps:?}`, \ + real diff: `{:?}`) at colors[0][{j}][{i}]", + (a - b).abs() + ); + assert!(a >= 0. && a <= 100.); + } + } + } else { + assert_eq!(ml.colors[0].len(), 0); + } + } + } + // STL for path in &stl_models { eprintln!(); @@ -220,6 +404,7 @@ fn test() { } let ml = mesh_loader::Mesh::merge(ml.meshes); eprintln!("merge(ml.meshes)={ml:?}"); + assert_ne!(ml.vertices.len(), 0); assert_eq!(ml.vertices.len(), ml.faces.len() * 3); assert_eq!(ml.vertices.len(), ml.normals.len()); for texcoords in &ml.texcoords { @@ -319,8 +504,8 @@ fn test() { assert!( (a - b).abs() < eps, "assertion failed: `(left !== right)` \ - (left: `{a:?}`, right: `{b:?}`, expect diff: `{eps:?}`, real diff: `{:?}`) \ - at normals[{j}][{i}]", + (left: `{a:?}`, right: `{b:?}`, expect diff: `{eps:?}`, \ + real diff: `{:?}`) at colors[0][{j}][{i}]", (a - b).abs() ); assert!(a >= 0. && a <= 100.);