feat(phase3a): introduce CGAL Surface_mesh as ConformalMesh foundation

Replaces the Java CoHDS with CGAL::Surface_mesh<Point3> (Simple_cartesian
kernel). Adds domain-specific property maps for lambda/theta/idx/alpha and
face geometry type — the direct C++ equivalent of CoVertex/CoEdge adapters.

New files:
  include/conformal_mesh.hpp   — ConformalMesh type + property-map helpers
  include/mesh_builder.hpp     — mesh factories (triangle, tetrahedron,
                                 quad-strip, fan) for tests and examples
  tests/cgal/                  — second test executable (conformallab_cgal_tests)
                                 built only with -DWITH_CGAL=ON

Test results (local, -DWITH_CGAL=ON):
  conformallab_tests:      36 registered | 23 passed | 13 skipped | 0 failed
  conformallab_cgal_tests: 14 registered | 14 passed |  0 skipped | 0 failed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-11 18:36:21 +02:00
parent ae5db7216e
commit bf9c323d60
5 changed files with 496 additions and 0 deletions

View File

@@ -27,3 +27,8 @@ target_link_libraries(conformallab_tests PRIVATE GTest::gtest_main)
include(GoogleTest)
gtest_discover_tests(conformallab_tests DISCOVERY_TIMEOUT 60)
# ── CGAL test suite (requires -DWITH_CGAL=ON) ────────────────────────────────
if(WITH_CGAL)
add_subdirectory(cgal)
endif()

View File

@@ -0,0 +1,48 @@
# tests/cgal/CMakeLists.txt
#
# CGAL-dependent test target. Only built when -DWITH_CGAL=ON.
# Requires Boost (find_package(Boost REQUIRED) is called in the root CMakeLists).
#
# Run with:
# cmake -S code -B build -DWITH_CGAL=ON
# cmake --build build --target conformallab_cgal_tests
# ctest --test-dir build -R cgal
add_executable(conformallab_cgal_tests
# ── Phase 3a: mesh infrastructure ──────────────────────────────────────
test_conformal_mesh.cpp
# ── Phase 3b: HyperIdealFunctional (to be added) ──────────────────────
# test_hyper_ideal_functional.cpp
# ── Phase 3c: SphericalFunctional (to be added) ──────────────────────
# test_spherical_functional.cpp
)
target_include_directories(conformallab_cgal_tests SYSTEM PRIVATE
${CMAKE_SOURCE_DIR}/deps/eigen-3.4.0
${CMAKE_SOURCE_DIR}/deps/CGAL-6.1.1/include
${Boost_INCLUDE_DIRS}
)
target_include_directories(conformallab_cgal_tests PRIVATE
${CMAKE_SOURCE_DIR}/include
)
target_compile_definitions(conformallab_cgal_tests PRIVATE
CGAL_DISABLE_GMP
CGAL_DISABLE_MPFR
)
# Suppress warnings from CGAL/Boost headers
target_compile_options(conformallab_cgal_tests PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-Wno-unused-parameter>
)
target_link_libraries(conformallab_cgal_tests PRIVATE GTest::gtest_main)
include(GoogleTest)
gtest_discover_tests(conformallab_cgal_tests
TEST_PREFIX "cgal."
DISCOVERY_TIMEOUT 60
)

View File

@@ -0,0 +1,240 @@
// test_conformal_mesh.cpp
//
// Phase 3a — CGAL Surface_mesh infrastructure tests.
//
// Verifies that ConformalMesh (CGAL::Surface_mesh<Point3>) and the
// mesh_builder factories behave correctly before we build the functionals
// on top of them (Phase 3b).
//
// Test groups
// ───────────
// Topology vertex/edge/face counts, Euler characteristic
// Traversal halfedge iteration around vertex / face / edge
// PropertyMaps read/write of lambda, theta, idx, alpha, f:type
// Validity all make_* factories produce valid, consistent meshes
#include "conformal_mesh.hpp"
#include "mesh_builder.hpp"
#include <CGAL/boost/graph/iterator.h>
#include <gtest/gtest.h>
#include <cmath>
using namespace conformallab;
// ════════════════════════════════════════════════════════════
// Topology
// ════════════════════════════════════════════════════════════
// Single triangle: 3 vertices, 1 face, 3 edges.
TEST(ConformalMeshTopology, SingleTriangle)
{
auto mesh = make_triangle();
EXPECT_EQ(3u, mesh.number_of_vertices());
EXPECT_EQ(1u, mesh.number_of_faces());
EXPECT_EQ(3u, mesh.number_of_edges());
}
// Tetrahedron: V=4, E=6, F=4 → Euler = 2 (sphere topology).
TEST(ConformalMeshTopology, TetrahedronEuler)
{
auto mesh = make_tetrahedron();
EXPECT_EQ(4u, mesh.number_of_vertices());
EXPECT_EQ(6u, mesh.number_of_edges());
EXPECT_EQ(4u, mesh.number_of_faces());
int euler = (int)mesh.number_of_vertices()
- (int)mesh.number_of_edges()
+ (int)mesh.number_of_faces();
EXPECT_EQ(2, euler) << "Euler characteristic of closed sphere must be 2";
}
// Two-triangle strip: V=4, E=5, F=2.
// The interior edge (shared diagonal) has no border halfedge.
TEST(ConformalMeshTopology, QuadStrip)
{
auto mesh = make_quad_strip();
EXPECT_EQ(4u, mesh.number_of_vertices());
EXPECT_EQ(5u, mesh.number_of_edges());
EXPECT_EQ(2u, mesh.number_of_faces());
// Count interior (non-boundary) edges
int interior = 0;
for (auto e : mesh.edges())
if (!mesh.is_border(e)) ++interior;
EXPECT_EQ(1, interior) << "Only the shared diagonal should be interior";
}
// Fan with n triangles: V=n+1, E=2n, F=n.
TEST(ConformalMeshTopology, FanCounts)
{
for (int n : {3, 4, 6, 8}) {
auto mesh = make_fan(n);
EXPECT_EQ((std::size_t)(n + 1), mesh.number_of_vertices());
EXPECT_EQ((std::size_t)(2 * n), mesh.number_of_edges());
EXPECT_EQ((std::size_t)(n), mesh.number_of_faces());
}
}
// ════════════════════════════════════════════════════════════
// Halfedge Traversal
// ════════════════════════════════════════════════════════════
// For a regular tetrahedron every vertex has valence 3.
TEST(ConformalMeshTraversal, TetrahedronVertexValence)
{
auto mesh = make_tetrahedron();
for (auto v : mesh.vertices()) {
int degree = 0;
for (auto h : CGAL::halfedges_around_target(v, mesh))
{ (void)h; ++degree; }
EXPECT_EQ(3, degree) << "Each tetrahedron vertex has degree 3";
}
}
// For a fan with n triangles the center vertex has valence n.
TEST(ConformalMeshTraversal, FanCenterValence)
{
for (int n : {3, 5, 7}) {
auto mesh = make_fan(n);
// Center vertex is always the first one added (index 0).
auto center = *mesh.vertices().begin();
int degree = 0;
for (auto h : CGAL::halfedges_around_target(center, mesh))
{ (void)h; ++degree; }
EXPECT_EQ(n, degree)
<< "Fan center vertex must have valence == n=" << n;
}
}
// Every face of the tetrahedron has exactly 3 halfedges.
TEST(ConformalMeshTraversal, FaceHalfedgeCount)
{
auto mesh = make_tetrahedron();
for (auto f : mesh.faces()) {
int count = 0;
for (auto h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh))
{ (void)h; ++count; }
EXPECT_EQ(3, count) << "Each triangular face must have exactly 3 halfedges";
}
}
// opposite(h) and h share the same edge; opposite(opposite(h)) == h.
TEST(ConformalMeshTraversal, OppositeHalfedgeConsistency)
{
auto mesh = make_tetrahedron();
for (auto h : mesh.halfedges()) {
auto opp = mesh.opposite(h);
EXPECT_EQ(mesh.edge(h), mesh.edge(opp))
<< "h and opposite(h) must share the same edge";
EXPECT_EQ(h, mesh.opposite(opp))
<< "opposite(opposite(h)) must equal h";
}
}
// ════════════════════════════════════════════════════════════
// Property Maps
// ════════════════════════════════════════════════════════════
// The conformal variable lambda can be written and read back per vertex.
TEST(ConformalMeshProperties, VertexLambdaReadWrite)
{
auto mesh = make_tetrahedron();
auto [lambda, theta, idx] = add_vertex_properties(mesh);
double value = 0.0;
for (auto v : mesh.vertices()) {
lambda[v] = value;
value += 1.0;
}
value = 0.0;
for (auto v : mesh.vertices()) {
EXPECT_DOUBLE_EQ(value, lambda[v]);
value += 1.0;
}
}
// Default solver index is -1 (pinned); can be overwritten.
TEST(ConformalMeshProperties, VertexSolverIndex)
{
auto mesh = make_tetrahedron();
auto [lambda, theta, idx] = add_vertex_properties(mesh);
// All vertices start at -1 (pinned / boundary)
for (auto v : mesh.vertices())
EXPECT_EQ(-1, idx[v]) << "Default solver index must be -1";
// Assign sequential indices
int i = 0;
for (auto v : mesh.vertices())
idx[v] = i++;
i = 0;
for (auto v : mesh.vertices())
EXPECT_EQ(i++, idx[v]);
}
// Edge alpha (intersection angle): set and retrieve per edge.
TEST(ConformalMeshProperties, EdgeAlpha)
{
auto mesh = make_quad_strip();
auto alpha = add_edge_properties(mesh);
const double kAlpha = M_PI / 3.0; // 60°
for (auto e : mesh.edges())
alpha[e] = kAlpha;
for (auto e : mesh.edges())
EXPECT_DOUBLE_EQ(kAlpha, alpha[e]);
EXPECT_EQ(5u, mesh.number_of_edges());
}
// Face geometry type: Euclidean by default, switchable to Hyperbolic.
TEST(ConformalMeshProperties, FaceGeometryType)
{
auto mesh = make_tetrahedron();
auto ftype = add_face_properties(mesh);
// Default: Euclidean
for (auto f : mesh.faces())
EXPECT_EQ(static_cast<int>(GeometryType::Euclidean), ftype[f]);
// Switch all to Hyperbolic
for (auto f : mesh.faces())
ftype[f] = static_cast<int>(GeometryType::Hyperbolic);
for (auto f : mesh.faces())
EXPECT_EQ(static_cast<int>(GeometryType::Hyperbolic), ftype[f]);
}
// Adding the same named property map twice: second call returns ok=false
// and both handles alias the same storage.
TEST(ConformalMeshProperties, PropertyMapIdempotent)
{
auto mesh = make_triangle();
auto [pm1, ok1] = mesh.add_property_map<Vertex_index, double>("v:lambda", 0.0);
auto [pm2, ok2] = mesh.add_property_map<Vertex_index, double>("v:lambda", 0.0);
EXPECT_TRUE(ok1) << "First add_property_map must succeed";
EXPECT_FALSE(ok2) << "Second add_property_map on existing name must return ok=false";
// Both handles must alias the same storage
auto v = *mesh.vertices().begin();
pm1[v] = 42.0;
EXPECT_DOUBLE_EQ(42.0, pm2[v]) << "Both handles must alias the same storage";
}
// ════════════════════════════════════════════════════════════
// Mesh validity
// ════════════════════════════════════════════════════════════
// CGAL's built-in validity check must pass for all factory meshes.
TEST(ConformalMeshValidity, AllBuilders)
{
EXPECT_TRUE(make_triangle().is_valid()) << "triangle mesh invalid";
EXPECT_TRUE(make_tetrahedron().is_valid()) << "tetrahedron mesh invalid";
EXPECT_TRUE(make_quad_strip().is_valid()) << "quad strip mesh invalid";
EXPECT_TRUE(make_fan(6).is_valid()) << "fan-6 mesh invalid";
}