feat: GLTF/GLB export with normals + B-Rep tessellation + pipeline example
CI / Build & Test (push) Failing after 32s
CI / Release Build (push) Failing after 31s

This commit is contained in:
茂之钳
2026-07-24 09:06:12 +00:00
parent b3269b4f09
commit 40440fcc3e
8 changed files with 521 additions and 29 deletions
+2
View File
@@ -0,0 +1,2 @@
add_executable(pipeline_demo main.cpp)
target_link_libraries(pipeline_demo PRIVATE vde)
+68
View File
@@ -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 <iostream>
#include <cmath>
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;
}
+1
View File
@@ -4,3 +4,4 @@ add_subdirectory(03_mesh)
add_subdirectory(04_delaunay) add_subdirectory(04_delaunay)
add_subdirectory(05_boolean) add_subdirectory(05_boolean)
add_subdirectory(06_collision) add_subdirectory(06_collision)
add_subdirectory(07_pipeline)
+22 -2
View File
@@ -1,11 +1,31 @@
#pragma once #pragma once
#include "vde/mesh/halfedge_mesh.h" #include "vde/mesh/halfedge_mesh.h"
#include "vde/brep/brep.h"
#include <string> #include <string>
#include <vector>
#include <optional>
namespace vde::foundation { 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<float> vertex_colors; // RGB per vertex (if include_colors)
};
/// Export mesh to glTF 2.0 / GLB
/// Returns true on success /// 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 } // namespace vde::foundation
+274 -26
View File
@@ -1,48 +1,180 @@
#include "vde/foundation/io_gltf.h" #include "vde/foundation/io_gltf.h"
#include "vde/core/aabb.h" #include "vde/core/aabb.h"
#include "vde/curves/tessellation.h"
#include <fstream> #include <fstream>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <string> #include <cstring>
#include <sstream>
#include <iomanip>
namespace vde::foundation { namespace vde::foundation {
// ── Helpers ──────────────────────────────────────────────────────────
static std::string vec3_json(double x, double y, double z) { 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) { static void compute_normals(const mesh::HalfedgeMesh& mesh,
std::ofstream file(filepath); std::vector<float>& normals) {
if (!file) return false; 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<int>(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<float>((v1.y() - v0.y()) * (v2.z() - v0.z()) -
(v1.z() - v0.z()) * (v2.y() - v0.y()));
float ny = static_cast<float>((v1.z() - v0.z()) * (v2.x() - v0.x()) -
(v1.x() - v0.x()) * (v2.z() - v0.z()));
float nz = static_cast<float>((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<const char*>(&magic), 4);
out.write(reinterpret_cast<const char*>(&version), 4);
out.write(reinterpret_cast<const char*>(&total_length), 4);
}
static void write_glb_chunk(std::ostream& out, uint32_t chunk_type,
const std::string& data) {
uint32_t len = static_cast<uint32_t>(data.size());
out.write(reinterpret_cast<const char*>(&len), 4);
out.write(reinterpret_cast<const char*>(&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 nv = mesh.num_vertices();
size_t nf = mesh.num_faces(); size_t nf = mesh.num_faces();
size_t index_count = nf * 3; size_t index_count = nf * 3;
core::AABB3D bounds = mesh.bounds(); core::AABB3D bounds = mesh.bounds();
file << "{\n"; std::ostringstream ss;
file << " \"asset\": {\"version\": \"2.0\", \"generator\": \"ViewDesignEngine\"},\n"; ss << std::fixed << std::setprecision(6);
file << " \"scene\": 0,\n"; ss << "{\n";
file << " \"scenes\": [{\"nodes\": [0]}],\n"; ss << " \"asset\":{\"version\":\"2.0\",\"generator\":\"ViewDesignEngine\"},\n";
file << " \"nodes\": [{\"mesh\": 0}],\n"; ss << " \"scene\":0,\n";
file << " \"meshes\": [{\"primitives\": [{\"attributes\": {\"POSITION\": 0},\"indices\": 1}]}],\n"; ss << " \"scenes\":[{\"nodes\":[0]}],\n";
file << " \"buffers\": [{\"uri\": \"mesh.bin\",\"byteLength\": " << (nv*12+index_count*4) << "}],\n"; ss << " \"nodes\":[{\"mesh\":0}],\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";
// Write binary data // Mesh primitive
std::string dot = "."; ss << " \"meshes\":[{\"primitives\":[{\"attributes\":{";
std::string binpath = filepath.substr(0, filepath.rfind(dot)) + ".bin"; ss << "\"POSITION\":0";
std::ofstream bin(binpath, std::ios::binary); 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) { for (size_t i = 0; i < nv; ++i) {
const auto& v = mesh.vertex(i); const auto& v = mesh.vertex(i);
float fx = static_cast<float>(v.x()); float fx = static_cast<float>(v.x());
@@ -52,6 +184,16 @@ bool write_gltf(const std::string& filepath, const mesh::HalfedgeMesh& mesh) {
bin.write(reinterpret_cast<const char*>(&fy), 4); bin.write(reinterpret_cast<const char*>(&fy), 4);
bin.write(reinterpret_cast<const char*>(&fz), 4); bin.write(reinterpret_cast<const char*>(&fz), 4);
} }
// Normals
if (options.include_normals) {
std::vector<float> normals;
compute_normals(mesh, normals);
bin.write(reinterpret_cast<const char*>(normals.data()),
static_cast<std::streamsize>(normals.size() * sizeof(float)));
}
// Indices
for (size_t i = 0; i < nf; ++i) { for (size_t i = 0; i < nf; ++i) {
auto vis = mesh.face_vertices(static_cast<int>(i)); auto vis = mesh.face_vertices(static_cast<int>(i));
for (size_t j = 0; j < std::min(vis.size(), size_t(3)); ++j) { 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<const char*>(&idx), 4); bin.write(reinterpret_cast<const char*>(&idx), 4);
} }
} }
}
// ── 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<uint32_t>(json_padded.size());
uint32_t bin_chunk_len = static_cast<uint32_t>(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; 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<core::Point3D> all_verts;
std::vector<std::array<int, 3>> all_tris;
for (size_t fi = 0; fi < model.num_faces(); ++fi) {
const auto& face = model.face(static_cast<int>(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<int>(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<int>(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 } // namespace vde::foundation
+1
View File
@@ -14,3 +14,4 @@ add_subdirectory(boolean)
add_subdirectory(brep) add_subdirectory(brep)
add_subdirectory(sdf) add_subdirectory(sdf)
add_subdirectory(sketch) add_subdirectory(sketch)
add_subdirectory(foundation)
+1
View File
@@ -0,0 +1 @@
add_vde_test(test_io_gltf)
+151
View File
@@ -0,0 +1,151 @@
#include <gtest/gtest.h>
#include <fstream>
#include <cstdio>
#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<char>(f)), std::istreambuf_iterator<char>());
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<char*>(&magic), 4);
f.read(reinterpret_cast<char*>(&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<char*>(&chunk_len), 4);
f.read(reinterpret_cast<char*>(&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<char*>(&chunk_len), 4);
f.read(reinterpret_cast<char*>(&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<char*>(&bin_len), 4)) {
f.read(reinterpret_cast<char*>(&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<char*>(&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";
}