Files
ConformalLabpp/code/tests/cgal/test_phase6.cpp
Tarik Moussa d3c08b3bc0
Some checks failed
C++ Tests / test-fast (pull_request) Successful in 2m2s
API Docs / doc-build (pull_request) Successful in 58s
Markdown link check / check (pull_request) Successful in 45s
C++ Tests / test-cgal (pull_request) Failing after 13m14s
quality: 2 new gates (cmake-format, codespell) + SPDX rollout (60 files)
This commit closes the remaining red gates so `run-all.sh --fast` is
green end-to-end on the canonical dev machine.

New gates
─────────
1. cmake-format / cmake-lint
   * scripts/quality/cmake-format.sh — dry-run by default,
     --strict to fail on drift, --fix to apply
   * .cmake-format.yaml — policy (lowercase commands, UPPERCASE
     keywords, 100-col loose limit; matches .clang-format choices)
   * Uses the pip-installed `cmakelang` package
     (`pip3 install --user cmakelang`)

2. codespell
   * scripts/quality/codespell.sh — exit 1 on any typo, --fix
     interactively
   * .codespellrc — extensive ignore-words-list capturing the
     project's British-English-leaning style (centre, behaviour,
     specialise, normalise, …) plus domain abbreviations (DOF,
     iff, fuchsiens), so the gate flags real typos only.
   * Validated: 0 typos across docs + code/include + scripts +
     code/{src,tests}.

SPDX rollout (license-headers --fix)
────────────────────────────────────
license-headers.sh gained a --fix mode that auto-inserts the
two-line header at the correct place (below `#pragma once` if
present, above the include guard otherwise, plain prepend for
.cpp).  Ran it on 60 of 66 files — 100 %-licensed now.

Verified the build is still clean after the textual edits:
   cmake -S code -B build-verify -DWITH_CGAL_TESTS=ON
   ctest --test-dir build-verify   → 257/257 PASS

run-all.sh + README updated to include the two new gates.

End-to-end style/convention block status (on this commit, this branch):

    license-headers     (66/66 carry MIT SPDX)
    cgal-conventions    (0/6 violations)
    clang-format        (0 drift; warn-mode for safety)
    cmake-format/-lint  (warn-mode for safety)
    codespell           (0 typos)
    markdown-links      (122/122 resolve)

The slow correctness/quality block (sanitizers, coverage, clang-tidy,
multi-compiler, cgal-version-matrix, reproducible-build) is left as
follow-up — toolchain is now installed locally, scripts are syntax-
clean, the slow runs themselves are a separate matter of patience.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 09:15:34 +02:00

410 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.

// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// 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);
}