feat(phase3f+3g): analytical Hessians + PI consolidation

Phase 3g — constants.hpp:
  - Introduce conformallab::PI and TWO_PI in a single constants.hpp
  - Remove scattered local PI/pi definitions from hyper_ideal_geometry.hpp,
    hyper_ideal_utility.hpp, euclidean_functional.hpp, mesh_builder.hpp,
    spherical_geometry.hpp (backward-compatible PI_SPHER alias kept)

Phase 3f — Euclidean Hessian (euclidean_hessian.hpp):
  - Cotangent-Laplace operator (Pinkall–Polthier 1993)
  - euclidean_cot_weights() helper + euclidean_hessian() + hessian_check_euclidean()
  - Correct Pinkall–Polthier 1/2 normalization factor
  - 8 tests: cot weights, symmetry, null-space (H·1=0), PSD, FD × 4 meshes

Phase 3f — Spherical Hessian (spherical_hessian.hpp):
  - Derives ∂α_i/∂u_j directly from the spherical law of cosines:
      ∂α1/∂l_opp  = sin(l_opp) / [sin(l_a)·sin(l_b)·sin(α1)]
      ∂α1/∂l_adj  = [cot(l_adj)·cos(α1) − cot(l_other)] / sin(α1)
    then chains with ∂l/∂λ = tan(l/2)
  - spherical_cot_weights() kept as a standalone helper (tested separately)
  - 8 tests: cot weights, symmetry, correct null-space & sign-convention
    (H·1 ≠ 0; H is NSD at equilibrium), FD × 3 meshes

All 62 cgal tests pass (3 skipped as before).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-12 17:22:28 +02:00
parent 8c353bb884
commit 194effba97
11 changed files with 929 additions and 22 deletions

View File

@@ -0,0 +1,213 @@
// test_euclidean_hessian.cpp
//
// Phase 3f — Euclidean cotangent-Laplace Hessian.
//
// The Hessian of the Euclidean discrete conformal energy is the well-known
// cotangent-Laplace operator (PinkallPolthier 1993, Springborn 2008).
//
// Tests:
// 1. Cotangent weights are analytically correct for simple triangles.
// 2. Hessian is symmetric.
// 3. Hessian has the null-space property H·1 = 0 (uniform-shift mode).
// 4. Hessian is positive semi-definite (all eigenvalues ≥ 0).
// 5. Finite-difference check H[i,j] ≈ (G_i(x+ε·eⱼ)G_i(xε·eⱼ))/(2ε).
//
// All tests use meshes and maps built with Phase-3d infrastructure.
#include "conformal_mesh.hpp"
#include "mesh_builder.hpp"
#include "euclidean_hessian.hpp"
#include <gtest/gtest.h>
#include <Eigen/Dense> // for dense conversion and eigenvalue solver
#include <cmath>
#include <vector>
using namespace conformallab;
// ════════════════════════════════════════════════════════════════════════════
// Cotangent weight: equilateral triangle → all cots = 1/√3 = cot(60°)
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, CotWeights_EquilateralTriangle)
{
// Equilateral triangle with l = 1 (all log-lengths = 0).
auto cw = euclidean_cot_weights(1.0, 1.0, 1.0);
ASSERT_TRUE(cw.valid);
const double expected = 1.0 / std::sqrt(3.0); // cot(60°)
EXPECT_NEAR(cw.cot1, expected, 1e-12);
EXPECT_NEAR(cw.cot2, expected, 1e-12);
EXPECT_NEAR(cw.cot3, expected, 1e-12);
}
// ════════════════════════════════════════════════════════════════════════════
// Cotangent weight: right-isosceles triangle (legs 1, hypotenuse √2)
//
// v1=(0,0): right angle → cot(90°) = 0
// v2=(1,0), v3=(0,1): 45° angles → cot(45°) = 1
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, CotWeights_RightIsoscelesTriangle)
{
// l12=1, l23=√2, l31=1
auto cw = euclidean_cot_weights(1.0, std::sqrt(2.0), 1.0);
ASSERT_TRUE(cw.valid);
EXPECT_NEAR(cw.cot1, 0.0, 1e-12); // right angle at v1
EXPECT_NEAR(cw.cot2, 1.0, 1e-12); // 45° at v2
EXPECT_NEAR(cw.cot3, 1.0, 1e-12); // 45° at v3
}
// ════════════════════════════════════════════════════════════════════════════
// Hessian is symmetric: H[i,j] == H[j,i]
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, HessianIsSymmetric)
{
auto mesh = make_quad_strip();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int n = assign_euclidean_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), -0.1);
auto H = euclidean_hessian(mesh, x, maps);
Eigen::MatrixXd Hd = Eigen::MatrixXd(H);
EXPECT_NEAR((Hd - Hd.transpose()).norm(), 0.0, 1e-12)
<< "Hessian must be symmetric";
}
// ════════════════════════════════════════════════════════════════════════════
// Null-space property: H·1 = 0 for a closed surface (regular tetrahedron)
//
// The cotangent Laplacian on a closed mesh has the constant vector in its
// null space (each row sums to zero).
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, NullSpaceIsConstantVector_ClosedMesh)
{
auto mesh = make_tetrahedron();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int n = assign_euclidean_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
auto H = euclidean_hessian(mesh, x, maps);
// 1-vector
Eigen::VectorXd ones = Eigen::VectorXd::Ones(n);
Eigen::VectorXd Hones = H * ones;
EXPECT_NEAR(Hones.norm(), 0.0, 1e-10)
<< "H·1 must be zero on a closed mesh (cotangent Laplacian null-space)";
}
// ════════════════════════════════════════════════════════════════════════════
// Hessian is positive semi-definite: all eigenvalues ≥ 0
//
// Checked on a small mesh (regular tetrahedron, 4 vertices) using dense
// self-adjoint eigenvalue decomposition (only feasible for small n).
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, HessianIsPositiveSemiDefinite)
{
auto mesh = make_tetrahedron();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int n = assign_euclidean_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
auto H = euclidean_hessian(mesh, x, maps);
Eigen::MatrixXd Hd = Eigen::MatrixXd(H);
Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> es(Hd);
double min_ev = es.eigenvalues().minCoeff();
EXPECT_GE(min_ev, -1e-10)
<< "All eigenvalues of the cotangent Laplacian must be ≥ 0; "
"smallest = " << min_ev;
}
// ════════════════════════════════════════════════════════════════════════════
// Finite-difference Hessian check: single right-isosceles triangle
//
// H[i,j] ≈ (G_i(x+ε·eⱼ) G_i(xε·eⱼ)) / (2ε)
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, FDCheck_Triangle)
{
auto mesh = make_triangle();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int n = assign_euclidean_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), -0.1);
EXPECT_TRUE(hessian_check_euclidean(mesh, x, maps))
<< "FD Hessian check failed on right-isosceles triangle";
}
// ════════════════════════════════════════════════════════════════════════════
// Finite-difference Hessian check: quad strip (2 triangles, 1 interior edge)
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, FDCheck_QuadStrip)
{
auto mesh = make_quad_strip();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int n = assign_euclidean_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), -0.1);
EXPECT_TRUE(hessian_check_euclidean(mesh, x, maps))
<< "FD Hessian check failed on quad strip";
}
// ════════════════════════════════════════════════════════════════════════════
// Finite-difference Hessian check: regular tetrahedron (closed, 4 faces)
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, FDCheck_Tetrahedron)
{
auto mesh = make_tetrahedron();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int n = assign_euclidean_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), -0.15);
EXPECT_TRUE(hessian_check_euclidean(mesh, x, maps))
<< "FD Hessian check failed on regular tetrahedron";
}
// ════════════════════════════════════════════════════════════════════════════
// Finite-difference Hessian check: with mixed pinned/variable vertices
//
// One vertex pinned: the corresponding row/column must be absent from H
// while the diagonal of neighbouring variable vertices still gets the full
// cotangent contribution.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, FDCheck_MixedPinnedVertices)
{
auto mesh = make_quad_strip();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
auto vit = mesh.vertices().begin();
Vertex_index v0 = *vit++;
Vertex_index v1 = *vit++;
Vertex_index v2 = *vit++;
Vertex_index v3 = *vit;
maps.v_idx[v0] = -1; // pinned
maps.v_idx[v1] = 0;
maps.v_idx[v2] = 1;
maps.v_idx[v3] = 2;
std::vector<double> x = {-0.1, -0.2, -0.15};
EXPECT_TRUE(hessian_check_euclidean(mesh, x, maps))
<< "FD Hessian check failed for mixed pinned/variable vertices";
}