Phase 8a MVP: CGAL traits + Discrete_conformal_map.h Euclidean entry
First step of the Phase 8 Hybrid MVP. Adds a thin CGAL-conformant public
API layer over the existing implementation, validated by 7 acceptance
tests. Total CGAL test count: 183 (was 176), 0 skipped.
New public headers
──────────────────
* code/include/CGAL/Conformal_map_traits.h
- ConformalMapTraits concept documentation
- Default_conformal_map_traits<Surface_mesh<P>, K> specialisation
- Static property-map accessors: vertex_points, theta_map,
vertex_index_map, lambda0_map
* code/include/CGAL/Discrete_conformal_map.h
- User-facing entry: discrete_conformal_map_euclidean(mesh, np)
- Conformal_map_result<FT> struct (u, iter, ‖G‖, converged flags)
- Natural-theta default: x = 0 is the equilibrium when no Θ supplied
- Honours user-provided Θ via vertex_curvature_map named parameter
* code/include/CGAL/Conformal_map/internal/parameters.h
- 4 named-parameter tags in CGAL::Conformal_map::internal_np:
vertex_curvature_map, gradient_tolerance,
max_iterations, fixed_vertex_map
- User-facing helpers in CGAL::parameters::*
Tests (test_cgal_traits_mvp.cpp, 7 cases)
─────────────────────────────────────────
* DefaultTraitsTypes: compile-time type sanity (static_assert)
* AccessorsReuseExistingMaps: traits accessors return identical pmaps
* SingleTriangleConverges,
QuadStripConverges: end-to-end Euclidean wrapper passes
* MaxIterationsTakesEffect: named parameter is read
* GradientToleranceTakesEffect: tolerance override changes Newton end-state
* WrapperMatchesLegacyAPI: cross-API result equality at 1e-10
Architecture
────────────
3-layer wrapper as designed (doc/api/cgal-package.md):
Layer 1: code/include/*.hpp (existing algorithms, unchanged)
Layer 2: CGAL/Conformal_map/internal/ (adapter, parameter tags)
Layer 3: CGAL/Conformal_map_traits.h, CGAL/Discrete_conformal_map.h
(user-facing)
No algorithm duplication. Existing 176 + 36 tests untouched.
Next: Phase 9a (Inversive-Distance) as the second client of this API —
the real acceptance test for the trait design.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
215
code/tests/cgal/test_cgal_traits_mvp.cpp
Normal file
215
code/tests/cgal/test_cgal_traits_mvp.cpp
Normal file
@@ -0,0 +1,215 @@
|
||||
// test_cgal_traits_mvp.cpp
|
||||
//
|
||||
// Phase 8 MVP — first tests for the new CGAL-style public API.
|
||||
//
|
||||
// Validates:
|
||||
// 1. Default_conformal_map_traits<Surface_mesh, K> compiles and
|
||||
// provides all advertised types and property-map accessors.
|
||||
// 2. discrete_conformal_map_euclidean() runs end-to-end on a small mesh.
|
||||
// 3. Named-parameter overrides (gradient_tolerance, max_iterations)
|
||||
// change the Newton behaviour as expected.
|
||||
// 4. The result agrees with the legacy newton_euclidean() at the
|
||||
// same DOF assignment — proving the wrapper is non-destructive.
|
||||
//
|
||||
// These tests are the Phase 8 MVP acceptance probe. Phase 9a
|
||||
// (Inversive-Distance) will become the next, deeper validation by
|
||||
// implementing a new functional against this same trait API.
|
||||
|
||||
#include <CGAL/Conformal_map_traits.h>
|
||||
#include <CGAL/Discrete_conformal_map.h>
|
||||
|
||||
#include "mesh_builder.hpp" // make_triangle, make_quad_strip, make_tetrahedron
|
||||
#include "euclidean_functional.hpp"
|
||||
#include "newton_solver.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
|
||||
using namespace conformallab;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 1. Traits class: compile-time type sanity
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(CGALConformalTraits, DefaultTraitsTypes)
|
||||
{
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
using Mesh = CGAL::Surface_mesh<K::Point_3>;
|
||||
using Tr = CGAL::Default_conformal_map_traits<Mesh, K>;
|
||||
|
||||
// FT comes from the kernel.
|
||||
static_assert(std::is_same_v<typename Tr::FT, double>);
|
||||
|
||||
// Descriptors come from boost::graph_traits, not from Surface_mesh directly.
|
||||
static_assert(std::is_same_v<typename Tr::Triangle_mesh, Mesh>);
|
||||
static_assert(std::is_same_v<
|
||||
typename Tr::Vertex_descriptor,
|
||||
typename boost::graph_traits<Mesh>::vertex_descriptor>);
|
||||
|
||||
// Property-map types should match Surface_mesh::Property_map for the
|
||||
// appropriate key.
|
||||
static_assert(std::is_same_v<
|
||||
typename Tr::Theta_pmap,
|
||||
typename Mesh::template Property_map<typename Tr::Vertex_descriptor, double>>);
|
||||
static_assert(std::is_same_v<
|
||||
typename Tr::Vertex_index_pmap,
|
||||
typename Mesh::template Property_map<typename Tr::Vertex_descriptor, int>>);
|
||||
static_assert(std::is_same_v<
|
||||
typename Tr::Lambda0_pmap,
|
||||
typename Mesh::template Property_map<typename Tr::Edge_descriptor, double>>);
|
||||
|
||||
// Default kernel: Simple_cartesian<double>.
|
||||
using TrDefault = CGAL::Default_conformal_map_traits<Mesh>;
|
||||
static_assert(std::is_same_v<typename TrDefault::Kernel,
|
||||
CGAL::Simple_cartesian<double>>);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 2. Traits property-map accessors are non-destructive
|
||||
//
|
||||
// Setting up via the trait helpers and via setup_euclidean_maps() must yield
|
||||
// the same property map (Surface_mesh deduplicates by name).
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(CGALConformalTraits, AccessorsReuseExistingMaps)
|
||||
{
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
using Mesh = CGAL::Surface_mesh<K::Point_3>;
|
||||
using Tr = CGAL::Default_conformal_map_traits<Mesh, K>;
|
||||
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
|
||||
auto theta_via_traits = Tr::theta_map(mesh);
|
||||
auto idx_via_traits = Tr::vertex_index_map(mesh);
|
||||
auto lambda0_via_traits = Tr::lambda0_map(mesh);
|
||||
|
||||
// Surface_mesh property maps with the same key type are equality-comparable
|
||||
// by name lookup — accessing through the traits class must return the
|
||||
// same map that setup_euclidean_maps() created.
|
||||
EXPECT_EQ(theta_via_traits, maps.theta_v);
|
||||
EXPECT_EQ(idx_via_traits, maps.v_idx);
|
||||
EXPECT_EQ(lambda0_via_traits, maps.lambda0);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 3. End-to-end: discrete_conformal_map_euclidean() on a small open mesh
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(CGALDiscreteConformalMap, SingleTriangleConverges)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto result = CGAL::discrete_conformal_map_euclidean(mesh);
|
||||
|
||||
EXPECT_TRUE(result.converged)
|
||||
<< "Newton did not converge on a single triangle";
|
||||
EXPECT_LT(result.gradient_norm, 1e-8);
|
||||
EXPECT_GE(result.iterations, 0);
|
||||
EXPECT_EQ(result.u_per_vertex.size(), num_vertices(mesh));
|
||||
|
||||
// With the default flat-disc target curvature and the first vertex pinned,
|
||||
// the natural-theta equilibrium is at u = 0 — Newton should accept x0=0.
|
||||
for (double u : result.u_per_vertex)
|
||||
EXPECT_NEAR(u, 0.0, 1e-8);
|
||||
}
|
||||
|
||||
TEST(CGALDiscreteConformalMap, QuadStripConverges)
|
||||
{
|
||||
auto mesh = make_quad_strip();
|
||||
auto result = CGAL::discrete_conformal_map_euclidean(mesh);
|
||||
|
||||
EXPECT_TRUE(result.converged);
|
||||
EXPECT_LT(result.gradient_norm, 1e-8);
|
||||
EXPECT_EQ(result.u_per_vertex.size(), num_vertices(mesh));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 4. Named-parameter overrides take effect
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(CGALDiscreteConformalMap, MaxIterationsTakesEffect)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
|
||||
// max_iterations(0) forces Newton to give up immediately.
|
||||
auto result = CGAL::discrete_conformal_map_euclidean(
|
||||
mesh,
|
||||
CGAL::parameters::max_iterations(0));
|
||||
|
||||
EXPECT_EQ(result.iterations, 0);
|
||||
// Trivial natural-theta case: gradient is already zero at x=0,
|
||||
// so even 0 iterations may report "converged" depending on the
|
||||
// initial gradient check. The point is just that the parameter
|
||||
// was *read* — verified by EXPECT_EQ on iterations above.
|
||||
}
|
||||
|
||||
TEST(CGALDiscreteConformalMap, GradientToleranceTakesEffect)
|
||||
{
|
||||
auto mesh = make_quad_strip();
|
||||
|
||||
// Loose tolerance — must still converge, but possibly in fewer steps.
|
||||
auto result_loose = CGAL::discrete_conformal_map_euclidean(
|
||||
mesh,
|
||||
CGAL::parameters::gradient_tolerance(1e-4));
|
||||
EXPECT_TRUE(result_loose.converged);
|
||||
|
||||
// Strict tolerance — also must converge, gradient norm must be tighter.
|
||||
auto result_strict = CGAL::discrete_conformal_map_euclidean(
|
||||
mesh,
|
||||
CGAL::parameters::gradient_tolerance(1e-12));
|
||||
EXPECT_TRUE(result_strict.converged);
|
||||
EXPECT_LT(result_strict.gradient_norm, 1e-10);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 5. Wrapper agrees with the legacy newton_euclidean() at the same setup
|
||||
//
|
||||
// This is the cross-API consistency check: same mesh, same default settings
|
||||
// (first vertex pinned, x0=0) — the u-vector returned by the wrapper must
|
||||
// match what newton_euclidean produces directly.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(CGALDiscreteConformalMap, WrapperMatchesLegacyAPI)
|
||||
{
|
||||
auto mesh = make_quad_strip();
|
||||
|
||||
// ── New API: applies natural-theta automatically ───────────────────────
|
||||
auto result_new = CGAL::discrete_conformal_map_euclidean(mesh);
|
||||
|
||||
// ── Legacy API on a fresh mesh — must replicate the *same* preparation
|
||||
// that the wrapper performs internally (pin first vertex, assign
|
||||
// DOFs, apply natural-theta). Otherwise the comparison is unfair
|
||||
// (Newton would diverge without natural-theta on these meshes). ────
|
||||
auto mesh_legacy = make_quad_strip();
|
||||
auto maps = setup_euclidean_maps(mesh_legacy);
|
||||
compute_euclidean_lambda0_from_mesh(mesh_legacy, maps);
|
||||
|
||||
// Pin first vertex (gauge), assign sequential DOFs to the rest.
|
||||
auto vit = mesh_legacy.vertices().begin();
|
||||
maps.v_idx[*vit++] = -1;
|
||||
int idx = 0;
|
||||
for (; vit != mesh_legacy.vertices().end(); ++vit)
|
||||
maps.v_idx[*vit] = idx++;
|
||||
|
||||
// Natural-theta: shift Θ so that x = 0 is the natural equilibrium.
|
||||
std::vector<double> x0(idx, 0.0);
|
||||
auto G0 = euclidean_gradient(mesh_legacy, x0, maps);
|
||||
for (auto v : mesh_legacy.vertices()) {
|
||||
int j = maps.v_idx[v];
|
||||
if (j >= 0) maps.theta_v[v] -= G0[static_cast<std::size_t>(j)];
|
||||
}
|
||||
|
||||
auto nr = newton_euclidean(mesh_legacy, x0, maps, 1e-10, 200);
|
||||
|
||||
// ── Compare ────────────────────────────────────────────────────────────
|
||||
EXPECT_EQ(result_new.converged, nr.converged);
|
||||
EXPECT_NEAR(result_new.gradient_norm, nr.grad_inf_norm, 1e-12);
|
||||
|
||||
// Pinned vertex u is 0 in both; for the rest the values agree.
|
||||
for (auto v : mesh_legacy.vertices()) {
|
||||
int j = maps.v_idx[v];
|
||||
double u_legacy = (j >= 0) ? nr.x[static_cast<std::size_t>(j)] : 0.0;
|
||||
EXPECT_NEAR(result_new.u_per_vertex[v.idx()], u_legacy, 1e-10)
|
||||
<< "Wrapper diverges from legacy for vertex " << v.idx();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user