diff --git a/CHANGELOG.md b/CHANGELOG.md index 91c92aa..d6e6705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/00-开发计划.md b/docs/00-开发计划.md index 2f01b4d..40c6fbc 100644 --- a/docs/00-开发计划.md +++ b/docs/00-开发计划.md @@ -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 支持 | 🔄 进行中 | --- diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fe0c6ad..079d314 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,3 +11,4 @@ add_subdirectory(mesh) add_subdirectory(spatial) add_subdirectory(collision) add_subdirectory(boolean) +add_subdirectory(brep) diff --git a/tests/boolean/CMakeLists.txt b/tests/boolean/CMakeLists.txt index 31a93d1..fe44b8b 100644 --- a/tests/boolean/CMakeLists.txt +++ b/tests/boolean/CMakeLists.txt @@ -1 +1,2 @@ add_vde_test(test_boolean_2d) +add_vde_test(test_boolean_mesh) diff --git a/tests/boolean/test_boolean_mesh.cpp b/tests/boolean/test_boolean_mesh.cpp new file mode 100644 index 0000000..e4efece --- /dev/null +++ b/tests/boolean/test_boolean_mesh.cpp @@ -0,0 +1,255 @@ +#include +#include "vde/boolean/boolean_mesh.h" +#include "vde/mesh/halfedge_mesh.h" +#include + +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 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> 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 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> 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()); +} diff --git a/tests/brep/CMakeLists.txt b/tests/brep/CMakeLists.txt new file mode 100644 index 0000000..916210d --- /dev/null +++ b/tests/brep/CMakeLists.txt @@ -0,0 +1 @@ +add_vde_test(test_brep) diff --git a/tests/brep/test_brep.cpp b/tests/brep/test_brep.cpp new file mode 100644 index 0000000..90248be --- /dev/null +++ b/tests/brep/test_brep.cpp @@ -0,0 +1,194 @@ +#include +#include "vde/brep/brep.h" +#include "vde/brep/modeling.h" +#include + +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); +} diff --git a/tests/mesh/CMakeLists.txt b/tests/mesh/CMakeLists.txt index 1042d4d..6775e8a 100644 --- a/tests/mesh/CMakeLists.txt +++ b/tests/mesh/CMakeLists.txt @@ -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) diff --git a/tests/mesh/test_delaunay_3d.cpp b/tests/mesh/test_delaunay_3d.cpp new file mode 100644 index 0000000..ac5f5e7 --- /dev/null +++ b/tests/mesh/test_delaunay_3d.cpp @@ -0,0 +1,149 @@ +#include +#include "vde/mesh/delaunay_3d.h" +#include + +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 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& 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 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 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 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 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 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 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); +}