Files
ConformalLabpp/code/tests/cgal/test_euclidean_hessian.cpp
Tarik Moussa adbf682f0f test: add degenerate-triangle limiting-angle and edge-DOF guard tests
Finding-F and Finding-G from doc/reviewer/external-audit-2026-05-30.md
(java-port-audit missing-test items 1 and 2).

Finding-F — Degenerate triangle: limiting angles (item 1)
  test_euclidean_functional.cpp:
    DegenerateTriangle_LimitingAngles_L{12,23,31}TooLong
      — verifies α_opposite = π, other two = 0, valid = false
      for all three edge-over-long cases
    DegenerateTriangle_GradientPicksUpPiCorner
      — end-to-end mesh test: forces effective l12 >> l23+l31 via
        lambda0, evaluates gradient, asserts G_v3 = π (not 2π from
        a skipped degenerate face) and G_v1=G_v2 = 2π

  test_spherical_functional.cpp:
    DegenerateTriangle_LimitingAngles_S{12,23,31}TooLong
      — same coverage for spherical_angles()

Finding-G — euclidean_hessian edge-DOF guard (item 2)
  test_euclidean_hessian.cpp:
    EdgeDOFGuard_Throws
      — assign_euclidean_all_dof_indices + euclidean_hessian → throw
    EdgeDOFGuard_VertexOnlyDoesNotThrow
      — vertex-only layout → no throw (regression guard)

275/275 CGAL tests pass, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 01:17:27 +02:00

256 lines
12 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.

// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// 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";
}
// ════════════════════════════════════════════════════════════════════════════
// Edge-DOF guard (Finding-G, java-port-audit item 2)
//
// euclidean_hessian() (vertex-only cotangent Laplacian) must throw
// std::logic_error when any edge DOF is active. Without this guard the
// function would silently return a Hessian with zero rows/cols for the
// edge DOFs, causing SimplicialLDLT to fail in a hard-to-diagnose way.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanHessian, EdgeDOFGuard_Throws)
{
auto mesh = make_tetrahedron();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
assign_euclidean_all_dof_indices(mesh, maps); // assigns vertex + edge DOFs
const int n = euclidean_dimension(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
EXPECT_THROW(euclidean_hessian(mesh, x, maps), std::logic_error)
<< "euclidean_hessian must throw when edge DOFs are present";
}
TEST(EuclideanHessian, EdgeDOFGuard_VertexOnlyDoesNotThrow)
{
// Vertex-only layout must NOT trigger the guard.
auto mesh = make_tetrahedron();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
auto gauge = *mesh.vertices().begin();
assign_euclidean_vertex_dof_indices(mesh, maps, gauge);
const int n = euclidean_dimension(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
EXPECT_NO_THROW(euclidean_hessian(mesh, x, maps))
<< "euclidean_hessian must not throw for vertex-only DOF layout";
}