From 40440fcc3e0298c535cd41f7c889cee1296b63b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8C=82=E4=B9=8B=E9=92=B3?= Date: Fri, 24 Jul 2026 09:06:12 +0000 Subject: [PATCH] feat: GLTF/GLB export with normals + B-Rep tessellation + pipeline example --- examples/07_pipeline/CMakeLists.txt | 2 + examples/07_pipeline/main.cpp | 68 +++++++ examples/CMakeLists.txt | 1 + include/vde/foundation/io_gltf.h | 24 ++- src/foundation/io_gltf.cpp | 302 +++++++++++++++++++++++++--- tests/CMakeLists.txt | 1 + tests/foundation/CMakeLists.txt | 1 + tests/foundation/test_io_gltf.cpp | 151 ++++++++++++++ 8 files changed, 521 insertions(+), 29 deletions(-) create mode 100644 examples/07_pipeline/CMakeLists.txt create mode 100644 examples/07_pipeline/main.cpp create mode 100644 tests/foundation/CMakeLists.txt create mode 100644 tests/foundation/test_io_gltf.cpp diff --git a/examples/07_pipeline/CMakeLists.txt b/examples/07_pipeline/CMakeLists.txt new file mode 100644 index 0000000..5c72626 --- /dev/null +++ b/examples/07_pipeline/CMakeLists.txt @@ -0,0 +1,2 @@ +add_executable(pipeline_demo main.cpp) +target_link_libraries(pipeline_demo PRIVATE vde) diff --git a/examples/07_pipeline/main.cpp b/examples/07_pipeline/main.cpp new file mode 100644 index 0000000..6bcbad2 --- /dev/null +++ b/examples/07_pipeline/main.cpp @@ -0,0 +1,68 @@ +#include "vde/sdf/sdf_primitives.h" +#include "vde/sdf/sdf_operations.h" +#include "vde/sdf/sdf_to_mesh.h" +#include "vde/brep/modeling.h" +#include "vde/mesh/marching_cubes.h" +#include "vde/mesh/halfedge_mesh.h" +#include "vde/foundation/io_gltf.h" +#include +#include + +int main() { + // ── Pipeline 1: SDF → Mesh → GLB ── + std::cout << "Pipeline 1: SDF → Mesh → GLB\n"; + + // Build SDF expression: smooth union of sphere and box + auto sphere_sdf = [](const vde::core::Point3D& p) { + return vde::sdf::sphere(p, 1.5); + }; + + auto combined = [&](double x, double y, double z) -> double { + vde::core::Point3D p(x, y, z); + double d_sphere = sphere_sdf(p); + double d_box = vde::sdf::box(p, vde::core::Point3D(1.0, 1.0, 1.0)); + return vde::sdf::op_smooth_union(d_sphere, d_box, 0.3); + }; + + // Marching cubes → MCMesh + double iso = 0.0; + vde::core::Point3D bmin(-3.0, -3.0, -3.0); + vde::core::Point3D bmax(3.0, 3.0, 3.0); + int resolution = 64; + + auto mc_result = vde::mesh::marching_cubes(combined, iso, bmin, bmax, resolution); + + // Build HalfedgeMesh from marching cubes result + vde::mesh::HalfedgeMesh sdf_mesh; + sdf_mesh.build_from_triangles(mc_result.vertices, mc_result.triangles); + + // Export + vde::foundation::GltfOptions opt; + opt.binary = true; + opt.include_normals = true; + + std::string sdf_out = "output_sdf_shape.glb"; + if (vde::foundation::write_gltf(sdf_out, sdf_mesh, opt)) { + std::cout << " → " << sdf_out << " written (" + << sdf_mesh.num_vertices() << " vertices, " + << sdf_mesh.num_faces() << " faces)\n"; + } else { + std::cerr << " ✗ Failed to write " << sdf_out << "\n"; + } + + // ── Pipeline 2: B-Rep → Tessellate → GLB ── + std::cout << "\nPipeline 2: B-Rep → Tessellate → GLB\n"; + + auto box_b = vde::brep::make_box(2.0, 2.0, 2.0); + auto shelled = vde::brep::shell(box_b, -1, 0.15); // closed shell, 0.15 thickness + + std::string brep_out = "output_brep_shell.glb"; + if (vde::foundation::write_brep_gltf(brep_out, shelled, 32)) { + std::cout << " → " << brep_out << " written\n"; + } else { + std::cerr << " ✗ Failed to write " << brep_out << "\n"; + } + + std::cout << "\nDone! Open .glb files in any 3D viewer.\n"; + return 0; +} diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 5ebde21..93afbbb 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -4,3 +4,4 @@ add_subdirectory(03_mesh) add_subdirectory(04_delaunay) add_subdirectory(05_boolean) add_subdirectory(06_collision) +add_subdirectory(07_pipeline) diff --git a/include/vde/foundation/io_gltf.h b/include/vde/foundation/io_gltf.h index c8a7c18..c3f6c95 100644 --- a/include/vde/foundation/io_gltf.h +++ b/include/vde/foundation/io_gltf.h @@ -1,11 +1,31 @@ #pragma once #include "vde/mesh/halfedge_mesh.h" +#include "vde/brep/brep.h" #include +#include +#include namespace vde::foundation { -/// Export mesh to glTF 2.0 (JSON + optional embedded binary) +/// GLTF export options +struct GltfOptions { + bool binary = true; // GLB (single file) vs glTF (JSON + .bin) + bool include_normals = true; // Compute and export vertex normals + bool include_colors = false; // Per-vertex colors + std::vector vertex_colors; // RGB per vertex (if include_colors) +}; + +/// Export mesh to glTF 2.0 / GLB /// Returns true on success -bool write_gltf(const std::string& filepath, const mesh::HalfedgeMesh& mesh); +bool write_gltf(const std::string& filepath, const mesh::HalfedgeMesh& mesh, + const GltfOptions& options = GltfOptions{}); + +/// Export B-Rep model to GLB by tessellating faces +/// @param filepath Output file (.glb recommended) +/// @param model B-Rep model to export +/// @param tessellation_res Resolution for curved surfaces (segments per edge) +/// @return true on success +bool write_brep_gltf(const std::string& filepath, const brep::BrepModel& model, + int tessellation_res = 32); } // namespace vde::foundation diff --git a/src/foundation/io_gltf.cpp b/src/foundation/io_gltf.cpp index 7fda23a..46dd3c8 100644 --- a/src/foundation/io_gltf.cpp +++ b/src/foundation/io_gltf.cpp @@ -1,48 +1,180 @@ #include "vde/foundation/io_gltf.h" #include "vde/core/aabb.h" +#include "vde/curves/tessellation.h" #include #include #include -#include +#include +#include +#include namespace vde::foundation { +// ── Helpers ────────────────────────────────────────────────────────── + static std::string vec3_json(double x, double y, double z) { - return "[" + std::to_string(x) + "," + std::to_string(y) + "," + std::to_string(z) + "]"; + std::ostringstream ss; + ss << std::fixed << "[" << x << "," << y << "," << z << "]"; + return ss.str(); } -bool write_gltf(const std::string& filepath, const mesh::HalfedgeMesh& mesh) { - std::ofstream file(filepath); - if (!file) return false; +static void compute_normals(const mesh::HalfedgeMesh& mesh, + std::vector& normals) { + normals.resize(mesh.num_vertices() * 3, 0.0f); + // For each face, compute face normal and accumulate to vertices + for (size_t fi = 0; fi < mesh.num_faces(); ++fi) { + auto vis = mesh.face_vertices(static_cast(fi)); + if (vis.size() < 3) continue; + const auto& v0 = mesh.vertex(vis[0]); + const auto& v1 = mesh.vertex(vis[1]); + const auto& v2 = mesh.vertex(vis[2]); + // Cross product for face normal + float nx = static_cast((v1.y() - v0.y()) * (v2.z() - v0.z()) - + (v1.z() - v0.z()) * (v2.y() - v0.y())); + float ny = static_cast((v1.z() - v0.z()) * (v2.x() - v0.x()) - + (v1.x() - v0.x()) * (v2.z() - v0.z())); + float nz = static_cast((v1.x() - v0.x()) * (v2.y() - v0.y()) - + (v1.y() - v0.y()) * (v2.x() - v0.x())); + for (size_t j = 0; j < 3 && j < vis.size(); ++j) { + normals[vis[j] * 3 + 0] += nx; + normals[vis[j] * 3 + 1] += ny; + normals[vis[j] * 3 + 2] += nz; + } + } + // Normalize + for (size_t i = 0; i < mesh.num_vertices(); ++i) { + float len = std::sqrt(normals[i * 3] * normals[i * 3] + + normals[i * 3 + 1] * normals[i * 3 + 1] + + normals[i * 3 + 2] * normals[i * 3 + 2]); + if (len > 1e-10f) { + normals[i * 3] /= len; + normals[i * 3 + 1] /= len; + normals[i * 3 + 2] /= len; + } + } +} +static void write_glb_header(std::ostream& out, uint32_t total_length) { + uint32_t magic = 0x46546C67; // "glTF" + uint32_t version = 2; + out.write(reinterpret_cast(&magic), 4); + out.write(reinterpret_cast(&version), 4); + out.write(reinterpret_cast(&total_length), 4); +} + +static void write_glb_chunk(std::ostream& out, uint32_t chunk_type, + const std::string& data) { + uint32_t len = static_cast(data.size()); + out.write(reinterpret_cast(&len), 4); + out.write(reinterpret_cast(&chunk_type), 4); + out.write(data.data(), data.size()); +} + +// ── GLB binary buffer layout ───────────────────────────────────────── + +struct GlbBufferLayout { + size_t positions_offset = 0; + size_t positions_byte_len = 0; + size_t normals_offset = 0; + size_t normals_byte_len = 0; + size_t indices_offset = 0; + size_t indices_byte_len = 0; + size_t total_bin_len = 0; +}; + +static GlbBufferLayout compute_layout(const mesh::HalfedgeMesh& mesh, + const GltfOptions& options) { + GlbBufferLayout layout; + size_t nv = mesh.num_vertices(); + size_t nf = mesh.num_faces(); + size_t index_count = nf * 3; + + layout.positions_offset = 0; + layout.positions_byte_len = nv * 12; // 3 floats * 4 bytes + + layout.normals_offset = layout.positions_byte_len; + layout.normals_byte_len = options.include_normals ? nv * 12 : 0; + + layout.indices_offset = layout.normals_offset + layout.normals_byte_len; + layout.indices_byte_len = index_count * 4; // uint32 per index + + layout.total_bin_len = layout.indices_offset + layout.indices_byte_len; + return layout; +} + +static std::string generate_json(const mesh::HalfedgeMesh& mesh, + const GltfOptions& options, + const GlbBufferLayout& layout) { size_t nv = mesh.num_vertices(); size_t nf = mesh.num_faces(); size_t index_count = nf * 3; core::AABB3D bounds = mesh.bounds(); - file << "{\n"; - file << " \"asset\": {\"version\": \"2.0\", \"generator\": \"ViewDesignEngine\"},\n"; - file << " \"scene\": 0,\n"; - file << " \"scenes\": [{\"nodes\": [0]}],\n"; - file << " \"nodes\": [{\"mesh\": 0}],\n"; - file << " \"meshes\": [{\"primitives\": [{\"attributes\": {\"POSITION\": 0},\"indices\": 1}]}],\n"; - file << " \"buffers\": [{\"uri\": \"mesh.bin\",\"byteLength\": " << (nv*12+index_count*4) << "}],\n"; - file << " \"bufferViews\": [\n"; - file << " {\"buffer\": 0, \"byteOffset\": 0, \"byteLength\": " << (nv*12) << "},\n"; - file << " {\"buffer\": 0, \"byteOffset\": " << (nv*12) << ", \"byteLength\": " << (index_count*4) << "}\n"; - file << " ],\n"; - file << " \"accessors\": [\n"; - file << " {\"bufferView\": 0, \"componentType\": 5126, \"count\": " << nv - << ", \"type\": \"VEC3\", \"max\": " << vec3_json(bounds.max().x(),bounds.max().y(),bounds.max().z()) - << ", \"min\": " << vec3_json(bounds.min().x(),bounds.min().y(),bounds.min().z()) << "},\n"; - file << " {\"bufferView\": 1, \"componentType\": 5125, \"count\": " << index_count << ", \"type\": \"SCALAR\"}\n"; - file << " ]\n}\n"; + std::ostringstream ss; + ss << std::fixed << std::setprecision(6); + ss << "{\n"; + ss << " \"asset\":{\"version\":\"2.0\",\"generator\":\"ViewDesignEngine\"},\n"; + ss << " \"scene\":0,\n"; + ss << " \"scenes\":[{\"nodes\":[0]}],\n"; + ss << " \"nodes\":[{\"mesh\":0}],\n"; - // Write binary data - std::string dot = "."; - std::string binpath = filepath.substr(0, filepath.rfind(dot)) + ".bin"; - std::ofstream bin(binpath, std::ios::binary); + // Mesh primitive + ss << " \"meshes\":[{\"primitives\":[{\"attributes\":{"; + ss << "\"POSITION\":0"; + if (options.include_normals) ss << ",\"NORMAL\":1"; + ss << "},\"indices\":" << (options.include_normals ? 2 : 1) << "}]}],\n"; + // Buffer + ss << " \"buffers\":[{\"byteLength\":" << layout.total_bin_len << "}],\n"; + + // Buffer views + ss << " \"bufferViews\":[\n"; + ss << " {\"buffer\":0,\"byteOffset\":" << layout.positions_offset + << ",\"byteLength\":" << layout.positions_byte_len + << ",\"target\":34962},\n"; + if (options.include_normals) { + ss << " {\"buffer\":0,\"byteOffset\":" << layout.normals_offset + << ",\"byteLength\":" << layout.normals_byte_len + << ",\"target\":34962},\n"; + } + ss << " {\"buffer\":0,\"byteOffset\":" << layout.indices_offset + << ",\"byteLength\":" << layout.indices_byte_len + << ",\"target\":34963}\n"; + ss << " ],\n"; + + // Accessors + ss << " \"accessors\":[\n"; + ss << " {\"bufferView\":0,\"componentType\":5126,\"count\":" << nv + << ",\"type\":\"VEC3\",\"max\":" + << vec3_json(bounds.max().x(), bounds.max().y(), bounds.max().z()) + << ",\"min\":" + << vec3_json(bounds.min().x(), bounds.min().y(), bounds.min().z()) + << "},\n"; + int acc_index = 1; + if (options.include_normals) { + ss << " {\"bufferView\":1,\"componentType\":5126,\"count\":" << nv + << ",\"type\":\"VEC3\"},\n"; + acc_index = 2; + } + ss << " {\"bufferView\":" << (options.include_normals ? 2 : 1) + << ",\"componentType\":5125,\"count\":" << index_count + << ",\"type\":\"SCALAR\"}\n"; + ss << " ]\n"; + ss << "}"; + + return ss.str(); +} + +// ── Write binary data ───────────────────────────────────────────────── + +static void write_binary_data(std::ostream& bin, + const mesh::HalfedgeMesh& mesh, + const GltfOptions& options, + const GlbBufferLayout& layout) { + size_t nv = mesh.num_vertices(); + size_t nf = mesh.num_faces(); + + // Positions for (size_t i = 0; i < nv; ++i) { const auto& v = mesh.vertex(i); float fx = static_cast(v.x()); @@ -52,6 +184,16 @@ bool write_gltf(const std::string& filepath, const mesh::HalfedgeMesh& mesh) { bin.write(reinterpret_cast(&fy), 4); bin.write(reinterpret_cast(&fz), 4); } + + // Normals + if (options.include_normals) { + std::vector normals; + compute_normals(mesh, normals); + bin.write(reinterpret_cast(normals.data()), + static_cast(normals.size() * sizeof(float))); + } + + // Indices for (size_t i = 0; i < nf; ++i) { auto vis = mesh.face_vertices(static_cast(i)); for (size_t j = 0; j < std::min(vis.size(), size_t(3)); ++j) { @@ -59,7 +201,113 @@ bool write_gltf(const std::string& filepath, const mesh::HalfedgeMesh& mesh) { bin.write(reinterpret_cast(&idx), 4); } } - return true; +} + +// ── Public API ──────────────────────────────────────────────────────── + +bool write_gltf(const std::string& filepath, const mesh::HalfedgeMesh& mesh, + const GltfOptions& options) { + if (mesh.num_vertices() == 0 || mesh.num_faces() == 0) return false; + + auto layout = compute_layout(mesh, options); + std::string json = generate_json(mesh, options, layout); + + if (options.binary) { + // ── GLB format ── + // Pad JSON chunk to 4-byte alignment with spaces + std::string json_padded = json; + while (json_padded.size() % 4 != 0) json_padded += ' '; + + uint32_t json_chunk_len = static_cast(json_padded.size()); + uint32_t bin_chunk_len = static_cast(layout.total_bin_len); + + // Total = header(12) + json_header(8) + json_data + bin_header(8) + bin_data + uint32_t total = 12 + 8 + json_chunk_len; + if (layout.total_bin_len > 0) total += 8 + bin_chunk_len; + + std::ofstream file(filepath, std::ios::binary); + if (!file) return false; + + write_glb_header(file, total); + write_glb_chunk(file, 0x4E4F534A, json_padded); // "JSON" + if (layout.total_bin_len > 0) { + // Serialize binary data into a string buffer + std::ostringstream bin_stream(std::ios::binary); + write_binary_data(bin_stream, mesh, options, layout); + std::string bin_data = bin_stream.str(); + // Pad bin data to 4-byte alignment + while (bin_data.size() % 4 != 0) bin_data.push_back('\0'); + write_glb_chunk(file, 0x004E4942, bin_data); // "BIN\0" + } + return true; + } else { + // ── glTF JSON + separate .bin ── + std::ofstream file(filepath); + if (!file) return false; + file << json; + + // Write separate bin + std::string dot = "."; + size_t dot_pos = filepath.rfind(dot); + std::string binpath; + if (dot_pos != std::string::npos) + binpath = filepath.substr(0, dot_pos) + ".bin"; + else + binpath = filepath + ".bin"; + + std::ofstream bin(binpath, std::ios::binary); + if (!bin) return false; + write_binary_data(bin, mesh, options, layout); + return true; + } +} + +bool write_brep_gltf(const std::string& filepath, const brep::BrepModel& model, + int tessellation_res) { + if (model.num_faces() == 0) return false; + + // Collect all vertices and triangles from tessellated faces + std::vector all_verts; + std::vector> all_tris; + + for (size_t fi = 0; fi < model.num_faces(); ++fi) { + const auto& face = model.face(static_cast(fi)); + if (face.loops.empty()) continue; + + // Get the surface for this face + int surf_id = face.surface_id; + if (surf_id < 0 || surf_id >= static_cast(model.num_surfaces())) + continue; + + const auto& surf = model.surface(surf_id); + + // Tessellate using the surface evaluator + int res = std::max(4, tessellation_res); + auto eval = [&surf](double u, double v) -> core::Point3D { + return surf.evaluate(u, v); + }; + + auto [verts, tris] = curves::tessellate(eval, res, res); + + // Offset indices + int base = static_cast(all_verts.size()); + for (const auto& v : verts) all_verts.push_back(v); + for (const auto& t : tris) { + all_tris.push_back({t[0] + base, t[1] + base, t[2] + base}); + } + } + + if (all_verts.empty() || all_tris.empty()) return false; + + // Build HalfedgeMesh from tessellated data + mesh::HalfedgeMesh mesh; + mesh.build_from_triangles(all_verts, all_tris); + + // Export as GLB + GltfOptions opt; + opt.binary = true; + opt.include_normals = true; + return write_gltf(filepath, mesh, opt); } } // namespace vde::foundation diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 41fd7d9..1aec3cd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,3 +14,4 @@ add_subdirectory(boolean) add_subdirectory(brep) add_subdirectory(sdf) add_subdirectory(sketch) +add_subdirectory(foundation) diff --git a/tests/foundation/CMakeLists.txt b/tests/foundation/CMakeLists.txt new file mode 100644 index 0000000..66999a0 --- /dev/null +++ b/tests/foundation/CMakeLists.txt @@ -0,0 +1 @@ +add_vde_test(test_io_gltf) diff --git a/tests/foundation/test_io_gltf.cpp b/tests/foundation/test_io_gltf.cpp new file mode 100644 index 0000000..56ca073 --- /dev/null +++ b/tests/foundation/test_io_gltf.cpp @@ -0,0 +1,151 @@ +#include +#include +#include +#include "vde/foundation/io_gltf.h" + +using namespace vde::foundation; +using namespace vde::mesh; +using namespace vde::core; + +// ── Helper: build a simple tetrahedron mesh ── +static HalfedgeMesh make_tetrahedron() { + HalfedgeMesh mesh; + mesh.build_from_triangles( + { + Point3D(0, 0, 0), Point3D(1, 0, 0), Point3D(0.5, 0.866, 0), Point3D(0.5, 0.289, 0.816) + }, + { + {0, 1, 2}, {0, 3, 1}, {1, 3, 2}, {0, 2, 3} + } + ); + return mesh; +} + +// ── Tests ────────────────────────────────────────────────────────── + +TEST(IoGltfTest, ExportTetToGltfJson) { + auto mesh = make_tetrahedron(); + GltfOptions opt; + opt.binary = false; + opt.include_normals = false; + + const char* path = "/tmp/test_tet.gltf"; + std::remove(path); // clean up from previous run + ASSERT_TRUE(write_gltf(path, mesh, opt)); + + std::ifstream f(path); + ASSERT_TRUE(f.good()); + std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + EXPECT_NE(content.find("\"asset\""), std::string::npos); + EXPECT_NE(content.find("\"meshes\""), std::string::npos); + EXPECT_NE(content.find("ViewDesignEngine"), std::string::npos); + + std::remove(path); + std::remove("/tmp/test_tet.bin"); +} + +TEST(IoGltfTest, ExportTetToGlbBinary) { + auto mesh = make_tetrahedron(); + GltfOptions opt; + opt.binary = true; + opt.include_normals = false; + + const char* path = "/tmp/test_tet.glb"; + std::remove(path); + ASSERT_TRUE(write_gltf(path, mesh, opt)); + + // Verify GLB magic bytes + std::ifstream f(path, std::ios::binary); + ASSERT_TRUE(f.good()); + + uint32_t magic = 0, version = 0; + f.read(reinterpret_cast(&magic), 4); + f.read(reinterpret_cast(&version), 4); + + EXPECT_EQ(magic, 0x46546C67u) << "GLB magic should be 'glTF'"; + EXPECT_EQ(version, 2u) << "GLB version should be 2"; + + // Should contain "JSON" chunk marker + uint32_t chunk_len = 0, chunk_type = 0; + f.read(reinterpret_cast(&chunk_len), 4); + f.read(reinterpret_cast(&chunk_type), 4); + EXPECT_EQ(chunk_type, 0x4E4F534Au) << "First chunk should be JSON"; + + std::remove(path); +} + +TEST(IoGltfTest, ExportWithNormals) { + auto mesh = make_tetrahedron(); + GltfOptions opt; + opt.binary = true; + opt.include_normals = true; + + const char* path = "/tmp/test_tet_normals.glb"; + std::remove(path); + ASSERT_TRUE(write_gltf(path, mesh, opt)); + + // Read GLB and verify it contains JSON with NORMAL attribute + std::ifstream f(path, std::ios::binary); + ASSERT_TRUE(f.good()); + + // Skip header (12 bytes) + f.seekg(12); + + // Read JSON chunk + uint32_t chunk_len = 0, chunk_type = 0; + f.read(reinterpret_cast(&chunk_len), 4); + f.read(reinterpret_cast(&chunk_type), 4); + std::string json(chunk_len, '\0'); + f.read(&json[0], chunk_len); + + EXPECT_NE(json.find("\"NORMAL\""), std::string::npos) + << "JSON should include NORMAL attribute when normals enabled"; + + // File should also have a BIN chunk (larger because of normals) + uint32_t bin_len = 0, bin_type = 0; + if (f.read(reinterpret_cast(&bin_len), 4)) { + f.read(reinterpret_cast(&bin_type), 4); + EXPECT_EQ(bin_type, 0x004E4942u) << "Second chunk should be BIN"; + EXPECT_GT(bin_len, 0u) << "BIN chunk should have data"; + } + + std::remove(path); +} + +TEST(IoGltfTest, ExportBrepBoxToGlb) { + auto box = vde::brep::make_box(2.0, 2.0, 2.0); + + const char* path = "/tmp/test_brep_box.glb"; + std::remove(path); + ASSERT_TRUE(write_brep_gltf(path, box, 32)); + + // Verify file exists and has magic + std::ifstream f(path, std::ios::binary); + ASSERT_TRUE(f.good()); + + uint32_t magic = 0; + f.read(reinterpret_cast(&magic), 4); + EXPECT_EQ(magic, 0x46546C67u); + + std::remove(path); +} + +TEST(IoGltfTest, EmptyMeshFailsGracefully) { + HalfedgeMesh empty; + GltfOptions opt; + opt.binary = true; + + const char* path = "/tmp/test_empty.glb"; + std::remove(path); + EXPECT_FALSE(write_gltf(path, empty, opt)) + << "Exporting empty mesh should return false"; +} + +TEST(IoGltfTest, EmptyBrepFailsGracefully) { + vde::brep::BrepModel empty; + + const char* path = "/tmp/test_empty_brep.glb"; + std::remove(path); + EXPECT_FALSE(write_brep_gltf(path, empty, 32)) + << "Exporting empty B-Rep should return false"; +}