diff --git a/cvcpkg/recipes/libcvc/recipe.yaml b/cvcpkg/recipes/libcvc/recipe.yaml index 1939b2f9..a98dd678 100644 --- a/cvcpkg/recipes/libcvc/recipe.yaml +++ b/cvcpkg/recipes/libcvc/recipe.yaml @@ -101,6 +101,20 @@ depends: - name: yaml - name: levmar - name: libiimod + # Native mesh/model import (Phase 3: cvc::model + read_model/read_geometry for + # obj/ply/stl/fbx/gltf/... via Assimp). Guarded by CVC_ENABLE_ASSIMP; the + # bundle links libassimp so it must be present in the flat prefix. (libcvc's + # CMake rewrites assimp's exported absolute-zlib-path quirk to ZLIB::ZLIB.) + # + # Scoped to the platforms assimp is published for (linux/macos-arm64/freebsd/ + # netbsd). It is NOT yet built for windows, and `cvcpkg install-deps` reads + # THIS recipe's closure to set up the build prefix — an unconditional assimp + # dep would fail the windows deps-install (no bundle) for both CI and publish. + # Where assimp is absent, find_package(assimp CONFIG) misses and CVC_ENABLE_ + # ASSIMP flips OFF, so libcvc still builds (without the model loader) — add + # windows here once an assimp windows bundle is published. + - name: assimp + platforms: [linux, macos, freebsd, netbsd] - name: openblas - name: openssl - name: c-ares diff --git a/inc/cvc/model/model.h b/inc/cvc/model/model.h new file mode 100644 index 00000000..fad7b457 --- /dev/null +++ b/inc/cvc/model/model.h @@ -0,0 +1,98 @@ +/* + Copyright 2024 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License version 2.1 as published by the + Free Software Foundation. +*/ + +#ifndef __CVC_MODEL_H__ +#define __CVC_MODEL_H__ + +// cvc::model — a small scene value type (Phase-3 mesh/model surface). A single +// mesh file (OBJ/glTF/FBX/...) can carry several meshes plus their materials and +// textures, so a model bundles a vector of meshes (each a cvc::geometry + a +// material index) and a vector of cvc::material. Textures decode BELOW the VTK +// line via cvc::image, so cvcGL, pycvc, and the mesh loaders consume already +// decoded pixels. Like cvc::geometry / cvc::image this is a plain, copyable +// value type; the reference-counted sharing lives inside geometry/image. + +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { + +// -------- +// material +// -------- +// Purpose: +// A minimal PBR-ish material description. Values are normalized to the glTF +// metallic/roughness model; OBJ/other importers map their fields onto it +// (Kd -> base_color rgb, d/opacity -> base_color a, etc.). base_color is a +// multiplier applied on top of base_color_texture when one is present. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation (Phase-3 model surface). +struct material { + std::string name; + boost::array base_color = { + {1, 1, 1, 1}}; // RGBA multiplier (glTF baseColorFactor / obj Kd+d) + double metallic = 0.0; + double roughness = 1.0; + boost::array emissive = {{0, 0, 0}}; + std::string + base_color_texture_path; // path exactly as referenced in the file (may be relative or empty) + image base_color_texture; // the LOADED texture (image.empty() if none / unresolved) + + bool has_base_color_texture() const { return !base_color_texture.empty(); } +}; + +// ----- +// model +// ----- +// Purpose: +// A scene: several meshes and the materials they reference. Each mesh pairs a +// cvc::geometry with an index into materials (-1 when the mesh has no material). +// ---- Change History ---- +// 2024 -- Joe R. -- Creation (Phase-3 model surface). +struct model { + struct mesh { + geometry geom; + int material = -1; // index into model::materials, or -1 for none + std::string name; + }; + + std::vector meshes; + std::vector materials; + + bool empty() const { return meshes.empty(); } + + // --------------- + // model::merged + // --------------- + // Purpose: + // Concatenate every mesh into a single cvc::geometry, offsetting each mesh's + // triangle (and line/quad) indices by the running vertex count so the merged + // index buffer is correct. Per-vertex attributes (normals/colors/uvs/tangents) + // are appended in the same order. Material assignment is not preserved. + geometry merged() const; + + // --------------- + // model::extents + // --------------- + // Purpose: + // Union of every mesh's bounding box (reuses geometry's bbox facility). + bounding_box extents() const; + + boost::uint64_t num_meshes() const { return meshes.size(); } +}; + +} // namespace cvc + +#endif // __CVC_MODEL_H__ diff --git a/inc/cvc/model/model_file_io.h b/inc/cvc/model/model_file_io.h new file mode 100644 index 00000000..b6b4b6c2 --- /dev/null +++ b/inc/cvc/model/model_file_io.h @@ -0,0 +1,196 @@ +/* + Copyright 2024 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License version 2.1 as published by the + Free Software Foundation. +*/ + +#ifndef __CVC_MODEL_FILE_IO__ +#define __CVC_MODEL_FILE_IO__ + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { +CVC_DEF_EXCEPTION(unsupported_model_file_type); + +// ------------- +// model_file_io +// ------------- +// Purpose: +// Provides I/O for model (multi-mesh scene) data. Mirrors geometry_file_io / +// volume_file_io: handlers declare the extensions they support and are +// dispatched by extension, first handler that succeeds wins. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation (Phase-3 model surface). +struct model_file_io { + static const char *FILE_EXTENSION_EXPR; + + // ----------------- + // model_file_io::id + // ----------------- + // Purpose: + // Returns a string that identifies this model_file_io object. This should + // be unique, but is freeform. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + virtual const std::string &id() const = 0; + + typedef std::list extension_list; + + // ------------------------- + // model_file_io::extensions + // ------------------------- + // Purpose: + // Returns a list of extensions that this model_file_io object supports. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + virtual const extension_list &extensions() const = 0; + + // ------------------- + // model_file_io::read + // ------------------- + // Purpose: + // Reads a file and outputs a model object. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + virtual model read(const std::string &filename) const = 0; + + // -------------------- + // model_file_io::write + // -------------------- + // Purpose: + // Writes a model to file. Export is optional; a handler that cannot write + // throws unsupported_model_file_type. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + virtual void write(const model &m, const std::string &filename) const = 0; + + // ---------------------- + // model_file_io::extents + // ---------------------- + // Purpose: + // Returns the smallest bounding box that includes all vertices in the file. + // Default implementation reads the entire file and computes it. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + virtual bounding_box extents(const std::string &filename); + + virtual ~model_file_io() {} + + typedef boost::shared_ptr ptr; + typedef std::vector handlers; + typedef std::map + handler_map; + + // ------------------------- + // model_file_io::get_handlers + // ------------------------- + // Purpose: + // Static initialization of handler map. Clients use the handler_map + // to add themselves to the collection of objects that are to be used + // to perform model file i/o operations. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + static handler_map &get_handlers(); + + // ----------------------------- + // model_file_io::insert_handler + // ----------------------------- + // Purpose: + // Convenience function for adding objects to the map. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + static void insert_handler(const ptr &mfio); + + // ----------------------------- + // model_file_io::remove_handler + // ----------------------------- + // Purpose: + // Convenience function for removing objects from the map. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + static void remove_handler(const ptr &mfio); + + // ----------------------------- + // model_file_io::remove_handler + // ----------------------------- + // Purpose: + // Convenience function for removing objects from the map. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + static void remove_handler(const std::string &name); + + // ----------------------------- + // model_file_io::get_extensions + // ----------------------------- + // Purpose: + // Returns the list of supported file extensions. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + static std::vector get_extensions(); + +private: + // ----------------------------- + // model_file_io::initialize_map + // ----------------------------- + // Purpose: + // Adds the standard model_file_io objects to a new handler_map object + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + static handler_map *initialize_map(); + + // ----------------------------- + // model_file_io::insert_handler + // ----------------------------- + // Purpose: + // Convenience function for adding objects to the specified map. + // ---- Change History ---- + // 2024 -- Joe R. -- Creation. + static void insert_handler(handler_map &hm, const ptr &mfio); +}; + +// ---------- +// read_model +// ---------- +// Purpose: +// The main read model function. Refers to the handler map to choose +// an appropriate IO object for reading the requested model file. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +model read_model(const std::string &filename); + +// ----------- +// write_model +// ----------- +// Purpose: +// The main write model function. Refers to the handler map to choose +// an appropriate IO object for writing the requested model file. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +void write_model(const model &m, const std::string &filename); + +// ---------------------------------- +// register_default_model_handlers +// ---------------------------------- +// Purpose: +// Register the built-in model I/O handlers without requiring a cvc::app +// instance. Called from model_file_io::get_handlers() the first time the +// handler map is requested. Registers nothing when no import backend is +// compiled in (e.g. CVC_ENABLE_ASSIMP off). +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +void register_default_model_handlers(); +} // namespace cvc + +#endif diff --git a/inc/cvc/volume/io_handlers.h b/inc/cvc/volume/io_handlers.h index df20fb26..64407424 100644 --- a/inc/cvc/volume/io_handlers.h +++ b/inc/cvc/volume/io_handlers.h @@ -35,6 +35,13 @@ void register_bunny_io(); void register_off_io(); void register_cvcraw_io(); +// Conditionally compiled geometry handler. The Assimp geometry flattening +// handler (defined in model/assimp_io.cpp) registers itself into the +// geometry_file_io registry so read_geometry("foo.obj") works. +#ifdef CVC_ENABLE_ASSIMP +void register_assimp_geometry_io(); +#endif + // Convenience entry point that calls each register_*_io() above. Used // by geometry_file_io::get_handlers() to populate the handler map on // first access without requiring a cvc::app instance to exist. diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index 627be1c8..4d3c397f 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -81,6 +81,8 @@ set(INCLUDE_FILES ../../inc/cvc/core/app.h ../../inc/cvc/volume/volmagick.h ../../inc/cvc/geometry/geometry_file_io.h + ../../inc/cvc/model/model.h + ../../inc/cvc/model/model_file_io.h ../../inc/cvc/volume/volume_ops.h ) @@ -97,6 +99,9 @@ set(SOURCE_FILES geometry/geometry_file_io.cpp image/image.cpp image/magick_io.cpp + model/model.cpp + model/model_file_io.cpp + model/assimp_io.cpp volume/mrc_io.cpp volume/null_io.cpp geometry/off_io.cpp @@ -645,6 +650,39 @@ if(CVC_ENABLE_IMAGEMAGICK) endif() endif() +# Assimp support (for native mesh/model import - cvc::model / read_geometry) +option(CVC_ENABLE_ASSIMP "Enable Assimp for native mesh/model import" ON) +if(CVC_ENABLE_ASSIMP) + find_package(assimp CONFIG) + if(assimp_FOUND) + message(STATUS "Assimp found: ${assimp_VERSION}") + # DEFENSIVE: the cvcpkg assimp bundle's exported target hardcodes an ABSOLUTE + # build-sandbox zlib path in INTERFACE_LINK_LIBRARIES. The recipe builds + # assimp with -DASSIMP_BUILD_ZLIB=OFF, and assimp's CMake bakes + # ${ZLIB_LIBRARIES} (an absolute path from FindZLIB) into the export instead + # of the relocatable ZLIB::ZLIB target — so the published bundle carries a + # path like /tmp/cvcpkg-builder/.../lib/libz.so that exists on no consumer, + # and linking assimp::assimp fails everywhere (locally and in CI). libcvc + # links zlib itself, so rewrite that stale entry to the relocatable + # ZLIB::ZLIB target (falling back to a bare -lz). Remove once the assimp + # recipe exports zlib via an imported target (tracked libcvc-deps follow-up). + find_package(ZLIB QUIET) + get_target_property(_cvc_assimp_ill assimp::assimp INTERFACE_LINK_LIBRARIES) + if(_cvc_assimp_ill AND "${_cvc_assimp_ill}" MATCHES "libz\\.|cvcpkg-builder") + if(TARGET ZLIB::ZLIB) + string(REGEX REPLACE "[^;]*libz\\.[^;]*" "ZLIB::ZLIB" _cvc_assimp_ill "${_cvc_assimp_ill}") + else() + string(REGEX REPLACE "[^;]*libz\\.[^;]*" "z" _cvc_assimp_ill "${_cvc_assimp_ill}") + endif() + set_target_properties(assimp::assimp PROPERTIES INTERFACE_LINK_LIBRARIES "${_cvc_assimp_ill}") + message(STATUS " (rewrote assimp's stale absolute zlib link to a relocatable target)") + endif() + else() + message(STATUS "Assimp not found - disabling native mesh/model import") + set(CVC_ENABLE_ASSIMP OFF) + endif() +endif() + # FFTW support (for volume_ops filtered back-projection) option(CVC_ENABLE_FFTW "Enable FFTW support for filtered back-projection in volume_ops" ON) if(CVC_ENABLE_FFTW) @@ -889,6 +927,13 @@ if(CVC_ENABLE_IMAGEMAGICK) endif() endif() +# Assimp linking. PUBLIC so tests inherit the CVC_ENABLE_ASSIMP define (as with +# ImageMagick). The assimp::assimp imported target carries its own include dirs. +if(CVC_ENABLE_ASSIMP) + target_compile_definitions(cvc PUBLIC CVC_ENABLE_ASSIMP) + target_link_libraries(cvc PUBLIC assimp::assimp) +endif() + # FFTW linking if(CVC_ENABLE_FFTW) target_compile_definitions(cvc PUBLIC CVC_ENABLE_FFTW) diff --git a/src/cvc/geometry/geometry_file_io.cpp b/src/cvc/geometry/geometry_file_io.cpp index 478fc015..57ca52bd 100644 --- a/src/cvc/geometry/geometry_file_io.cpp +++ b/src/cvc/geometry/geometry_file_io.cpp @@ -213,5 +213,8 @@ void register_default_geometry_handlers() { register_bunny_io(); register_off_io(); register_cvcraw_io(); +#ifdef CVC_ENABLE_ASSIMP + register_assimp_geometry_io(); +#endif } } // namespace cvc diff --git a/src/cvc/model/assimp_io.cpp b/src/cvc/model/assimp_io.cpp new file mode 100644 index 00000000..136b7657 --- /dev/null +++ b/src/cvc/model/assimp_io.cpp @@ -0,0 +1,441 @@ +/* + Copyright 2024 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License version 2.1 as published by the + Free Software Foundation. +*/ + +// assimp_io — the Assimp-backed cvc::model / cvc::geometry importer. Assimp +// decodes a broad set of mesh/scene formats (obj, ply, stl, fbx, gltf/glb, dae, +// ...) into aiScene; here we lower each aiMesh into a cvc::geometry (positions, +// normals, vertex colors, UV0, tangents) and each aiMaterial into a +// cvc::material, resolving + decoding the base-color texture into a cvc::image +// (below the VTK line). Two handlers are exposed: a model_file_io that returns +// the full multi-mesh scene, and a geometry_file_io that flattens it via +// model::merged() so read_geometry("foo.obj") works. Everything Assimp-specific +// is guarded by CVC_ENABLE_ASSIMP; when it is off the register hooks are no-ops +// and read_model/read_geometry raise "no handler" (mirroring the ImageMagick +// image handler). + +#include + +#ifdef CVC_ENABLE_ASSIMP +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +namespace cvc { +namespace { + +// Assimp's common importable set (leading dot, matching the geometry_file_io +// handler-map key convention). +model_file_io::extension_list assimp_extensions() { + model_file_io::extension_list e; + e.push_back(".obj"); + e.push_back(".ply"); + e.push_back(".stl"); + e.push_back(".fbx"); + e.push_back(".gltf"); + e.push_back(".glb"); + e.push_back(".dae"); + e.push_back(".3ds"); + e.push_back(".blend"); + e.push_back(".x"); + e.push_back(".off"); + e.push_back(".lwo"); + e.push_back(".ms3d"); + e.push_back(".ase"); + e.push_back(".ifc"); + return e; +} + +// Post-process flags. NB: no aiProcess_FlipUVs — the GeometryNode flips V in the +// texture path, so UVs are kept exactly as authored. +unsigned int import_flags() { + return aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_CalcTangentSpace | + aiProcess_JoinIdenticalVertices | aiProcess_ImproveCacheLocality; +} + +// Directory containing `p` ("." if none), for resolving relative texture paths. +std::string dir_of(const std::string &p) { + std::string::size_type s = p.find_last_of("/\\"); + return s == std::string::npos ? std::string(".") : p.substr(0, s); +} + +bool is_absolute_path(const std::string &p) { + if (p.empty()) + return false; + if (p[0] == '/' || p[0] == '\\') + return true; + // Windows drive letter (e.g. C:\...) + if (p.size() >= 2 && p[1] == ':') + return true; + return false; +} + +std::string default_temp_dir() { + const char *env[] = {std::getenv("TMPDIR"), std::getenv("TMP"), std::getenv("TEMP")}; + for (int i = 0; i < 3; ++i) + if (env[i] && env[i][0]) + return std::string(env[i]); + return "/tmp"; +} + +// Decode an embedded, compressed texture blob (mHeight == 0: pcData is `mWidth` +// bytes of e.g. PNG/JPEG). cvc's image reader only reads from a path, so spill +// the blob to a temp file with the format-hint extension and read_image() it. +image decode_embedded_compressed(const aiTexture *tex) { + std::string hint(tex->achFormatHint); + std::string ext = hint.empty() ? std::string("bin") : hint; + // Unique temp name: an atomic counter (intra-process races) qualified by the + // pid (two processes sharing TMPDIR) — a plain static int would collide. + static std::atomic counter(0); +#if defined(_WIN32) + const long pid = static_cast(_getpid()); +#else + const long pid = static_cast(::getpid()); +#endif + std::string tmp = default_temp_dir() + "/cvc_embedded_tex_" + std::to_string(pid) + "_" + + std::to_string(counter.fetch_add(1)) + "." + ext; + { + std::ofstream ofs(tmp.c_str(), std::ios::binary); + if (!ofs) + throw std::runtime_error("could not open temp file for embedded texture"); + ofs.write(reinterpret_cast(tex->pcData), + static_cast(tex->mWidth)); + } + image out; + try { + out = read_image(tmp); + } catch (...) { + std::remove(tmp.c_str()); + throw; + } + std::remove(tmp.c_str()); + return out; +} + +// Build a cvc::image from a raw, uncompressed embedded texture (mHeight != 0: +// pcData is mWidth*mHeight aiTexel, which are BGRA in memory). Convert to cvc's +// interleaved RGBA. +image decode_embedded_raw(const aiTexture *tex) { + int w = static_cast(tex->mWidth); + int h = static_cast(tex->mHeight); + image out(w, h, image::pixel_format::RGBA, image::data_type::u8); + if (w <= 0 || h <= 0) + return out; + unsigned char *d = out.data(); + const std::size_t n = static_cast(w) * static_cast(h); + for (std::size_t i = 0; i < n; ++i) { + const aiTexel &t = tex->pcData[i]; + d[i * 4 + 0] = t.r; + d[i * 4 + 1] = t.g; + d[i * 4 + 2] = t.b; + d[i * 4 + 3] = t.a; + } + return out; +} + +// Resolve + load a base-color texture into a cvc::image. A missing/unreadable +// texture is non-fatal: return an empty image (the path is still recorded). +image load_texture(const aiScene *scene, const std::string &texpath, const std::string &model_dir) { + if (texpath.empty()) + return image(); + try { + if (texpath[0] == '*') { // assimp embedded-texture index, e.g. "*0" + int idx = std::atoi(texpath.c_str() + 1); + if (idx < 0 || static_cast(idx) >= scene->mNumTextures) + return image(); + const aiTexture *tex = scene->mTextures[idx]; + if (!tex) + return image(); + return tex->mHeight == 0 ? decode_embedded_compressed(tex) : decode_embedded_raw(tex); + } + std::string resolved = is_absolute_path(texpath) ? texpath : (model_dir + "/" + texpath); + return read_image(resolved); + } catch (const std::exception &e) { + // Don't fail the whole load for a missing/unsupported texture. + std::cerr << "cvc::model assimp: could not load texture '" << texpath << "': " << e.what() + << std::endl; + return image(); + } +} + +// Lower one aiMesh into a cvc::geometry. +geometry build_geometry(const aiMesh *mesh) { + geometry geom; + + const unsigned int nv = mesh->mNumVertices; + + { + geometry::points_t &pts = geom.points(); + pts.reserve(nv); + for (unsigned int v = 0; v < nv; ++v) { + point_t p; + p[0] = mesh->mVertices[v].x; + p[1] = mesh->mVertices[v].y; + p[2] = mesh->mVertices[v].z; + pts.push_back(p); + } + } + + if (mesh->HasNormals()) { + geometry::normals_t &nrm = geom.normals(); + nrm.reserve(nv); + for (unsigned int v = 0; v < nv; ++v) { + geometry::normal_t n; + n[0] = mesh->mNormals[v].x; + n[1] = mesh->mNormals[v].y; + n[2] = mesh->mNormals[v].z; + nrm.push_back(n); + } + } + + if (mesh->HasVertexColors(0)) { + geometry::colors_t &col = geom.colors(); + col.reserve(nv); + for (unsigned int v = 0; v < nv; ++v) { + color_t c; + c[0] = mesh->mColors[0][v].r; + c[1] = mesh->mColors[0][v].g; + c[2] = mesh->mColors[0][v].b; + col.push_back(c); + } + } + + if (mesh->HasTextureCoords(0)) { + geometry::uvs_t &uv = geom.uvs(); + uv.reserve(nv); + for (unsigned int v = 0; v < nv; ++v) { + uv_t t; + // Keep UVs as authored (no V flip); the texture path handles orientation. + t[0] = mesh->mTextureCoords[0][v].x; + t[1] = mesh->mTextureCoords[0][v].y; + uv.push_back(t); + } + } + + if (mesh->HasTangentsAndBitangents()) { + // Assimp stores a 3-component tangent + a separate bitangent. cvc's tangent + // is xyz + a handedness w = sign(dot(cross(n, t), bitangent)); reconstruct it. + geometry::tangents_t &tng = geom.tangents(); + tng.reserve(nv); + const bool have_n = mesh->HasNormals(); + for (unsigned int v = 0; v < nv; ++v) { + const aiVector3D &t = mesh->mTangents[v]; + const aiVector3D &b = mesh->mBitangents[v]; + double w = 1.0; + if (have_n) { + const aiVector3D &n = mesh->mNormals[v]; + // cross(n, t) + double cx = n.y * t.z - n.z * t.y; + double cy = n.z * t.x - n.x * t.z; + double cz = n.x * t.y - n.y * t.x; + double dot = cx * b.x + cy * b.y + cz * b.z; + w = dot < 0.0 ? -1.0 : 1.0; + } + tangent_t out; + out[0] = t.x; + out[1] = t.y; + out[2] = t.z; + out[3] = w; + tng.push_back(out); + } + } + + { + geometry::tris_t &tris = geom.tris(); + tris.reserve(mesh->mNumFaces); + for (unsigned int f = 0; f < mesh->mNumFaces; ++f) { + const aiFace &face = mesh->mFaces[f]; + // Post-Triangulate every polygon is a triangle; skip anything else + // (points/lines emitted by degenerate faces) defensively. + if (face.mNumIndices != 3) + continue; + tri_t tr; + tr[0] = face.mIndices[0]; + tr[1] = face.mIndices[1]; + tr[2] = face.mIndices[2]; + tris.push_back(tr); + } + } + + return geom; +} + +// Lower one aiMaterial into a cvc::material (resolving + loading its texture). +material build_material(const aiScene *scene, const aiMaterial *aim, const std::string &model_dir) { + material mat; + + aiString nm; + if (aim->Get(AI_MATKEY_NAME, nm) == AI_SUCCESS) + mat.name = nm.C_Str(); + + // base color: prefer the PBR base-color factor, fall back to diffuse. + aiColor4D base; + if (aim->Get(AI_MATKEY_BASE_COLOR, base) == AI_SUCCESS) { + mat.base_color[0] = base.r; + mat.base_color[1] = base.g; + mat.base_color[2] = base.b; + mat.base_color[3] = base.a; + } else { + aiColor3D diff(1.f, 1.f, 1.f); + if (aim->Get(AI_MATKEY_COLOR_DIFFUSE, diff) == AI_SUCCESS) { + mat.base_color[0] = diff.r; + mat.base_color[1] = diff.g; + mat.base_color[2] = diff.b; + } + } + // opacity folds into the base-color alpha. + float opacity = 1.f; + if (aim->Get(AI_MATKEY_OPACITY, opacity) == AI_SUCCESS) + mat.base_color[3] = opacity; + + float mf = 0.f; + if (aim->Get(AI_MATKEY_METALLIC_FACTOR, mf) == AI_SUCCESS) + mat.metallic = mf; + float rf = 1.f; + if (aim->Get(AI_MATKEY_ROUGHNESS_FACTOR, rf) == AI_SUCCESS) + mat.roughness = rf; + + aiColor3D em(0.f, 0.f, 0.f); + if (aim->Get(AI_MATKEY_COLOR_EMISSIVE, em) == AI_SUCCESS) { + mat.emissive[0] = em.r; + mat.emissive[1] = em.g; + mat.emissive[2] = em.b; + } + + // base-color texture: prefer BASE_COLOR, fall back to legacy DIFFUSE. + aiString texpath; + if (aim->GetTexture(aiTextureType_BASE_COLOR, 0, &texpath) == AI_SUCCESS || + aim->GetTexture(aiTextureType_DIFFUSE, 0, &texpath) == AI_SUCCESS) { + mat.base_color_texture_path = texpath.C_Str(); + mat.base_color_texture = load_texture(scene, mat.base_color_texture_path, model_dir); + } + + return mat; +} + +// Shared read path for both handlers. +model read_model_assimp(const std::string &path) { + Assimp::Importer imp; + const aiScene *s = imp.ReadFile(path, import_flags()); + if (!s || (s->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !s->mRootNode) + throw std::runtime_error(std::string("cvc::model assimp read '") + path + + "': " + imp.GetErrorString()); + + const std::string model_dir = dir_of(path); + + model m; + m.materials.reserve(s->mNumMaterials); + for (unsigned int i = 0; i < s->mNumMaterials; ++i) + m.materials.push_back(build_material(s, s->mMaterials[i], model_dir)); + + m.meshes.reserve(s->mNumMeshes); + for (unsigned int i = 0; i < s->mNumMeshes; ++i) { + const aiMesh *am = s->mMeshes[i]; + model::mesh mm; + mm.geom = build_geometry(am); + mm.material = static_cast(am->mMaterialIndex); + mm.name = am->mName.C_Str(); + m.meshes.push_back(mm); + } + + return m; +} + +// ------------- +// assimp_model_io +// ------------- +// A model_file_io handler that returns the full multi-mesh scene. +class assimp_model_io : public model_file_io { +public: + assimp_model_io() : _id("assimp_model_io : v1.0"), _extensions(assimp_extensions()) {} + + virtual const std::string &id() const { return _id; } + virtual const extension_list &extensions() const { return _extensions; } + + virtual model read(const std::string &filename) const { return read_model_assimp(filename); } + + virtual void write(const model & /*m*/, const std::string &filename) const { + throw unsupported_model_file_type( + std::string("cvc::model assimp write: export not supported: ") + filename); + } + +private: + std::string _id; + extension_list _extensions; +}; + +// ----------------- +// assimp_geometry_io +// ----------------- +// A geometry_file_io handler that flattens the scene so read_geometry() works on +// the same formats. +class assimp_geometry_io : public geometry_file_io { +public: + assimp_geometry_io() : _id("assimp_geometry_io : v1.0") { + model_file_io::extension_list e = assimp_extensions(); + for (model_file_io::extension_list::const_iterator i = e.begin(); i != e.end(); ++i) + _extensions.push_back(*i); + } + + virtual const std::string &id() const { return _id; } + virtual const extension_list &extensions() const { return _extensions; } + + virtual geometry read(const std::string &filename) const { + return read_model_assimp(filename).merged(); + } + + virtual void write(const geometry & /*geom*/, const std::string &filename) const { + throw unsupported_geometry_file_type( + std::string("cvc::model assimp write: export not supported: ") + filename); + } + +private: + std::string _id; + extension_list _extensions; +}; + +} // namespace +} // namespace cvc +#endif // CVC_ENABLE_ASSIMP + +namespace cvc { +// Register the built-in model handlers. Called once (lazily) from +// model_file_io::get_handlers(). When Assimp is disabled this registers nothing +// — read_model then raises "no handler" until another backend is added. +void register_default_model_handlers() { +#ifdef CVC_ENABLE_ASSIMP + model_file_io::insert_handler(model_file_io::ptr(new assimp_model_io())); +#endif +} + +#ifdef CVC_ENABLE_ASSIMP +// Register the Assimp geometry flattening handler into the geometry_file_io +// registry (same mechanism as off_io). Called from +// register_default_geometry_handlers(). +void register_assimp_geometry_io() { + geometry_file_io::insert_handler(geometry_file_io::ptr(new assimp_geometry_io())); +} +#endif +} // namespace cvc diff --git a/src/cvc/model/model.cpp b/src/cvc/model/model.cpp new file mode 100644 index 00000000..df821593 --- /dev/null +++ b/src/cvc/model/model.cpp @@ -0,0 +1,90 @@ +/* + Copyright 2024 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License version 2.1 as published by the + Free Software Foundation. +*/ + +#include + +namespace cvc { + +// --------------- +// model::merged +// --------------- +// Purpose: +// Concatenate every mesh into a single geometry. cvc::geometry::merge already +// appends the per-vertex attribute arrays and offsets the appended +// line/tri/quad indices by the running vertex count, so a fold over merge() +// produces the correct flattened index buffer. +geometry model::merged() const { + // geometry::merge appends the per-vertex attribute arrays (normals/colors/uvs/ + // tangents) by raw insert, WITHOUT padding them to the vertex count — it only + // offsets the appended index buffers. So merging meshes with heterogeneous + // attribute presence (e.g. one textured mesh carrying UVs+tangents and one + // untextured mesh carrying neither, which is routine for a multi-material + // OBJ/glTF) would leave a merged array shorter than points() and misaligned + // relative to the correctly-offset triangle indices — a consumer indexing + // uvs[vertexIndex] then reads out of bounds or the wrong vertex. Normalize + // first: if ANY mesh carries an attribute, pad the meshes that lack it to full + // length with a neutral default, so every per-vertex array in the result stays + // exactly num_points() long and index-aligned. + bool any_normals = false, any_colors = false, any_uvs = false, any_tangents = false; + for (std::vector::const_iterator i = meshes.begin(); i != meshes.end(); ++i) { + any_normals = any_normals || !i->geom.const_normals().empty(); + any_colors = any_colors || !i->geom.const_colors().empty(); + any_uvs = any_uvs || !i->geom.const_uvs().empty(); + any_tangents = any_tangents || !i->geom.const_tangents().empty(); + } + + geometry::normal_t def_n; + def_n[0] = 0; + def_n[1] = 0; + def_n[2] = 1; + geometry::color_t def_c; + def_c[0] = 1; + def_c[1] = 1; + def_c[2] = 1; + geometry::uv_t def_uv; + def_uv[0] = 0; + def_uv[1] = 0; + geometry::tangent_t def_t; + def_t[0] = 1; + def_t[1] = 0; + def_t[2] = 0; + def_t[3] = 1; + + geometry out; + for (std::vector::const_iterator i = meshes.begin(); i != meshes.end(); ++i) { + geometry g = i->geom; // COW copy; the padding below detaches only what it touches + const std::size_t nv = static_cast(g.num_points()); + if (any_normals && g.const_normals().empty()) + g.normals().assign(nv, def_n); + if (any_colors && g.const_colors().empty()) + g.colors().assign(nv, def_c); + if (any_uvs && g.const_uvs().empty()) + g.uvs().assign(nv, def_uv); + if (any_tangents && g.const_tangents().empty()) + g.tangents().assign(nv, def_t); + out.merge(g); + } + return out; +} + +// --------------- +// model::extents +// --------------- +// Purpose: +// Union of every mesh's bounding box. generic_bounding_box::operator+ treats a +// null (zero-volume) box as the identity, so an empty model yields a null box. +bounding_box model::extents() const { + bounding_box bbox; + for (std::vector::const_iterator i = meshes.begin(); i != meshes.end(); ++i) + bbox = bbox + i->geom.extents(); + return bbox; +} + +} // namespace cvc diff --git a/src/cvc/model/model_file_io.cpp b/src/cvc/model/model_file_io.cpp new file mode 100644 index 00000000..82252ab1 --- /dev/null +++ b/src/cvc/model/model_file_io.cpp @@ -0,0 +1,213 @@ +/* + Copyright 2024 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License version 2.1 as published by the + Free Software Foundation. +*/ + +#include +#include +#include +#include + +namespace cvc { +// A regex to extract a filename extension +const char *model_file_io::FILE_EXTENSION_EXPR = "^(.*)(\\.\\S*)$"; + +// ---------------------- +// model_file_io::extents +// ---------------------- +// Purpose: +// Returns the smallest bounding box that includes all vertices in the file. +// This default implementation reads the entire file and computes it. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +bounding_box model_file_io::extents(const std::string &filename) { + return read(filename).extents(); +} + +// --------------------------- +// model_file_io::get_handlers +// --------------------------- +// Purpose: +// Static initialization of handler map. Clients use the handler_map +// to add themselves to the collection of objects that are to be used +// to perform model file i/o operations. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +model_file_io::handler_map &model_file_io::get_handlers() { + // It's ok to leak: http://www.parashift.com/c++-faq-lite/ctors.html#faq-10.15 + static handler_map *p = initialize_map(); + + // Ensure the default model I/O handlers are registered the first time the + // handler map is requested. The handler register_* helpers call back into + // insert_handler() and therefore re-enter get_handlers(); an explicit guard + // avoids re-entering the same function-local static initialiser, which would + // be undefined behaviour. + static bool _registering_defaults = false; + static bool _defaults_registered = false; + if (!_defaults_registered && !_registering_defaults) { + _registering_defaults = true; + register_default_model_handlers(); + _defaults_registered = true; + _registering_defaults = false; + } + + return *p; +} + +// ----------------------------- +// model_file_io::insert_handler +// ----------------------------- +// Purpose: +// Convenence function for adding objects to the map. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +void model_file_io::insert_handler(const ptr &mfio) { insert_handler(get_handlers(), mfio); } + +// ----------------------------- +// model_file_io::remove_handler +// ----------------------------- +// Purpose: +// Convenence function for removing objects from the map. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +void model_file_io::remove_handler(const ptr &mfio) { + for (handler_map::iterator i = get_handlers().begin(); i != get_handlers().end(); i++) { + handlers h; + for (handlers::iterator j = i->second.begin(); j != i->second.end(); j++) { + if (*j != mfio) + h.push_back(*j); + } + i->second = h; + } +} + +// ----------------------------- +// model_file_io::remove_handler +// ----------------------------- +// Purpose: +// Convenence function for removing objects from the map. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +void model_file_io::remove_handler(const std::string &id) { + for (handler_map::iterator i = get_handlers().begin(); i != get_handlers().end(); i++) { + handlers h; + for (handlers::iterator j = i->second.begin(); j != i->second.end(); j++) { + if ((*j)->id() != id) + h.push_back(*j); + } + i->second = h; + } +} + +// ----------------------------- +// model_file_io::get_extensions +// ----------------------------- +// Purpose: +// Returns the list of supported file extensions. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +std::vector model_file_io::get_extensions() { + std::vector ret; + BOOST_FOREACH (handler_map::value_type &i, get_handlers()) { + ret.push_back(i.first); + } + return ret; +} + +// ----------------------------- +// model_file_io::initialize_map +// ----------------------------- +// Purpose: +// Adds the standard model_file_io objects to a new handler_map object +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +model_file_io::handler_map *model_file_io::initialize_map() { + handler_map *map = new handler_map; + return map; +} + +// ----------------------------- +// model_file_io::insert_handler +// ----------------------------- +// Purpose: +// Convenence function for adding objects to the specified map. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +void model_file_io::insert_handler(handler_map &hm, const ptr &mfio) { + if (!mfio) + return; + for (model_file_io::extension_list::const_iterator i = mfio->extensions().begin(); + i != mfio->extensions().end(); i++) { + hm[*i].push_back(mfio); + } +} + +// ---------- +// read_model +// ---------- +// Purpose: +// The main read model function. Refers to the handler map to choose +// an appropriate IO object for reading the requested model file. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +model read_model(const std::string &filename) { + using namespace std; + using namespace boost; + string errors; + smatch what; + const regex file_extension(model_file_io::FILE_EXTENSION_EXPR); + if (regex_match(filename, what, file_extension)) { + if (model_file_io::get_handlers()[what[2]].empty()) + throw unsupported_model_file_type(string(BOOST_CURRENT_FUNCTION) + string(": Cannot read ") + + filename); + model_file_io::handlers &h = model_file_io::get_handlers()[what[2]]; + // use the first handler that succeds + for (model_file_io::handlers::iterator i = h.begin(); i != h.end(); i++) + try { + if (*i) + return (*i)->read(filename); + } catch (exception &e) { + errors += string(" :: ") + e.what(); + } + } + throw unsupported_model_file_type(str(boost::format("%1% : Cannot read '%2%'%3%") % + BOOST_CURRENT_FUNCTION % filename % errors)); +} + +// ----------- +// write_model +// ----------- +// Purpose: +// The main write model function. Refers to the handler map to choose +// an appropriate IO object for writing the requested model file. +// ---- Change History ---- +// 2024 -- Joe R. -- Creation. +void write_model(const model &m, const std::string &filename) { + using namespace std; + using namespace boost; + string errors; + smatch what; + const regex file_extension(model_file_io::FILE_EXTENSION_EXPR); + if (regex_match(filename, what, file_extension)) { + if (model_file_io::get_handlers()[what[2]].empty()) + throw unsupported_model_file_type(string(BOOST_CURRENT_FUNCTION) + string(": Cannot write ") + + filename); + model_file_io::handlers &h = model_file_io::get_handlers()[what[2]]; + // use the first handler that succeds + for (model_file_io::handlers::iterator i = h.begin(); i != h.end(); i++) + try { + if (*i) + return (*i)->write(m, filename); + } catch (exception &e) { + errors += string(" :: ") + e.what(); + } + } + throw unsupported_model_file_type(str(boost::format("%1% : Cannot write '%2%'%3%") % + BOOST_CURRENT_FUNCTION % filename % errors)); +} +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index cc359e91..b562b269 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -93,6 +93,7 @@ add_executable(procedural_geometry_test procedural_geometry_test.cpp) add_executable(volume_ops_test volume_ops_test.cpp) add_executable(volume_io_test volume_io_test.cpp) add_executable(image_test image_test.cpp) +add_executable(model_test model_test.cpp) add_executable(geometry_attributes_test geometry_attributes_test.cpp) add_executable(utility_test utility_test.cpp) @@ -141,6 +142,7 @@ set(TEST_TARGETS volume_ops_test volume_io_test image_test + model_test geometry_attributes_test utility_test ) @@ -260,6 +262,13 @@ target_link_libraries(image_test GTest::gtest_main ) +target_link_libraries(model_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(geometry_attributes_test PRIVATE cvc @@ -787,6 +796,7 @@ target_compile_features(procedural_geometry_test PRIVATE cxx_std_14) target_compile_features(volume_ops_test PRIVATE cxx_std_14) target_compile_features(volume_io_test PRIVATE cxx_std_17) target_compile_features(image_test PRIVATE cxx_std_17) +target_compile_features(model_test PRIVATE cxx_std_17) target_compile_features(geometry_attributes_test PRIVATE cxx_std_17) target_compile_features(utility_test PRIVATE cxx_std_17) target_compile_features(state_change_journal_test PRIVATE cxx_std_17) @@ -948,6 +958,7 @@ endif() gtest_discover_tests(volume_ops_test) gtest_discover_tests(volume_io_test) gtest_discover_tests(image_test) +gtest_discover_tests(model_test) gtest_discover_tests(geometry_attributes_test) gtest_discover_tests(utility_test) gtest_discover_tests(state_change_journal_test) diff --git a/src/cvc/tests/model_test.cpp b/src/cvc/tests/model_test.cpp new file mode 100644 index 00000000..df19c630 --- /dev/null +++ b/src/cvc/tests/model_test.cpp @@ -0,0 +1,327 @@ +// Tests for cvc::model (Phase-3 mesh/model surface): the value type +// (merged()/extents()), the model_file_io registry dispatch, and the +// Assimp-backed loader (guarded on CVC_ENABLE_ASSIMP) for OBJ (UVs + material + +// texture), the read_geometry() flatten path, and STL (no UVs). + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef CVC_ENABLE_ASSIMP +#include +#endif + +using cvc::geometry; +using cvc::model; + +namespace { + +// A single triangle in the z=0 plane with indices (0,1,2). +geometry make_triangle() { + geometry g; + cvc::point_t p; + p[0] = 0; + p[1] = 0; + p[2] = 0; + g.points().push_back(p); + p[0] = 1; + p[1] = 0; + p[2] = 0; + g.points().push_back(p); + p[0] = 0; + p[1] = 1; + p[2] = 0; + g.points().push_back(p); + cvc::tri_t t; + t[0] = 0; + t[1] = 1; + t[2] = 2; + g.tris().push_back(t); + return g; +} + +// A non-planar 4-point cloud whose bbox is min..max (has non-zero volume, so the +// bounding_box union does not treat it as the null/identity box). +geometry make_box_corner(double ox, double oy, double oz) { + geometry g; + const double pts[4][3] = {{ox, oy, oz}, {ox + 1, oy, oz}, {ox, oy + 1, oz}, {ox, oy, oz + 1}}; + for (int i = 0; i < 4; ++i) { + cvc::point_t p; + p[0] = pts[i][0]; + p[1] = pts[i][1]; + p[2] = pts[i][2]; + g.points().push_back(p); + } + return g; +} + +// The same triangle as make_triangle() but carrying per-vertex UVs (distinct +// values so a misalignment after merge is detectable). +geometry make_triangle_with_uvs() { + geometry g = make_triangle(); + const double uvs[3][2] = {{0.10, 0.20}, {0.30, 0.40}, {0.50, 0.60}}; + for (int i = 0; i < 3; ++i) { + cvc::uv_t uv; + uv[0] = uvs[i][0]; + uv[1] = uvs[i][1]; + g.uvs().push_back(uv); + } + return g; +} + +} // namespace + +// ── value-type tests (no Assimp needed) ────────────────────────────────────── + +TEST(ModelTest, DefaultIsEmpty) { + model m; + EXPECT_TRUE(m.empty()); + EXPECT_EQ(m.num_meshes(), 0u); +} + +TEST(ModelTest, MergedOffsetsIndices) { + model m; + model::mesh a; + a.geom = make_triangle(); + model::mesh b; + b.geom = make_triangle(); + m.meshes.push_back(a); + m.meshes.push_back(b); + EXPECT_EQ(m.num_meshes(), 2u); + + geometry merged = m.merged(); + ASSERT_EQ(merged.num_points(), 6u); + ASSERT_EQ(merged.num_tris(), 2u); + // First mesh's triangle is unshifted. + EXPECT_EQ(merged.const_tris()[0][0], 0u); + EXPECT_EQ(merged.const_tris()[0][1], 1u); + EXPECT_EQ(merged.const_tris()[0][2], 2u); + // Second mesh's triangle is offset by the running vertex count (3). + EXPECT_EQ(merged.const_tris()[1][0], 3u); + EXPECT_EQ(merged.const_tris()[1][1], 4u); + EXPECT_EQ(merged.const_tris()[1][2], 5u); +} + +// Regression for the merged() attribute-desync blocker: geometry::merge appends +// per-vertex arrays without padding, so merging a UV'd mesh with a UV-less one +// would leave uvs shorter than points and misaligned against the offset indices. +// merged() must normalize (pad) so every present per-vertex array stays exactly +// num_points() long and index-aligned regardless of mesh order. +TEST(ModelTest, MergedPadsHeterogeneousAttributes) { + model m; + model::mesh a; + a.geom = make_triangle_with_uvs(); // 3 verts WITH uvs + model::mesh b; + b.geom = make_triangle(); // 3 verts, NO uvs + m.meshes.push_back(a); + m.meshes.push_back(b); + + geometry merged = m.merged(); + ASSERT_EQ(merged.num_points(), 6u); + // Padded to full length (would be 3 without the fix). + ASSERT_EQ(merged.const_uvs().size(), 6u); + // Mesh A's authored UVs stay attached to vertices 0..2. + EXPECT_NEAR(merged.const_uvs()[0][0], 0.10, 1e-6); + EXPECT_NEAR(merged.const_uvs()[1][0], 0.30, 1e-6); + EXPECT_NEAR(merged.const_uvs()[2][0], 0.50, 1e-6); + // Mesh B (no UVs) gets neutral (0,0) padding at 3..5 — NOT A's values. + EXPECT_NEAR(merged.const_uvs()[3][0], 0.0, 1e-6); + EXPECT_NEAR(merged.const_uvs()[4][1], 0.0, 1e-6); + + // Reverse order: a UV-less mesh first must not slide A's UVs onto wrong verts. + model m2; + model::mesh b2; + b2.geom = make_triangle(); + m2.meshes.push_back(b2); + model::mesh a2; + a2.geom = make_triangle_with_uvs(); + m2.meshes.push_back(a2); + geometry merged2 = m2.merged(); + ASSERT_EQ(merged2.const_uvs().size(), 6u); + EXPECT_NEAR(merged2.const_uvs()[0][0], 0.0, 1e-6); // padded (mesh B) + EXPECT_NEAR(merged2.const_uvs()[3][0], 0.10, 1e-6); // mesh A vertex 0 + EXPECT_NEAR(merged2.const_uvs()[5][0], 0.50, 1e-6); // mesh A vertex 2 +} + +TEST(ModelTest, ExtentsUnion) { + model m; + model::mesh a; + a.geom = make_box_corner(0, 0, 0); // bbox 0,0,0 .. 1,1,1 + model::mesh b; + b.geom = make_box_corner(2, 2, 2); // bbox 2,2,2 .. 3,3,3 + m.meshes.push_back(a); + m.meshes.push_back(b); + + cvc::bounding_box bb = m.extents(); + EXPECT_DOUBLE_EQ(bb.XMin(), 0.0); + EXPECT_DOUBLE_EQ(bb.YMin(), 0.0); + EXPECT_DOUBLE_EQ(bb.ZMin(), 0.0); + EXPECT_DOUBLE_EQ(bb.XMax(), 3.0); + EXPECT_DOUBLE_EQ(bb.YMax(), 3.0); + EXPECT_DOUBLE_EQ(bb.ZMax(), 3.0); +} + +TEST(ModelTest, UnsupportedExtensionThrows) { + EXPECT_ANY_THROW(cvc::read_model("/no/such/file.qwerty")); +} + +// ── Assimp-backed loader tests ─────────────────────────────────────────────── + +#ifdef CVC_ENABLE_ASSIMP + +namespace { + +// Write a small OBJ + MTL + PNG texture into TempDir and return the .obj path. +// Returns "" (with a skip flag set) if the PNG could not be written (no image +// delegate) — the caller GTEST_SKIP()s in that case. +std::string write_obj_fixture(bool &texture_ok) { + const std::string dir = ::testing::TempDir(); + const std::string obj = dir + "cvc_model_test_mesh.obj"; + const std::string mtl = dir + "cvc_model_test_mesh.mtl"; + const std::string png = dir + "cvc_model_test_tex.png"; + + // 4x4 RGBA texture via cvc::image (needs the ImageMagick handler). + texture_ok = false; + try { + cvc::image tex(4, 4, cvc::image::pixel_format::RGBA, cvc::image::data_type::u8); + unsigned char *p = tex.data(); + for (int i = 0; i < 4 * 4; ++i) { + p[i * 4 + 0] = 200; + p[i * 4 + 1] = 100; + p[i * 4 + 2] = 50; + p[i * 4 + 3] = 255; + } + tex.save(png); + texture_ok = true; + } catch (const std::exception &) { + texture_ok = false; + } + + { + std::ofstream om(mtl.c_str()); + om << "newmtl cvc_mat\n"; + om << "Kd 0.8 0.2 0.1\n"; + om << "d 1.0\n"; + om << "map_Kd cvc_model_test_tex.png\n"; + } + { + std::ofstream oo(obj.c_str()); + oo << "mtllib cvc_model_test_mesh.mtl\n"; + oo << "v 0 0 0\n"; + oo << "v 1 0 0\n"; + oo << "v 0 1 0\n"; + oo << "vt 0 0\n"; + oo << "vt 1 0\n"; + oo << "vt 0 1\n"; + oo << "usemtl cvc_mat\n"; + oo << "f 1/1 2/2 3/3\n"; + } + return obj; +} + +bool uv_present(const geometry::uvs_t &uvs, double u, double v) { + for (std::size_t i = 0; i < uvs.size(); ++i) + if (std::abs(uvs[i][0] - u) < 1e-5 && std::abs(uvs[i][1] - v) < 1e-5) + return true; + return false; +} + +} // namespace + +TEST(ModelTest, RegistryHasObjExtension) { + std::vector exts = cvc::model_file_io::get_extensions(); + bool has_obj = false; + for (std::size_t i = 0; i < exts.size(); ++i) + if (exts[i] == ".obj") + has_obj = true; + EXPECT_TRUE(has_obj); +} + +TEST(ModelTest, ObjWithUvsMaterialTexture) { + bool texture_ok = false; + std::string obj = write_obj_fixture(texture_ok); + if (!texture_ok) + GTEST_SKIP() << "No image delegate available to write the OBJ texture PNG"; + + model m = cvc::read_model(obj); + ASSERT_GE(m.num_meshes(), 1u); + const model::mesh &mesh = m.meshes[0]; + + EXPECT_EQ(mesh.geom.num_points(), 3u); + EXPECT_EQ(mesh.geom.num_tris(), 1u); + + // UVs present and matching the authored (unflipped) coordinates. + const geometry::uvs_t &uvs = mesh.geom.const_uvs(); + ASSERT_EQ(uvs.size(), 3u); + EXPECT_TRUE(uv_present(uvs, 0, 0)); + EXPECT_TRUE(uv_present(uvs, 1, 0)); + EXPECT_TRUE(uv_present(uvs, 0, 1)); + + // Material: diffuse Kd -> base_color rgb, d=1 -> alpha, texture path + image. + ASSERT_GE(mesh.material, 0); + ASSERT_LT(static_cast(mesh.material), m.materials.size()); + const cvc::material &mat = m.materials[mesh.material]; + EXPECT_NEAR(mat.base_color[0], 0.8, 1e-3); + EXPECT_NEAR(mat.base_color[1], 0.2, 1e-3); + EXPECT_NEAR(mat.base_color[2], 0.1, 1e-3); + EXPECT_NEAR(mat.base_color[3], 1.0, 1e-3); + EXPECT_EQ(mat.base_color_texture_path, "cvc_model_test_tex.png"); + ASSERT_TRUE(mat.has_base_color_texture()); + EXPECT_EQ(mat.base_color_texture.width(), 4); + EXPECT_EQ(mat.base_color_texture.height(), 4); + // Decoded pixels round-trip (PNG is lossless; the texture is a uniform color so + // this is flip-invariant) — guards the channel order / decode path, not just dims. + const unsigned char *tp = mat.base_color_texture.data(); + ASSERT_TRUE(tp != NULL); + EXPECT_EQ(static_cast(tp[0]), 200); + EXPECT_EQ(static_cast(tp[1]), 100); + EXPECT_EQ(static_cast(tp[2]), 50); + EXPECT_EQ(static_cast(tp[3]), 255); + + // The mesh has UVs, so aiProcess_CalcTangentSpace produced tangents: full-length + // and unit-handedness (w == +/-1), which is how build_geometry reconstructs w. + const geometry::tangents_t &tan = mesh.geom.const_tangents(); + ASSERT_EQ(tan.size(), mesh.geom.num_points()); + for (std::size_t i = 0; i < tan.size(); ++i) + EXPECT_NEAR(std::abs(tan[i][3]), 1.0, 1e-6); +} + +TEST(ModelTest, ReadGeometryObjFlatten) { + bool texture_ok = false; + std::string obj = write_obj_fixture(texture_ok); + // The flatten path does not require the texture; only the mesh matters. + geometry g = cvc::read_geometry(obj); + EXPECT_EQ(g.num_points(), 3u); + EXPECT_EQ(g.num_tris(), 1u); +} + +TEST(ModelTest, StlNoUvs) { + const std::string dir = ::testing::TempDir(); + const std::string stl = dir + "cvc_model_test_tri.stl"; + { + std::ofstream os(stl.c_str()); + os << "solid cvc\n"; + os << " facet normal 0 0 1\n"; + os << " outer loop\n"; + os << " vertex 0 0 0\n"; + os << " vertex 1 0 0\n"; + os << " vertex 0 1 0\n"; + os << " endloop\n"; + os << " endfacet\n"; + os << "endsolid cvc\n"; + } + + model m = cvc::read_model(stl); + ASSERT_GE(m.num_meshes(), 1u); + const model::mesh &mesh = m.meshes[0]; + EXPECT_GE(mesh.geom.num_points(), 3u); + EXPECT_EQ(mesh.geom.num_tris(), 1u); + EXPECT_TRUE(mesh.geom.const_uvs().empty()); +} + +#endif // CVC_ENABLE_ASSIMP