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>
266 lines
12 KiB
C++
266 lines
12 KiB
C++
#pragma once
|
||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// spherical_hessian.hpp
|
||
//
|
||
// Analytical Hessian of the spherical discrete conformal energy —
|
||
// the spherical cotangent-Laplace operator.
|
||
//
|
||
// Ported from de.varylab.discreteconformal.functional.SphericalFunctional
|
||
// (the hessian() method, vertex DOFs).
|
||
//
|
||
// ┌──────────────────────────────────────────────────────────────────────────┐
|
||
// │ Hessian formula (vertex DOFs only) │
|
||
// │ │
|
||
// │ For a spherical face (v1, v2, v3) with vertex angles α1, α2, α3: │
|
||
// │ │
|
||
// │ Spherical cotangent weight for edge (vi, vj) with opposite vk: │
|
||
// │ β_k = (π − αi − αj + αk) / 2 │
|
||
// │ w_k = cot(β_k) = 1/tan(β_k) │
|
||
// │ │
|
||
// │ Euclidean limit: α1+α2+α3 → π, β_k → αk, w_k → cot(αk). ✓ │
|
||
// │ │
|
||
// │ Hessian contributions per face: │
|
||
// │ H[vi, vi] += w_ij + w_ik (diagonal: weights of incident edges) │
|
||
// │ H[vi, vj] -= w_ij (off-diagonal: weight of edge ij) │
|
||
// │ │
|
||
// │ where w_ij is the weight of the edge between vi and vj (opposite vk): │
|
||
// │ w_ij = cot(β_k) with β_k = (π − αi − αj + αk) / 2. │
|
||
// └──────────────────────────────────────────────────────────────────────────┘
|
||
//
|
||
// Requires Eigen (header-only). Returns Eigen::SparseMatrix<double>.
|
||
|
||
#include "spherical_functional.hpp"
|
||
#include <Eigen/Sparse>
|
||
#include <vector>
|
||
#include <cmath>
|
||
#include <stdexcept>
|
||
|
||
namespace conformallab {
|
||
|
||
// ── Spherical cotangent weight helper ────────────────────────────────────────
|
||
//
|
||
// Given the three face angles α1, α2, α3 of a spherical triangle, return the
|
||
// three edge cotangent weights w1 (edge opp v1), w2 (edge opp v2), w3 (edge opp v3).
|
||
//
|
||
// w_k = cot(β_k) where β_k = (π − α_adj1 − α_adj2 + α_opp) / 2
|
||
// = (π − αi − αj + αk) / 2 for edge (vi,vj), opposite vk
|
||
//
|
||
// Mapping in our CGAL halfedge convention:
|
||
// h0 = halfedge(f): edge v1-v2 → opposite v3 → w = cot(β3), β3=(π-α1-α2+α3)/2
|
||
// h1: edge v2-v3 → opposite v1 → w = cot(β1), β1=(π-α2-α3+α1)/2
|
||
// h2: edge v3-v1 → opposite v2 → w = cot(β2), β2=(π-α3-α1+α2)/2
|
||
//
|
||
// Returns valid=false if any β_k is out of range (degenerate face).
|
||
/// Three spherical "cotangent" weights for the three edges of a face,
|
||
/// derived from the per-vertex interior angles `α₁, α₂, α₃` via
|
||
/// `w_ij = cot(β_k)` with `β_k = (π − α_i − α_j + α_k) / 2`.
|
||
struct SpherCotWeights {
|
||
double w12; ///< Weight for edge v₁-v₂ (opposite vertex v₃).
|
||
double w23; ///< Weight for edge v₂-v₃ (opposite vertex v₁).
|
||
double w31; ///< Weight for edge v₃-v₁ (opposite vertex v₂).
|
||
bool valid; ///< `false` when any β_k is out of `(0, π/2]` (degenerate face).
|
||
};
|
||
|
||
/// Compute the three spherical cot weights from the three interior
|
||
/// angles `(α₁, α₂, α₃)` of a spherical triangle. See `SpherCotWeights`.
|
||
inline SpherCotWeights spherical_cot_weights(double alpha1, double alpha2, double alpha3)
|
||
{
|
||
// β for each edge:
|
||
// β3 = (π - α1 - α2 + α3)/2 — weight for edge v1-v2 (opposite v3)
|
||
// β1 = (π - α2 - α3 + α1)/2 — weight for edge v2-v3 (opposite v1)
|
||
// β2 = (π - α3 - α1 + α2)/2 — weight for edge v3-v1 (opposite v2)
|
||
const double beta3 = (PI - alpha1 - alpha2 + alpha3) * 0.5;
|
||
const double beta1 = (PI - alpha2 - alpha3 + alpha1) * 0.5;
|
||
const double beta2 = (PI - alpha3 - alpha1 + alpha2) * 0.5;
|
||
|
||
// Each β_k must be in (0, π/2] for the weight to be positive and well-defined.
|
||
// For degenerate or very flat triangles some β may be ≤ 0 or ≥ π/2.
|
||
if (beta1 <= 0.0 || beta2 <= 0.0 || beta3 <= 0.0) return {0.0, 0.0, 0.0, false};
|
||
|
||
const double tb1 = std::tan(beta1);
|
||
const double tb2 = std::tan(beta2);
|
||
const double tb3 = std::tan(beta3);
|
||
|
||
if (std::abs(tb1) < 1e-15 || std::abs(tb2) < 1e-15 || std::abs(tb3) < 1e-15)
|
||
return {0.0, 0.0, 0.0, false};
|
||
|
||
// w_ij = cot(β_k) where β_k is for the edge opposite vk.
|
||
// w12 is for edge v1-v2 (opposite v3): cot(β3)
|
||
// w23 is for edge v2-v3 (opposite v1): cot(β1)
|
||
// w31 is for edge v3-v1 (opposite v2): cot(β2)
|
||
return {1.0 / tb3, 1.0 / tb1, 1.0 / tb2, true};
|
||
}
|
||
|
||
/// Analytical Spherical Hessian via `∂α/∂u` from the spherical law of
|
||
/// cosines + chain rule `∂l/∂u = tan(l/2)`; returns an n×n sparse
|
||
/// matrix with `n = spherical_dimension(mesh, m)`. See block comment
|
||
/// inside the body for the per-face derivation.
|
||
inline Eigen::SparseMatrix<double> spherical_hessian(
|
||
ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const SphericalMaps& m)
|
||
{
|
||
const int n = spherical_dimension(mesh, m);
|
||
|
||
// Only the vertex block of the spherical Hessian is implemented here. If any
|
||
// edge DOF is variable, the edge-edge and vertex-edge blocks present in the
|
||
// Java reference (conformalHessian) are missing, which would leave singular
|
||
// zero rows/cols. Fail loudly instead of silently returning a rank-deficient
|
||
// matrix (mirrors the euclidean_hessian guard — Finding 4).
|
||
for (auto e : mesh.edges()) {
|
||
if (m.e_idx[e] >= 0)
|
||
throw std::logic_error(
|
||
"spherical_hessian: edge DOFs are not supported "
|
||
"(only the vertex-block cotangent Laplacian is implemented)");
|
||
}
|
||
|
||
std::vector<Eigen::Triplet<double>> trips;
|
||
trips.reserve(static_cast<std::size_t>(n) * 9);
|
||
|
||
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.
|
||
double u1 = spher_dof_val(m.v_idx[v1], x);
|
||
double u2 = spher_dof_val(m.v_idx[v2], x);
|
||
double u3 = spher_dof_val(m.v_idx[v3], x);
|
||
|
||
double lam12 = m.lambda0[e12] + u1 + u2 + spher_dof_val(m.e_idx[e12], x);
|
||
double lam23 = m.lambda0[e23] + u2 + u3 + spher_dof_val(m.e_idx[e23], x);
|
||
double lam31 = m.lambda0[e31] + u3 + u1 + spher_dof_val(m.e_idx[e31], x);
|
||
|
||
const double l12 = spherical_l(lam12);
|
||
const double l23 = spherical_l(lam23);
|
||
const double l31 = spherical_l(lam31);
|
||
|
||
SphericalFaceAngles fa = spherical_angles(l12, l23, l31);
|
||
if (!fa.valid) continue;
|
||
|
||
const double sinl12 = std::sin(l12), cosl12 = std::cos(l12);
|
||
const double sinl23 = std::sin(l23), cosl23 = std::cos(l23);
|
||
const double sinl31 = std::sin(l31), cosl31 = std::cos(l31);
|
||
|
||
if (sinl12 < 1e-15 || sinl23 < 1e-15 || sinl31 < 1e-15) continue;
|
||
|
||
const double cot12 = cosl12 / sinl12;
|
||
const double cot23 = cosl23 / sinl23;
|
||
const double cot31 = cosl31 / sinl31;
|
||
|
||
// ∂l_ij/∂λ_ij = tan(l_ij/2)
|
||
const double t12 = std::tan(l12 * 0.5);
|
||
const double t23 = std::tan(l23 * 0.5);
|
||
const double t31 = std::tan(l31 * 0.5);
|
||
|
||
const double sinA1 = std::sin(fa.alpha1), cosA1 = std::cos(fa.alpha1);
|
||
const double sinA2 = std::sin(fa.alpha2), cosA2 = std::cos(fa.alpha2);
|
||
const double sinA3 = std::sin(fa.alpha3), cosA3 = std::cos(fa.alpha3);
|
||
|
||
if (sinA1 < 1e-15 || sinA2 < 1e-15 || sinA3 < 1e-15) continue;
|
||
|
||
// ∂α1/∂l_jk (α1 at v1; opposite l23, adjacent l12,l31)
|
||
const double dA1_dl12 = (cot12 * cosA1 - cot31) / sinA1;
|
||
const double dA1_dl31 = (cot31 * cosA1 - cot12) / sinA1;
|
||
const double dA1_dl23 = sinl23 / (sinl12 * sinl31 * sinA1);
|
||
|
||
// ∂α2/∂l_jk (α2 at v2; opposite l31, adjacent l12,l23)
|
||
const double dA2_dl12 = (cot12 * cosA2 - cot23) / sinA2;
|
||
const double dA2_dl23 = (cot23 * cosA2 - cot12) / sinA2;
|
||
const double dA2_dl31 = sinl31 / (sinl12 * sinl23 * sinA2);
|
||
|
||
// ∂α3/∂l_jk (α3 at v3; opposite l12, adjacent l23,l31)
|
||
const double dA3_dl23 = (cot23 * cosA3 - cot31) / sinA3;
|
||
const double dA3_dl31 = (cot31 * cosA3 - cot23) / sinA3;
|
||
const double dA3_dl12 = sinl12 / (sinl23 * sinl31 * sinA3);
|
||
|
||
// Chain rule: ∂α_i/∂u_j (u1 affects l12,l31; u2 affects l12,l23; u3 affects l23,l31)
|
||
const double dA1_du1 = dA1_dl12 * t12 + dA1_dl31 * t31;
|
||
const double dA1_du2 = dA1_dl12 * t12 + dA1_dl23 * t23;
|
||
const double dA1_du3 = dA1_dl23 * t23 + dA1_dl31 * t31;
|
||
|
||
const double dA2_du1 = dA2_dl12 * t12 + dA2_dl31 * t31;
|
||
const double dA2_du2 = dA2_dl12 * t12 + dA2_dl23 * t23;
|
||
const double dA2_du3 = dA2_dl23 * t23 + dA2_dl31 * t31;
|
||
|
||
const double dA3_du1 = dA3_dl12 * t12 + dA3_dl31 * t31;
|
||
const double dA3_du2 = dA3_dl12 * t12 + dA3_dl23 * t23;
|
||
const double dA3_du3 = dA3_dl23 * t23 + dA3_dl31 * t31;
|
||
|
||
const int i1 = m.v_idx[v1];
|
||
const int i2 = m.v_idx[v2];
|
||
const int i3 = m.v_idx[v3];
|
||
|
||
// H[vi, vj] -= ∂α_i/∂u_j (G_v = θ_v − Σ α_v, so ∂G_i/∂u_j = −∂α_i/∂u_j)
|
||
if (i1 >= 0) trips.emplace_back(i1, i1, -dA1_du1);
|
||
if (i2 >= 0) trips.emplace_back(i2, i2, -dA2_du2);
|
||
if (i3 >= 0) trips.emplace_back(i3, i3, -dA3_du3);
|
||
|
||
if (i1 >= 0 && i2 >= 0) {
|
||
trips.emplace_back(i1, i2, -dA1_du2);
|
||
trips.emplace_back(i2, i1, -dA2_du1);
|
||
}
|
||
if (i2 >= 0 && i3 >= 0) {
|
||
trips.emplace_back(i2, i3, -dA2_du3);
|
||
trips.emplace_back(i3, i2, -dA3_du2);
|
||
}
|
||
if (i3 >= 0 && i1 >= 0) {
|
||
trips.emplace_back(i3, i1, -dA3_du1);
|
||
trips.emplace_back(i1, i3, -dA1_du3);
|
||
}
|
||
}
|
||
|
||
Eigen::SparseMatrix<double> H(n, n);
|
||
H.setFromTriplets(trips.begin(), trips.end());
|
||
return H;
|
||
}
|
||
|
||
/// FD Hessian check for the Spherical functional. Compares analytic
|
||
/// `H` column-by-column to `(G(x+εeⱼ) − G(x−εeⱼ)) / (2ε)`.
|
||
inline bool hessian_check_spherical(
|
||
ConformalMesh& mesh,
|
||
const std::vector<double>& x0,
|
||
const SphericalMaps& m,
|
||
double eps = 1e-5,
|
||
double tol = 1e-4)
|
||
{
|
||
const int n = static_cast<int>(x0.size());
|
||
auto H = spherical_hessian(mesh, x0, m);
|
||
|
||
std::vector<double> xp = x0, xm = x0;
|
||
bool ok = true;
|
||
|
||
for (int j = 0; j < n; ++j) {
|
||
const std::size_t sj = static_cast<std::size_t>(j);
|
||
xp[sj] = x0[sj] + eps;
|
||
xm[sj] = x0[sj] - eps;
|
||
|
||
auto Gp = spherical_gradient(mesh, xp, m);
|
||
auto Gm = spherical_gradient(mesh, xm, m);
|
||
|
||
xp[sj] = xm[sj] = x0[sj];
|
||
|
||
for (int i = 0; i < n; ++i) {
|
||
double fd_ij = (Gp[static_cast<std::size_t>(i)]
|
||
- Gm[static_cast<std::size_t>(i)]) / (2.0 * eps);
|
||
double H_ij = H.coeff(i, j);
|
||
double err = std::abs(H_ij - fd_ij);
|
||
double scale = std::max(1.0, std::abs(H_ij));
|
||
if (err / scale > tol) ok = false;
|
||
}
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
} // namespace conformallab
|