Files
ConformalLabpp/code/tests/cgal/test_inversive_distance_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

263 lines
12 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_inversive_distance_functional.cpp
//
// Phase 9a.2 — Inversive-distance functional (Luo 2004) tests.
//
// Validation against three mathematical references:
//
// [Luo 2004] _ij² = exp(2u_i) + exp(2u_j) + 2 I_ij exp(u_i+u_j)
// ∂E/∂u_v = Θ_v Σ α_v (Lemma 3.1)
//
// [BS 2004] I_ij = (ℓ² r_i² r_j²) / (2 r_i r_j)
// I = 1 ⇒ tangential circles
// I = 0 ⇒ orthogonal circles
//
// [Glickenstein 2011 §5]
// correspondence to BPS-2010 face-based CP:
// I_ij = cos θ_e on the face-dual mesh
//
// No Java reference exists for this functional in
// de.varylab.discreteconformal. Cross-validation is done via:
// 1. FD-vs-analytic gradient check (numerical),
// 2. Luo's edge-length identity check (mathematical),
// 3. Tangential-limit identity I=1 ⇒ = r_i+r_j (geometric).
#include "inversive_distance_functional.hpp"
#include "euclidean_functional.hpp"
#include "mesh_builder.hpp"
#include "conformal_mesh.hpp"
#include <gtest/gtest.h>
#include <vector>
#include <random>
#include <cmath>
using namespace conformallab;
// ════════════════════════════════════════════════════════════════════════════
// 1. Edge-length formula (Luo 2004 §3)
//
// ℓ² = exp(2u_i) + exp(2u_j) + 2 I exp(u_i+u_j)
// = r_i² + r_j² + 2 I r_i r_j
//
// Special cases:
// I = 1 ⇒ ℓ² = (r_i + r_j)² ⇒ = r_i + r_j (tangential)
// I = 0 ⇒ ℓ² = r_i² + r_j² (orthogonal — circles meet at 90°)
// I = 1 ⇒ ℓ² = (r_i r_j)² ⇒ = |r_i r_j| (inside-tangent)
// ════════════════════════════════════════════════════════════════════════════
TEST(InversiveDistanceFunctional, EdgeLengthFormula_TangentialLimit)
{
// ui = 0 ⇒ ri = 1; uj = log(2) ⇒ rj = 2; I = 1 (tangential):
// ℓ² = 1 + 4 + 2·1·1·2 = 9 ⇒ = 3 = r_i + r_j ✓
double l2 = id_detail::edge_length_squared(0.0, std::log(2.0), 1.0);
EXPECT_NEAR(std::sqrt(l2), 3.0, 1e-12);
}
TEST(InversiveDistanceFunctional, EdgeLengthFormula_OrthogonalLimit)
{
// r_i = 3, r_j = 4, I = 0: ℓ² = 9 + 16 = 25 ⇒ = 5 (Pythagorean)
double l2 = id_detail::edge_length_squared(std::log(3.0), std::log(4.0), 0.0);
EXPECT_NEAR(std::sqrt(l2), 5.0, 1e-12);
}
TEST(InversiveDistanceFunctional, EdgeLengthFormula_InsideTangentLimit)
{
// r_i = 2, r_j = 5, I = 1: ℓ² = (5 2)² = 9 ⇒ = 3
double l2 = id_detail::edge_length_squared(std::log(2.0), std::log(5.0), -1.0);
EXPECT_NEAR(std::sqrt(l2), 3.0, 1e-12);
}
TEST(InversiveDistanceFunctional, EdgeLengthFormula_DegenerateReturnsMinusOne)
{
// r_i = r_j = 1, I = 2: ℓ² = 1 + 1 4 = 2 (impossible packing)
double l2 = id_detail::edge_length_squared(0.0, 0.0, -2.0);
EXPECT_EQ(l2, -1.0) << "should signal degenerate packing";
}
// ════════════════════════════════════════════════════════════════════════════
// 2. Bowers-Stephenson identity round-trip
//
// Given (, r_i, r_j), the I_ij that compute_init produces must satisfy
// Luo's edge-length formula exactly: ℓ²(I_ij, r_i, r_j) = ℓ².
// ════════════════════════════════════════════════════════════════════════════
TEST(InversiveDistanceFunctional, BowersStephensonRoundTrip)
{
auto mesh = make_triangle(); // (0,0,0)-(1,0,0)-(0,1,0)
auto m = setup_inversive_distance_maps(mesh);
compute_inversive_distance_init_from_mesh(mesh, m);
// At u = 0, exp(u) = r0. Reconstruct from (r_i, r_j, I_ij) and compare
// to the 3-D Euclidean edge length from the mesh.
for (auto e : mesh.edges()) {
auto h = mesh.halfedge(e);
auto p1 = mesh.point(mesh.source(h));
auto p2 = mesh.point(mesh.target(h));
double dx = p1.x() - p2.x();
double dy = p1.y() - p2.y();
double dz = p1.z() - p2.z();
double l_3d = std::sqrt(dx*dx + dy*dy + dz*dz);
double ri = m.r0[mesh.source(h)];
double rj = m.r0[mesh.target(h)];
double l2_reconstructed = ri*ri + rj*rj + 2.0 * m.I_e[e] * ri * rj;
EXPECT_NEAR(std::sqrt(l2_reconstructed), l_3d, 1e-12)
<< "Bowers-Stephenson round-trip failed for an edge";
}
}
// ════════════════════════════════════════════════════════════════════════════
// 3. Properties of the init step
// ════════════════════════════════════════════════════════════════════════════
TEST(InversiveDistanceFunctional, InitProducesValidPositiveRadii)
{
auto mesh = make_tetrahedron();
auto m = setup_inversive_distance_maps(mesh);
compute_inversive_distance_init_from_mesh(mesh, m);
for (auto v : mesh.vertices()) {
EXPECT_GT(m.r0[v], 0.0) << "init radius must be positive";
EXPECT_TRUE(std::isfinite(m.r0[v]));
}
for (auto e : mesh.edges()) {
EXPECT_TRUE(std::isfinite(m.I_e[e]));
// I > 1 is required for any valid inversive-distance packing.
EXPECT_GT(m.I_e[e], -1.0);
}
}
// ════════════════════════════════════════════════════════════════════════════
// 4. Gradient at the "natural equilibrium" is zero by construction
//
// Same trick as in test_euclidean_functional.cpp:
// • Set u = 0 ⇒ r = r0 ⇒ = _3d (Bowers-Stephenson round-trip)
// • Compute G(0) — that's the angle defect Θ Σ_actual.
// • Subtract G(0) from Θ → new G(0) is zero.
// This means u = 0 is now the Newton equilibrium of the functional, just
// like in the euclidean functional natural-theta trick.
// ════════════════════════════════════════════════════════════════════════════
TEST(InversiveDistanceFunctional, NaturalThetaGivesZeroGradientAtU0)
{
auto mesh = make_triangle();
auto m = setup_inversive_distance_maps(mesh);
compute_inversive_distance_init_from_mesh(mesh, m);
// Assign DOFs to all vertices.
int n = 0;
for (auto v : mesh.vertices()) m.v_idx[v] = n++;
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
auto G0 = inversive_distance_gradient(mesh, x, m);
for (auto v : mesh.vertices()) {
int i = m.v_idx[v];
m.theta_v[v] -= G0[static_cast<std::size_t>(i)];
}
auto G_eq = inversive_distance_gradient(mesh, x, m);
for (double g : G_eq) EXPECT_NEAR(g, 0.0, 1e-13);
}
// ════════════════════════════════════════════════════════════════════════════
// 5. FD-vs-analytic gradient check (the main acceptance test for the port)
//
// Pattern: identical to test_euclidean_functional.cpp's
// GradientCheck_TriangleVertex (lines 137-149). The energy is the path
// integral of the gradient (by construction); a consistent FD-vs-analytic
// match validates both energy and gradient implementations together.
// ════════════════════════════════════════════════════════════════════════════
TEST(InversiveDistanceFunctional, FDGradientCheck_Triangle)
{
auto mesh = make_triangle();
auto m = setup_inversive_distance_maps(mesh);
compute_inversive_distance_init_from_mesh(mesh, m);
int n = 0;
for (auto v : mesh.vertices()) m.v_idx[v] = n++;
// Small perturbation u_v ≈ 0.1 keeps every triangle valid.
std::vector<double> x(static_cast<std::size_t>(n), -0.1);
EXPECT_TRUE(gradient_check_inversive_distance(mesh, x, m))
<< "FD gradient mismatch on single triangle (u = 0.1)";
}
TEST(InversiveDistanceFunctional, FDGradientCheck_QuadStrip)
{
auto mesh = make_quad_strip();
auto m = setup_inversive_distance_maps(mesh);
compute_inversive_distance_init_from_mesh(mesh, m);
int n = 0;
for (auto v : mesh.vertices()) m.v_idx[v] = n++;
std::vector<double> x(static_cast<std::size_t>(n), -0.15);
EXPECT_TRUE(gradient_check_inversive_distance(mesh, x, m))
<< "FD gradient mismatch on quad strip";
}
TEST(InversiveDistanceFunctional, FDGradientCheck_Tetrahedron)
{
auto mesh = make_tetrahedron();
auto m = setup_inversive_distance_maps(mesh);
compute_inversive_distance_init_from_mesh(mesh, m);
int n = 0;
for (auto v : mesh.vertices()) m.v_idx[v] = n++;
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
EXPECT_TRUE(gradient_check_inversive_distance(mesh, x, m))
<< "FD gradient mismatch on regular tetrahedron";
}
// ════════════════════════════════════════════════════════════════════════════
// 6. Cross-validation with euclidean_functional.hpp
//
// The two functionals are DIFFERENT geometric models. At u = 0 with their
// natural inits both produce a valid triangulation, but the per-edge length
// is different:
// • Euclidean: = _3d (exact, by lambda0 init)
// • Inversive distance: = _3d (exact, by BS round-trip)
//
// HOWEVER the GRADIENT at u = 0 differs because the chain rule ∂ℓ/∂u is
// different. Specifically:
// • Euclidean: ∂(2 log )/∂u_i = 1
// • Inversive distance: ∂(2 log )/∂u_i = (r_i² + I r_i r_j) / ℓ²
//
// This test pins one quantitative consequence: at u = 0 both gradients have
// the SAME angle-defect structure Θ Σ_actual. After applying the natural-
// theta trick on each, both must be at equilibrium with G(0) = 0.
// ════════════════════════════════════════════════════════════════════════════
TEST(InversiveDistanceFunctional, AngleDefectAtU0_AgreesWithEuclideanAtU0)
{
auto mesh = make_quad_strip();
// ── Inversive distance side ────────────────────────────────────────────
auto m_id = setup_inversive_distance_maps(mesh);
compute_inversive_distance_init_from_mesh(mesh, m_id);
int n_id = 0;
for (auto v : mesh.vertices()) m_id.v_idx[v] = n_id++;
std::vector<double> x_id(static_cast<std::size_t>(n_id), 0.0);
auto G_id = inversive_distance_gradient(mesh, x_id, m_id);
// ── Euclidean side (same mesh, same DOF order) ─────────────────────────
auto m_eu = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, m_eu);
int n_eu = 0;
for (auto v : mesh.vertices()) m_eu.v_idx[v] = n_eu++;
std::vector<double> x_eu(static_cast<std::size_t>(n_eu), 0.0);
auto G_eu = euclidean_gradient(const_cast<ConformalMesh&>(mesh), x_eu, m_eu);
// Both should report the same actual angle sum per vertex at u = 0
// (since both reproduce = _3d at u = 0). Therefore Θ Σ_actual
// is identical for the two functionals (Θ default 2π in both).
ASSERT_EQ(G_id.size(), G_eu.size());
for (std::size_t i = 0; i < G_id.size(); ++i) {
EXPECT_NEAR(G_id[i], G_eu[i], 1e-10)
<< "angle-defect mismatch at u=0, DOF " << i
<< ": id=" << G_id[i] << " eu=" << G_eu[i];
}
}