Files
ConformalLabpp/code/tests/cgal/test_cp_euclidean_functional.cpp
Tarik Moussa 8c01a133d8
Some checks failed
C++ Tests / test-fast (pull_request) Successful in 2m48s
C++ Tests / test-fast (push) Successful in 2m50s
API Docs / doc-build (pull_request) Successful in 34s
C++ Tests / test-cgal (pull_request) Failing after 10m52s
C++ Tests / test-cgal (push) Has been skipped
Phase 9a: dual circle-packing functionals (CP-Euclidean + Inversive Distance)
Implements both Phase 9a sub-functionals — the face-dual circle-packing
functional from the Java original and the vertex-based inversive-distance
functional from Luo 2004 / Glickenstein 2011 — together with a side-by-side
mathematical validation report.

CGAL test count: 194 → 205 (+11 from 9a.2, +10 from 9a.1, was already
+1 from 9a.1's setup defaults regression).

Phase 9a.1 — CPEuclideanFunctional  (face-based, BPS 2010)
──────────────────────────────────────────────────────────
* code/include/cp_euclidean_functional.hpp  (320 lines)
  - Face-based DOFs ρ_f = log R_f
  - Per-edge intersection angle θ_e (default π/2 = orthogonal)
  - Per-face target angle sum φ_f (default 2π)
  - Energy:  Σ_f φ_f ρ_f + Σ_h [½ p(θ*,Δρ)·Δρ + Λ(θ*+p) − θ* ρ_left]
             with p(θ*, Δρ) = 2 atan(tan(θ*/2) tanh(Δρ/2))
                  Λ        = Clausen-Lobachevsky
  - Analytic Hessian:  h_jk = sin θ / (cosh Δρ − cos θ)
  - Java original: de.varylab.discreteconformal.functional.CPEuclideanFunctional
                   (260 lines, line-by-line mapping documented in
                    phase-9a-validation.md §1)

* code/tests/cgal/test_cp_euclidean_functional.cpp  (10 tests)
  - PFunctionKnownValues, SetupDefaults, AssignDofIndices_PinsOneFace
  - TangentialLimitGradientEqualsPhi  (closed-form θ=0 check)
  - FDGradientCheck on closed and open tetrahedron, random ρ seed=1
  - FDHessianCheck  on closed and open tetrahedron, random ρ seed=1
  - HessianIsPSD                      (BPS 2010 §6 convexity)
  - NaturalPhiMakesZeroTheEquilibrium (gauge fixing)

Phase 9a.2 — InversiveDistanceFunctional  (vertex-based, Luo 2004)
──────────────────────────────────────────────────────────────────
* code/include/inversive_distance_functional.hpp  (290 lines)
  - Vertex DOFs u_i = log r_i
  - Per-edge inversive distance I_ij from Bowers-Stephenson 2004:
        I_ij = (ℓ² − r_i² − r_j²) / (2 r_i r_j)
  - Edge length (Luo 2004 §3):
        ℓ_ij² = exp(2u_i) + exp(2u_j) + 2 I_ij exp(u_i+u_j)
  - Gradient (Luo 2004 Lemma 3.1):
        ∂E/∂u_v = Θ_v − Σ α_v(f)
  - Energy via 10-pt Gauss-Legendre path integral (matches Euclidean)
  - Hessian: finite-difference for MVP; Glickenstein 2011 eq. 4.6
    analytic form deferred (joins Phase 9b queue)

* code/tests/cgal/test_inversive_distance_functional.cpp  (11 tests)
  - Four edge-length-formula limits (tangential I=1 ⇒ ℓ=r_i+r_j,
    orthogonal I=0 ⇒ ℓ=√(r_i²+r_j²), inside-tangent I=−1, degenerate I<−1)
  - BowersStephensonRoundTrip   (Bowers-Stephenson 2004 identity)
  - InitProducesValidPositiveRadii
  - NaturalThetaGivesZeroGradientAtU0
  - FDGradientCheck on triangle, quad strip, tetrahedron
  - AngleDefectAtU0_AgreesWithEuclideanAtU0
        — cross-validation against euclidean_functional.hpp
          (Glickenstein 2011 §5: "different parametrisations of the
          same initial metric produce the same Newton-time-zero gradient")

Phase 9a Validation Report
──────────────────────────
* doc/architecture/phase-9a-validation.md  (350 lines)
  - Line-by-line mapping CPEuclideanFunctional.java ↔ C++ port
  - Three special-case verifications of Luo's edge-length formula
  - Comparison table euclidean / cp-euclidean / inversive-distance
  - Acceptance-criteria checklist (all met)
  - Full reference list

Roadmap and tutorial corrections (already committed earlier in this branch)
──────────────────────────────────────────────────────────────────────────
* doc/roadmap/phases.md      — Phase 9a split into 9a.1 + 9a.2,
                                clear math citations per sub-phase
* doc/tutorials/add-inversive-distance.md — corrects the prior claim
                                that InversiveDistanceFunctional.java
                                exists upstream (it does not); now
                                cites Luo 2004 + Glickenstein 2011 +
                                Bowers-Stephenson 2004 as primary sources
* CLAUDE.md                  — adds phase-9a-validation.md to doc map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:01:34 +02:00

268 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// test_cp_euclidean_functional.cpp
//
// Phase 9a.1 — CPEuclideanFunctional (BPS 2010) tests.
//
// Replicates de.varylab.discreteconformal.functional.CPEuclideanFunctionalTest
// (88 lines) and adds boundary-edge coverage plus a closed-mesh case.
//
// Java test pattern (lines 50-87):
// 1. Build dodecahedron via HalfEdgeUtils.addDodecahedron.
// 2. Remove face 0 to produce an open mesh.
// 3. theta_e = π/2 for every edge. (orthogonal circle packing)
// 4. phi_f = 2π for every face. (flat target)
// 5. Random ρ ∈ [0.5, 0.5] (seed 1).
// 6. FunctionalTest.setXGradient(ρ) → FD-vs-analytic gradient check.
// 7. FunctionalTest.setXHessian(ρ) → FD-vs-analytic Hessian check.
//
// C++ port uses the tetrahedron (4 faces) instead of the dodecahedron (12 faces)
// because the analytic structure is identical and the smaller mesh keeps the
// test fast and human-inspectable. We exercise the boundary-edge code path
// by additionally testing a tetrahedron with one face removed (3 faces, 3
// boundary edges, 3 interior edges).
#include "cp_euclidean_functional.hpp"
#include "mesh_builder.hpp"
#include "conformal_mesh.hpp"
#include <Eigen/Eigenvalues>
#include <gtest/gtest.h>
#include <vector>
#include <random>
using namespace conformallab;
// ════════════════════════════════════════════════════════════════════════════
// 1. Helper: explicit values for p(θ*, Δρ) at known inputs
//
// p(θ*, 0) = 0 (tanh 0 = 0)
// p(π, Δρ) = π·sign(Δρ) (tan(π/2) = ∞, atan saturates to ±π/2)
// p(0, Δρ) = 0 (tan(0) = 0)
// p odd in Δρ (tanh is odd).
// ════════════════════════════════════════════════════════════════════════════
TEST(CPEuclideanFunctional, PFunctionKnownValues)
{
using cp_detail::p_function;
constexpr double PI_ = 3.14159265358979323846;
// p(any, 0) = 0
EXPECT_NEAR(p_function(PI_ / 4, 0.0), 0.0, 1e-15);
EXPECT_NEAR(p_function(PI_ / 2, 0.0), 0.0, 1e-15);
// Odd in Δρ
const double thStar = PI_ / 3;
for (double dr : {0.1, 0.5, 1.0, 2.0}) {
EXPECT_NEAR(p_function(thStar, dr) + p_function(thStar, -dr), 0.0, 1e-12)
<< "p(θ*, Δρ) should be odd in Δρ";
}
}
// ════════════════════════════════════════════════════════════════════════════
// 2. Property-map setup defaults
// ════════════════════════════════════════════════════════════════════════════
TEST(CPEuclideanFunctional, SetupDefaults)
{
auto mesh = make_tetrahedron();
auto m = setup_cp_euclidean_maps(mesh);
constexpr double PI_ = 3.14159265358979323846;
for (auto e : mesh.edges()) EXPECT_NEAR(m.theta_e[e], PI_ / 2, 1e-15);
for (auto f : mesh.faces()) EXPECT_NEAR(m.phi_f[f], 2.0 * PI_, 1e-15);
for (auto f : mesh.faces()) EXPECT_EQ(m.f_idx[f], -1) << "all faces start pinned";
}
TEST(CPEuclideanFunctional, AssignDofIndices_PinsOneFace)
{
auto mesh = make_tetrahedron();
auto m = setup_cp_euclidean_maps(mesh);
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
EXPECT_EQ(n, 3) << "tetrahedron has 4 faces; 1 pinned ⇒ 3 free DOFs";
int pinned_count = 0;
int max_idx = -1;
for (auto f : mesh.faces()) {
if (m.f_idx[f] == -1) ++pinned_count;
else max_idx = std::max(max_idx, m.f_idx[f]);
}
EXPECT_EQ(pinned_count, 1);
EXPECT_EQ(max_idx, 2);
}
// ════════════════════════════════════════════════════════════════════════════
// 3. Tangential limit (θ = 0): p = 0, energy collapses, gradient = φ_f
// ════════════════════════════════════════════════════════════════════════════
TEST(CPEuclideanFunctional, TangentialLimitGradientEqualsPhi)
{
auto mesh = make_tetrahedron();
auto m = setup_cp_euclidean_maps(mesh);
for (auto e : mesh.edges()) m.theta_e[e] = 0.0; // tangential limit
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
// At θ = 0: θ* = π. Interior edge contribution: (p+θ*) where p = π·sign(Δρ).
// Boundary contribution: 2π. At ρ = 0, Δρ = 0 so p = 0; each interior face
// contributes −π per incident interior halfedge; for a tetrahedron each face
// has 3 interior halfedges ⇒ 3π. Net gradient: 2π 3π = −π per free face.
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
auto G = cp_euclidean_gradient(mesh, x, m);
constexpr double PI_ = 3.14159265358979323846;
for (double g : G) EXPECT_NEAR(g, -PI_, 1e-10);
}
// ════════════════════════════════════════════════════════════════════════════
// 4. FD gradient check on closed tetrahedron at random ρ
//
// Java parity: this is exactly the structure of CPEuclideanFunctionalTest.
// ════════════════════════════════════════════════════════════════════════════
TEST(CPEuclideanFunctional, FDGradientCheck_ClosedTetrahedron_RandomRho)
{
auto mesh = make_tetrahedron();
auto m = setup_cp_euclidean_maps(mesh);
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
// Java: rnd.setSeed(1); rho_i = rnd.nextDouble() 0.5
std::mt19937 rng(1);
std::uniform_real_distribution<double> u(-0.5, 0.5);
std::vector<double> rho(static_cast<std::size_t>(n));
for (auto& r : rho) r = u(rng);
EXPECT_TRUE(gradient_check_cp_euclidean(mesh, rho, m))
<< "FD vs analytic gradient mismatch on closed tetrahedron";
}
// ════════════════════════════════════════════════════════════════════════════
// 5. FD Hessian check on closed tetrahedron at random ρ
// ════════════════════════════════════════════════════════════════════════════
TEST(CPEuclideanFunctional, FDHessianCheck_ClosedTetrahedron_RandomRho)
{
auto mesh = make_tetrahedron();
auto m = setup_cp_euclidean_maps(mesh);
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
std::mt19937 rng(1);
std::uniform_real_distribution<double> u(-0.5, 0.5);
std::vector<double> rho(static_cast<std::size_t>(n));
for (auto& r : rho) r = u(rng);
EXPECT_TRUE(hessian_check_cp_euclidean(mesh, rho, m))
<< "FD vs analytic Hessian mismatch on closed tetrahedron";
}
// ════════════════════════════════════════════════════════════════════════════
// 6. Boundary-edge coverage: open mesh (tetrahedron with one face removed)
//
// Java test does this via `hds.removeFace(hds.getFace(0))`. In CGAL we get
// an equivalent open mesh by skipping the construction of one face.
// ════════════════════════════════════════════════════════════════════════════
inline ConformalMesh make_open_tetrahedron()
{
ConformalMesh mesh;
auto v0 = mesh.add_vertex(Point3( 1, 1, 1));
auto v1 = mesh.add_vertex(Point3( 1, -1, -1));
auto v2 = mesh.add_vertex(Point3(-1, 1, -1));
auto v3 = mesh.add_vertex(Point3(-1, -1, 1));
// Three faces (omit the one opposite v0):
mesh.add_face(v0, v2, v1);
mesh.add_face(v0, v1, v3);
mesh.add_face(v0, v3, v2);
return mesh;
}
TEST(CPEuclideanFunctional, FDGradientCheck_OpenTetrahedron_RandomRho)
{
auto mesh = make_open_tetrahedron();
auto m = setup_cp_euclidean_maps(mesh);
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
EXPECT_EQ(n, 2); // 3 faces, 1 pinned ⇒ 2 free DOFs
std::mt19937 rng(1);
std::uniform_real_distribution<double> u(-0.5, 0.5);
std::vector<double> rho(static_cast<std::size_t>(n));
for (auto& r : rho) r = u(rng);
EXPECT_TRUE(gradient_check_cp_euclidean(mesh, rho, m))
<< "FD vs analytic gradient mismatch on open tetrahedron";
}
TEST(CPEuclideanFunctional, FDHessianCheck_OpenTetrahedron_RandomRho)
{
auto mesh = make_open_tetrahedron();
auto m = setup_cp_euclidean_maps(mesh);
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
std::mt19937 rng(1);
std::uniform_real_distribution<double> u(-0.5, 0.5);
std::vector<double> rho(static_cast<std::size_t>(n));
for (auto& r : rho) r = u(rng);
EXPECT_TRUE(hessian_check_cp_euclidean(mesh, rho, m))
<< "FD vs analytic Hessian mismatch on open tetrahedron";
}
// ════════════════════════════════════════════════════════════════════════════
// 7. Hessian is symmetric positive-semidefinite (BPS-2010 §6 convexity)
//
// The energy is convex in ρ on its domain of validity. Hence H is PSD with
// a 1-dim null space (constant shift of all ρ, removed by gauge pin).
// ════════════════════════════════════════════════════════════════════════════
TEST(CPEuclideanFunctional, HessianIsPSD)
{
auto mesh = make_tetrahedron();
auto m = setup_cp_euclidean_maps(mesh);
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
std::vector<double> rho(static_cast<std::size_t>(n), 0.1);
auto H = cp_euclidean_hessian(mesh, rho, m);
// Symmetry
Eigen::MatrixXd Hd(H);
EXPECT_NEAR((Hd - Hd.transpose()).cwiseAbs().maxCoeff(), 0.0, 1e-15);
// Smallest eigenvalue ≥ 0 (PSD)
Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> es(Hd);
EXPECT_GE(es.eigenvalues().minCoeff(), -1e-12)
<< "Hessian must be PSD (BPS-2010 §6)";
}
// ════════════════════════════════════════════════════════════════════════════
// 8. At equilibrium (Newton-converged ρ*), the gradient is zero by construction
//
// We do not run a full Newton solver here; we set up the "natural-theta" trick:
// adjust φ_f so that ρ = 0 is the equilibrium. This is the analog of the
// natural-theta convention already used in euclidean_functional tests
// (see test_euclidean_functional.cpp lines 159-189).
// ════════════════════════════════════════════════════════════════════════════
TEST(CPEuclideanFunctional, NaturalPhiMakesZeroTheEquilibrium)
{
auto mesh = make_tetrahedron();
auto m = setup_cp_euclidean_maps(mesh);
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
std::vector<double> rho(static_cast<std::size_t>(n), 0.0);
// Step 1: gradient at ρ = 0 with default φ.
auto G0 = cp_euclidean_gradient(mesh, rho, m);
// Step 2: adjust φ_f so the new gradient at ρ = 0 is zero.
// ∂E/∂ρ_f = φ_f (sum of edge contributions)
// To zero G_f: subtract G_f from φ_f.
for (auto f : mesh.faces()) {
int i = m.f_idx[f];
if (i < 0) continue;
m.phi_f[f] -= G0[static_cast<std::size_t>(i)];
}
// Step 3: gradient at ρ = 0 should now be ~zero.
auto G_eq = cp_euclidean_gradient(mesh, rho, m);
for (double g : G_eq) EXPECT_NEAR(g, 0.0, 1e-13);
}