Files
ConformalLabpp/code/tests/cgal/test_phase6.cpp
Tarik Moussa 4fc48b39f0 feat(phase6): exact hyperbolic layout, Gauss–Bonnet, cut graph, normalisation — 121 tests
New files:
- gauss_bonnet.hpp: euler_characteristic, genus, Σ(2π-Θ_v) sum/rhs/deficit,
  check_gauss_bonnet (throws), enforce_gauss_bonnet (correct sign: Δ=(lhs-rhs)/V)
- cut_graph.hpp: CutGraph struct + compute_cut_graph (tree-cotree, Erickson–Whittlesey
  2005); boundary edges correctly excluded from cut set
- test_phase6.cpp: 26 new tests (GaussBonnet ×8, CutGraph ×6, HyperbolicTrilateration
  ×4, Normalisation ×4 — all pass)

layout.hpp (Phase 6 rewrite):
- detail::trilaterate_hyp: exact Möbius + hyperbolic law of cosines replacing old tanh(d/2)
- detail::center_poincare_disk: Möbius centering for hyperbolic normalisation
- normalise_euclidean: centroid → origin + PCA major-axis rotation
- normalise_hyperbolic: Möbius centering in the Poincaré disk
- normalise_spherical: Rodrigues rotation → north pole
- euclidean_layout / hyper_ideal_layout: optional CutGraph* + HolonomyData* + normalise

Bug fixes caught by new tests:
- gauss_bonnet.hpp: enforce_gauss_bonnet had wrong sign for delta
- cut_graph.hpp: boundary edges were incorrectly marked as cut edges

121 tests pass, 2 skipped (Hessian stubs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:15:41 +02:00

407 lines
15 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_phase6.cpp
//
// Phase 6 — Tests for:
// - gauss_bonnet.hpp : euler_characteristic, genus, GB sum/rhs, check, enforce
// - cut_graph.hpp : compute_cut_graph (tree-cotree algorithm)
// - layout.hpp Phase6 : exact hyperbolic trilateration math
// normalise_euclidean (centroid + PCA)
// normalise_hyperbolic (Möbius centering)
#include "conformal_mesh.hpp"
#include "mesh_builder.hpp"
#include "euclidean_functional.hpp"
#include "spherical_functional.hpp"
#include "hyper_ideal_functional.hpp"
#include "newton_solver.hpp"
#include "gauss_bonnet.hpp"
#include "cut_graph.hpp"
#include "layout.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <complex>
#include <vector>
using namespace conformallab;
// mesh_builder.hpp provides make_triangle(), make_tetrahedron(), make_quad_strip().
// ════════════════════════════════════════════════════════════════════════════
// GaussBonnet — topology helpers
// ════════════════════════════════════════════════════════════════════════════
TEST(GaussBonnet, EulerCharacteristic_Triangle)
{
// V=3 E=3 F=1 → χ = 1
auto m = make_triangle();
EXPECT_EQ(euler_characteristic(m), 1);
}
TEST(GaussBonnet, EulerCharacteristic_QuadStrip)
{
// V=4 E=5 F=2 → χ = 1
auto m = make_quad_strip();
EXPECT_EQ(euler_characteristic(m), 1);
}
TEST(GaussBonnet, EulerCharacteristic_Tetrahedron)
{
// V=4 E=6 F=4 → χ = 2
auto m = make_tetrahedron();
EXPECT_EQ(euler_characteristic(m), 2);
}
TEST(GaussBonnet, Genus_Tetrahedron_IsZero)
{
auto m = make_tetrahedron();
EXPECT_EQ(genus(m), 0);
}
TEST(GaussBonnet, RHS_Triangle)
{
// χ=1 → 2π·χ = 2π
auto m = make_triangle();
EXPECT_NEAR(gauss_bonnet_rhs(m), 2.0 * M_PI, 1e-12);
}
TEST(GaussBonnet, RHS_Tetrahedron)
{
// χ=2 → 2π·χ = 4π
auto m = make_tetrahedron();
EXPECT_NEAR(gauss_bonnet_rhs(m), 4.0 * M_PI, 1e-12);
}
// ════════════════════════════════════════════════════════════════════════════
// GaussBonnet — sum / deficit / check / enforce
// ════════════════════════════════════════════════════════════════════════════
TEST(GaussBonnet, DeficitIsNonZeroWithDefaultTheta)
{
// Default theta_v = 2π at all V=4 vertices of quad-strip →
// Σ(2π2π) = 0, rhs = 2π·1 = 2π → deficit = 2π ≠ 0.
auto m = make_quad_strip();
auto maps = setup_euclidean_maps(m);
double def = gauss_bonnet_deficit(m, maps);
EXPECT_GT(std::abs(def), 1e-6);
}
TEST(GaussBonnet, EnforceAdjustsTheta_Deficit_BecomesZero)
{
auto m = make_quad_strip();
auto maps = setup_euclidean_maps(m);
// Set clearly wrong theta
for (auto v : m.vertices()) maps.theta_v[v] = M_PI;
EXPECT_GT(std::abs(gauss_bonnet_deficit(m, maps)), 1e-6);
enforce_gauss_bonnet(m, maps);
EXPECT_NEAR(gauss_bonnet_deficit(m, maps), 0.0, 1e-10);
}
TEST(GaussBonnet, CheckThrowsWhenViolated)
{
auto m = make_triangle();
auto maps = setup_euclidean_maps(m);
// theta_v = 2π everywhere → sum = 0, rhs = 2π → |deficit| = 2π
EXPECT_THROW(check_gauss_bonnet(m, maps), std::runtime_error);
}
TEST(GaussBonnet, CheckPassesAfterEnforce)
{
auto m = make_triangle();
auto maps = setup_euclidean_maps(m);
enforce_gauss_bonnet(m, maps);
EXPECT_NO_THROW(check_gauss_bonnet(m, maps));
}
TEST(GaussBonnet, SumAfterEnforce_EqualsRHS)
{
auto m = make_tetrahedron();
auto maps = setup_euclidean_maps(m);
// theta_v starts at 2π everywhere; sum = 0, rhs = 4π
enforce_gauss_bonnet(m, maps);
double lhs = gauss_bonnet_sum(m, maps);
double rhs = gauss_bonnet_rhs(m);
EXPECT_NEAR(lhs, rhs, 1e-10);
}
TEST(GaussBonnet, ManuallySetAnalyticalTheta_PassesCheck)
{
// Single triangle (χ=1): need Σ(2πΘ_v) = 2π.
// Set each of the 3 boundary vertices to Θ_v = 4π/3.
// Then Σ(2π 4π/3) = 3·(2π/3) = 2π. ✓
auto m = make_triangle();
auto maps = setup_euclidean_maps(m);
for (auto v : m.vertices()) maps.theta_v[v] = 4.0 * M_PI / 3.0;
EXPECT_NO_THROW(check_gauss_bonnet(m, maps));
}
// ════════════════════════════════════════════════════════════════════════════
// CutGraph — tree-cotree algorithm
// ════════════════════════════════════════════════════════════════════════════
TEST(CutGraph, Triangle_ZeroCutEdges)
{
// Open disk → genus 0 → 0 cut edges.
auto m = make_triangle();
auto cg = compute_cut_graph(m);
EXPECT_EQ(cg.cut_edge_indices.size(), 0u);
}
TEST(CutGraph, QuadStrip_ZeroCutEdges)
{
// Open disk → genus 0 → 0 cut edges.
auto m = make_quad_strip();
auto cg = compute_cut_graph(m);
EXPECT_EQ(cg.cut_edge_indices.size(), 0u);
}
TEST(CutGraph, Tetrahedron_ZeroCutEdges_ClosedSphere)
{
// Closed genus-0 surface → 2g = 0 cut edges.
auto m = make_tetrahedron();
auto cg = compute_cut_graph(m);
EXPECT_EQ(cg.cut_edge_indices.size(), 0u);
EXPECT_EQ(cg.genus, 0);
}
TEST(CutGraph, FlagsVsIndicesConsistent)
{
// Every index in cut_edge_indices has flag=true;
// count of true flags == number of indices.
auto m = make_tetrahedron();
auto cg = compute_cut_graph(m);
for (std::size_t idx : cg.cut_edge_indices)
EXPECT_TRUE(cg.cut_edge_flags[idx]);
std::size_t count = 0;
for (bool b : cg.cut_edge_flags) count += b ? 1u : 0u;
EXPECT_EQ(count, cg.cut_edge_indices.size());
}
TEST(CutGraph, IsCutMethodMatchesFlags)
{
auto m = make_tetrahedron();
auto cg = compute_cut_graph(m);
for (auto e : m.edges()) {
bool flag = cg.cut_edge_flags[static_cast<std::size_t>(e.idx())];
EXPECT_EQ(cg.is_cut(e), flag);
}
}
TEST(CutGraph, FlagsVectorHasCorrectSize)
{
auto m = make_quad_strip();
auto cg = compute_cut_graph(m);
EXPECT_EQ(cg.cut_edge_flags.size(), m.number_of_edges());
}
// ════════════════════════════════════════════════════════════════════════════
// Exact hyperbolic trilateration — Möbius + law of cosines
// ════════════════════════════════════════════════════════════════════════════
namespace {
/// Poincaré-disk hyperbolic distance.
double hyp_dist_disk(Eigen::Vector2d a, Eigen::Vector2d b)
{
double num = (a.x()-b.x())*(a.x()-b.x()) + (a.y()-b.y())*(a.y()-b.y());
double da = 1.0 - a.x()*a.x() - a.y()*a.y();
double db_ = 1.0 - b.x()*b.x() - b.y()*b.y();
double arg = 1.0 + 2.0*num/(da*db_);
return std::acosh(std::max(1.0, arg));
}
/// Reproduce the exact trilateration formula from layout.hpp detail namespace.
Eigen::Vector2d trilaterate_hyp(Eigen::Vector2d pa, Eigen::Vector2d pb,
double D, double da, double db)
{
if (D < 1e-12 || da < 1e-12) return pa;
if (std::sinh(da) * std::sinh(D) < 1e-14) return pa;
using C = std::complex<double>;
auto mobius_fwd = [](C z, C center) -> C {
return (z - center) / (C(1.0) - std::conj(center) * z);
};
auto mobius_inv = [](C w, C center) -> C {
return (w + center) / (C(1.0) + std::conj(center) * w);
};
C a(pa.x(), pa.y()), b(pb.x(), pb.y());
C b_mapped = mobius_fwd(b, a);
double theta_b = std::arg(b_mapped);
double cos_alpha = (std::cosh(da) * std::cosh(D) - std::cosh(db))
/ (std::sinh(da) * std::sinh(D));
cos_alpha = std::max(-1.0, std::min(1.0, cos_alpha));
double alpha = std::acos(cos_alpha);
C pc_origin = std::tanh(da * 0.5) * std::exp(C(0.0, theta_b + alpha));
C pc_c = mobius_inv(pc_origin, a);
return Eigen::Vector2d(pc_c.real(), pc_c.imag());
}
} // namespace
TEST(HyperbolicTrilateration, RecoveredDistances_Small)
{
// pa at origin, pb at tanh(D/2) on x-axis.
double D = 0.8, da = 0.5, db = 0.6;
Eigen::Vector2d pa(0.0, 0.0);
Eigen::Vector2d pb(std::tanh(D * 0.5), 0.0);
Eigen::Vector2d pc = trilaterate_hyp(pa, pb, D, da, db);
EXPECT_NEAR(hyp_dist_disk(pa, pb), D, 1e-10);
EXPECT_NEAR(hyp_dist_disk(pa, pc), da, 1e-9);
EXPECT_NEAR(hyp_dist_disk(pb, pc), db, 1e-9);
}
TEST(HyperbolicTrilateration, RecoveredDistances_Large)
{
double D = 2.0, da = 1.5, db = 1.0;
Eigen::Vector2d pa(0.0, 0.0);
Eigen::Vector2d pb(std::tanh(D * 0.5), 0.0);
Eigen::Vector2d pc = trilaterate_hyp(pa, pb, D, da, db);
EXPECT_NEAR(hyp_dist_disk(pa, pc), da, 1e-8);
EXPECT_NEAR(hyp_dist_disk(pb, pc), db, 1e-8);
}
TEST(HyperbolicTrilateration, ResultInsidePoincareDisk)
{
double D = 1.2, da = 0.9, db = 0.7;
Eigen::Vector2d pa(0.0, 0.0);
Eigen::Vector2d pb(std::tanh(D * 0.5), 0.0);
Eigen::Vector2d pc = trilaterate_hyp(pa, pb, D, da, db);
EXPECT_LT(pc.norm(), 1.0);
}
TEST(HyperbolicTrilateration, OffOrigin_StillCorrect)
{
// Place pa somewhere in the disk (not at origin) to test Möbius map.
double D = 0.6, da = 0.4, db = 0.5;
// pa at (0.3, 0.0) in Poincaré coords.
Eigen::Vector2d pa(0.3, 0.0);
// pb at distance D from pa — compute pb in Poincaré disk:
// Möbius inverse: tanh(D/2) maps back from origin frame.
using C = std::complex<double>;
C a_c(pa.x(), pa.y());
C pb_c = (std::tanh(D*0.5) + a_c) / (C(1.0) + std::conj(a_c) * std::tanh(D*0.5));
Eigen::Vector2d pb(pb_c.real(), pb_c.imag());
Eigen::Vector2d pc = trilaterate_hyp(pa, pb, D, da, db);
EXPECT_LT(pc.norm(), 1.0);
EXPECT_NEAR(hyp_dist_disk(pa, pc), da, 1e-8);
EXPECT_NEAR(hyp_dist_disk(pb, pc), db, 1e-8);
}
// ════════════════════════════════════════════════════════════════════════════
// Normalisation — euclidean_layout (centroid) + hyper_ideal_layout (Möbius)
// ════════════════════════════════════════════════════════════════════════════
/// Build a solved euclidean layout for the quad-strip at the natural equilibrium.
static Layout2D make_euclidean_layout_normalised(bool normalise)
{
auto mesh = make_quad_strip();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// Pin first vertex; assign free DOF indices to all others.
auto vit = mesh.vertices().begin();
maps.v_idx[*vit++] = -1;
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++;
const int n = idx;
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
// Adjust theta so G(x=0) = 0 (natural equilibrium).
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)];
}
auto res = newton_euclidean(mesh, x0, maps, 1e-10, 100);
return euclidean_layout(mesh, res.x, maps, nullptr, nullptr, normalise);
}
TEST(Normalisation, Euclidean_CentroidAtOrigin)
{
auto L = make_euclidean_layout_normalised(true);
ASSERT_GT(L.uv.size(), 0u);
double cx = 0.0, cy = 0.0;
for (auto& p : L.uv) { cx += p.x(); cy += p.y(); }
int n = static_cast<int>(L.uv.size());
EXPECT_NEAR(cx / n, 0.0, 1e-9);
EXPECT_NEAR(cy / n, 0.0, 1e-9);
}
TEST(Normalisation, Euclidean_NormalisePreservesEdgeLengthRatios)
{
auto L_no = make_euclidean_layout_normalised(false);
auto L_yes = make_euclidean_layout_normalised(true);
// Ratio of the lengths of the first two edges must be preserved.
ASSERT_GE(L_no.uv.size(), 4u);
// edges 0→1 and 0→2 (the two edges from vertex 0 in the quad-strip)
double len0_no = (L_no.uv[1] - L_no.uv[0]).norm();
double len1_no = (L_no.uv[2] - L_no.uv[0]).norm();
double len0_yes = (L_yes.uv[1] - L_yes.uv[0]).norm();
double len1_yes = (L_yes.uv[2] - L_yes.uv[0]).norm();
if (len1_no > 1e-12 && len1_yes > 1e-12) {
double ratio_no = len0_no / len1_no;
double ratio_yes = len0_yes / len1_yes;
EXPECT_NEAR(ratio_no, ratio_yes, 1e-9);
}
}
/// Build a solved hyper-ideal layout for a triangle at natural equilibrium.
static Layout2D make_hyper_ideal_layout_normalised(bool normalise)
{
auto mesh = make_triangle();
auto maps = setup_hyper_ideal_maps(mesh);
int n = assign_all_dof_indices(mesh, maps);
std::vector<double> xbase(static_cast<std::size_t>(n));
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v]; if (iv >= 0) xbase[iv] = 1.0;
}
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e]; if (ie >= 0) xbase[ie] = 0.5;
}
auto G0 = evaluate_hyper_ideal(mesh, xbase, maps, false).gradient;
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v]; if (iv >= 0) maps.theta_v[v] += G0[iv];
}
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e]; if (ie >= 0) maps.theta_e[e] += G0[ie];
}
auto res = newton_hyper_ideal(mesh, xbase, maps, 1e-9, 100);
return hyper_ideal_layout(mesh, res.x, maps, nullptr, nullptr, normalise);
}
TEST(Normalisation, HyperIdeal_PositionsInsidePoincareDisk)
{
auto L = make_hyper_ideal_layout_normalised(true);
ASSERT_GT(L.uv.size(), 0u);
for (auto& p : L.uv) {
EXPECT_LT(p.norm(), 1.0 + 1e-9)
<< "Vertex outside Poincaré disk: r=" << p.norm();
}
}
TEST(Normalisation, HyperIdeal_CenteringReducesAverageRadius)
{
// After Möbius centering the average Euclidean radius inside the disk
// must be ≤ the unconstrained average.
auto L_no = make_hyper_ideal_layout_normalised(false);
auto L_yes = make_hyper_ideal_layout_normalised(true);
int n = static_cast<int>(L_no.uv.size());
double r_no = 0.0, r_yes = 0.0;
for (int i = 0; i < n; ++i) {
r_no += L_no.uv[i].norm();
r_yes += L_yes.uv[i].norm();
}
EXPECT_LE(r_yes / n, r_no / n + 1e-9);
}