feat(phase3d+3e): port EuclideanCyclicFunctional + add SphericalFunctional gauge-fix

Phase 3d — EuclideanCyclicFunctional:
  • euclidean_geometry.hpp: t-value / atan2 corner-angle formula with
    centering trick (μ = (Λ̃₁₂+Λ̃₂₃+Λ̃₃₁)/6) for numerical stability
  • euclidean_functional.hpp: EuclideanMaps bundle, gradient (G_v = Θ_v − Σα_v,
    G_e = α_opp⁺ + α_opp⁻ − φ_e), 10-point GL path-integral energy,
    gradient_check_euclidean — identical halfedge convention to SphericalFunctional
  • test_euclidean_functional.cpp: 11 tests (1 skip) covering angle formula,
    right-isosceles triangle, angle sum = π, degenerate detection, gradient
    checks on triangle/quad-strip/tetrahedron/fan-5/mixed-pinned, NaN check

Phase 3e — Spherical gauge-fix:
  • spherical_gauge_shift(): Newton + backtracking line search to find t*
    where ΣG_v(x + t·1) = 0 (maximises E along the global scale direction);
    bisection used when sign change is detectable, Newton+backtrack otherwise
  • apply_spherical_gauge(): in-place wrapper
  • 3 new tests: GaugeFix_SpherTetVertexZerosSumGv, GaugeFix_ApplyInPlace,
    GaugeFix_AlreadyAtGaugeReturnsTNearZero

Total: 45 cgal tests pass, 3 skipped (@Ignore Hessian stubs, one per functional)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-12 07:39:14 +02:00
parent dc0d3ca005
commit 8c353bb884
7 changed files with 924 additions and 21 deletions

View File

@@ -0,0 +1,325 @@
#pragma once
// euclidean_functional.hpp
//
// Energy and gradient of the Euclidean discrete conformal functional
// (EuclideanCyclicFunctional) evaluated on a ConformalMesh.
//
// Ported from de.varylab.discreteconformal.functional.EuclideanCyclicFunctional.
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ DOFs │
// │ x[v_idx[v]] = u_v conformal factor at vertex v │
// │ x[e_idx[e]] = λ_e edge log-length variable (optional) │
// │ -1 means "pinned" (u_v = 0 / λ_e = 0, only λ° contributes) │
// │ │
// │ Effective log-length (always additive, unlike SphericalFunctional): │
// │ Λ̃_ij = λ°_ij + u_i + u_j + (x[e_idx[e]] if variable, else 0) │
// │ │
// │ Side length: l_ij = exp(Λ̃_ij / 2) │
// │ │
// │ Gradient: │
// │ ∂E/∂u_v = Θ_v Σ_{faces adj. v} α_v(face) │
// │ ∂E/∂λ_e = α_opp(face⁺) + α_opp(face⁻) φ_e │
// │ │
// │ Energy: │
// │ Computed as the path integral E(x) = ∫₀¹ ⟨G(tx), x⟩ dt │
// │ using 10-point Gauss-Legendre quadrature (same as SphericalFunctional)│
// │ This is E(0)=0 by construction and exact for conservative G. │
// └──────────────────────────────────────────────────────────────────────────┘
//
// Halfedge convention (identical to SphericalFunctional):
// h0 = mesh.halfedge(f), h1 = next(h0), h2 = next(h1)
// v1 = source(h0), v2 = source(h1), v3 = source(h2)
// h_alpha[h0] = α3 (angle at v3, opposite edge h0 = v1v2)
// h_alpha[h1] = α1 (angle at v1, opposite edge h1 = v2v3)
// h_alpha[h2] = α2 (angle at v2, opposite edge h2 = v3v1)
//
// Property-map name prefix: "ev:" (vertex) and "ee:" (edge).
#include "conformal_mesh.hpp"
#include "euclidean_geometry.hpp"
#include <CGAL/boost/graph/iterator.h>
#include <vector>
#include <cmath>
#include <cstdint>
namespace conformallab {
// ── Property-map type aliases ─────────────────────────────────────────────────
using EuclVMapD = ConformalMesh::Property_map<Vertex_index, double>;
using EuclVMapI = ConformalMesh::Property_map<Vertex_index, int>;
using EuclEMapD = ConformalMesh::Property_map<Edge_index, double>;
using EuclEMapI = ConformalMesh::Property_map<Edge_index, int>;
// ── Persistent map bundle ─────────────────────────────────────────────────────
struct EuclideanMaps {
EuclVMapI v_idx; ///< DOF index per vertex (-1 = pinned / u_v = 0)
EuclEMapI e_idx; ///< DOF index per edge (-1 = no edge DOF)
EuclVMapD theta_v; ///< target cone angle Θ_v (default 2π)
EuclEMapD phi_e; ///< target edge turn angle φ_e (default π)
EuclEMapD lambda0; ///< base log-length λ°_e (default 0.0)
};
// Create and attach property maps with sensible defaults.
// theta_v = 2π (flat vertex), phi_e = π (interior edge, flat surface).
inline EuclideanMaps setup_euclidean_maps(ConformalMesh& mesh)
{
constexpr double TWO_PI = 2.0 * 3.14159265358979323846;
constexpr double PI = 3.14159265358979323846;
EuclideanMaps m;
m.v_idx = mesh.add_property_map<Vertex_index, int> ("ev:idx", -1 ).first;
m.e_idx = mesh.add_property_map<Edge_index, int> ("ee:idx", -1 ).first;
m.theta_v= mesh.add_property_map<Vertex_index, double>("ev:theta", TWO_PI ).first;
m.phi_e = mesh.add_property_map<Edge_index, double>("ee:phi", PI ).first;
m.lambda0= mesh.add_property_map<Edge_index, double>("ee:lam0", 0.0 ).first;
return m;
}
// Assign DOF indices 0..n-1 for all vertices only (no edge DOFs).
inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
{
int idx = 0;
for (auto v : mesh.vertices()) m.v_idx[v] = idx++;
return idx;
}
// Assign DOF indices for all vertices AND all edges.
inline int assign_euclidean_all_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
{
int idx = 0;
for (auto v : mesh.vertices()) m.v_idx[v] = idx++;
for (auto e : mesh.edges()) m.e_idx[e] = idx++;
return idx;
}
// Count variable DOFs (vertices + edges).
inline int euclidean_dimension(const ConformalMesh& mesh, const EuclideanMaps& m)
{
int dim = 0;
for (auto v : mesh.vertices()) if (m.v_idx[v] >= 0) ++dim;
for (auto e : mesh.edges()) if (m.e_idx[e] >= 0) ++dim;
return dim;
}
// Set lambda0 from mesh vertex positions (Euclidean):
// λ°_e = 2·log(|p_i p_j|) (natural log of Euclidean edge length squared)
//
// This gives exp(Λ̃_ij / 2) = l_ij at x=0.
inline void compute_euclidean_lambda0_from_mesh(ConformalMesh& mesh, EuclideanMaps& m)
{
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 len = std::sqrt(dx*dx + dy*dy + dz*dz);
if (len > 1e-15)
m.lambda0[e] = 2.0 * std::log(len);
else
m.lambda0[e] = -30.0; // degenerate edge
}
}
// ── Internal helpers ──────────────────────────────────────────────────────────
static inline double eucl_dof_val(int idx, const std::vector<double>& x)
{
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
}
static inline std::size_t eucl_hidx(Halfedge_index h)
{
return static_cast<std::size_t>(static_cast<std::uint32_t>(h));
}
// ── Gradient ──────────────────────────────────────────────────────────────────
//
// G_v = Θ_v Σ_{faces adj. v} α_v(face)
// G_e = α_opp(face⁺) + α_opp(face⁻) φ_e
//
// Corner-angle storage (h_alpha):
// h_alpha[h] = corner angle OPPOSITE to the edge of halfedge h in its face.
// h_alpha[h0] = α3, h_alpha[h1] = α1, h_alpha[h2] = α2
// (same convention as SphericalFunctional)
inline std::vector<double> euclidean_gradient(
ConformalMesh& mesh,
const std::vector<double>& x,
const EuclideanMaps& m)
{
const int n = euclidean_dimension(mesh, m);
std::vector<double> G(static_cast<std::size_t>(n), 0.0);
// Per-halfedge corner-angle storage.
const std::size_t nh = mesh.number_of_halfedges();
std::vector<double> h_alpha(nh, 0.0);
// ── Pass 1: compute corner angles per face ────────────────────────────────
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);
// Effective log-lengths (always additive: u_i + u_j regardless of DOF status)
double u1 = eucl_dof_val(m.v_idx[v1], x);
double u2 = eucl_dof_val(m.v_idx[v2], x);
double u3 = eucl_dof_val(m.v_idx[v3], x);
double lam12 = m.lambda0[e12] + u1 + u2 + eucl_dof_val(m.e_idx[e12], x);
double lam23 = m.lambda0[e23] + u2 + u3 + eucl_dof_val(m.e_idx[e23], x);
double lam31 = m.lambda0[e31] + u3 + u1 + eucl_dof_val(m.e_idx[e31], x);
auto fa = euclidean_angles(lam12, lam23, lam31);
if (!fa.valid) continue; // degenerate triangle: contributes 0
// h_alpha[h] = corner angle OPPOSITE to h's edge:
// h0 (edge v1v2) → opposite corner at v3 → α3
// h1 (edge v2v3) → opposite corner at v1 → α1
// h2 (edge v3v1) → opposite corner at v2 → α2
h_alpha[eucl_hidx(h0)] = fa.alpha3;
h_alpha[eucl_hidx(h1)] = fa.alpha1;
h_alpha[eucl_hidx(h2)] = fa.alpha2;
}
// ── Pass 2: accumulate vertex gradient ───────────────────────────────────
// G_v = Θ_v Σ h_alpha[prev(h)] for each incoming non-border h to v.
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[eucl_hidx(mesh.prev(h))];
}
G[static_cast<std::size_t>(iv)] = m.theta_v[v] - sum_alpha;
}
// ── Pass 3: accumulate edge gradient ─────────────────────────────────────
// G_e = α_opp(f⁺) + α_opp(f⁻) φ_e
// α_opp of edge e in face f = h_alpha[halfedge h of e pointing INTO f].
for (auto e : mesh.edges()) {
int ie = m.e_idx[e];
if (ie < 0) continue;
auto h = mesh.halfedge(e);
auto ho = mesh.opposite(h);
double sum = -m.phi_e[e];
if (!mesh.is_border(h)) sum += h_alpha[eucl_hidx(h)];
if (!mesh.is_border(ho)) sum += h_alpha[eucl_hidx(ho)];
G[static_cast<std::size_t>(ie)] = sum;
}
return G;
}
// ── Energy via Gauss-Legendre path integral ───────────────────────────────────
//
// E(x) = ∫₀¹ ⟨G(tx), x⟩ dt (10-point GL quadrature, same as SphericalFunctional)
inline double euclidean_energy(
ConformalMesh& mesh,
const std::vector<double>& x,
const EuclideanMaps& 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;
for (int k = 0; k < 10; ++k) {
double t = (1.0 + gl_s[k]) * 0.5;
double wt = gl_w[k] * 0.5;
std::vector<double> tx(n);
for (std::size_t i = 0; i < n; ++i) tx[i] = t * x[i];
auto G = euclidean_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;
}
// ── Full evaluation (energy + gradient) ──────────────────────────────────────
struct EuclideanResult {
double energy = 0.0;
std::vector<double> gradient;
};
inline EuclideanResult evaluate_euclidean(
ConformalMesh& mesh,
const std::vector<double>& x,
const EuclideanMaps& m,
bool need_energy = true,
bool need_gradient = true)
{
EuclideanResult res;
if (need_gradient)
res.gradient = euclidean_gradient(mesh, x, m);
if (need_energy)
res.energy = euclidean_energy(mesh, x, m);
return res;
}
// ── Finite-difference gradient check ─────────────────────────────────────────
//
// Tests |G[i] (E(x+εeᵢ) E(xεeᵢ))/(2ε)| / max(1,|G[i]|) < tol
// for all variable DOFs.
inline bool gradient_check_euclidean(
ConformalMesh& mesh,
const std::vector<double>& x0,
const EuclideanMaps& m,
double eps = 1e-5,
double tol = 1e-4)
{
auto G = euclidean_gradient(mesh, x0, m);
const int n = static_cast<int>(G.size());
std::vector<double> xp = x0, xm = x0;
bool ok = true;
for (int i = 0; i < n; ++i) {
std::size_t si = static_cast<std::size_t>(i);
xp[si] = x0[si] + eps;
xm[si] = x0[si] - eps;
double Ep = euclidean_energy(mesh, xp, m);
double Em = euclidean_energy(mesh, xm, m);
xp[si] = xm[si] = x0[si]; // restore
double fd = (Ep - Em) / (2.0 * eps);
double err = std::abs(G[si] - fd);
double scale = std::max(1.0, std::abs(G[si]));
if (err / scale > tol) ok = false;
}
return ok;
}
} // namespace conformallab

View File

@@ -0,0 +1,91 @@
#pragma once
// euclidean_geometry.hpp
//
// Corner-angle formula for Euclidean triangles in the discrete conformal
// (log-length) parametrisation.
//
// Ported from de.varylab.discreteconformal.functional.EuclideanCyclicFunctional.
//
// In the discrete conformal parametrisation a Euclidean triangle is described by
// its three effective log-lengths Λ̃_ij = λ°_ij + u_i + u_j (+ edge DOF).
// The corresponding side lengths are l_ij = exp(Λ̃_ij / 2).
//
// Vertex ordering convention (matches EuclideanCyclicFunctional.java):
// v1 is opposite edge l23, v2 is opposite l31, v3 is opposite l12.
//
// t-value trick (Springborn 2008 §3):
// t12 = l12 + l23 + l31 = 2(s l12)
// t23 = +l12 l23 + l31 = 2(s l23)
// t31 = +l12 + l23 l31 = 2(s l31)
// denom = sqrt(t12 · t23 · t31 · l123) = 4 · Area
//
// α_v = 2 · atan2( product of t-values adjacent to v, denom )
//
// The centering trick (l_ij ← exp((Λ̃_ij 2·μ)/2), μ = (Λ̃12+Λ̃23+Λ̃31)/6)
// rescales all three sides by the same factor, leaving angles unchanged but
// keeping the arguments of exp in a safe numerical range.
#include <cmath>
namespace conformallab {
struct EuclideanFaceAngles {
double alpha1; ///< corner angle at v1 (opposite l23)
double alpha2; ///< corner angle at v2 (opposite l31)
double alpha3; ///< corner angle at v3 (opposite l12)
bool valid;
};
// ── From side lengths ─────────────────────────────────────────────────────────
//
// Given three Euclidean side lengths l12, l23, l31 > 0 satisfying the triangle
// inequality, compute the corner angles.
//
// Returns valid=false if the triangle inequality is violated (any t-value ≤ 0).
inline EuclideanFaceAngles euclidean_angles_from_lengths(
double l12, double l23, double l31)
{
const double t12 = -l12 + l23 + l31; // 2*(s l12)
const double t23 = +l12 - l23 + l31; // 2*(s l23)
const double t31 = +l12 + l23 - l31; // 2*(s l31)
if (t12 <= 0.0 || t23 <= 0.0 || t31 <= 0.0)
return {0.0, 0.0, 0.0, false};
const double l123 = l12 + l23 + l31;
const double denom2 = t12 * t23 * t31 * l123; // = (4·Area)²
if (denom2 <= 0.0)
return {0.0, 0.0, 0.0, false};
const double denom = std::sqrt(denom2);
// α at v1 (opposite l23): adjacent t-values are t12 and t31
// α at v2 (opposite l31): adjacent t-values are t12 and t23
// α at v3 (opposite l12): adjacent t-values are t23 and t31
return {
2.0 * std::atan2(t12 * t31, denom),
2.0 * std::atan2(t12 * t23, denom),
2.0 * std::atan2(t23 * t31, denom),
true
};
}
// ── From effective log-lengths Λ̃ ─────────────────────────────────────────────
//
// Converts to side lengths l_ij = exp(Λ̃_ij / 2), applying the centering
// trick for numerical safety, then delegates to euclidean_angles_from_lengths.
//
// The centering constant μ = (Λ̃12 + Λ̃23 + Λ̃31) / 6 ensures
// l12 · l23 · l31 = 1 (geometric mean = 1)
// which keeps all l values near 1 and prevents float overflow for large |Λ̃|.
inline EuclideanFaceAngles euclidean_angles(
double lam12, double lam23, double lam31)
{
const double mu = (lam12 + lam23 + lam31) / 6.0;
const double l12 = std::exp((lam12 - 2.0 * mu) * 0.5);
const double l23 = std::exp((lam23 - 2.0 * mu) * 0.5);
const double l31 = std::exp((lam31 - 2.0 * mu) * 0.5);
return euclidean_angles_from_lengths(l12, l23, l31);
}
} // namespace conformallab

View File

@@ -354,4 +354,122 @@ inline bool gradient_check_spherical(
return ok;
}
// ── Gauge-fix for closed spherical surfaces ───────────────────────────────────
//
// On a closed (boundaryless) spherical surface the energy E(u + t·1) has a
// unique maximum w.r.t. t ∈ (the "global scale" gauge mode). Without
// fixing this gauge the functional is unbounded below and no solver converges.
//
// This function returns the scalar shift t* such that
// Σ_v G_v(x + t*·1_v) = 0
// i.e., the derivative of E(u+t·1) w.r.t. t is zero at t = t*.
//
// Apply the shift by adding t* to every vertex DOF in x.
//
// Implementation: bisection on f(t) = Σ_v G_v(x + t·1_v).
// f is strictly monotone decreasing (second derivative < 0) for a convex
// functional, so bisection converges in O(log₂(2·bracket/tol)) iterations.
//
// Parameters:
// bracket initial search interval [bracket, +bracket] (default 50)
// tol absolute tolerance on t* (default 1e-8)
//
// Returns 0.0 if the zero cannot be bracketed (already at gauge maximum,
// or open surface — no shift needed).
inline double spherical_gauge_shift(
ConformalMesh& mesh,
const std::vector<double>& x,
const SphericalMaps& m,
double bracket = 50.0,
double tol = 1e-8)
{
// Helper: sum of all vertex gradient components at x + t·1_v.
auto sum_Gv = [&](double t) -> double {
// Build a shifted copy of x (only vertex DOFs shifted).
std::vector<double> xt = x;
for (auto v : mesh.vertices()) {
int iv = m.v_idx[v];
if (iv >= 0)
xt[static_cast<std::size_t>(iv)] += t;
}
auto G = spherical_gradient(mesh, xt, m);
double sum = 0.0;
for (auto v : mesh.vertices()) {
int iv = m.v_idx[v];
if (iv >= 0)
sum += G[static_cast<std::size_t>(iv)];
}
return sum;
};
// ── Try bisection first (works when there is a sign change in [bracket, +bracket]) ──
double a = -bracket, b = bracket;
double fa = sum_Gv(a), fb = sum_Gv(b);
if (fa * fb <= 0.0) {
for (int iter = 0; iter < 120; ++iter) {
double c = 0.5 * (a + b);
double fc = sum_Gv(c);
if (std::abs(fc) < tol || (b - a) < tol) return c;
if (fa * fc < 0.0) { b = c; fb = fc; }
else { a = c; fa = fc; }
}
return 0.5 * (a + b);
}
// ── No sign change (zero may lie at a domain boundary). ───────────────────
// Use damped Newton's method with backtracking line search.
// f'(t) estimated by forward finite difference.
// When the Newton step overshoots the valid domain (ΣG_v jumps back up
// because faces become degenerate), backtracking halves the step until
// |f| strictly decreases.
const double fd_eps = 1e-5;
double t = 0.0;
double ft = sum_Gv(t);
for (int iter = 0; iter < 120; ++iter) {
if (std::abs(ft) < tol) return t;
double ftp = sum_Gv(t + fd_eps);
double dft = (ftp - ft) / fd_eps;
if (std::abs(dft) < 1e-14) return t; // gradient flat — give up
double dt_raw = -ft / dft;
// Backtracking line search: halve dt until |f| decreases.
double alpha = 1.0;
bool improved = false;
for (int back = 0; back < 40; ++back) {
double t_try = t + alpha * dt_raw;
t_try = std::max(-bracket, std::min(bracket, t_try));
double ft_try = sum_Gv(t_try);
if (std::abs(ft_try) < std::abs(ft)) {
t = t_try;
ft = ft_try;
improved = true;
break;
}
alpha *= 0.5;
}
if (!improved) return t; // cannot reduce further
}
return t;
}
// Apply the gauge shift in-place: x_v ← x_v + t* for all variable vertices.
inline void apply_spherical_gauge(
ConformalMesh& mesh,
std::vector<double>& x,
const SphericalMaps& m,
double bracket = 50.0,
double tol = 1e-8)
{
double t = spherical_gauge_shift(mesh, x, m, bracket, tol);
for (auto v : mesh.vertices()) {
int iv = m.v_idx[v];
if (iv >= 0)
x[static_cast<std::size_t>(iv)] += t;
}
}
} // namespace conformallab