Files
ConformalLabpp/code/tests/cgal/test_spherical_functional.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

704 lines
34 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_spherical_functional.cpp (Phase 3c + 3e)
//
// Phase 3c — SphericalFunctional ported to ConformalMesh.
//
// Corresponds to de.varylab.discreteconformal.functional.SphericalFunctionalTest.
//
// Test map (Java → C++)
// ──────────────────────
// testHessian (Ignored) → GradientCheck_Hessian (ported)
// testGradientWithHyperIdeal… → GradientCheck_OctaFaceVertex (ported)
// testGradientInExtendedDomain → GradientCheck_SpherTetVertex (ported)
// testGradientWithHyperelliptic → GradientCheck_SpherTetAllDofs (ported)
// testFunctionalAtNaNValue → AnglesFiniteAtKnownPoint (ported)
//
// Energy model
// ────────────
// The energy is computed as the Schläfli path integral E(x) = ∫₀¹⟨G(tx),x⟩dt
// using 10-point Gauss-Legendre quadrature. The gradient check therefore
// verifies that G is curl-free (the integrability / exactness condition of
// the spherical discrete conformal functional). This is equivalent to the
// Java FunctionalTest gradient check.
#include "conformal_mesh.hpp"
#include "mesh_builder.hpp"
#include "spherical_functional.hpp"
#include "spherical_hessian.hpp"
#include "spherical_geometry.hpp"
#include "clausen.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <vector>
using namespace conformallab;
// ════════════════════════════════════════════════════════════════════════════
// Cross-module Hessian check: spherical_gradient() ↔ spherical_hessian()
//
// Java @Ignore reason: "no Hessian implemented" — the Java functional test
// was written before the Hessian existed. In C++ the analytic spherical
// Hessian (spherical_hessian.hpp, Phase 3f) is complete.
//
// This test verifies cross-module consistency between the functional and
// the Hessian module. The spherical Hessian is NSD (negative semi-definite)
// because the spherical energy is concave — hessian_check_spherical() uses
// the sign-corrected FD check appropriate for the spherical case.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, GradientCheck_Hessian)
{
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))
<< "Cross-module: spherical_gradient() and spherical_hessian() are inconsistent";
}
// ════════════════════════════════════════════════════════════════════════════
// Angle formula: octahedron-face triangle has all angles = π/2
//
// The triangle (1,0,0)(0,1,0)(0,0,1) has l_ij = π/2 for all edges.
// Half-angle formula: s = 3π/4, s_ij = π/4 for all three.
// All angles = π/2 (right-angled spherical triangle).
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, OctaFaceAnglesAreRightAngles)
{
// l_ij = π/2 for all edges (octahedron face on unit sphere)
const double l = PI_SPHER / 2.0;
auto fa = spherical_angles(l, l, l);
ASSERT_TRUE(fa.valid) << "Equilateral spherical triangle must be valid";
EXPECT_NEAR(PI_SPHER / 2.0, fa.alpha1, 1e-12);
EXPECT_NEAR(PI_SPHER / 2.0, fa.alpha2, 1e-12);
EXPECT_NEAR(PI_SPHER / 2.0, fa.alpha3, 1e-12);
}
// ════════════════════════════════════════════════════════════════════════════
// Angle sum of a spherical triangle exceeds π (positive curvature)
//
// For the spherical tetrahedron face (arccos(1/3) ≈ 1.9106 per edge):
// The dihedral angle = arccos(1/3) ≈ 70.53°; by symmetry the face angles
// (vertex angles of the spherical triangle) are all equal.
// Angle sum must be > π and equal 3·arccos(1/3) ≈ 3·1.2310 ≈ 3.693 rad.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, SpherTetAngleSumExceedsPi)
{
// Edge length of spherical tetrahedron face: arccos(1/3)
const double l = std::acos(-1.0 / 3.0);
auto fa = spherical_angles(l, l, l);
ASSERT_TRUE(fa.valid);
EXPECT_GT(fa.alpha1 + fa.alpha2 + fa.alpha3, PI_SPHER)
<< "Angle sum of spherical triangle must exceed π";
// By symmetry all three angles must be equal
EXPECT_NEAR(fa.alpha1, fa.alpha2, 1e-12);
EXPECT_NEAR(fa.alpha2, fa.alpha3, 1e-12);
// For a regular spherical tetrahedron with edge arccos(1/3):
// half-angle: tan(α/2) = √(sin(l/2)/sin(3l/2)) = √3 → α/2 = π/3 → α = 2π/3.
// (arccos(1/3) ≈ 1.231 is the 3D dihedral angle of a Euclidean tetrahedron, not this.)
double expected = 2.0 * PI_SPHER / 3.0; // 120°
EXPECT_NEAR(fa.alpha1, expected, 1e-10);
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: octahedron-face triangle, vertex DOFs only
//
// Sets λ° from mesh geometry (unit sphere), all u_i = 0.3 (slightly smaller).
// Mirrors Java testGradientWithHyperIdeal… on a single-triangle mesh.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, GradientCheck_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);
// Small uniform conformal factor: shrink the triangle slightly.
std::vector<double> x(static_cast<std::size_t>(n), -0.3);
EXPECT_TRUE(gradient_check_spherical(mesh, x, maps))
<< "Gradient check failed on octahedron-face triangle (vertex DOFs)";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: spherical tetrahedron (4 faces), vertex DOFs only
//
// Closed surface; exercises accumulation over multiple faces per vertex.
// Mirrors Java testGradientInTheExtendedDomain.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, GradientCheck_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(gradient_check_spherical(mesh, x, maps))
<< "Gradient check failed on spherical tetrahedron (vertex DOFs)";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: spherical tetrahedron, all DOFs (vertex + edge)
//
// Exercises the edge-gradient branch: G_e = α_opp⁺ + α_opp⁻ π.
// Mirrors Java testGradientWithHyperellipticCurve.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, GradientCheck_SpherTetAllDofs)
{
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
int n = assign_all_spherical_dof_indices(mesh, maps);
// Replacement parameterization (Finding 3): when an edge carries a DOF its
// value *replaces* λ°_ij + u_i + u_j entirely, so Λ_ij = λ_e. Here the edge
// DOFs stay at 0 and only the vertex DOFs are perturbed; this checks that the
// gradient is curl-free (energy = Schläfli path integral), not Java-faithfulness
// of the edge formula — that is locked separately by
// EdgeGradient_RegularTetClosedForm below.
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
// Set vertex DOFs (indices 0..3) to -0.2 to keep triangle well-formed.
for (int i = 0; i < 4; ++i) x[static_cast<std::size_t>(i)] = -0.2;
EXPECT_TRUE(gradient_check_spherical(mesh, x, maps))
<< "Gradient check failed on spherical tetrahedron (all DOFs)";
}
// ════════════════════════════════════════════════════════════════════════════
// Closed-form oracle for the edge-DOF gradient (Finding 3, missing-test item 4)
//
// The FD gradient check above can only confirm that G is conservative — the
// spherical energy is *defined* as the path integral of G, so the energy↔gradient
// FD agreement is automatic and CANNOT detect a wrong-but-conservative edge
// formula. This test instead pins the edge gradient against an independent,
// closed-form geometric value, so it would fail if the Finding-3 formula
// (G_e = α_opp⁺ + α_opp⁻ θ_e, dropping the additive (S⁺+S⁻)/2 term) ever
// regressed.
//
// Geometry: the regular spherical tetrahedron has all edges a = arccos(1/3),
// so by the spherical law of cosines every interior corner angle is
// cos α = (cos a cos²a)/sin²a = cos a/(1+cos a) = (1/3)/(2/3) = 1/2
// ⇒ α = 2π/3.
// Each edge is shared by two faces, so both opposite angles equal 2π/3 and
// G_e = 2π/3 + 2π/3 θ_e with θ_e = π (default) = π/3.
//
// Setup: all edges carry DOFs, set to their λ⁰ (the replacement convention then
// reproduces the original tetrahedron metric exactly), vertex DOFs left at 0.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, EdgeGradient_RegularTetClosedForm)
{
const double PI_ = std::acos(-1.0);
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
int n = assign_all_spherical_dof_indices(mesh, maps);
// Edge DOF = λ⁰ → Λ_ij = λ⁰ → reproduces the arccos(1/3) tetrahedron.
// Vertex DOFs stay at 0 (ignored by the replacement convention for DOF edges).
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
int n_edge_dofs = 0;
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e];
if (ie >= 0) { x[static_cast<std::size_t>(ie)] = maps.lambda0[e]; ++n_edge_dofs; }
}
ASSERT_EQ(n_edge_dofs, 6) << "regular tetrahedron must have 6 edge DOFs";
auto G = spherical_gradient(mesh, x, maps);
const double expected = PI_ / 3.0; // 2·(2π/3) π
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e];
if (ie < 0) continue;
EXPECT_NEAR(G[static_cast<std::size_t>(ie)], expected, 1e-9)
<< "edge gradient at DOF " << ie
<< " must equal the closed-form value π/3 (Finding 3)";
}
}
// ════════════════════════════════════════════════════════════════════════════
// Angles are finite at a known interior point
//
// Mirrors Java testFunctionalAtNaNValue: choose DOFs that could hit
// a degenerate branch (l_ij → 0 or triangle inequality fails) and check
// the gradient vector is free of NaN/Inf.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, AnglesFiniteAtKnownPoint)
{
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);
// u_i = -1.5: contracts the triangle heavily but stays non-degenerate.
std::vector<double> x(static_cast<std::size_t>(n), -1.5);
auto G = spherical_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-4 mesh on unit sphere, vertex DOFs only
//
// Make a fan of 4 triangles around the north pole (0,0,1);
// rim vertices projected onto the equator.
// Exercises high-valence vertex gradient accumulation.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, GradientCheck_SpherFan4Vertex)
{
// Build a fan with 4 spherical triangles manually (can't use make_fan
// directly because those vertices are not on the unit sphere).
ConformalMesh mesh;
auto center = mesh.add_vertex(Point3(0, 0, 1)); // north pole
const int n_rim = 4;
std::vector<Vertex_index> rim(n_rim);
const double dtheta = 2.0 * PI_SPHER / n_rim;
const double phi = PI_SPHER / 4.0; // 45° colatitude
for (int i = 0; i < n_rim; ++i) {
double theta = i * dtheta;
rim[i] = mesh.add_vertex(Point3(
std::sin(phi) * std::cos(theta),
std::sin(phi) * std::sin(theta),
std::cos(phi)));
}
for (int i = 0; i < n_rim; ++i)
mesh.add_face(center, rim[i], rim[(i + 1) % n_rim]);
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
int ndof = assign_vertex_dof_indices(mesh, maps);
std::vector<double> x(static_cast<std::size_t>(ndof), -0.3);
EXPECT_TRUE(gradient_check_spherical(mesh, x, maps))
<< "Gradient check failed on spherical fan-4 mesh";
}
// ════════════════════════════════════════════════════════════════════════════
// Gradient check: mixed pinned/variable vertices
//
// One vertex pinned (u_v = 0 fixed), others variable.
// Verifies that the gradient accumulation skips pinned vertices correctly.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, GradientCheck_MixedPinnedVertices)
{
auto mesh = make_octahedron_face();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
// Pin v0, make v1 and v2 variable.
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.4};
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;
}
// ─────────────────────────────────────────────────────────────────────────────
// Golden-value oracle — pin the spherical law-of-cosines angle formula, the β
// half-angle relations, and the Lobachevsky energy term bit-for-bit against the
// upstream Java reference (SphericalFunctional.triangleEnergyAndAlphas, lines
// 401-453), captured by running the compiled Java library (openjdk 17) with the
// real de.varylab…Clausen.Л on these exact arc lengths. Unlike the Schläfli
// path-integral gradient check (which only verifies curl-freeness), this locks
// the absolute angle/β/energy values against an independent implementation,
// catching any silent index/sign/convention drift in the spherical port.
//
// To regenerate: /tmp/oracle/SphereOracle.java (recipe in doc/reviewer/
// java-port-audit.md). Values are Java printf %.17g. Tolerance 1e-12.
//
// Index map (C++ spherical_angles ↔ Java): with l12=lij, l23=ljk, l31=lki the
// returned alpha1/alpha2/alpha3 are Java αi/αj/αk (angle opposite ljk/lki/lij).
// ─────────────────────────────────────────────────────────────────────────────
TEST(SphericalGoldenJava, AngleBetaEnergyFromLengths)
{
auto check = [](double lij, double ljk, double lki,
double ai_g, double aj_g, double ak_g,
double bi_g, double bj_g, double bk_g, double L_g) {
auto fa = spherical_angles(lij, ljk, lki);
EXPECT_TRUE(fa.valid);
EXPECT_NEAR(fa.alpha1, ai_g, 1e-12);
EXPECT_NEAR(fa.alpha2, aj_g, 1e-12);
EXPECT_NEAR(fa.alpha3, ak_g, 1e-12);
const double ai = fa.alpha1, aj = fa.alpha2, ak = fa.alpha3;
const double bi = 0.5 * (PI + ai - aj - ak);
const double bj = 0.5 * (PI - ai + aj - ak);
const double bk = 0.5 * (PI - ai - aj + ak);
EXPECT_NEAR(bi, bi_g, 1e-12);
EXPECT_NEAR(bj, bj_g, 1e-12);
EXPECT_NEAR(bk, bk_g, 1e-12);
const double Lterm =
Lobachevsky(ai) + Lobachevsky(aj) + Lobachevsky(ak) +
Lobachevsky(bi) + Lobachevsky(bj) + Lobachevsky(bk) +
Lobachevsky(0.5 * (PI - ai - aj - ak));
EXPECT_NEAR(Lterm, L_g, 1e-12);
};
// Scalene spherical triangle (all Δ > 0, Δ_ijk < 2π).
check(1.0, 1.2, 0.9,
1.5305813141072122, 0.99684981272794600, 1.1246069519101605,
1.2753586015294494, 0.74162710015018330, 0.86938423933239760,
1.3576100185550408);
// Equilateral spherical triangle.
check(0.7, 0.7, 0.7,
1.1225596283199812, 1.1225596283199812, 1.1225596283199812,
1.0095165126349062, 1.0095165126349060, 1.0095165126349060,
1.6806828584976297);
}
// ─────────────────────────────────────────────────────────────────────────────
// FULL-MESH golden oracle (spherical) — drives the REAL upstream SphericalFunctional
// on the shared tetrahedron (scaled so every arc length stays < π) and pins BOTH
// the per-vertex gradient G_v = Θ_v Σα AND ΔE = E(x) E(0) bit-for-bit.
//
// IMPORTANT — the oracle calls Java's `conformalEnergyAndGradient` (the RAW
// θ−Σα gradient), NOT `evaluate()`: `evaluate()` first runs a 1-D Brent
// maximization over the global-scale gauge direction (`maximizeInNegativeDirection`),
// which the C++ `spherical_gradient` deliberately does NOT — C++ factors that
// gauge into the Newton solver's `spherical_gauge_shift` instead. Comparing
// against `evaluate()` would (wrongly) drive every component to ~0. The raw
// gradient is the piece that corresponds 1:1 to the C++ functional.
//
// Setup parity (UnwrapUtility.prepareInvariantDataHyperbolicAndSpherical, scale):
// closed mesh, ALL 4 vertices variable, Θ_v = 2π, no edge DOFs,
// λ°_e = 2·log(SCALE·|p_i p_j|) (Java uses chord length × scale, whereas the
// C++ compute_lambda0_from_mesh helper assumes unit-sphere vertices and uses
// ARC length — so we set λ° directly here to match Java exactly),
// per-vertex u(P) = 0.10·X 0.07·Y + 0.13·Z, SCALE = 0.2.
//
// To regenerate: /tmp/oracle/{tet.obj,SphereMeshOracle.java}. Values are Java %.17g.
// ─────────────────────────────────────────────────────────────────────────────
TEST(SphericalGoldenJava, FullMeshGradientAndEnergy_Tetrahedron)
{
constexpr double TWO_PI = 2.0 * 3.14159265358979323846264338328;
constexpr double SCALE = 0.2;
auto mesh = make_tetrahedron(); // same 4 vertices as /tmp/oracle/tet.obj
auto maps = setup_spherical_maps(mesh);
// λ°_e = 2·log(SCALE · chord), matching Java's prepareInvariantData(scale).
for (auto e : mesh.edges()) {
auto h = mesh.halfedge(e);
const auto& p1 = mesh.point(mesh.source(h));
const auto& p2 = mesh.point(mesh.target(h));
const double dx = p1.x() - p2.x(), dy = p1.y() - p2.y(), dz = p1.z() - p2.z();
const double chord = std::sqrt(dx*dx + dy*dy + dz*dz);
maps.lambda0[e] = 2.0 * std::log(SCALE * chord);
}
int idx = 0;
for (auto v : mesh.vertices()) {
maps.v_idx[v] = idx++;
maps.theta_v[v] = TWO_PI;
}
auto u_of = [](const Point3& p) {
return 0.10 * p.x() - 0.07 * p.y() + 0.13 * p.z();
};
std::vector<double> x(static_cast<std::size_t>(idx), 0.0);
for (auto v : mesh.vertices())
x[static_cast<std::size_t>(maps.v_idx[v])] = u_of(mesh.point(v));
auto G = spherical_gradient(mesh, x, maps);
struct GoldRow { double X, Y, Z, G; };
const GoldRow gold[4] = {
{ 1, 1, 1, 2.7671034786104927},
{ 1, -1, -1, 2.5216834054857546},
{-1, 1, -1, 1.5401761310866633},
{-1, -1, 1, 2.6518569531861100},
};
for (auto v : mesh.vertices()) {
const auto& p = mesh.point(v);
const double g = G[static_cast<std::size_t>(maps.v_idx[v])];
bool matched = false;
for (const auto& row : gold) {
if (std::abs(p.x() - row.X) < 1e-9 &&
std::abs(p.y() - row.Y) < 1e-9 &&
std::abs(p.z() - row.Z) < 1e-9) {
EXPECT_NEAR(g, row.G, 1e-12)
<< "gradient mismatch at (" << p.x() << "," << p.y()
<< "," << p.z() << ")";
matched = true;
break;
}
}
EXPECT_TRUE(matched) << "unexpected vertex position";
}
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
const double dE = spherical_energy(mesh, x, maps)
- spherical_energy(mesh, x0, maps);
EXPECT_NEAR(dE, 0.16409141487397116, 1e-12);
}
// ─────────────────────────────────────────────────────────────────────────────
// FULL-MESH EDGE-DOF golden oracle (spherical, Finding 3) — the missing solution-
// level Java oracle for the edge-DOF gradient path. Makes two opposite edges of
// the shared tetrahedron variable (replacement parameterization: λ_e = x[e_idx],
// no u-shift) and pins BOTH the vertex gradient (Θ−Σα) AND the edge gradient
// (α_opp⁺ + α_opp⁻ θ_e, θ_e = π) bit-for-bit against the REAL upstream
// SphericalFunctional.conformalEnergyAndGradient (raw gradient, not evaluate()).
//
// This is a pure GRADIENT oracle (no ΔE): with an edge DOF, x = 0 means λ_e = 0
// ⇒ spherical arc length = π (degenerate), so the path-integral-from-origin
// energy reference is ill-defined — the gradient at a fixed non-degenerate x is
// the unambiguous Finding-3 quantity. It does NOT touch the spherical Hessian,
// so it is unaffected by the Finding-4 edge-DOF Hessian guard.
//
// To regenerate: /tmp/oracle/SphereEdgeOracle.java. Values are Java %.17g.
// ─────────────────────────────────────────────────────────────────────────────
TEST(SphericalGoldenJava, FullMeshEdgeDofGradient_Tetrahedron)
{
constexpr double TWO_PI = 2.0 * 3.14159265358979323846264338328;
constexpr double PI_C = 3.14159265358979323846264338328;
constexpr double SCALE = 0.2;
auto mesh = make_tetrahedron();
auto maps = setup_spherical_maps(mesh);
for (auto e : mesh.edges()) {
auto h = mesh.halfedge(e);
const auto& p1 = mesh.point(mesh.source(h));
const auto& p2 = mesh.point(mesh.target(h));
const double dx = p1.x()-p2.x(), dy = p1.y()-p2.y(), dz = p1.z()-p2.z();
maps.lambda0[e] = 2.0 * std::log(SCALE * std::sqrt(dx*dx + dy*dy + dz*dz));
maps.theta_e[e] = PI_C;
maps.e_idx[e] = -1;
}
int idx = 0;
for (auto v : mesh.vertices()) { maps.v_idx[v] = idx++; maps.theta_v[v] = TWO_PI; }
auto pos = [&](Vertex_index v){ return mesh.point(v); };
auto same = [](const Point3& p, double X, double Y, double Z) {
return std::abs(p.x()-X)<1e-9 && std::abs(p.y()-Y)<1e-9 && std::abs(p.z()-Z)<1e-9;
};
// Edge connects positions (aX,aY,aZ)(bX,bY,bZ) in either order?
auto connects = [&](Edge_index e, const double a[3], const double b[3]) {
auto h = mesh.halfedge(e);
const Point3& s = pos(mesh.source(h));
const Point3& t = pos(mesh.target(h));
return (same(s,a[0],a[1],a[2]) && same(t,b[0],b[1],b[2])) ||
(same(s,b[0],b[1],b[2]) && same(t,a[0],a[1],a[2]));
};
const double A[3]={1,1,1}, B[3]={1,-1,-1}, C[3]={-1,1,-1}, D[3]={-1,-1,1};
// Make edges AB and CD variable (replacement parameterization).
int edof = idx; // edge DOFs start after the 4 vertex DOFs
Edge_index eAB, eCD;
for (auto e : mesh.edges()) {
if (connects(e, A, B)) { maps.e_idx[e] = edof++; eAB = e; }
else if (connects(e, C, D)) { maps.e_idx[e] = edof++; eCD = e; }
}
ASSERT_EQ(edof, 6); // 4 vertex + 2 edge DOFs
auto u_of = [](const Point3& p){ return 0.10*p.x() - 0.07*p.y() + 0.13*p.z(); };
std::vector<double> x(6, 0.0);
for (auto v : mesh.vertices())
x[static_cast<std::size_t>(maps.v_idx[v])] = u_of(mesh.point(v));
// Edge DOF value = λ⁰_e + 0.1 (same perturbation as the Java oracle).
x[static_cast<std::size_t>(maps.e_idx[eAB])] = maps.lambda0[eAB] + 0.1;
x[static_cast<std::size_t>(maps.e_idx[eCD])] = maps.lambda0[eCD] + 0.1;
auto G = spherical_gradient(mesh, x, maps);
// Vertex gradients keyed by position.
struct VRow { double X, Y, Z, G; };
const VRow vg[4] = {
{ 1, 1, 1, 2.5036751008175546},
{ 1, -1, -1, 2.2303362601050205},
{-1, 1, -1, 1.8463710314817632},
{-1, -1, 1, 2.7499719487341570},
};
for (auto v : mesh.vertices()) {
const auto& p = mesh.point(v);
const double g = G[static_cast<std::size_t>(maps.v_idx[v])];
bool m = false;
for (auto& r : vg)
if (same(p, r.X, r.Y, r.Z)) { EXPECT_NEAR(g, r.G, 1e-12); m = true; break; }
EXPECT_TRUE(m);
}
// Edge gradients: AB and CD (Java golden values).
EXPECT_NEAR(G[static_cast<std::size_t>(maps.e_idx[eAB])], -0.35189517043413690, 1e-12);
EXPECT_NEAR(G[static_cast<std::size_t>(maps.e_idx[eCD])], -0.44101986058895950, 1e-12);
}
// ════════════════════════════════════════════════════════════════════════════
// Degenerate spherical triangle — limiting angles (Finding-F, java-port-audit item 1)
//
// spherical_angles() must return the limiting angles (π opposite the
// over-long edge, 0/0 elsewhere) when the spherical triangle inequality
// is violated, with valid = false. This mirrors the Euclidean behaviour
// and is required for the convex C¹ BPS extension.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, DegenerateTriangle_LimitingAngles_S12TooLong)
{
// s12 > s23 + s31: s12 = 2.5, s23 = s31 = 0.5 (all < π so valid arc lengths)
// s23 < 0 → actually use s-based check
// Easier: use s12 = π ε (nearly degenerate hemisphere edge)
// and very short s23, s31 so s12 > s23 + s31.
const double s12 = 2.0, s23 = 0.4, s31 = 0.4; // s12 > s23+s31 = 0.8
auto fa = spherical_angles(s12, s23, s31);
EXPECT_FALSE(fa.valid);
// s12 is the edge opposite v3 → α3 = π
EXPECT_NEAR(fa.alpha3, PI, 1e-12)
<< "corner opposite over-long s12 must be π";
EXPECT_NEAR(fa.alpha1, 0.0, 1e-12);
EXPECT_NEAR(fa.alpha2, 0.0, 1e-12);
}
TEST(SphericalFunctional, DegenerateTriangle_LimitingAngles_S23TooLong)
{
// s23 > s12 + s31 → α1 = π
const double s12 = 0.4, s23 = 2.0, s31 = 0.4;
auto fa = spherical_angles(s12, s23, s31);
EXPECT_FALSE(fa.valid);
EXPECT_NEAR(fa.alpha1, PI, 1e-12)
<< "corner opposite over-long s23 must be π";
EXPECT_NEAR(fa.alpha2, 0.0, 1e-12);
EXPECT_NEAR(fa.alpha3, 0.0, 1e-12);
}
TEST(SphericalFunctional, DegenerateTriangle_LimitingAngles_S31TooLong)
{
// s31 > s12 + s23 → α2 = π
const double s12 = 0.4, s23 = 0.4, s31 = 2.0;
auto fa = spherical_angles(s12, s23, s31);
EXPECT_FALSE(fa.valid);
EXPECT_NEAR(fa.alpha2, PI, 1e-12)
<< "corner opposite over-long s31 must be π";
EXPECT_NEAR(fa.alpha1, 0.0, 1e-12);
EXPECT_NEAR(fa.alpha3, 0.0, 1e-12);
}