feat: Sprint 9 — 3D Delaunay/B-Rep/Boolean 测试补全 + 文档更新
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
# 更新日志
|
||||
|
||||
## [0.9.0] — 2026-07-23
|
||||
|
||||
### Sprint 9 — 3D Delaunay/B-Rep/Boolean 测试补全
|
||||
- **测试新增**:test_delaunay_3d(6 个用例)、test_boolean_mesh(22 个用例)、test_brep(17 个用例)
|
||||
- **3D Delaunay 测试**:空输入、正四面体、Delaunay 空外接球性质验证、退化点(共面/共线)
|
||||
- **3D 网格布尔测试**:相交/不相交/包含场景的 union/intersection/difference/sym_diff、空网格边界、点分类、invert/merge 辅助函数
|
||||
- **B-Rep 测试**:make_box/make_sphere/make_cylinder 实体创建、拓扑查询(face_edges/edge_faces/vertex_edges)、to_mesh 离散化、有效性验证、空模型边缘情况
|
||||
- **构建更新**:tests/CMakeLists.txt 添加 brep 子目录,mesh/boolean CMakeLists 添加新测试
|
||||
- **文档更新**:开发计划 S9 状态更新为进行中
|
||||
|
||||
## [0.8.0] — 2026-07-23
|
||||
|
||||
### Sprint 8 收尾 — spatial + mesh
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
| **S6** | boolean | 2D 布尔运算 | ✅ 基础完成 |
|
||||
| **S7** | 测试 + 示例 | 11 个测试文件、6 个示例 | ✅ 完成 |
|
||||
| **S8** | 补齐实现 | mesh simplify/smooth/boolean、Octree/KDTree/RTree | ✅ 完成 |
|
||||
| **S9** | 3D 扩展 | 3D Delaunay、3D 布尔运算、B-Rep 支持 | 📋 规划中 |
|
||||
| **S9** | 3D 扩展 | 3D Delaunay、3D 布尔运算、B-Rep 支持 | 🔄 进行中 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ add_subdirectory(mesh)
|
||||
add_subdirectory(spatial)
|
||||
add_subdirectory(collision)
|
||||
add_subdirectory(boolean)
|
||||
add_subdirectory(brep)
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
add_vde_test(test_boolean_2d)
|
||||
add_vde_test(test_boolean_mesh)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
#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;
|
||||
using core::Point3D;
|
||||
|
||||
// ── 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());
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
add_vde_test(test_brep)
|
||||
@@ -0,0 +1,194 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include "vde/brep/brep.h"
|
||||
#include "vde/brep/modeling.h"
|
||||
#include <cmath>
|
||||
|
||||
using namespace vde::brep;
|
||||
using core::Point3D;
|
||||
using core::Vector3D;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Basic entity creation (box, sphere, cylinder)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
TEST(BrepTest, MakeBox_CreatesValidEntity) {
|
||||
auto box = make_box(2, 3, 4);
|
||||
|
||||
EXPECT_TRUE(box.is_valid());
|
||||
EXPECT_EQ(box.num_bodies(), 1u);
|
||||
// A box should have 8 vertices (corners) — but shared corners mean 24?
|
||||
// make_box adds 4 vertices per face = 24 vertices
|
||||
EXPECT_GE(box.num_vertices(), 8u);
|
||||
EXPECT_GE(box.num_faces(), 6u);
|
||||
// Each face has 4 edges → 24 edges (no sharing in this implementation)
|
||||
EXPECT_GE(box.num_edges(), 12u);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeBox_Bounds) {
|
||||
auto box = make_box(2, 3, 4);
|
||||
auto b = box.bounds();
|
||||
|
||||
EXPECT_NEAR(b.min().x(), -1.0, 1e-6);
|
||||
EXPECT_NEAR(b.max().x(), 1.0, 1e-6);
|
||||
EXPECT_NEAR(b.min().y(), -1.5, 1e-6);
|
||||
EXPECT_NEAR(b.max().y(), 1.5, 1e-6);
|
||||
EXPECT_NEAR(b.min().z(), -2.0, 1e-6);
|
||||
EXPECT_NEAR(b.max().z(), 2.0, 1e-6);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeBox_VertexQuery) {
|
||||
auto box = make_box(2, 2, 2);
|
||||
|
||||
// Vertex 0 should exist and have a point
|
||||
const auto& v = box.vertex(0);
|
||||
EXPECT_NEAR(v.point.x(), -1.0, 1e-6);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeBox_EdgeQuery) {
|
||||
auto box = make_box(2, 2, 2);
|
||||
|
||||
// Edge 0 should connect two valid vertices
|
||||
const auto& e = box.edge(0);
|
||||
EXPECT_GE(e.v_start, 0);
|
||||
EXPECT_GE(e.v_end, 0);
|
||||
EXPECT_NE(e.v_start, e.v_end);
|
||||
EXPECT_NE(e.curve, nullptr);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeBox_FaceQuery) {
|
||||
auto box = make_box(2, 2, 2);
|
||||
|
||||
const auto& f = box.face(0);
|
||||
EXPECT_GE(f.surface_id, 0);
|
||||
EXPECT_FALSE(f.loops.empty());
|
||||
EXPECT_FALSE(f.reversed);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeBox_FaceEdges) {
|
||||
auto box = make_box(2, 2, 2);
|
||||
auto edges = box.face_edges(0);
|
||||
|
||||
// A box face should have 4 edges
|
||||
EXPECT_EQ(edges.size(), 4u);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeBox_EdgeFaces) {
|
||||
auto box = make_box(2, 2, 2);
|
||||
|
||||
// Edge 0 should belong to at least one face
|
||||
auto faces = box.edge_faces(0);
|
||||
EXPECT_GE(faces.size(), 1u);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeBox_VertexEdges) {
|
||||
auto box = make_box(2, 2, 2);
|
||||
auto edges = box.vertex_edges(0);
|
||||
|
||||
// Each vertex should have at least 3 incident edges
|
||||
EXPECT_GE(edges.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeBox_ToMesh) {
|
||||
auto box = make_box(2, 2, 2);
|
||||
auto mesh = box.to_mesh();
|
||||
|
||||
// Tessellation should produce triangles
|
||||
EXPECT_GT(mesh.num_faces(), 0u);
|
||||
EXPECT_GT(mesh.num_vertices(), 0u);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Sphere tests
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
TEST(BrepTest, MakeSphere_CreatesValidEntity) {
|
||||
auto sphere = make_sphere(1.0);
|
||||
|
||||
EXPECT_TRUE(sphere.is_valid());
|
||||
EXPECT_EQ(sphere.num_bodies(), 1u);
|
||||
EXPECT_GE(sphere.num_vertices(), 2u);
|
||||
EXPECT_GE(sphere.num_faces(), 4u);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeSphere_Bounds) {
|
||||
auto sphere = make_sphere(2.0);
|
||||
auto b = sphere.bounds();
|
||||
|
||||
EXPECT_NEAR(b.extent().x(), 4.0, 0.1);
|
||||
EXPECT_NEAR(b.extent().y(), 4.0, 0.1);
|
||||
EXPECT_NEAR(b.extent().z(), 4.0, 0.1);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeSphere_ToMesh) {
|
||||
auto sphere = make_sphere(1.0);
|
||||
auto mesh = sphere.to_mesh();
|
||||
|
||||
EXPECT_GT(mesh.num_faces(), 0u);
|
||||
EXPECT_GT(mesh.num_vertices(), 0u);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Cylinder tests
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
TEST(BrepTest, MakeCylinder_CreatesValidEntity) {
|
||||
auto cyl = make_cylinder(1.0, 3.0);
|
||||
|
||||
EXPECT_TRUE(cyl.is_valid());
|
||||
EXPECT_EQ(cyl.num_bodies(), 1u);
|
||||
EXPECT_GE(cyl.num_faces(), 3u); // top + bottom + sides
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeCylinder_Bounds) {
|
||||
auto cyl = make_cylinder(1.0, 4.0);
|
||||
auto b = cyl.bounds();
|
||||
|
||||
EXPECT_NEAR(b.extent().x(), 2.0, 0.1);
|
||||
EXPECT_NEAR(b.extent().y(), 2.0, 0.1);
|
||||
EXPECT_NEAR(b.extent().z(), 4.0, 0.1);
|
||||
}
|
||||
|
||||
TEST(BrepTest, MakeCylinder_ToMesh) {
|
||||
auto cyl = make_cylinder(1.0, 3.0);
|
||||
auto mesh = cyl.to_mesh();
|
||||
|
||||
EXPECT_GT(mesh.num_faces(), 0u);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Validity checks
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
TEST(BrepTest, IsValid_ValidModel_ReturnsTrue) {
|
||||
auto box = make_box(1, 1, 1);
|
||||
EXPECT_TRUE(box.is_valid());
|
||||
}
|
||||
|
||||
TEST(BrepTest, IsValid_EmptyModel_ReturnsTrue) {
|
||||
BrepModel empty;
|
||||
EXPECT_TRUE(empty.is_valid());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Empty model tests
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
TEST(BrepTest, EmptyModel_HasNoContent) {
|
||||
BrepModel empty;
|
||||
EXPECT_EQ(empty.num_vertices(), 0u);
|
||||
EXPECT_EQ(empty.num_edges(), 0u);
|
||||
EXPECT_EQ(empty.num_faces(), 0u);
|
||||
EXPECT_EQ(empty.num_bodies(), 0u);
|
||||
}
|
||||
|
||||
TEST(BrepTest, EmptyModel_IsValid) {
|
||||
BrepModel empty;
|
||||
EXPECT_TRUE(empty.is_valid());
|
||||
}
|
||||
|
||||
TEST(BrepTest, EmptyModel_ToMesh) {
|
||||
BrepModel empty;
|
||||
auto mesh = empty.to_mesh();
|
||||
EXPECT_EQ(mesh.num_faces(), 0u);
|
||||
EXPECT_EQ(mesh.num_vertices(), 0u);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ add_vde_test(test_halfedge)
|
||||
add_vde_test(test_delaunay)
|
||||
add_vde_test(test_quality)
|
||||
add_vde_test(test_smooth)
|
||||
add_vde_test(test_delaunay_3d)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include "vde/mesh/delaunay_3d.h"
|
||||
#include <cmath>
|
||||
|
||||
using namespace vde::mesh;
|
||||
using core::Point3D;
|
||||
|
||||
// ── Helper: compute circumsphere center and radius of 4 points ──
|
||||
// Returns (center, radius_squared). If points are coplanar, radius_sq < 0.
|
||||
static std::pair<Point3D, double> circumsphere(
|
||||
const Point3D& a, const Point3D& b, const Point3D& c, const Point3D& d)
|
||||
{
|
||||
// Build the linear system using the property that ||p - center||² = r²
|
||||
// for all four points. Subtract first equation from others to get 3x3 system.
|
||||
Eigen::Matrix3d M;
|
||||
Eigen::Vector3d rhs;
|
||||
|
||||
auto row = [&](const Point3D& pi, const Point3D& p0) {
|
||||
Eigen::Vector3d diff = (pi - p0);
|
||||
return std::make_pair(diff, (pi.squaredNorm() - p0.squaredNorm()) * 0.5);
|
||||
};
|
||||
|
||||
auto [da, ra] = row(b, a);
|
||||
auto [db, rb] = row(c, a);
|
||||
auto [dc, rc] = row(d, a);
|
||||
|
||||
M.row(0) = da; M.row(1) = db; M.row(2) = dc;
|
||||
rhs << ra, rb, rc;
|
||||
|
||||
// Check if singular (coplanar)
|
||||
if (std::abs(M.determinant()) < 1e-12) {
|
||||
return {Point3D::Zero(), -1.0};
|
||||
}
|
||||
|
||||
Point3D center = M.colPivHouseholderQr().solve(rhs);
|
||||
double r2 = (a - center).squaredNorm();
|
||||
return {center, r2};
|
||||
}
|
||||
|
||||
// ── Helper: check Delaunay empty-sphere property ──
|
||||
// For every tetrahedron, no other input point should be inside its circumsphere.
|
||||
static bool verify_empty_circumsphere(
|
||||
const TetrahedronMesh& mesh,
|
||||
const std::vector<Point3D>& input_points)
|
||||
{
|
||||
for (const auto& tet : mesh.tetrahedra) {
|
||||
const auto& p0 = mesh.vertices[tet[0]];
|
||||
const auto& p1 = mesh.vertices[tet[1]];
|
||||
const auto& p2 = mesh.vertices[tet[2]];
|
||||
const auto& p3 = mesh.vertices[tet[3]];
|
||||
|
||||
auto [center, r2] = circumsphere(p0, p1, p2, p3);
|
||||
if (r2 < 0.0) continue; // degenerate
|
||||
|
||||
double r = std::sqrt(r2);
|
||||
for (const auto& pt : input_points) {
|
||||
// Skip vertices of this tetrahedron
|
||||
double d2 = (pt - center).squaredNorm();
|
||||
// Allow small epsilon for floating point
|
||||
if (d2 < r2 - 1e-9) {
|
||||
return false; // point inside circumsphere → not Delaunay
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Test cases
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
TEST(Delaunay3DTest, EmptyInput_ReturnsEmpty) {
|
||||
std::vector<Point3D> points;
|
||||
auto result = delaunay_3d(points);
|
||||
EXPECT_EQ(result.vertices.size(), 0u);
|
||||
EXPECT_EQ(result.tetrahedra.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(Delaunay3DTest, FourPointsTetrahedron_OneTetrahedron) {
|
||||
// Regular tetrahedron with side length sqrt(2), centered at origin
|
||||
std::vector<Point3D> pts = {
|
||||
{1, 1, 1},
|
||||
{1, -1, -1},
|
||||
{-1, 1, -1},
|
||||
{-1, -1, 1}
|
||||
};
|
||||
auto result = delaunay_3d(pts);
|
||||
EXPECT_EQ(result.vertices.size(), 4u);
|
||||
EXPECT_GE(result.tetrahedra.size(), 1u);
|
||||
}
|
||||
|
||||
TEST(Delaunay3DTest, FivePointsCube_VerifyDelaunayProperty) {
|
||||
// 4 corners of a tetrahedron + 1 point inside the circumsphere
|
||||
// Use a well-distributed set: origin + unit tetrahedron
|
||||
std::vector<Point3D> pts = {
|
||||
{0, 0, 0},
|
||||
{1, 0, 0},
|
||||
{0, 1, 0},
|
||||
{0, 0, 1},
|
||||
{0.25, 0.25, 0.25} // inside the tetrahedron
|
||||
};
|
||||
auto result = delaunay_3d(pts);
|
||||
EXPECT_EQ(result.vertices.size(), 5u);
|
||||
EXPECT_GE(result.tetrahedra.size(), 2u);
|
||||
}
|
||||
|
||||
TEST(Delaunay3DTest, FivePointsDelaunayProperty_EmptyCircumsphere) {
|
||||
// Regular tetrahedron + center point — should satisfy Delaunay
|
||||
std::vector<Point3D> pts = {
|
||||
{1, 1, 1},
|
||||
{1, -1, -1},
|
||||
{-1, 1, -1},
|
||||
{-1, -1, 1},
|
||||
{0, 0, 0}
|
||||
};
|
||||
auto result = delaunay_3d(pts);
|
||||
EXPECT_EQ(result.vertices.size(), 5u);
|
||||
EXPECT_GE(result.tetrahedra.size(), 4u);
|
||||
|
||||
bool del_ok = verify_empty_circumsphere(result, pts);
|
||||
EXPECT_TRUE(del_ok);
|
||||
}
|
||||
|
||||
TEST(Delaunay3DTest, CollinearPoints_HandlesGracefully) {
|
||||
// 4 points on a line
|
||||
std::vector<Point3D> pts = {
|
||||
{0, 0, 0},
|
||||
{1, 0, 0},
|
||||
{2, 0, 0},
|
||||
{3, 0, 0}
|
||||
};
|
||||
auto result = delaunay_3d(pts);
|
||||
// Collinear points may produce 0 tetrahedra (degenerate)
|
||||
// The function should not crash
|
||||
EXPECT_GE(result.tetrahedra.size(), 0u);
|
||||
}
|
||||
|
||||
TEST(Delaunay3DTest, CoplanarPoints_HandlesGracefully) {
|
||||
// 4 points on a plane
|
||||
std::vector<Point3D> pts = {
|
||||
{0, 0, 0},
|
||||
{1, 0, 0},
|
||||
{1, 1, 0},
|
||||
{0, 1, 0}
|
||||
};
|
||||
auto result = delaunay_3d(pts);
|
||||
// Coplanar points should not crash; may return degenerate result
|
||||
EXPECT_GE(result.tetrahedra.size(), 0u);
|
||||
}
|
||||
Reference in New Issue
Block a user