Files
ConformalLabpp/code/tests/cgal/test_geometry_utils.cpp
Tarik Moussa e958afbd19
Some checks failed
C++ Tests / test-fast (push) Successful in 2m15s
C++ Tests / test-cgal (push) Has been skipped
C++ Tests / test-fast (pull_request) Successful in 2m23s
C++ Tests / test-cgal (pull_request) Failing after 2m36s
chore: translate all German text to English across code, docs, and CI
Unified the codebase language to English throughout. German text appeared
in code comments, test file headers, CI step names, and several markdown
documents. All natural-language text is now English; proper nouns
(Institut für Mathematik, Technische Universität Berlin) are unchanged.

Files changed:
- .gitea/workflows/cpp-tests.yml  — CI step names and job comments
- code/include/mesh_utils.hpp     — inline comment
- code/tests/cgal/CMakeLists.txt  — section comment block
- code/tests/cgal/test_geometry_utils.cpp — full file header + all test comments
- doc/math/references.md          — geometry-central section
- doc/math/validation.md          — Section 9 (geometry-central cross-validation)
- doc/roadmap/phases.md           — Optional geometry-central track (GC-1/2/3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:09:09 +02:00

511 lines
26 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_geometry_utils.cpp
//
// Port of the Java ConformalLab geometry utility tests.
//
// Java source Java test method Status
// ─────────────────────────────────────────────────────────────────────────────────────
// CuttinUtilityTest.java testIsInConvexTextureFace_False PORTED
// CuttinUtilityTest.java testIsInConvexTextureFace_True PORTED
// UnwrapUtilityTest.java testGetAngleReturnsPI PORTED
// ConvergenceUtilityTests.java testGetTextureCircumRadius PORTED
// ConvergenceUtilityTests.java testGetTextureTriangleArea PORTED
// ConvergenceUtilityTests.java testScaleInvariantCircumCircleRadius PORTED
// HomologyTest.java testHomology PORTED
// EuclideanLayoutTest.java testDoLayout PORTED
// EuclideanCyclicConvergenceTest.java testEuclideanConvergence PORTED
// SphericalConvergenceTest.java testSphericalConvergence PORTED
//
// ─── Geometric background ────────────────────────────────────────────────────────────
//
// Tests 12 Point-in-convex-triangle (2D UV space, barycentric sign method)
// Java: CuttingUtility.isInConvexTextureFace(pp, face, adapters)
// Note: Java test 2 has a 5-element T-array with w=0 (point at
// infinity), which is a typo in the original. Equivalent, well-formed
// coordinates are used here instead.
//
// Test 3 Corner angle for collinear vertices via the law of cosines.
// Java: UnwrapUtility.getAngle(edge, adapters) — returns the angle at
// the target vertex. For v0=(-1,0,0), v1=(0,0,0), v2=(1,0,0) the
// angle at v1 is exactly π (degenerate triangle inequality).
//
// Tests 45 2D circumradius and triangle area.
// Java: ConvergenceUtility.getTextureCircumCircleRadius(face)
// ConvergenceUtility.getTextureTriangleArea(face)
// Formulas: Area = |det([B-A, C-A])| / 2
// R = (a·b·c) / (4·Area)
//
// Test 6 Scale-invariant circumradius over a mesh.
// Java: ConvergenceUtility.getMaxMeanSumScaleInvariantCircumRadius(hds)
// Returns [max, mean, sum] of R_f / sqrt(total_texture_area).
// Invariant under uniform scaling of texture coordinates (tested with
// homogeneous weight w: position = (T[0]/w, T[1]/w)).
//
// Test 7 Genus-2 homology generators.
// Java: HomologyTest.testHomology (brezel2.obj)
// Expected: getGeneratorPaths(root).size() == 4 (2g = 4 for g = 2)
// C++: compute_cut_graph(mesh).cut_edge_indices.size() == 4
// Mesh: code/data/obj/brezel2.obj (V=2622, F=5248, χ=2, g=2)
// Path set at compile time via CONFORMALLAB_DATA_DIR (CMakeLists.txt).
//
// Tests 89 Layout edge-length preservation (tetraflat.obj).
// Java: EuclideanLayoutTest.testDoLayout
// After layout with u=0, UV edge lengths must equal 3D edge lengths (±1e-10).
//
// Test 10 Euclidean Newton on cathead.obj — convergence + angle deficit.
// Java: EuclideanLayoutTest.testLayout02 (130-value array for cathead.heml)
// C++: Newton from u=0, checks convergence + Σα_v ≈ 2π for all interior nodes.
//
// Test 11 Spherical Newton on octahedron — convergence + angle deficit.
// Java: SphericalConvergenceTest.testSphericalConvergence (octahedron, randomly
// perturbed radii, seed=1). C++: constructed regular octahedron, checks
// convergence and that Σα_v ≈ 2π (target for sphere after prepareInvariantData).
//
// ─────────────────────────────────────────────────────────────────────────────────────
#include "cut_graph.hpp"
#include "gauss_bonnet.hpp"
#include "conformal_mesh.hpp"
#include "mesh_builder.hpp"
#include "mesh_io.hpp"
#include "euclidean_functional.hpp"
#include "spherical_functional.hpp"
#include "newton_solver.hpp"
#include "layout.hpp"
#include <gtest/gtest.h>
#include <Eigen/Dense>
#include <array>
#include <cmath>
#include <string>
#include <vector>
using namespace conformallab;
// ─────────────────────────────────────────────────────────────────────────────
// Local geometry helper functions
// (ported from Java CuttingUtility / ConvergenceUtility)
// ─────────────────────────────────────────────────────────────────────────────
/// Point-in-triangle test (2D, barycentric sign method).
/// Returns true if p lies strictly inside or on the boundary of v0-v1-v2.
/// Java: CuttingUtility.isInConvexTextureFace
static bool point_in_triangle_2d(
Eigen::Vector2d p,
Eigen::Vector2d v0, Eigen::Vector2d v1, Eigen::Vector2d v2)
{
auto cross2d = [](Eigen::Vector2d a, Eigen::Vector2d b) -> double {
return a.x() * b.y() - a.y() * b.x();
};
double d0 = cross2d(v1 - v0, p - v0);
double d1 = cross2d(v2 - v1, p - v1);
double d2 = cross2d(v0 - v2, p - v2);
bool has_neg = (d0 < 0.0) || (d1 < 0.0) || (d2 < 0.0);
bool has_pos = (d0 > 0.0) || (d1 > 0.0) || (d2 > 0.0);
return !(has_neg && has_pos);
}
/// 2D triangle area (half cross product).
/// Java: ConvergenceUtility.getTextureTriangleArea
static double triangle_area_2d(
Eigen::Vector2d A, Eigen::Vector2d B, Eigen::Vector2d C)
{
return std::abs((B - A).x() * (C - A).y()
- (B - A).y() * (C - A).x()) * 0.5;
}
/// 2D circumradius: R = (a·b·c) / (4·Area).
/// Java: ConvergenceUtility.getTextureCircumCircleRadius
static double circumradius_2d(
Eigen::Vector2d A, Eigen::Vector2d B, Eigen::Vector2d C)
{
double a = (B - C).norm();
double b = (A - C).norm();
double c = (A - B).norm();
double area = triangle_area_2d(A, B, C);
if (area < 1e-14) return 0.0;
return (a * b * c) / (4.0 * area);
}
/// Scale-invariant circumradius for a mesh:
/// scale_R_f = R_f / sqrt(total_area)
/// Returns {max, mean, sum} over all faces.
/// Java: ConvergenceUtility.getMaxMeanSumScaleInvariantCircumRadius
///
/// Homogeneous coordinates: position = (x/w, y/w).
static std::array<double, 3> scale_invariant_circumradius_stats(
const std::vector<Eigen::Vector2d>& verts,
const std::vector<std::array<int, 3>>& faces)
{
// Total area
double total_area = 0.0;
for (auto& f : faces)
total_area += triangle_area_2d(verts[f[0]], verts[f[1]], verts[f[2]]);
if (total_area < 1e-14) return {0, 0, 0};
double sqrt_total = std::sqrt(total_area);
double max_r = 0.0, sum_r = 0.0;
for (auto& f : faces) {
double R = circumradius_2d(verts[f[0]], verts[f[1]], verts[f[2]]);
double sr = R / sqrt_total;
max_r = std::max(max_r, sr);
sum_r += sr;
}
double mean_r = sum_r / static_cast<double>(faces.size());
return {max_r, mean_r, sum_r};
}
// ════════════════════════════════════════════════════════════════════════════
// Tests 12 — CuttingUtility: point-in-convex-triangle (2D UV space)
// Java: CuttinUtilityTest.testIsInConvexTextureFace_False / _True
// ════════════════════════════════════════════════════════════════════════════
// Test 1: point lies far outside — exact Java coordinates
TEST(CuttingUtility, IsInConvexTextureFace_False)
{
// Tiny triangle around (0.7488, 0.0629) — Java test coordinates (T[3]=1, w=1)
Eigen::Vector2d v0(0.7488102998904661, 0.06293998610761144);
Eigen::Vector2d v1(0.7487811940754379, 0.06289451051246124);
Eigen::Vector2d v2(0.7487254625255592, 0.06291429499873116);
// Test point far away at (0.447, 0.000228)
Eigen::Vector2d pp(0.44661534423161037, 2.2808373704822393e-4);
EXPECT_FALSE(point_in_triangle_2d(pp, v0, v1, v2));
}
// Test 2: point lies inside
// Note: the original Java array p2 has 5 elements with w=0 (typo in the
// Java original). Equivalent, well-formed coordinates are used here
// that represent the same geometric scenario.
TEST(CuttingUtility, IsInConvexTextureFace_True)
{
// Triangle: (0,0) — (1e-8, 0) — (0, 1e-8)
Eigen::Vector2d v0(0.0, 0.0);
Eigen::Vector2d v1(1e-8, 0.0);
Eigen::Vector2d v2(0.0, 1e-8);
// Centroid of the triangle — always lies inside
Eigen::Vector2d pp(1e-8 / 3.0, 1e-8 / 3.0);
EXPECT_TRUE(point_in_triangle_2d(pp, v0, v1, v2));
}
// Additional: simple unit triangle for clarity
TEST(CuttingUtility, IsInConvexTextureFace_UnitTriangle_InAndOut)
{
Eigen::Vector2d v0(0.0, 0.0), v1(1.0, 0.0), v2(0.0, 1.0);
EXPECT_TRUE( point_in_triangle_2d(Eigen::Vector2d(0.25, 0.25), v0, v1, v2));
EXPECT_FALSE(point_in_triangle_2d(Eigen::Vector2d(2.0, 2.0), v0, v1, v2));
EXPECT_FALSE(point_in_triangle_2d(Eigen::Vector2d(0.6, 0.6), v0, v1, v2)); // beyond hypotenuse
}
// ════════════════════════════════════════════════════════════════════════════
// Test 3 — UnwrapUtility: corner angle = π for collinear vertices
// Java: UnwrapUtilityTest.testGetAngleReturnsPI
// ════════════════════════════════════════════════════════════════════════════
// Java: v0=(-1,0,0), v1=(0,0,0), v2=(1,0,0) collinear.
// Edge e from v2 to v1. getAngle(e) = angle at v1 = π.
//
// C++: law of cosines with edge lengths a=|v0-v1|=1, b=|v1-v2|=1, c=|v0-v2|=2.
// cos(γ_v1) = (a² + b² c²) / (2ab) = (1 + 1 4) / 2 = 1 → γ = π
TEST(UnwrapUtility, GetAngle_CollinearVertices_ReturnsPI)
{
const double a = 1.0; // |v0 v1|
const double b = 1.0; // |v1 v2|
const double c = 2.0; // |v0 v2| (= a + b, degenerate)
double cos_angle = (a*a + b*b - c*c) / (2.0 * a * b);
cos_angle = std::max(-1.0, std::min(1.0, cos_angle)); // numeric clamp
double angle = std::acos(cos_angle);
EXPECT_NEAR(M_PI, angle, 1e-15);
}
// Counter-check: equilateral triangle → angle = π/3
TEST(UnwrapUtility, GetAngle_EquilateralTriangle_ReturnsPiOver3)
{
const double s = 1.0;
double cos_angle = (s*s + s*s - s*s) / (2.0 * s * s); // = 0.5
double angle = std::acos(cos_angle);
EXPECT_NEAR(M_PI / 3.0, angle, 1e-15);
}
// ════════════════════════════════════════════════════════════════════════════
// Test 4 — ConvergenceUtility: 2D circumradius
// Java: ConvergenceUtilityTests.testGetTextureCircumRadius
// ════════════════════════════════════════════════════════════════════════════
TEST(ConvergenceUtility, TextureCircumRadius_RightTriangle)
{
// A=(0,0), B=(1,0), C=(0,1): right isosceles triangle
// Sides: 1, 1, √2. R = √2 / (4 · 0.5) = √2/2
Eigen::Vector2d A(0.0, 0.0), B(1.0, 0.0), C(0.0, 1.0);
EXPECT_NEAR(std::sqrt(2.0) / 2.0, circumradius_2d(A, B, C), 1e-10);
}
TEST(ConvergenceUtility, TextureCircumRadius_SmallerTriangle)
{
// A=(0,0), B=(0.5,0.5), C=(0,1): Java variant with B.T={0.5,0.5,0,1}
// Sides: √0.5, √0.5, 1. Area = 0.25. R = (√0.5·√0.5·1)/(4·0.25) = 0.5
Eigen::Vector2d A(0.0, 0.0), B(0.5, 0.5), C(0.0, 1.0);
EXPECT_NEAR(0.5, circumradius_2d(A, B, C), 1e-10);
}
// ════════════════════════════════════════════════════════════════════════════
// Test 5 — ConvergenceUtility: 2D triangle area
// Java: ConvergenceUtilityTests.testGetTextureTriangleArea
// ════════════════════════════════════════════════════════════════════════════
TEST(ConvergenceUtility, TextureTriangleArea_RightTriangle)
{
// A=(0,0), B=(1,0), C=(0,1) → area = 0.5
Eigen::Vector2d A(0.0, 0.0), B(1.0, 0.0), C(0.0, 1.0);
EXPECT_NEAR(0.5, triangle_area_2d(A, B, C), 1e-10);
}
TEST(ConvergenceUtility, TextureTriangleArea_SmallerTriangle)
{
// A=(0,0), B=(0.5,0.5), C=(0,1) → area = 0.25
Eigen::Vector2d A(0.0, 0.0), B(0.5, 0.5), C(0.0, 1.0);
EXPECT_NEAR(0.25, triangle_area_2d(A, B, C), 1e-10);
}
// ════════════════════════════════════════════════════════════════════════════
// Test 6 — ConvergenceUtility: scale-invariant circumradius
// Java: ConvergenceUtilityTests.testScaleInvariantCircumCircleRadius
//
// Mesh: 4 vertices (v1..v4), 2 faces (f1: v1-v2-v3, f2: v1-v3-v4).
// Scale-invariant quantity: R_f / sqrt(total_area) — invariant under
// uniform scaling (homogeneous weight w: pos = (x/w, y/w)).
// ════════════════════════════════════════════════════════════════════════════
TEST(ConvergenceUtility, ScaleInvariantCircumRadius_BaseScale)
{
// Positions at w=1 (T[3]=1): v1=(0,0), v2=(1,0), v3=(0,1), v4=(-1,0)
std::vector<Eigen::Vector2d> verts = {
{0.0, 0.0}, // v1
{1.0, 0.0}, // v2
{0.0, 1.0}, // v3
{-1.0, 0.0}, // v4
};
// f1: v1-v2-v3, f2: v1-v3-v4
std::vector<std::array<int, 3>> faces = { {0, 1, 2}, {0, 2, 3} };
// Per-face check (Java testGetTextureTriangleArea requirement)
EXPECT_NEAR(0.5, triangle_area_2d(verts[0], verts[1], verts[2]), 1e-10);
EXPECT_NEAR(0.5, triangle_area_2d(verts[0], verts[2], verts[3]), 1e-10);
auto [max_r, mean_r, sum_r] = scale_invariant_circumradius_stats(verts, faces);
// Expected: sin(π/4) = √2/2 for max and mean (both triangles identical)
EXPECT_NEAR(std::sin(M_PI / 4.0), max_r, 1e-10);
EXPECT_NEAR(std::sin(M_PI / 4.0), mean_r, 1e-10);
EXPECT_NEAR(2.0 * std::sin(M_PI / 4.0), sum_r, 1e-10);
}
TEST(ConvergenceUtility, ScaleInvariantCircumRadius_HalvedByW2_SameResult)
{
// Scaling by w=2: all positions halved (homogeneous coordinates)
// pos_scaled = (T[0]/2, T[1]/2)
std::vector<Eigen::Vector2d> verts = {
{0.0, 0.0}, // v1/2
{0.5, 0.0}, // v2/2
{0.0, 0.5}, // v3/2
{-0.5, 0.0}, // v4/2
};
std::vector<std::array<int, 3>> faces = { {0, 1, 2}, {0, 2, 3} };
// Areas are one quarter of the original (lengths halved → Area / 4)
EXPECT_NEAR(0.125, triangle_area_2d(verts[0], verts[1], verts[2]), 1e-10);
EXPECT_NEAR(0.125, triangle_area_2d(verts[0], verts[2], verts[3]), 1e-10);
auto [max_r, mean_r, sum_r] = scale_invariant_circumradius_stats(verts, faces);
// Scale-invariant quantity must be identical to the w=1 case
EXPECT_NEAR(std::sin(M_PI / 4.0), max_r, 1e-10);
EXPECT_NEAR(std::sin(M_PI / 4.0), mean_r, 1e-10);
EXPECT_NEAR(2.0 * std::sin(M_PI / 4.0), sum_r, 1e-10);
}
// ════════════════════════════════════════════════════════════════════════════
// Test 7 — HomologyTest: genus-2 homology generators
// Java: HomologyTest.testHomology
//
// Java test:
// CoHDS hds = TestUtility.readOBJ("brezel2.obj"); // genus-2 pretzel surface
// List<Set<CoEdge>> paths = getGeneratorPaths(hds.getVertex(0), weightAdapter);
// Assert.assertEquals(4, paths.size()); // 2g = 4 for g = 2
//
// C++ equivalent:
// ConformalMesh mesh = load_mesh("code/data/obj/brezel2.obj");
// CutGraph cg = compute_cut_graph(mesh);
// EXPECT_EQ(4u, cg.cut_edge_indices.size()); // 2g = 4
// EXPECT_EQ(2, cg.genus);
//
// Mesh: V=2622, F=5248, E=7872, χ=2, genus=2.
// Path via CONFORMALLAB_DATA_DIR (CMakeLists.txt: ${CMAKE_SOURCE_DIR}/data).
// ════════════════════════════════════════════════════════════════════════════
TEST(HomologyGenerators, Genus2_FourCutEdges)
{
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/brezel2.obj";
ConformalMesh mesh;
ASSERT_NO_THROW(mesh = load_mesh(path)) << "brezel2.obj not found at: " << path;
// Topology check: genus-2 surface has χ = -2.
EXPECT_EQ(-2, euler_characteristic(mesh));
// Tree-cotree algorithm must produce exactly 2g = 4 cut edges.
CutGraph cg = compute_cut_graph(mesh);
EXPECT_EQ(4u, cg.cut_edge_indices.size())
<< "Genus-2 surface must have 2g = 4 cut edges (homology generators).";
EXPECT_EQ(2, cg.genus);
}
// ════════════════════════════════════════════════════════════════════════════
// Tests 89 — EuclideanLayoutTest: edge-length preservation on tetraflat.obj
// Java: EuclideanLayoutTest.testDoLayout
//
// Java test:
// Vector u = new SparseVector(n); // u = 0 (no conformal factor)
// EuclideanLayout.doLayout(hds, fun, u);
// for (CoEdge e : hds.getEdges())
// assertEquals(Pn.distanceBetween(s.P, t.P), Pn.distanceBetween(s.T, t.T), 1E-11);
//
// Meaning: with u=0 the conformal factor is 0, so ℓ̃ = (no deformation).
// The layout must reproduce the original 3D edge lengths exactly.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanLayout, DoLayout_TetraFlat_EdgeLengthsPreserved)
{
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/tetraflat.obj";
ConformalMesh mesh;
ASSERT_NO_THROW(mesh = load_mesh(path)) << "tetraflat.obj not found at: " << path;
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// u = 0: no conformal deformation — layout must preserve 3D edge lengths exactly.
// tetraflat.obj is an open mesh; pin boundary vertices, sequential DOFs interior.
int idx = 0;
for (auto v : mesh.vertices())
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
const int n = idx;
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
Layout2D layout = euclidean_layout(mesh, x, maps);
// For every edge: UV length must equal 3D length within 1e-10.
for (auto e : mesh.edges()) {
auto h = mesh.halfedge(e);
auto vs = mesh.source(h);
auto vt = mesh.target(h);
auto ps = mesh.point(vs);
auto pt = mesh.point(vt);
double l3d = std::sqrt(
(pt.x()-ps.x())*(pt.x()-ps.x()) +
(pt.y()-ps.y())*(pt.y()-ps.y()) +
(pt.z()-ps.z())*(pt.z()-ps.z()));
auto us = layout.uv[vs.idx()];
auto ut = layout.uv[vt.idx()];
double luv = (ut - us).norm();
EXPECT_NEAR(l3d, luv, 1e-10)
<< "Edge " << e.idx() << ": 3D=" << l3d << " UV=" << luv;
}
}
// ════════════════════════════════════════════════════════════════════════════
// Test 10 — EuclideanCyclicConvergenceTest: Newton on cathead.obj
// Java: EuclideanLayoutTest.testLayout02 (130-value regression on cathead.heml)
// EuclideanCyclicConvergenceTest.testEuclideanConvergence
//
// Java test:
// EuclideanLayout.doLayout(hdsCat, fun, uCat);
// for (CoVertex v : interior vertices)
// assertEquals(2*PI, calculateAngleSum(v), 1E-6);
// for (CoEdge e : positiveEdges)
// assertEquals(fun.getNewLength(e, u), tLength, 1E-6);
//
// C++ equivalent: Newton converges on cathead.obj; interior angle sums ≈ 2π.
// The 130-value u-vector from the Java test is cathead-topology-specific and
// depends on vertex ordering in the Java CoHDS — not portable directly.
// Instead we verify the same mathematical invariant: convergence + angle sums.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanLayout, CatHead_NewtonConverges_AngleSumsTwoPi)
{
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 at: " << path;
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// cathead.obj is an open mesh (boundary present).
// Pin boundary vertices (v_idx = -1), assign sequential DOFs to interior.
int idx = 0;
for (auto v : mesh.vertices())
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
const int n = idx;
ASSERT_GT(n, 0) << "No interior vertices found in cathead.obj";
enforce_gauss_bonnet(mesh, maps);
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto res = newton_euclidean(mesh, x0, maps, 1e-8, 200);
EXPECT_TRUE(res.converged)
<< "Newton did not converge on cathead.obj (iterations=" << res.iterations
<< ", |G|inf=" << res.grad_inf_norm << ")";
EXPECT_LT(res.grad_inf_norm, 1e-8);
EXPECT_LT(res.iterations, 200);
// After convergence: all interior vertex angle sums must equal θ_v (2π for flat).
// Matches Java: assertEquals(2*PI, calculateAngleSum(v), 1E-6) for interior v.
auto G_final = euclidean_gradient(mesh, res.x, maps);
for (std::size_t i = 0; i < G_final.size(); ++i)
EXPECT_NEAR(0.0, G_final[i], 1e-6)
<< "Angle sum residual at DOF " << i << " = " << G_final[i];
}
// ════════════════════════════════════════════════════════════════════════════
// Test 11 — SphericalConvergenceTest: Newton on octahedron
// Java: SphericalConvergenceTest.testSphericalConvergence
//
// Java test:
// FunctionalTest.createOctahedron(hds, aSet);
// // randomly perturb vertex radii (seed=1)
// prepareInvariantDataHyperbolicAndSpherical(functional, hds, aSet, u);
// optimizer.minimize(u, opt);
// for (CoVertex v) assertEquals(2*PI, sum of angles at v, 1E-8);
//
// C++: regular octahedron (all vertices on S², no perturbation), spherical Newton,
// checks convergence + residual gradients (≡ angle deficit = 0 after convergence).
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalLayout, SphericalTetrahedron_NewtonConverges_AngleSumsTwoPi)
{
// Build a spherical tetrahedron (genus 0, 4 vertices, 4 faces).
// Java uses a randomly-perturbed octahedron; we use the canonical
// spherical tetrahedron from mesh_builder.hpp for reproducibility.
ConformalMesh mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps); // SphericalMaps version
int n = assign_vertex_dof_indices(mesh, maps); // pins gauge_vertex, assigns DOFs
// Note: enforce_gauss_bonnet not needed — natural theta from mesh satisfies Σ(2π-Θ)>0.
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto res = newton_spherical(mesh, x0, maps, 1e-8, 200);
EXPECT_TRUE(res.converged)
<< "Spherical Newton did not converge (iterations=" << res.iterations
<< ", |G|inf=" << res.grad_inf_norm << ")";
EXPECT_LT(res.grad_inf_norm, 1e-8);
// Angle sum residual = 0 after convergence (≡ each interior vertex has Σα = θ_v).
auto G_final = spherical_gradient(mesh, res.x, maps);
for (std::size_t i = 0; i < G_final.size(); ++i)
EXPECT_NEAR(0.0, G_final[i], 1e-6)
<< "Spherical angle sum residual at DOF " << i << " = " << G_final[i];
}