2026-07-23 14:17:07 +00:00
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
|
|
#include "vde/boolean/boolean_mesh.h"
|
|
|
|
|
|
#include "vde/mesh/halfedge_mesh.h"
|
|
|
|
|
|
#include <cmath>
|
|
|
|
|
|
|
|
|
|
|
|
using namespace vde::boolean;
|
|
|
|
|
|
using namespace vde::mesh;
|
2026-07-23 15:29:04 +00:00
|
|
|
|
using namespace vde::core;
|
2026-07-23 14:17:07 +00:00
|
|
|
|
|
|
|
|
|
|
// ── Helper: build a box mesh (12 triangles, 8 vertices) ──
|
|
|
|
|
|
// Face normals point outward (CCW winding from outside)
|
|
|
|
|
|
static HalfedgeMesh make_box(double w, double h, double d) {
|
|
|
|
|
|
double hw = w * 0.5, hh = h * 0.5, hd = d * 0.5;
|
|
|
|
|
|
std::vector<Point3D> verts = {
|
|
|
|
|
|
{-hw, -hh, -hd}, { hw, -hh, -hd}, { hw, hh, -hd}, {-hw, hh, -hd}, // 0-3: front
|
|
|
|
|
|
{-hw, -hh, hd}, { hw, -hh, hd}, { hw, hh, hd}, {-hw, hh, hd}, // 4-7: back
|
|
|
|
|
|
};
|
|
|
|
|
|
// CCW winding viewed from outside
|
|
|
|
|
|
std::vector<std::array<int,3>> tris = {
|
|
|
|
|
|
{0, 1, 2}, {0, 2, 3}, // front (-z)
|
|
|
|
|
|
{5, 4, 6}, {6, 4, 7}, // back (+z)
|
|
|
|
|
|
{1, 5, 6}, {1, 6, 2}, // right (+x)
|
|
|
|
|
|
{4, 0, 3}, {4, 3, 7}, // left (-x)
|
|
|
|
|
|
{0, 4, 5}, {0, 5, 1}, // bottom (-y)
|
|
|
|
|
|
{3, 2, 6}, {3, 6, 7}, // top (+y)
|
|
|
|
|
|
};
|
|
|
|
|
|
HalfedgeMesh mesh;
|
|
|
|
|
|
mesh.build_from_triangles(verts, tris);
|
|
|
|
|
|
return mesh;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Helper: build a translated box ──
|
|
|
|
|
|
static HalfedgeMesh make_box_at(double cx, double cy, double cz,
|
|
|
|
|
|
double w, double h, double d) {
|
|
|
|
|
|
double hw = w * 0.5, hh = h * 0.5, hd = d * 0.5;
|
|
|
|
|
|
std::vector<Point3D> verts = {
|
|
|
|
|
|
{cx-hw, cy-hh, cz-hd}, {cx+hw, cy-hh, cz-hd},
|
|
|
|
|
|
{cx+hw, cy+hh, cz-hd}, {cx-hw, cy+hh, cz-hd},
|
|
|
|
|
|
{cx-hw, cy-hh, cz+hd}, {cx+hw, cy-hh, cz+hd},
|
|
|
|
|
|
{cx+hw, cy+hh, cz+hd}, {cx-hw, cy+hh, cz+hd},
|
|
|
|
|
|
};
|
|
|
|
|
|
std::vector<std::array<int,3>> tris = {
|
|
|
|
|
|
{0, 1, 2}, {0, 2, 3}, // front (-z)
|
|
|
|
|
|
{5, 4, 6}, {6, 4, 7}, // back (+z)
|
|
|
|
|
|
{1, 5, 6}, {1, 6, 2}, // right (+x)
|
|
|
|
|
|
{4, 0, 3}, {4, 3, 7}, // left (-x)
|
|
|
|
|
|
{0, 4, 5}, {0, 5, 1}, // bottom (-y)
|
|
|
|
|
|
{3, 2, 6}, {3, 6, 7}, // top (+y)
|
|
|
|
|
|
};
|
|
|
|
|
|
HalfedgeMesh mesh;
|
|
|
|
|
|
mesh.build_from_triangles(verts, tris);
|
|
|
|
|
|
return mesh;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// Union tests
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Union_IntersectingBoxes_HasResult) {
|
|
|
|
|
|
auto a = make_box(2, 2, 2); // centered at origin
|
|
|
|
|
|
auto b = make_box_at(1, 0, 0, 2, 2, 2); // shifted +x by 1
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_union(a, b);
|
|
|
|
|
|
EXPECT_GT(result.num_faces(), 0u);
|
|
|
|
|
|
// Union should have at least as many faces as the individual boxes
|
|
|
|
|
|
// (since they partially overlap, the exterior faces are kept)
|
|
|
|
|
|
EXPECT_GE(result.num_faces(), a.num_faces() / 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Union_DisjointBoxes_HasAllFaces) {
|
|
|
|
|
|
auto a = make_box(1, 1, 1); // centered at origin
|
|
|
|
|
|
auto b = make_box_at(3, 0, 0, 1, 1, 1); // far away
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_union(a, b);
|
|
|
|
|
|
// Disjoint: both meshes should be fully retained
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), a.num_faces() + b.num_faces());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// Intersection tests
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Intersection_OverlappingBoxes_HasResult) {
|
|
|
|
|
|
auto a = make_box(2, 2, 2); // [-1,1]³
|
|
|
|
|
|
auto b = make_box_at(1, 0, 0, 2, 2, 2); // [0,2]×[-1,1]×[-1,1]
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_intersection(a, b);
|
|
|
|
|
|
// Overlap region = [0,1]×[-1,1]×[-1,1] — should have faces
|
|
|
|
|
|
EXPECT_GT(result.num_faces(), 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Intersection_Disjoint_ReturnsEmpty) {
|
|
|
|
|
|
auto a = make_box(1, 1, 1);
|
|
|
|
|
|
auto b = make_box_at(3, 0, 0, 1, 1, 1);
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_intersection(a, b);
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Intersection_BContainsA_ReturnsA) {
|
|
|
|
|
|
auto a = make_box(1, 1, 1); // small cube at origin
|
|
|
|
|
|
auto b = make_box(4, 4, 4); // large cube containing a
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_intersection(a, b);
|
|
|
|
|
|
// All faces of A are inside B → intersection should have 12 faces
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), a.num_faces());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// Difference tests
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Difference_Overlapping_HasResult) {
|
|
|
|
|
|
auto a = make_box(2, 2, 2);
|
|
|
|
|
|
auto b = make_box_at(1, 0, 0, 2, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_difference(a, b);
|
|
|
|
|
|
// A-b: faces of A whose centroids are outside B
|
|
|
|
|
|
EXPECT_GT(result.num_faces(), 0u);
|
|
|
|
|
|
// Should be fewer faces than original A (some faces are clipped)
|
|
|
|
|
|
EXPECT_LT(result.num_faces(), a.num_faces());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Difference_Disjoint_ReturnsAllA) {
|
|
|
|
|
|
auto a = make_box(1, 1, 1);
|
|
|
|
|
|
auto b = make_box_at(3, 0, 0, 1, 1, 1);
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_difference(a, b);
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), a.num_faces());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Difference_BContainsA_ReturnsEmpty) {
|
|
|
|
|
|
auto a = make_box(1, 1, 1); // small inside
|
|
|
|
|
|
auto b = make_box(4, 4, 4); // large container
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_difference(a, b);
|
|
|
|
|
|
// A is fully inside B → all faces are clipped
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// Symmetric difference tests
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, SymDiff_Overlapping_HasResult) {
|
|
|
|
|
|
auto a = make_box(2, 2, 2);
|
|
|
|
|
|
auto b = make_box_at(1, 0, 0, 2, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_sym_diff(a, b);
|
|
|
|
|
|
EXPECT_GT(result.num_faces(), 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// Empty mesh input tests
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Union_EmptyA_ReturnsB) {
|
|
|
|
|
|
HalfedgeMesh empty;
|
|
|
|
|
|
auto b = make_box(2, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_union(empty, b);
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), b.num_faces());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Union_EmptyB_ReturnsA) {
|
|
|
|
|
|
auto a = make_box(2, 2, 2);
|
|
|
|
|
|
HalfedgeMesh empty;
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_union(a, empty);
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), a.num_faces());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Intersection_EmptyA_ReturnsEmpty) {
|
|
|
|
|
|
HalfedgeMesh empty;
|
|
|
|
|
|
auto b = make_box(2, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_intersection(empty, b);
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Difference_EmptyA_ReturnsEmpty) {
|
|
|
|
|
|
HalfedgeMesh empty;
|
|
|
|
|
|
auto b = make_box(2, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_difference(empty, b);
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Difference_EmptyB_ReturnsA) {
|
|
|
|
|
|
auto a = make_box(2, 2, 2);
|
|
|
|
|
|
HalfedgeMesh empty;
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_difference(a, empty);
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), a.num_faces());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// BooleanOp dispatcher
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Dispatch_Union) {
|
|
|
|
|
|
auto a = make_box(2, 2, 2);
|
|
|
|
|
|
auto b = make_box_at(3, 0, 0, 2, 2, 2);
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_boolean(a, b, BooleanOp::Union);
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), a.num_faces() + b.num_faces());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Dispatch_Intersection_Disjoint) {
|
|
|
|
|
|
auto a = make_box(1, 1, 1);
|
|
|
|
|
|
auto b = make_box_at(3, 0, 0, 1, 1, 1);
|
|
|
|
|
|
|
|
|
|
|
|
auto result = mesh_boolean(a, b, BooleanOp::Intersection);
|
|
|
|
|
|
EXPECT_EQ(result.num_faces(), 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// Point-in-mesh classification
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, PointInside_OriginInCenteredBox) {
|
|
|
|
|
|
auto box = make_box(2, 2, 2);
|
|
|
|
|
|
Point3D origin(0, 0, 0);
|
|
|
|
|
|
|
|
|
|
|
|
bool inside = is_point_inside_mesh(origin, box);
|
|
|
|
|
|
EXPECT_TRUE(inside);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, PointInside_FarPointOutsideBox) {
|
|
|
|
|
|
auto box = make_box(2, 2, 2);
|
|
|
|
|
|
Point3D far(10, 0, 0);
|
|
|
|
|
|
|
|
|
|
|
|
bool inside = is_point_inside_mesh(far, box);
|
|
|
|
|
|
EXPECT_FALSE(inside);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// Invert / merge helpers
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Invert_PreservesFaceCount) {
|
|
|
|
|
|
auto box = make_box(2, 2, 2);
|
|
|
|
|
|
auto inverted = invert_mesh(box);
|
|
|
|
|
|
EXPECT_EQ(inverted.num_faces(), box.num_faces());
|
|
|
|
|
|
EXPECT_EQ(inverted.num_vertices(), box.num_vertices());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TEST(BooleanMeshTest, Merge_TwoBoxes) {
|
|
|
|
|
|
auto a = make_box(1, 1, 1);
|
|
|
|
|
|
auto b = make_box_at(3, 0, 0, 1, 1, 1);
|
|
|
|
|
|
|
|
|
|
|
|
auto merged = merge_meshes(a, b);
|
|
|
|
|
|
EXPECT_EQ(merged.num_faces(), a.num_faces() + b.num_faces());
|
|
|
|
|
|
EXPECT_EQ(merged.num_vertices(), a.num_vertices() + b.num_vertices());
|
|
|
|
|
|
}
|