feat(phase3d+3e): port EuclideanCyclicFunctional + add SphericalFunctional gauge-fix

Phase 3d — EuclideanCyclicFunctional:
  • euclidean_geometry.hpp: t-value / atan2 corner-angle formula with
    centering trick (μ = (Λ̃₁₂+Λ̃₂₃+Λ̃₃₁)/6) for numerical stability
  • euclidean_functional.hpp: EuclideanMaps bundle, gradient (G_v = Θ_v − Σα_v,
    G_e = α_opp⁺ + α_opp⁻ − φ_e), 10-point GL path-integral energy,
    gradient_check_euclidean — identical halfedge convention to SphericalFunctional
  • test_euclidean_functional.cpp: 11 tests (1 skip) covering angle formula,
    right-isosceles triangle, angle sum = π, degenerate detection, gradient
    checks on triangle/quad-strip/tetrahedron/fan-5/mixed-pinned, NaN check

Phase 3e — Spherical gauge-fix:
  • spherical_gauge_shift(): Newton + backtracking line search to find t*
    where ΣG_v(x + t·1) = 0 (maximises E along the global scale direction);
    bisection used when sign change is detectable, Newton+backtrack otherwise
  • apply_spherical_gauge(): in-place wrapper
  • 3 new tests: GaugeFix_SpherTetVertexZerosSumGv, GaugeFix_ApplyInPlace,
    GaugeFix_AlreadyAtGaugeReturnsTNearZero

Total: 45 cgal tests pass, 3 skipped (@Ignore Hessian stubs, one per functional)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-12 07:39:14 +02:00
parent dc0d3ca005
commit 8c353bb884
7 changed files with 924 additions and 21 deletions

View File

@@ -15,8 +15,11 @@ add_executable(conformallab_cgal_tests
# ── Phase 3b: HyperIdealFunctional ─────────────────────────────────────
test_hyper_ideal_functional.cpp
# ── Phase 3c: SphericalFunctional ─────────────────────────────────────
# ── Phase 3c + 3e: SphericalFunctional + gauge-fix ────────────────────
test_spherical_functional.cpp
# ── Phase 3d: EuclideanCyclicFunctional ───────────────────────────────
test_euclidean_functional.cpp
)
target_include_directories(conformallab_cgal_tests SYSTEM PRIVATE

View File

@@ -0,0 +1,269 @@
// test_euclidean_functional.cpp
//
// Phase 3d — EuclideanCyclicFunctional ported to ConformalMesh.
//
// Corresponds to de.varylab.discreteconformal.functional.EuclideanCyclicFunctionalTest.
//
// Test map (Java → C++)
// ──────────────────────
// testHessian (Ignored) → GradientCheck_Hessian (SKIPPED)
// testGradient…Triangle → GradientCheck_TriangleVertex (ported)
// testGradient…QuadStrip → GradientCheck_QuadStripVertex (ported)
// testGradient…Tetrahedron → GradientCheck_TetrahedronVertex (ported)
// testGradient…AllDofs → GradientCheck_TetrahedronAllDofs (ported)
// testFunctionalAtNaNValue → AnglesFiniteAtKnownPoint (ported)
//
// Energy model
// ────────────
// Uses the Schläfli path integral E(x) = ∫₀¹⟨G(tx),x⟩dt (10-point GL).
// The gradient check verifies G is curl-free.
#include "conformal_mesh.hpp"
#include "mesh_builder.hpp"
#include "euclidean_geometry.hpp"
#include "euclidean_functional.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <vector>
using namespace conformallab;
// ════════════════════════════════════════════════════════════════════════════
// @Ignore in Java: no Hessian implemented yet
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, GradientCheck_Hessian)
{
GTEST_SKIP() << "@Ignore in Java Hessian not yet implemented";
}
// ════════════════════════════════════════════════════════════════════════════
// Angle formula: equilateral triangle → all angles = π/3
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, EquilateralTriangleAnglesArePiOver3)
{
// All sides equal: l = 1.0, log-length = 0.
auto fa = euclidean_angles(0.0, 0.0, 0.0);
ASSERT_TRUE(fa.valid) << "Equilateral triangle must be valid";
constexpr double PI_3 = 3.14159265358979323846 / 3.0;
EXPECT_NEAR(fa.alpha1, PI_3, 1e-12);
EXPECT_NEAR(fa.alpha2, PI_3, 1e-12);
EXPECT_NEAR(fa.alpha3, PI_3, 1e-12);
}
// ════════════════════════════════════════════════════════════════════════════
// Angle formula: right isosceles triangle (legs 1, hypotenuse √2)
//
// For the 45-45-90 triangle: angles are π/4, π/4, π/2.
// From make_triangle default (v0=(0,0), v1=(1,0), v2=(0,1)):
// e01: l=1, λ°=0
// e12: l=√2, λ°=log(2)
// e02: l=1, λ°=0
// Angle at v0 (opposite e12) = π/2.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, RightIsoscelesTriangleAnglesCorrect)
{
const double log2 = std::log(2.0);
// lam12 = 0 (v0-v1, length 1), lam23 = log(2) (v1-v2, length √2), lam31 = 0 (v2-v0, length 1)
// v1 = v0 in our ordering → remap: l01=1, l12=√2, l20=1
// Using euclidean_angles(lam_v1v2, lam_v2v3, lam_v3v1):
// v1=(0,0), v2=(1,0), v3=(0,1)
// lam12 = log(1²) = 0, lam23 = log(√2 ²) = log2, lam31 = log(1²) = 0
auto fa = euclidean_angles(0.0, log2, 0.0);
ASSERT_TRUE(fa.valid);
constexpr double PI = 3.14159265358979323846;
// v1=(0,0) is at the right-angle corner (opposite the hypotenuse l23=√2) → α1 = 90°.
// v2=(1,0) and v3=(0,1) are the 45° corners (each opposite a leg of length 1).
EXPECT_NEAR(fa.alpha1, PI / 2.0, 1e-12); // angle at v1 (opposite l23=√2): 90°
EXPECT_NEAR(fa.alpha2, PI / 4.0, 1e-12); // angle at v2 (opposite l31=1): 45°
EXPECT_NEAR(fa.alpha3, PI / 4.0, 1e-12); // angle at v3 (opposite l12=1): 45°
}
// ════════════════════════════════════════════════════════════════════════════
// Angle sum = π for any valid Euclidean triangle
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, AngleSumEqualsPi)
{
// Scalene triangle with log-lengths (0, 0.5, -0.3).
auto fa = euclidean_angles(0.0, 0.5, -0.3);
ASSERT_TRUE(fa.valid);
constexpr double PI = 3.14159265358979323846;
EXPECT_NEAR(fa.alpha1 + fa.alpha2 + fa.alpha3, PI, 1e-12);
}
// ════════════════════════════════════════════════════════════════════════════
// Degenerate triangle → valid = false
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, DegenerateTriangleReturnsFalse)
{
// l12 = l23 = 1, l31 = 3 → violates triangle inequality.
auto fa = euclidean_angles_from_lengths(1.0, 1.0, 3.0);
EXPECT_FALSE(fa.valid);
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: default right-isosceles triangle, vertex DOFs only
//
// Mirrors Java testGradient…SingleTriangle.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, GradientCheck_TriangleVertex)
{
auto mesh = make_triangle(); // (0,0)(1,0)(0,1)
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int n = assign_euclidean_vertex_dof_indices(mesh, maps);
// Small uniform conformal perturbation.
std::vector<double> x(static_cast<std::size_t>(n), -0.1);
EXPECT_TRUE(gradient_check_euclidean(mesh, x, maps))
<< "Gradient check failed on right-isosceles triangle (vertex DOFs)";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: quad strip (2 triangles, 1 interior edge), vertex DOFs only
//
// Mirrors Java testGradient…QuadStrip / testGradientInExtendedDomain.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, GradientCheck_QuadStripVertex)
{
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.2);
EXPECT_TRUE(gradient_check_euclidean(mesh, x, maps))
<< "Gradient check failed on quad strip (vertex DOFs)";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: regular tetrahedron, vertex DOFs only
//
// Closed surface (4 faces, 4 vertices, 6 interior edges).
// Exercises per-vertex angle-sum accumulation on multiple faces.
// Mirrors Java testGradient…Tetrahedron / testGradientWithHyperIdeal…
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, GradientCheck_TetrahedronVertex)
{
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(gradient_check_euclidean(mesh, x, maps))
<< "Gradient check failed on regular tetrahedron (vertex DOFs)";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: tetrahedron, all DOFs (vertex + edge)
//
// Exercises the edge-gradient branch G_e = α_opp⁺ + α_opp⁻ π.
// Mirrors Java testGradientWithHyperellipticCurve.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, GradientCheck_TetrahedronAllDofs)
{
auto mesh = make_tetrahedron();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int n = assign_euclidean_all_dof_indices(mesh, maps);
// 4 vertex DOFs + 6 edge DOFs = 10 total.
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
// Set vertex DOFs slightly negative to keep triangles non-degenerate.
for (int i = 0; i < 4; ++i)
x[static_cast<std::size_t>(i)] = -0.15;
EXPECT_TRUE(gradient_check_euclidean(mesh, x, maps))
<< "Gradient check failed on regular tetrahedron (all DOFs)";
}
// ════════════════════════════════════════════════════════════════════════════
// Angles are finite at a known interior point
//
// Mirrors Java testFunctionalAtNaNValue: stress-test the angle formula with
// large negative conformal factors (compressed triangle) to ensure no NaN/Inf.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, AnglesFiniteAtKnownPoint)
{
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);
// Very compressed: u_i = -3 (all sides shrunk by exp(-3) ≈ 0.05).
// Triangle stays well-formed (equilateral shrinks uniformly).
std::vector<double> x(static_cast<std::size_t>(n), -3.0);
auto G = euclidean_gradient(mesh, x, maps);
for (std::size_t i = 0; i < G.size(); ++i) {
EXPECT_FALSE(std::isnan(G[i])) << "Gradient component " << i << " is NaN";
EXPECT_FALSE(std::isinf(G[i])) << "Gradient component " << i << " is Inf";
}
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: fan of 5 flat triangles, vertex DOFs only
//
// High-valence central vertex: exercises per-vertex angle accumulation
// across 5 incident faces.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, GradientCheck_Fan5Vertex)
{
auto mesh = make_fan(5);
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.05);
EXPECT_TRUE(gradient_check_euclidean(mesh, x, maps))
<< "Gradient check failed on flat fan-5 mesh";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: mixed pinned/variable vertices
//
// Pins the first vertex (u_v0 = 0 fixed), lets the rest be variable.
// Verifies that the gradient accumulator skips pinned vertices correctly.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, GradientCheck_MixedPinnedVertices)
{
auto mesh = make_quad_strip();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// Manually pin v0; assign v1, v2, v3 as DOFs 0, 1, 2.
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.3, -0.2};
EXPECT_TRUE(gradient_check_euclidean(mesh, x, maps))
<< "Gradient check failed for mixed pinned/variable vertices";
}

View File

@@ -1,4 +1,4 @@
// test_spherical_functional.cpp
// test_spherical_functional.cpp (Phase 3c + 3e)
//
// Phase 3c — SphericalFunctional ported to ConformalMesh.
//
@@ -244,3 +244,97 @@ TEST(SphericalFunctional, GradientCheck_MixedPinnedVertices)
EXPECT_TRUE(gradient_check_spherical(mesh, x, maps))
<< "Gradient check failed for mixed pinned/variable vertices";
}
// ════════════════════════════════════════════════════════════════════════════
// Phase 3e — Gauge-fix for closed spherical surfaces
//
// On a closed spherical surface, the functional has a gauge mode:
// E(u + t·1) is maximised at some t*.
// At t*, the sum of all vertex gradients equals zero: Σ G_v = 0.
//
// Test: start from a point with non-zero ΣG_v, apply the gauge shift,
// and verify ΣG_v(x + t*·1) ≈ 0.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, GaugeFix_SpherTetVertexZerosSumGv)
{
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);
// Off-gauge starting point: all u_i = -0.5
std::vector<double> x(static_cast<std::size_t>(n), -0.5);
// Compute ΣG_v before gauge shift.
{
auto G = spherical_gradient(mesh, x, maps);
double sum = 0.0;
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv >= 0) sum += G[static_cast<std::size_t>(iv)];
}
// At -0.5 the surface is compressed; ΣG_v should be non-zero.
EXPECT_NE(sum, 0.0) << "Pre-gauge ΣG_v should be non-zero";
}
// Compute gauge shift and apply.
double t = spherical_gauge_shift(mesh, x, maps);
std::vector<double> x_fixed = x;
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv >= 0)
x_fixed[static_cast<std::size_t>(iv)] += t;
}
// Verify ΣG_v ≈ 0 at the gauge-fixed point.
{
auto G = spherical_gradient(mesh, x_fixed, maps);
double sum = 0.0;
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv >= 0) sum += G[static_cast<std::size_t>(iv)];
}
EXPECT_NEAR(sum, 0.0, 1e-6)
<< "After gauge fix, Σ G_v should vanish; t* = " << t;
}
}
TEST(SphericalFunctional, GaugeFix_ApplyInPlace)
{
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.3: compressed but inside the valid spherical domain.
std::vector<double> x(static_cast<std::size_t>(n), -0.3);
apply_spherical_gauge(mesh, x, maps);
// After in-place gauge fix, ΣG_v must be near 0.
auto G = spherical_gradient(mesh, x, maps);
double sum = 0.0;
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv >= 0) sum += G[static_cast<std::size_t>(iv)];
}
EXPECT_NEAR(sum, 0.0, 1e-6)
<< "apply_spherical_gauge must drive Σ G_v to zero";
}
TEST(SphericalFunctional, GaugeFix_AlreadyAtGaugeReturnsTNearZero)
{
// A symmetric, equilateral spherical tetrahedron at x=0 is already
// at the gauge maximum (by symmetry, ΣG_v = 0).
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.0);
double t = spherical_gauge_shift(mesh, x, maps);
// Symmetric starting point → t* should be very close to 0.
EXPECT_NEAR(t, 0.0, 1e-5)
<< "Gauge shift from the symmetric point should be ~0; got " << t;
}