All checks were successful
C++ Tests / test-fast (pull_request) Successful in 1m57s
API Docs / doc-build (pull_request) Successful in 1m3s
Markdown link check / check (pull_request) Successful in 44s
C++ Tests / test-cgal (pull_request) Has been skipped
C++ Tests / quality-gates (pull_request) Successful in 2m11s
Bundles the 2026-05-29 Java↔C++ math-correctness audit (doc/reviewer/ java-port-audit.md, 11 findings) with two follow-up fixes. Audit code changes: - Finding 3 (spherical_functional): edge-DOF replacement parameterization via spher_eff_lambda; edge gradient α_opp⁺+α_opp⁻−θ_e (drops additive −(S⁺+S⁻)/2) - Finding 4 (spherical_hessian): always-compiled edge-DOF throw guard - Finding 6 (period_matrix): faithful normalizeModulus (0≤Re≤½, Im≥0, |τ|≥1) - Finding 9 (inversive_distance): degenerate-face limiting angles, no skip - Findings 1/2 (euclidean): degenerate gradient limiting angles + Hessian guard Euclidean holonomy/τ fix: develop the cut surface across the dual spanning tree only (cut_graph now exposes is_dual_tree), so genus-1 cut edges yield non-degenerate lattice generators. Previously τ came out 0 / NaN / 1e13 on the bundled tori; now matches the analytic revolution modulus i·√(R²−r²)/r. Re-enabled τ reporting in the Euclidean CLI; rewrote validation.md §3/§4 accordingly. Tests (240 CGAL, 0 skipped): - HolonomyEndToEnd ×3 — tori of revolution (4×4, hex 6×6, 8×8) vs analytic modulus - SphericalFunctional.EdgeGradient_RegularTetClosedForm — independent closed-form π/3 oracle locking the Finding-3 edge formula (the path-integral FD check cannot detect a wrong-but-conservative gradient) Also documents the latent spherical/hyperbolic holonomy-extraction bug (same single-development pattern, dead code today) in research-track.md (Phase 9c/10), and adds favour/normalisations to the codespell ignore list. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
386 lines
17 KiB
C++
386 lines
17 KiB
C++
#pragma once
|
||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// 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 ────────────────────────────────────────────────
|
||
|
||
/// Property map vertex → `int` for the Inversive-Distance functional.
|
||
using IDVMapI = ConformalMesh::Property_map<Vertex_index, int>;
|
||
/// Property map vertex → `double` for the Inversive-Distance functional.
|
||
using IDVMapD = ConformalMesh::Property_map<Vertex_index, double>;
|
||
/// Property map edge → `double` for the Inversive-Distance functional.
|
||
using IDEMapD = ConformalMesh::Property_map<Edge_index, double>;
|
||
|
||
// ── Persistent map bundle ─────────────────────────────────────────────────────
|
||
|
||
/// Bundle of the four property maps consumed by the Inversive-Distance
|
||
/// circle-packing functional (Luo 2004 / Bowers-Stephenson 2004).
|
||
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)
|
||
};
|
||
|
||
/// Attach the four inversive-distance property maps to `mesh` and
|
||
/// return their handles.
|
||
///
|
||
/// Defaults are intentionally trivial — every real use of this
|
||
/// functional must call `compute_inversive_distance_init_from_mesh()`
|
||
/// next to populate `r0` and `I_e` from the input geometry.
|
||
/// * `v_idx[v] = -1` (all vertices pinned initially)
|
||
/// * `theta_v[v] = 2π` (regular interior vertex)
|
||
/// * `r0[v] = 1.0` (placeholder)
|
||
/// * `I_e[e] = 1.0` (tangential default — overwritten by init step)
|
||
///
|
||
/// The maps use the `"iv:"` / `"ie:"` prefix so they do not collide
|
||
/// with the Euclidean / Spherical / HyperIdeal / CP-Euclidean maps.
|
||
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 `0..n-1` to every vertex.
|
||
///
|
||
/// **Note:** this overload does NOT pin a gauge vertex. The caller
|
||
/// is expected to either:
|
||
/// 1. set one `m.v_idx[v] = -1` *before* calling this function (then
|
||
/// the call is a no-op for that vertex) — OR —
|
||
/// 2. flip one assigned index back to `-1` *after* this function.
|
||
///
|
||
/// For a closed mesh, exactly one pin is required to remove the
|
||
/// global rotational mode.
|
||
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 the free DOFs (vertices with `v_idx >= 0`).
|
||
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;
|
||
}
|
||
|
||
/// Two-phase initialisation from initial mesh geometry. Mirrors the
|
||
/// role of `compute_lambda0_from_mesh` in the Euclidean functional, but
|
||
/// adapted to Luo's vertex-based radius parametrisation.
|
||
///
|
||
/// **Phase 1.** Pick a positive radius per vertex:
|
||
/// \code
|
||
/// r_i^(0) = (1/3) · min{ℓ_e : e adjacent to v_i}
|
||
/// \endcode
|
||
/// This is a heuristic — the user may override `m.r0[v]` for any
|
||
/// vertex between `setup_inversive_distance_maps()` and this call.
|
||
///
|
||
/// **Phase 2.** Compute the per-edge inversive distance via the
|
||
/// Bowers-Stephenson 2004 identity:
|
||
/// \code
|
||
/// I_ij = ( ℓ_ij² − r_i² − r_j² ) / ( 2 r_i r_j )
|
||
/// \endcode
|
||
///
|
||
/// \pre Every edge has positive 3-D length.
|
||
/// \pre Radii produced in Phase 1 are positive (degenerate isolated
|
||
/// vertices fall back to `r_i = 1`).
|
||
/// \post Every `I_e[e] > -1` for a valid packing. The chosen
|
||
/// Phase-1 heuristic keeps `I_e > 0` 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
|
||
|
||
/// Inversive-Distance gradient `G_v = Θ_v − Σ_faces α_v(face)`. Same
|
||
/// half-edge corner-angle storage convention as `euclidean_gradient`.
|
||
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]);
|
||
|
||
// A non-real circle configuration (ℓ² ≤ 0) has no limiting angle — skip it.
|
||
if (l12sq <= 0 || l23sq <= 0 || l31sq <= 0) continue;
|
||
|
||
// euclidean_angles expects 2·log(ℓ) per edge — feed log(ℓ²).
|
||
// For a triangle-inequality-violating face euclidean_angles returns the
|
||
// *limiting* angles (π opposite the over-long edge, 0/0 otherwise) with
|
||
// valid=false. We deliberately do NOT skip on !fa.valid: using those
|
||
// limiting angles is the convex C¹ extension onto the infeasible region,
|
||
// so Newton can pass through a flip instead of stalling (Finding 9 —
|
||
// mirrors the Euclidean/Spherical fix in Finding 1). The angles come
|
||
// from genuine Euclidean side lengths, so the extension is geometric.
|
||
auto fa = euclidean_angles(std::log(l12sq), std::log(l23sq), std::log(l31sq));
|
||
|
||
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;
|
||
}
|
||
|
||
/// Inversive-Distance energy `E(u) = ∫₀¹ ⟨G(t·u), u⟩ dt`, evaluated
|
||
/// with 10-point Gauss-Legendre (constants shared with `euclidean_energy`).
|
||
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;
|
||
}
|
||
|
||
/// FD gradient check for the Inversive-Distance functional (central diff).
|
||
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: returns `true` iff the gradient at `x`
|
||
/// is below `tol` in infinity norm (Σ adj-face angles equal Θ_v).
|
||
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
|