Build-verified attempt to port the Java EuclideanCyclicConvergenceTest (cathead, "circular hole edge" φ = π−0.1). - ✅ GREEN `CyclicCircularEdge_PhiEntersGradient_CatHead`: solver-free cross-validation that the circular-edge φ target enters the cyclic edge gradient exactly (ΔG_e = −Δφ_e at 1e-12; no other component moves). - ⏸️ DISABLED `CyclicCircularEdge_CatHead_JavaXVal`: the full Java convergence assertion (α_opp+α_opp = π−0.1), kept with golden semantics. Auto-activates once the edge-DOF Hessian lands. Build-verification finding: `newton_euclidean` -> `euclidean_hessian` throws "edge DOFs are not supported" — the cyclic full solve is blocked by a missing edge-DOF Euclidean Hessian (gradient supports edge DOFs, analytic Hessian does not). Spherical convergence (Tier-1 #2) is already covered by test_newton_solver. Documented in doc/reviewer/java-ignore-crossvalidation.md. All 13 EuclideanFunctional tests pass (1 disabled). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
556 lines
28 KiB
C++
556 lines
28 KiB
C++
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// 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 (ported)
|
||
// 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 "euclidean_hessian.hpp"
|
||
#include "clausen.hpp"
|
||
#include "mesh_io.hpp"
|
||
#include "newton_solver.hpp"
|
||
#include <gtest/gtest.h>
|
||
#include <cmath>
|
||
#include <vector>
|
||
#include <string>
|
||
|
||
using namespace conformallab;
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Cross-module Hessian check: euclidean_gradient() ↔ euclidean_hessian()
|
||
//
|
||
// Java @Ignore reason: "no Hessian implemented yet" — the Java functional
|
||
// test was written before the Hessian existed. In C++ the analytic
|
||
// cotangent-Laplace Hessian (euclidean_hessian.hpp, Phase 3f) is complete.
|
||
//
|
||
// This test verifies cross-module consistency:
|
||
// H[i,j] ≈ (G_i(x+ε·eⱼ) − G_i(x−ε·eⱼ)) / (2ε)
|
||
// using the gradient from euclidean_functional.hpp and the Hessian from
|
||
// euclidean_hessian.hpp. A bug in DOF-index mapping or sign convention
|
||
// that affects both modules independently would only be caught here.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(EuclideanFunctional, GradientCheck_Hessian)
|
||
{
|
||
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);
|
||
|
||
// hessian_check_euclidean: H[i,j] ≈ FD(G)[i,j] using euclidean_gradient()
|
||
EXPECT_TRUE(hessian_check_euclidean(mesh, x, maps))
|
||
<< "Cross-module: euclidean_gradient() and euclidean_hessian() are inconsistent";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 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";
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Golden-value oracle — pin the Euclidean angle formula and the 2·Л(α) energy
|
||
// term bit-for-bit against the upstream Java reference (EuclideanCyclicFunctional
|
||
// .triangleEnergyAndAlphas, lines 341-361), captured by running the compiled Java
|
||
// library (openjdk 17) with the real de.varylab…Clausen.Л on these exact edge
|
||
// lengths. Companion to HyperIdealGoldenJava and SphericalGoldenJava: locks the
|
||
// absolute angle/energy values against an independent implementation, catching
|
||
// silent index/sign drift the curl-free path-integral gradient check cannot see.
|
||
//
|
||
// To regenerate: /tmp/oracle/EucOracle.java. Values are Java printf %.17g.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
TEST(EuclideanGoldenJava, AngleAndLobachevskyEnergyFromLengths)
|
||
{
|
||
auto check = [](double l12, double l23, double l31,
|
||
double a1_g, double a2_g, double a3_g, double L_g) {
|
||
auto fa = euclidean_angles_from_lengths(l12, l23, l31);
|
||
EXPECT_TRUE(fa.valid);
|
||
EXPECT_NEAR(fa.alpha1, a1_g, 1e-12);
|
||
EXPECT_NEAR(fa.alpha2, a2_g, 1e-12);
|
||
EXPECT_NEAR(fa.alpha3, a3_g, 1e-12);
|
||
const double Lterm = 2.0 * Lobachevsky(fa.alpha1)
|
||
+ 2.0 * Lobachevsky(fa.alpha2)
|
||
+ 2.0 * Lobachevsky(fa.alpha3);
|
||
EXPECT_NEAR(Lterm, L_g, 1e-12);
|
||
};
|
||
check(1.0, 1.2, 0.9,
|
||
1.3637649752769678, 0.82416964552030680, 0.95365803279251860,
|
||
1.9456273836230942);
|
||
check(1.0, 1.0, 1.0,
|
||
1.0471975511965979, 1.0471975511965979, 1.0471975511965979,
|
||
2.0298832128193070);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// FULL-MESH golden oracle — the strongest cross-check: drives the REAL upstream
|
||
// EuclideanCyclicFunctional (openjdk 17) on a tetrahedron loaded from a shared
|
||
// OBJ (identical topology + geometry to make_tetrahedron()), and pins BOTH the
|
||
// per-vertex gradient G_v = Θ_v − Σα AND the energy difference ΔE = E(x) − E(0)
|
||
// bit-for-bit against it.
|
||
//
|
||
// This closes audit missing-test item 5 (full-mesh energy + gradient at a known
|
||
// x). It is genuinely independent of the C++ implementation in two ways:
|
||
// • the gradient is the upstream library's own analytic gradient (not an FD
|
||
// check, which only proves curl-freeness of the C++ self-consistent energy);
|
||
// • the energy is Java's CLOSED-FORM functional value, whereas C++ computes it
|
||
// as a Gauss-Legendre PATH INTEGRAL of its gradient — two different methods
|
||
// that must agree on ΔE (the initialEnergy constant and the φ·λ⁰ term cancel
|
||
// in the difference, and there are no edge DOFs here).
|
||
//
|
||
// Setup parity (verified against UnwrapUtility.prepareInvariantDataEuclidean):
|
||
// closed mesh, ALL 4 vertices variable (no pin), Θ_v = 2π, no edge DOFs,
|
||
// λ°_e = 2·log(|p_i − p_j|), per-vertex u(P) = 0.10·X − 0.07·Y + 0.13·Z.
|
||
//
|
||
// To regenerate: /tmp/oracle/{tet.obj,EucMeshOracle.java}. Values are Java %.17g.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
TEST(EuclideanGoldenJava, FullMeshGradientAndEnergy_Tetrahedron)
|
||
{
|
||
constexpr double TWO_PI = 2.0 * 3.14159265358979323846264338328;
|
||
|
||
auto mesh = make_tetrahedron(); // same 4 vertices as /tmp/oracle/tet.obj
|
||
auto maps = setup_euclidean_maps(mesh);
|
||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||
|
||
// All four vertices are DOFs (Java prepareInvariantData makes every interior
|
||
// vertex variable on a closed mesh); Θ_v = 2π; no edge DOFs.
|
||
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 = euclidean_gradient(mesh, x, maps);
|
||
|
||
// Java golden gradients keyed by vertex position (order-independent lookup).
|
||
struct GoldRow { double X, Y, Z, G; };
|
||
const GoldRow gold[4] = {
|
||
{ 1, 1, 1, 3.5277511803984396},
|
||
{ 1, -1, -1, 3.2837611905358440},
|
||
{-1, 1, -1, 2.3437485291237100},
|
||
{-1, -1, 1, 3.4111097143011780},
|
||
};
|
||
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";
|
||
}
|
||
|
||
// ΔE = E(x) − E(0). C++ path integral vs Java closed-form functional value.
|
||
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
|
||
const double dE = euclidean_energy(mesh, x, maps)
|
||
- euclidean_energy(mesh, x0, maps);
|
||
EXPECT_NEAR(dE, 0.15962619236187336, 1e-12);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Java cross-validation (Tier 1, GREEN) — circular-edge φ wiring (no solver)
|
||
//
|
||
// The "circular hole edge" of EuclideanCyclicConvergenceTest works by setting a
|
||
// non-default edge turn angle φ_e. Since the cyclic edge gradient is exactly
|
||
// G_e = α_opp(f⁺) + α_opp(f⁻) − φ_e,
|
||
// lowering φ_e by 0.1 must raise G_e by exactly 0.1 — independent of geometry —
|
||
// and must leave every other gradient component untouched. This pins the φ
|
||
// wiring without needing the (not-yet-implemented) edge-DOF Hessian, so it runs
|
||
// today and is the evaluation-level prerequisite of the DISABLED convergence
|
||
// test below.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
TEST(EuclideanFunctional, CyclicCircularEdge_PhiEntersGradient_CatHead)
|
||
{
|
||
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/cathead.obj";
|
||
ConformalMesh mesh;
|
||
ASSERT_NO_THROW(mesh = load_mesh(path)) << "cathead.obj not found: " << path;
|
||
|
||
auto maps = setup_euclidean_maps(mesh);
|
||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||
|
||
int idx = 0;
|
||
for (auto v : mesh.vertices())
|
||
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
|
||
for (auto e : mesh.edges())
|
||
maps.e_idx[e] = idx++;
|
||
const int n = idx;
|
||
ASSERT_GT(n, 0);
|
||
|
||
Edge_index e_circ{};
|
||
bool found = false;
|
||
for (auto e : mesh.edges()) {
|
||
auto h = mesh.halfedge(e);
|
||
auto ho = mesh.opposite(h);
|
||
if (mesh.is_border(h) || mesh.is_border(ho)) continue;
|
||
e_circ = e; found = true; break;
|
||
}
|
||
ASSERT_TRUE(found) << "no interior edge found on cathead";
|
||
const std::size_t ie = static_cast<std::size_t>(maps.e_idx[e_circ]);
|
||
|
||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||
|
||
maps.phi_e[e_circ] = PI;
|
||
auto G1 = euclidean_gradient(mesh, x, maps);
|
||
maps.phi_e[e_circ] = PI - 0.1;
|
||
auto G2 = euclidean_gradient(mesh, x, maps);
|
||
|
||
// Lowering φ_e by 0.1 raises exactly this edge's gradient component by 0.1.
|
||
EXPECT_NEAR(0.1, G2[ie] - G1[ie], 1e-12)
|
||
<< "circular-edge φ target not wired into the cyclic gradient";
|
||
|
||
// No other gradient component changes.
|
||
double max_other = 0.0;
|
||
for (std::size_t k = 0; k < G1.size(); ++k)
|
||
if (k != ie) max_other = std::max(max_other, std::abs(G2[k] - G1[k]));
|
||
EXPECT_LT(max_other, 1e-12)
|
||
<< "changing one φ_e perturbed unrelated gradient components";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Java cross-validation (Tier 1) — EuclideanCyclicConvergenceTest
|
||
//
|
||
// Ports de.varylab.discreteconformal.functional.EuclideanCyclicConvergenceTest:
|
||
// prescribe a non-default edge turn angle φ = π − 0.1 on one interior edge of
|
||
// cathead.obj ("circular hole edge"), solve the cyclic Euclidean functional
|
||
// (vertex + edge DOFs), then assert the realised opposite-corner-angle sum
|
||
// across that edge equals π − 0.1.
|
||
//
|
||
// The C++ edge gradient is G_e = α_opp(f⁺) + α_opp(f⁻) − φ_e, so at the
|
||
// solution (G_e = 0) the geometric angle sum equals φ_e — exactly the Java
|
||
// assertion `circularEdge.getAlpha() + opposite.getAlpha() == π − 0.1`.
|
||
//
|
||
// "Natural targets" first make x = 0 the equilibrium (so the *only* deviation
|
||
// is the prescribed φ); the test therefore FAILS if the solver ignores a
|
||
// non-default φ (the sum would stay at its natural value, not π − 0.1).
|
||
//
|
||
// ⚠️ DISABLED (build-verified 2026-05-29): blocked by a missing feature, not a
|
||
// bug. `newton_euclidean` uses `euclidean_hessian`, which throws
|
||
// "euclidean_hessian: edge DOFs are not supported
|
||
// (only the vertex-block cotangent Laplacian is implemented)".
|
||
// The cyclic functional needs vertex+edge DOFs, so the full Newton solve is not
|
||
// yet possible in C++ (the gradient supports edge DOFs; the analytic Hessian
|
||
// does not). PREREQUISITE: edge-DOF Euclidean Hessian — see
|
||
// `doc/roadmap/research-track.md` and `doc/reviewer/java-ignore-crossvalidation.md`.
|
||
// The golden semantics (φ = π−0.1 ⇒ realised α_opp+α_opp = π−0.1) are kept here
|
||
// so this test auto-activates once that Hessian lands; just drop the DISABLED_.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
TEST(EuclideanFunctional, DISABLED_CyclicCircularEdge_CatHead_JavaXVal)
|
||
{
|
||
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/cathead.obj";
|
||
ConformalMesh mesh;
|
||
ASSERT_NO_THROW(mesh = load_mesh(path)) << "cathead.obj not found: " << path;
|
||
|
||
auto maps = setup_euclidean_maps(mesh);
|
||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||
|
||
// Cyclic DOFs: interior vertices (border pinned) + all edges.
|
||
int idx = 0;
|
||
for (auto v : mesh.vertices())
|
||
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
|
||
for (auto e : mesh.edges())
|
||
maps.e_idx[e] = idx++;
|
||
const int n = idx;
|
||
ASSERT_GT(n, 0);
|
||
|
||
// Natural targets: set Θ_v / φ_e so that x = 0 is the equilibrium (G(0)=0).
|
||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||
auto G0 = euclidean_gradient(mesh, x0, maps);
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v];
|
||
if (iv >= 0) maps.theta_v[v] -= G0[static_cast<std::size_t>(iv)];
|
||
}
|
||
for (auto e : mesh.edges()) {
|
||
int ie = maps.e_idx[e];
|
||
if (ie >= 0) maps.phi_e[e] += G0[static_cast<std::size_t>(ie)];
|
||
}
|
||
|
||
// Pick one interior edge: both incident faces present, both endpoints interior.
|
||
Edge_index circular{};
|
||
bool found = false;
|
||
for (auto e : mesh.edges()) {
|
||
auto h = mesh.halfedge(e);
|
||
auto ho = mesh.opposite(h);
|
||
if (mesh.is_border(h) || mesh.is_border(ho)) continue;
|
||
if (mesh.is_border(mesh.source(h)) || mesh.is_border(mesh.target(h))) continue;
|
||
circular = e; found = true; break;
|
||
}
|
||
ASSERT_TRUE(found) << "no interior edge found on cathead";
|
||
const std::size_t ie = static_cast<std::size_t>(maps.e_idx[circular]);
|
||
|
||
// Prescribe the circular edge turn angle φ = π − 0.1 (Java CustomEdgeInfo.phi).
|
||
const double phi_target = PI - 0.1;
|
||
maps.phi_e[circular] = phi_target;
|
||
|
||
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-11, /*max_iter=*/200);
|
||
ASSERT_TRUE(res.converged)
|
||
<< "Newton did not converge; ||G||=" << res.grad_inf_norm;
|
||
|
||
// Realised geometric opposite-corner-angle sum = φ_e + G_e(x*) (= α_opp+α_opp).
|
||
auto Gf = euclidean_gradient(mesh, res.x, maps);
|
||
const double realised = maps.phi_e[circular] + Gf[ie];
|
||
EXPECT_NEAR(phi_target, realised, 1e-9)
|
||
<< "prescribed circular edge turn angle π−0.1 not realised at the solution";
|
||
}
|