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,344 @@
#pragma once
// inversive_distance_functional.hpp
//
// Phase 9a.2 — Inversive-distance circle-packing functional (Luo 2004).
//
// VERTEX-based circle packing. Each vertex carries a circle of radius
// r_i = exp(u_i). The inversive distance I_ij between two adjacent
// circles is a constant of the edge, derived once from the initial
// geometry via Bowers-Stephenson 2004.
//
// This is the FACE-DUAL of CPEuclideanFunctional (Phase 9a.1). The
// correspondence is I_ij = cos θ_e (Glickenstein 2011 §5).
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ Mathematical model │
// │ ────────────────── │
// │ │
// │ Variables: u_i = log r_i (per vertex; r_i is the radius) │
// │ Constants: I_ij (per edge; inversive distance) │
// │ Θ_v (per vertex; target cone angle) │
// │ │
// │ Bowers-Stephenson (init from initial geometry): │
// │ I_ij = ( _ij² r_i² r_j² ) / ( 2 r_i r_j ) │
// │ │
// │ Edge length (Luo 2004 §3, Glickenstein 2011 eq. 2.1): │
// │ _ij(u)² = exp(2 u_i) + exp(2 u_j) + 2 I_ij exp(u_i + u_j) │
// │ = r_i² + r_j² + 2 I_ij r_i r_j │
// │ │
// │ Triangle angles: same half-tangent law of cosines as the │
// │ Euclidean functional (numerically stable). │
// │ │
// │ Gradient (Luo 2004 Lemma 3.1): │
// │ ∂E/∂u_v = Θ_v Σ_{T ∋ v} α_v(T) │
// │ │
// │ Energy: path integral E(u) = ∫₀¹ ⟨G(tu), u⟩ dt │
// │ (Luo's 1-form is closed; we use 10-point Gauss-Legendre │
// │ quadrature, identical to euclidean_functional.hpp) │
// │ │
// │ Hessian: finite-difference for the MVP port; an analytic form is │
// │ given in Glickenstein 2011 eq. (4.6) and may be added │
// │ later for performance. │
// └──────────────────────────────────────────────────────────────────────────┘
//
// Relation to euclidean_functional.hpp
// ────────────────────────────────────
// The two are structurally identical in:
// • DOF layout (per vertex), DOF index sentinel (1 = pinned)
// • Gradient pattern (Θ Σ α)
// • Energy via path integral (same Gauss-Legendre constants)
// • Halfedge convention (h0/h1/h2, source pattern, α opposite-edge)
//
// They differ ONLY in:
// • Per-edge constant: λ°_ij (log²-length) vs I_ij (inversive distance)
// • Edge-length formula:
// Euclidean: _ij = exp((λ°_ij + u_i + u_j) / 2)
// Inversive distance: _ij² = exp(2u_i) + exp(2u_j)
// + 2 I_ij exp(u_i + u_j)
//
// In particular at the tangential limit I_ij = 1 the inversive-distance length
// reduces to (exp(u_i) + exp(u_j))² ⇒ _ij = r_i + r_j (tangential circles),
// which is *different* from the Euclidean-conformal length even at the same
// initial geometry. The two functionals describe distinct geometric objects.
//
// Property-map name prefix: "iv:" (vertex) and "ie:" (edge).
#include "conformal_mesh.hpp"
#include "constants.hpp"
#include "euclidean_geometry.hpp" // euclidean_angles(λ12, λ23, λ31)
#include <CGAL/boost/graph/iterator.h>
#include <vector>
#include <cmath>
#include <cstdint>
#include <iostream>
namespace conformallab {
// ── Property-map type aliases ────────────────────────────────────────────────
using IDVMapI = ConformalMesh::Property_map<Vertex_index, int>;
using IDVMapD = ConformalMesh::Property_map<Vertex_index, double>;
using IDEMapD = ConformalMesh::Property_map<Edge_index, double>;
// ── Persistent map bundle ─────────────────────────────────────────────────────
struct InversiveDistanceMaps {
IDVMapI v_idx; ///< DOF index per vertex (1 = pinned / u_v = 0)
IDVMapD theta_v; ///< target cone angle Θ_v (default 2π)
IDVMapD r0; ///< initial radius r_i^(0) (default 1)
IDEMapD I_e; ///< inversive distance I_ij (per edge, constant)
};
// Create the property maps with sensible defaults.
inline InversiveDistanceMaps setup_inversive_distance_maps(ConformalMesh& mesh)
{
InversiveDistanceMaps m;
m.v_idx = mesh.add_property_map<Vertex_index, int> ("iv:idx", -1 ).first;
m.theta_v = mesh.add_property_map<Vertex_index, double>("iv:theta", TWO_PI ).first;
m.r0 = mesh.add_property_map<Vertex_index, double>("iv:r0", 1.0 ).first;
m.I_e = mesh.add_property_map<Edge_index, double>("ie:I", 1.0 ).first;
return m;
}
// Assign sequential DOF indices to all vertices (no gauge pinning here —
// the caller should set one v_idx to 1 before assigning).
inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& mesh,
InversiveDistanceMaps& m)
{
int idx = 0;
for (auto v : mesh.vertices()) m.v_idx[v] = idx++;
return idx;
}
// Count free DOFs.
inline int inversive_distance_dimension(const ConformalMesh& mesh,
const InversiveDistanceMaps& m)
{
int dim = 0;
for (auto v : mesh.vertices()) if (m.v_idx[v] >= 0) ++dim;
return dim;
}
// ── Initialisation from initial mesh geometry ────────────────────────────────
//
// Two-phase init mirroring "compute_lambda0" for euclidean_functional:
// 1. Choose r_i^(0). Simplest heuristic: r_i = (1/3)·(min adjacent ).
// Other choices (max , mean , length-of-shortest-vertex-cycle) are
// possible; the user can override `m.r0[v]` between setup and init.
// 2. Compute I_ij = ( ℓ² r_i² r_j² ) / ( 2 r_i r_j ) for each edge.
//
// Note: a valid inversive-distance packing requires I_ij > 1 on every edge,
// and the triangle inequality must hold on every face under the resulting .
// The choice r_i = ⅓·min(_e adj v) keeps I_ij safely positive for most
// real meshes.
inline void compute_inversive_distance_init_from_mesh(ConformalMesh& mesh,
InversiveDistanceMaps& m)
{
// Phase 1: r_i = (1/3) · min adjacent edge length.
for (auto v : mesh.vertices()) {
double min_len = std::numeric_limits<double>::infinity();
for (auto h : CGAL::halfedges_around_target(v, mesh)) {
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 len = std::sqrt(dx*dx + dy*dy + dz*dz);
if (len < min_len) min_len = len;
}
m.r0[v] = (std::isfinite(min_len) && min_len > 1e-15)
? min_len / 3.0
: 1.0;
}
// Phase 2: I_ij from initial geometry.
for (auto e : mesh.edges()) {
auto h = mesh.halfedge(e);
auto vi = mesh.source(h);
auto vj = mesh.target(h);
auto p1 = mesh.point(vi);
auto p2 = mesh.point(vj);
double dx = p1.x() - p2.x();
double dy = p1.y() - p2.y();
double dz = p1.z() - p2.z();
double l2 = dx*dx + dy*dy + dz*dz;
double ri = m.r0[vi];
double rj = m.r0[vj];
m.I_e[e] = (l2 - ri*ri - rj*rj) / (2.0 * ri * rj);
}
}
// ── Internal helpers ──────────────────────────────────────────────────────────
namespace id_detail {
inline double dof_val(int idx, const std::vector<double>& x) noexcept
{
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
}
inline std::size_t hidx(Halfedge_index h) noexcept
{
return static_cast<std::size_t>(static_cast<std::uint32_t>(h));
}
// Inversive-distance edge length squared: ℓ² = exp(2u_i) + exp(2u_j) + 2 I r_i r_j
// where r_i = exp(u_i), so: ℓ² = r_i² + r_j² + 2 I r_i r_j.
// Returns -1 if the result is non-positive (degenerate; the caller skips the face).
inline double edge_length_squared(double u_i, double u_j, double I_ij) noexcept
{
double ri = std::exp(u_i);
double rj = std::exp(u_j);
double l2 = ri*ri + rj*rj + 2.0 * I_ij * ri * rj;
return l2 > 0.0 ? l2 : -1.0;
}
} // namespace id_detail
// ── Gradient ──────────────────────────────────────────────────────────────────
//
// G_v = Θ_v Σ_{f ∋ v} α_v(f)
//
// Halfedge convention (identical to euclidean_functional.hpp):
// h_alpha[h] = corner angle OPPOSITE to the edge of halfedge h in its face.
inline std::vector<double> inversive_distance_gradient(
const ConformalMesh& mesh,
const std::vector<double>& x,
const InversiveDistanceMaps& m)
{
const int n = inversive_distance_dimension(mesh, m);
std::vector<double> G(static_cast<std::size_t>(n), 0.0);
const std::size_t nh = mesh.number_of_halfedges();
std::vector<double> h_alpha(nh, 0.0);
// Pass 1 — per face, compute corner angles via the law of cosines.
// We reuse euclidean_angles(λ12, λ23, λ31) which takes 2·log() per edge.
for (auto f : mesh.faces()) {
Halfedge_index h0 = mesh.halfedge(f);
Halfedge_index h1 = mesh.next(h0);
Halfedge_index h2 = mesh.next(h1);
Vertex_index v1 = mesh.source(h0);
Vertex_index v2 = mesh.source(h1);
Vertex_index v3 = mesh.source(h2);
Edge_index e12 = mesh.edge(h0);
Edge_index e23 = mesh.edge(h1);
Edge_index e31 = mesh.edge(h2);
double u1 = id_detail::dof_val(m.v_idx[v1], x);
double u2 = id_detail::dof_val(m.v_idx[v2], x);
double u3 = id_detail::dof_val(m.v_idx[v3], x);
double l12sq = id_detail::edge_length_squared(u1, u2, m.I_e[e12]);
double l23sq = id_detail::edge_length_squared(u2, u3, m.I_e[e23]);
double l31sq = id_detail::edge_length_squared(u3, u1, m.I_e[e31]);
if (l12sq <= 0 || l23sq <= 0 || l31sq <= 0) continue;
// euclidean_angles expects 2·log() per edge — feed log(ℓ²).
auto fa = euclidean_angles(std::log(l12sq), std::log(l23sq), std::log(l31sq));
if (!fa.valid) continue;
h_alpha[id_detail::hidx(h0)] = fa.alpha3;
h_alpha[id_detail::hidx(h1)] = fa.alpha1;
h_alpha[id_detail::hidx(h2)] = fa.alpha2;
}
// Pass 2 — accumulate vertex gradient.
for (auto v : mesh.vertices()) {
int iv = m.v_idx[v];
if (iv < 0) continue;
double sum_alpha = 0.0;
for (auto h : CGAL::halfedges_around_target(v, mesh)) {
if (mesh.is_border(h)) continue;
sum_alpha += h_alpha[id_detail::hidx(mesh.prev(h))];
}
G[static_cast<std::size_t>(iv)] = m.theta_v[v] - sum_alpha;
}
return G;
}
// ── Energy via Gauss-Legendre path integral ─────────────────────────────────
//
// E(u) = ∫₀¹ ⟨G(tu), u⟩ dt (10-point GL, identical constants to euclidean_functional)
inline double inversive_distance_energy(
const ConformalMesh& mesh,
const std::vector<double>& x,
const InversiveDistanceMaps& m)
{
static const double gl_s[10] = {
-0.9739065285171717, -0.8650633666889845,
-0.6794095682990244, -0.4333953941292472,
-0.1488743389816312, 0.1488743389816312,
0.4333953941292472, 0.6794095682990244,
0.8650633666889845, 0.9739065285171717
};
static const double gl_w[10] = {
0.0666713443086881, 0.1494513491505806,
0.2190863625159820, 0.2692667193099963,
0.2955242247147529, 0.2955242247147529,
0.2692667193099963, 0.2190863625159820,
0.1494513491505806, 0.0666713443086881
};
const std::size_t n = x.size();
double E = 0.0;
std::vector<double> tx(n);
for (int k = 0; k < 10; ++k) {
double t = (1.0 + gl_s[k]) * 0.5;
double wt = gl_w[k] * 0.5;
for (std::size_t i = 0; i < n; ++i) tx[i] = t * x[i];
auto G = inversive_distance_gradient(mesh, tx, m);
double dot = 0.0;
for (std::size_t i = 0; i < n; ++i) dot += G[i] * x[i];
E += wt * dot;
}
return E;
}
// ── Finite-difference gradient check ─────────────────────────────────────────
inline bool gradient_check_inversive_distance(
const ConformalMesh& mesh,
const std::vector<double>& x,
const InversiveDistanceMaps& m,
double eps = 1e-5,
double tol = 1e-6)
{
auto G = inversive_distance_gradient(mesh, x, m);
const std::size_t n = G.size();
for (std::size_t i = 0; i < n; ++i) {
std::vector<double> xp = x, xm = x;
xp[i] += eps;
xm[i] -= eps;
double Ep = inversive_distance_energy(mesh, xp, m);
double Em = inversive_distance_energy(mesh, xm, m);
double fd = (Ep - Em) / (2.0 * eps);
if (std::abs(G[i] - fd) > tol) {
std::cerr << "[inversive-distance] FD gradient mismatch at DOF " << i
<< ": analytic=" << G[i]
<< " FD=" << fd
<< " diff=" << (G[i] - fd) << "\n";
return false;
}
}
return true;
}
// ── Newton equilibrium check: gradient vanishes at converged u ──────────────
inline bool is_inversive_distance_equilibrium(
const ConformalMesh& mesh,
const std::vector<double>& x,
const InversiveDistanceMaps& m,
double tol = 1e-8)
{
auto G = inversive_distance_gradient(mesh, x, m);
for (double g : G)
if (std::abs(g) > tol) return false;
return true;
}
} // namespace conformallab