feat(euclidean): full analytic edge-DOF (cyclic) Hessian; Tier-2 Wente finding

Upgrades the cyclic Euclidean Hessian from block-FD to closed-form, satisfying
the novelty-statement §3.2 "analytic Hessians, not finite difference" claim for
the Euclidean path.

- euclidean_hessian.hpp: `euclidean_hessian_analytic` — closed-form cyclic
  Hessian from the law-of-cosines angle derivatives
    ∂α_i/∂s_i = ℓ_i²/4A,  ∂α_i/∂s_j = ½cot α_i − ℓ_j²/4A   (Σ_j = 0),
  chained to (u, λ_e) and sign-mapped to the gradient outputs (−α vertex,
  +α_opp edge). Reuses euclidean_cot_weights. Block-FD kept as cross-check.
- newton_solver.hpp: newton_euclidean cyclic path now uses the analytic Hessian.
- tests: CyclicHessian_Analytic_MatchesBlockFD_Tetrahedron — analytic == block-FD
  (1e-6), == gradient FD (1e-5), symmetric (1e-9). Existing cyclic convergence
  oracle still GREEN with the analytic Hessian routed in.

Tier-2 (Wente) finding: wente_torus02.obj is a QUAD mesh (1240 quads) and the
Java golden comes from cyclic (quad-net) uniformization; the C++ period-matrix
pipeline is triangle-based, so a faithful bit-vs-Java τ comparison needs a
quad/cyclic pipeline (Phase 9f). Deferred and documented; golden τ = ½+i√3/2.

244/244 cgal tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-30 00:18:41 +02:00
committed by Tarik Moussa
parent ea5f01d73d
commit 822a27da69
4 changed files with 198 additions and 7 deletions

View File

@@ -298,6 +298,122 @@ inline Eigen::SparseMatrix<double> euclidean_hessian_block_fd_sym(
return (H + Ht) * 0.5;
}
// ── Analytic cyclic Hessian (vertex + edge DOFs) ─────────────────────────────
//
// Closed-form Jacobian of the cyclic gradient. With s_i = effective log-length
// (Λ̃) of the edge OPPOSITE vertex i (s = 2·ln ), the Euclidean corner-angle
// derivatives are (derived from the law of cosines; Σ_j ∂α_i/∂s_j = 0):
//
// ∂α_i/∂s_i = _i² / (4A)
// ∂α_i/∂s_j = ½ cot α_i _j² / (4A) (j ≠ i)
//
// where A is the triangle area (4A = denom2/2, denom2 = 8A from the cot helper).
// Chaining through s₁ = Λ̃(e₂₃), s₂ = Λ̃(e₃₁), s₃ = Λ̃(e₁₂) and
// Λ̃_e = λ⁰_e + u_i + u_j + λ_e gives the per-face 6×6 block over the local
// DOFs (u₁,u₂,u₃,λ₁₂,λ₂₃,λ₃₁); per-face outputs carry the gradient signs
// (α vertex, +α_opp edge), so the assembled matrix equals ∂G/∂x exactly.
// This is the analytic counterpart of `euclidean_hessian_block_fd` (no FD).
/// Analytic Euclidean cyclic Hessian (vertex + edge DOFs), sparse.
/// Closed-form (no finite differences); matches `euclidean_hessian_block_fd`
/// to round-off and the analytic vertex-only Laplacian on vertex-only layouts.
inline Eigen::SparseMatrix<double> euclidean_hessian_analytic(
ConformalMesh& mesh,
const std::vector<double>& x,
const EuclideanMaps& m)
{
const int n = euclidean_dimension(mesh, m);
std::vector<Eigen::Triplet<double>> trips;
trips.reserve(static_cast<std::size_t>(36) * mesh.number_of_faces());
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);
const double u1 = eucl_dof_val(m.v_idx[v1], x);
const double u2 = eucl_dof_val(m.v_idx[v2], x);
const double u3 = eucl_dof_val(m.v_idx[v3], x);
const double lam12 = m.lambda0[e12] + u1 + u2 + eucl_dof_val(m.e_idx[e12], x); // s3
const double lam23 = m.lambda0[e23] + u2 + u3 + eucl_dof_val(m.e_idx[e23], x); // s1
const double lam31 = m.lambda0[e31] + u3 + u1 + eucl_dof_val(m.e_idx[e31], x); // s2
// Centered side lengths (scale-invariant for ℓ²/4A and cot).
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);
const double t12 = -l12 + l23 + l31;
const double t23 = l12 - l23 + l31;
const double t31 = l12 + l23 - l31;
if (t12 <= 0.0 || t23 <= 0.0 || t31 <= 0.0) continue;
const double l123 = l12 + l23 + l31;
const double denom2_sq = t12 * t23 * t31 * l123;
if (denom2_sq <= 0.0) continue;
const double denom2 = 2.0 * std::sqrt(denom2_sq); // = 8A
const double fourA = denom2 * 0.5; // = 4A
auto cw = euclidean_cot_weights(l12, l23, l31);
if (!cw.valid) continue;
const double cot[3] = { cw.cot1, cw.cot2, cw.cot3 }; // at v1, v2, v3
// _i² = squared length of edge opposite vertex i:
// opp(v1)=e23=l23, opp(v2)=e31=l31, opp(v3)=e12=l12
const double Lsq[3] = { l23 * l23, l31 * l31, l12 * l12 };
// dA[i][j] = ∂α_i/∂s_j (i,j ∈ {0,1,2}; s_j opposite v_{j+1})
double dA[3][3];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
dA[i][j] = (i == j) ? (Lsq[i] / fourA)
: (0.5 * cot[i] - Lsq[j] / fourA);
// ∂α_i/∂(local dof), dof order (u1,u2,u3, λ12,λ23,λ31).
// u1 ↔ s2,s3 ; u2 ↔ s1,s3 ; u3 ↔ s1,s2 ; λ12 ↔ s3 ; λ23 ↔ s1 ; λ31 ↔ s2.
double g[3][6];
for (int i = 0; i < 3; ++i) {
g[i][0] = dA[i][1] + dA[i][2]; // u1
g[i][1] = dA[i][0] + dA[i][2]; // u2
g[i][2] = dA[i][0] + dA[i][1]; // u3
g[i][3] = dA[i][2]; // λ12 (s3)
g[i][4] = dA[i][0]; // λ23 (s1)
g[i][5] = dA[i][1]; // λ31 (s2)
}
// Output slot → (angle index, sign): (α1,α2,α3,+α3,+α1,+α2).
const int out_a[6] = { 0, 1, 2, 2, 0, 1 };
const double out_sg[6] = { -1.0, -1.0, -1.0, +1.0, +1.0, +1.0 };
const int idx[6] = {
m.v_idx[v1], m.v_idx[v2], m.v_idx[v3],
m.e_idx[e12], m.e_idx[e23], m.e_idx[e31]
};
for (int oi = 0; oi < 6; ++oi) {
if (idx[oi] < 0) continue;
for (int dj = 0; dj < 6; ++dj) {
if (idx[dj] < 0) continue;
const double val = out_sg[oi] * g[out_a[oi]][dj];
if (std::abs(val) > 1e-15)
trips.emplace_back(idx[oi], idx[dj], val);
}
}
}
Eigen::SparseMatrix<double> H(n, n);
H.setFromTriplets(trips.begin(), trips.end());
return H;
}
// ── Finite-difference Hessian check ──────────────────────────────────────────
/// FD Hessian check for the Euclidean functional. Compares analytic
/// `H` column-by-column to `(G(x+εeⱼ) G(xεeⱼ)) / (2ε)`; returns