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>
410 lines
15 KiB
C++
410 lines
15 KiB
C++
// 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);
|
||
}
|