Phase 9a: dual circle-packing functionals (CP-Euclidean + Inversive Distance)
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

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>
This commit is contained in:
Tarik Moussa
2026-05-19 23:29:50 +02:00
committed by Tarik Moussa
parent 1a6e731ad2
commit 8c01a133d8
7 changed files with 1477 additions and 0 deletions

View File

@@ -0,0 +1,267 @@
// 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);
}