Files
ConformalLabpp/code/tests/cgal/test_spherical_hessian.cpp
Tarik Moussa 194effba97 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>
2026-05-12 17:22:28 +02:00

206 lines
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// test_spherical_hessian.cpp
//
// Phase 3f — Spherical cotangent-Laplace Hessian.
//
// The Hessian of the spherical discrete conformal energy is the "spherical
// cotangent Laplacian" with edge weight
// w_k = cot(β_k), β_k = (π αi αj + αk) / 2
// for the edge (vi, vj) with opposite vertex vk and angles αi, αj, αk.
//
// In the flat limit (αi+αj+αk → π) this reduces to the Euclidean cotangent
// Laplacian (β_k → αk, w_k → cot(αk)).
//
// Tests:
// 1. Spherical cot weights match Euclidean weights in the near-flat limit.
// 2. Hessian is symmetric.
// 3. Hessian has H·1 ≈ 0 on the spherical tetrahedron (null-space property).
// 4. Finite-difference check (the primary correctness criterion).
#include "conformal_mesh.hpp"
#include "mesh_builder.hpp"
#include "spherical_hessian.hpp"
#include "euclidean_hessian.hpp" // for Euclidean comparison
#include <gtest/gtest.h>
#include <Eigen/Dense>
#include <cmath>
#include <vector>
using namespace conformallab;
// ════════════════════════════════════════════════════════════════════════════
// Spherical cot weights reduce to Euclidean cot weights in the flat limit
//
// For a nearly-flat equilateral spherical triangle (l → 0, α → 60°):
// β_k = (π 60° 60° + 60°)/2 = 60° → cot(60°) = 1/√3 ✓
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalHessian, CotWeights_NearFlatEquilateral)
{
// Use a very small equilateral spherical triangle: α1=α2=α3=60°
const double alpha = PI / 3.0;
auto sw = spherical_cot_weights(alpha, alpha, alpha);
ASSERT_TRUE(sw.valid);
const double expected = 1.0 / std::sqrt(3.0); // = cot(60°)
EXPECT_NEAR(sw.w12, expected, 1e-12);
EXPECT_NEAR(sw.w23, expected, 1e-12);
EXPECT_NEAR(sw.w31, expected, 1e-12);
}
// ════════════════════════════════════════════════════════════════════════════
// Spherical cot weights are positive for the regular spherical tetrahedron
//
// Each face has α_k = 2π/3 (120°), angle sum = 2π.
// β_k = (π 2π/3 2π/3 + 2π/3)/2 = (π 2π/3)/2 = π/6
// w_k = cot(π/6) = √3
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalHessian, CotWeights_RegularSphericalTetrahedronFace)
{
const double alpha = 2.0 * PI / 3.0; // 120°
auto sw = spherical_cot_weights(alpha, alpha, alpha);
ASSERT_TRUE(sw.valid);
const double expected = std::sqrt(3.0); // cot(π/6)
EXPECT_NEAR(sw.w12, expected, 1e-10);
EXPECT_NEAR(sw.w23, expected, 1e-10);
EXPECT_NEAR(sw.w31, expected, 1e-10);
}
// ════════════════════════════════════════════════════════════════════════════
// Hessian is symmetric: H[i,j] == H[j,i]
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalHessian, HessianIsSymmetric)
{
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
int n = assign_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
auto H = spherical_hessian(mesh, x, maps);
Eigen::MatrixXd Hd = Eigen::MatrixXd(H);
EXPECT_NEAR((Hd - Hd.transpose()).norm(), 0.0, 1e-12)
<< "Spherical Hessian must be symmetric";
}
// ════════════════════════════════════════════════════════════════════════════
// H·1 is NOT zero for the spherical Hessian (unlike the Euclidean case).
//
// In the Euclidean case, a uniform shift u_i → u_i + c scales all edge
// lengths by e^c, leaving angles unchanged → H·1 = 0 exactly.
//
// In the spherical case, l_ij = 2·asin(exp(λ_ij/2)) is NOT a linear
// function of the DOFs, so a uniform shift DOES change the angles
// → H·1 ≠ 0 in general. This test verifies that property.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalHessian, ConstantVectorNotInNullSpace)
{
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
int n = assign_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
auto H = spherical_hessian(mesh, x, maps);
Eigen::VectorXd ones = Eigen::VectorXd::Ones(n);
Eigen::VectorXd Hones = H * ones;
// H·1 should be non-trivial (norm well above zero)
EXPECT_GT(Hones.norm(), 1e-3)
<< "Spherical H·1 should be non-zero; norm = " << Hones.norm();
}
// ════════════════════════════════════════════════════════════════════════════
// Hessian is negative semi-definite at the equilibrium point (x = 0)
//
// The spherical discrete conformal energy is concave in the vertex DOFs
// (unlike the Euclidean case which is convex). At the equilibrium x = 0,
// the Hessian is NSD: all eigenvalues ≤ 0, with a multi-dimensional null
// space corresponding to degenerate directions.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalHessian, HessianIsNegativeSemiDefiniteAtEquilibrium)
{
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
int n = assign_vertex_dof_indices(mesh, maps);
// x = 0 is the equilibrium for the regular spherical tetrahedron.
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
auto H = spherical_hessian(mesh, x, maps);
Eigen::MatrixXd Hd = Eigen::MatrixXd(H);
Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> es(Hd);
double max_ev = es.eigenvalues().maxCoeff();
EXPECT_LE(max_ev, 1e-9)
<< "Largest eigenvalue of spherical Hessian at equilibrium must be ≤ 0; got " << max_ev;
}
// ════════════════════════════════════════════════════════════════════════════
// Finite-difference Hessian check: octahedron-face triangle (vertex DOFs)
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalHessian, FDCheck_OctaFaceVertex)
{
auto mesh = make_octahedron_face();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
int n = assign_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), -0.3);
EXPECT_TRUE(hessian_check_spherical(mesh, x, maps))
<< "FD Hessian check failed on octahedron-face triangle";
}
// ════════════════════════════════════════════════════════════════════════════
// Finite-difference Hessian check: spherical tetrahedron (vertex DOFs)
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalHessian, FDCheck_SpherTetVertex)
{
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
int n = assign_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
EXPECT_TRUE(hessian_check_spherical(mesh, x, maps))
<< "FD Hessian check failed on spherical tetrahedron";
}
// ════════════════════════════════════════════════════════════════════════════
// Finite-difference Hessian check: mixed pinned/variable vertices
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalHessian, FDCheck_MixedPinnedVertices)
{
auto mesh = make_octahedron_face();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
auto vit = mesh.vertices().begin();
Vertex_index v0 = *vit++;
Vertex_index v1 = *vit++;
Vertex_index v2 = *vit;
maps.v_idx[v0] = -1; // pinned
maps.v_idx[v1] = 0;
maps.v_idx[v2] = 1;
std::vector<double> x = {-0.2, -0.3};
EXPECT_TRUE(hessian_check_spherical(mesh, x, maps))
<< "FD Hessian check failed for mixed pinned/variable vertices";
}