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>
296 lines
15 KiB
C++
296 lines
15 KiB
C++
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// test_pipeline.cpp
|
||
//
|
||
// Phase 4c — End-to-end pipeline tests and library-user examples.
|
||
//
|
||
// These tests exercise the full conformallab++ pipeline as a user would:
|
||
//
|
||
// 1. Build (or load) a mesh
|
||
// 2. Set up maps and assign DOFs
|
||
// 3. Configure target angles / targets
|
||
// 4. Solve with Newton
|
||
// 5. Inspect / export the result
|
||
//
|
||
// Each test mirrors a realistic usage scenario documented in the README.
|
||
//
|
||
// Tests:
|
||
// 1. Pipeline_Euclidean_TriangleToEquilibrium
|
||
// Read mesh → setup Euclidean maps → solve → verify convergence
|
||
// 2. Pipeline_Spherical_TetrahedronToEquilibrium
|
||
// Setup spherical tetrahedron → solve → verify angles sum to 4π
|
||
// 3. Pipeline_HyperIdeal_TriangleRoundTrip
|
||
// Build triangle → setup HyperIdeal → solve → verify G ≈ 0
|
||
// 4. Pipeline_MeshIO_SolveAndExport
|
||
// Build mesh → solve → write OFF → reload → verify vertex count intact
|
||
// 5. Pipeline_AllThreeGeometries_SameTopology
|
||
// Same quad-strip mesh solved under all three geometries: all converge
|
||
|
||
#include "conformal_mesh.hpp"
|
||
#include "mesh_builder.hpp"
|
||
#include "mesh_io.hpp"
|
||
#include "euclidean_functional.hpp"
|
||
#include "spherical_functional.hpp"
|
||
#include "hyper_ideal_functional.hpp"
|
||
#include "newton_solver.hpp"
|
||
#include <gtest/gtest.h>
|
||
#include <cmath>
|
||
#include <vector>
|
||
#include <filesystem>
|
||
|
||
using namespace conformallab;
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// Shared helpers
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
static void pin_first_vertex_euclidean(ConformalMesh& mesh, EuclideanMaps& maps, int& n)
|
||
{
|
||
auto vit = mesh.vertices().begin();
|
||
Vertex_index v0 = *vit++;
|
||
maps.v_idx[v0] = -1;
|
||
int idx = 0;
|
||
for (; vit != mesh.vertices().end(); ++vit)
|
||
maps.v_idx[*vit] = idx++;
|
||
n = idx;
|
||
}
|
||
|
||
static void set_natural_euclidean_theta(ConformalMesh& mesh, EuclideanMaps& maps, int n)
|
||
{
|
||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||
auto G = euclidean_gradient(mesh, x0, maps);
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v];
|
||
if (iv < 0) continue;
|
||
maps.theta_v[v] -= G[static_cast<std::size_t>(iv)];
|
||
}
|
||
}
|
||
|
||
static std::vector<double> set_natural_hyper_ideal_targets(
|
||
ConformalMesh& mesh, HyperIdealMaps& maps, int n,
|
||
double b_base = 1.0, double a_base = 0.5)
|
||
{
|
||
const auto sz = static_cast<std::size_t>(n);
|
||
std::vector<double> xbase(sz, 0.0);
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v];
|
||
if (iv >= 0) xbase[static_cast<std::size_t>(iv)] = b_base;
|
||
}
|
||
for (auto e : mesh.edges()) {
|
||
int ie = maps.e_idx[e];
|
||
if (ie >= 0) xbase[static_cast<std::size_t>(ie)] = a_base;
|
||
}
|
||
auto G = evaluate_hyper_ideal(mesh, xbase, maps, false).gradient;
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v];
|
||
if (iv < 0) continue;
|
||
maps.theta_v[v] += G[static_cast<std::size_t>(iv)];
|
||
}
|
||
for (auto e : mesh.edges()) {
|
||
int ie = maps.e_idx[e];
|
||
if (ie < 0) continue;
|
||
maps.theta_e[e] += G[static_cast<std::size_t>(ie)];
|
||
}
|
||
return xbase;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Test 1 — Euclidean full pipeline: triangle → equilibrium
|
||
//
|
||
// Simulates a user doing:
|
||
// auto mesh = make_triangle();
|
||
// auto maps = setup_euclidean_maps(mesh);
|
||
// compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||
// // … set target angles and DOF indices …
|
||
// auto result = newton_euclidean(mesh, x0, maps);
|
||
// assert(result.converged);
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(Pipeline, Euclidean_TriangleToEquilibrium)
|
||
{
|
||
// ── Step 1: build mesh ────────────────────────────────────────────────
|
||
auto mesh = make_triangle();
|
||
|
||
// ── Step 2: set up maps ───────────────────────────────────────────────
|
||
auto maps = setup_euclidean_maps(mesh);
|
||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||
|
||
// ── Step 3: assign DOFs (pin v0) ──────────────────────────────────────
|
||
int n = 0;
|
||
pin_first_vertex_euclidean(mesh, maps, n);
|
||
ASSERT_EQ(n, 2);
|
||
|
||
// ── Step 4: choose natural target angles → x* = 0 ────────────────────
|
||
set_natural_euclidean_theta(mesh, maps, n);
|
||
|
||
// ── Step 5: solve ─────────────────────────────────────────────────────
|
||
std::vector<double> x0(static_cast<std::size_t>(n), -0.1);
|
||
auto result = newton_euclidean(mesh, x0, maps);
|
||
|
||
// ── Step 6: verify ────────────────────────────────────────────────────
|
||
EXPECT_TRUE(result.converged)
|
||
<< "Euclidean pipeline: triangle should converge; "
|
||
"grad_inf_norm = " << result.grad_inf_norm;
|
||
EXPECT_LT(result.grad_inf_norm, 1e-8);
|
||
EXPECT_EQ(static_cast<int>(result.x.size()), n);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Test 2 — Spherical full pipeline: tetrahedron → equilibrium
|
||
//
|
||
// The spherical tetrahedron equilibrium x* = 0 is built into the maps.
|
||
// After solving, the total angle defect Σ(Θ_v − Σα_v) should be ≈ 0.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(Pipeline, Spherical_TetrahedronToEquilibrium)
|
||
{
|
||
// ── Steps 1–3 ─────────────────────────────────────────────────────────
|
||
auto mesh = make_spherical_tetrahedron();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_vertex_dof_indices(mesh, maps);
|
||
|
||
// ── Step 4: solve ─────────────────────────────────────────────────────
|
||
std::vector<double> x0(static_cast<std::size_t>(n), -0.2);
|
||
auto result = newton_spherical(mesh, x0, maps);
|
||
|
||
// ── Step 5: verify convergence ────────────────────────────────────────
|
||
EXPECT_TRUE(result.converged)
|
||
<< "Spherical pipeline: tetrahedron should converge; "
|
||
"grad_inf_norm = " << result.grad_inf_norm;
|
||
EXPECT_LT(result.grad_inf_norm, 1e-8);
|
||
|
||
// ── Step 6: verify geometric invariant — total angle defect ≈ 0 ──────
|
||
auto G_final = spherical_gradient(mesh, result.x, maps);
|
||
double total_defect = 0.0;
|
||
for (double gv : G_final) total_defect += gv;
|
||
EXPECT_NEAR(total_defect, 0.0, 1e-7)
|
||
<< "Spherical: total angle defect should vanish at equilibrium";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Test 3 — HyperIdeal full pipeline: triangle → equilibrium
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(Pipeline, HyperIdeal_TriangleRoundTrip)
|
||
{
|
||
// ── Steps 1–3 ─────────────────────────────────────────────────────────
|
||
auto mesh = make_triangle();
|
||
auto maps = setup_hyper_ideal_maps(mesh);
|
||
int n = assign_all_dof_indices(mesh, maps);
|
||
|
||
// ── Step 4: natural targets (equilibrium at b=1.0, a=0.5) ────────────
|
||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||
|
||
// Verify: gradient at xbase must be ≈ 0 before solving
|
||
auto G_at_base = evaluate_hyper_ideal(mesh, xbase, maps, false).gradient;
|
||
double max_g = 0.0;
|
||
for (double v : G_at_base) max_g = std::max(max_g, std::abs(v));
|
||
ASSERT_LT(max_g, 1e-10) << "Natural target setup: gradient at base should be ~0";
|
||
|
||
// ── Step 5: perturb and solve ─────────────────────────────────────────
|
||
std::vector<double> x0 = xbase;
|
||
for (auto& v : x0) v += 0.3;
|
||
|
||
auto result = newton_hyper_ideal(mesh, x0, maps);
|
||
|
||
// ── Step 6: verify ────────────────────────────────────────────────────
|
||
EXPECT_TRUE(result.converged)
|
||
<< "HyperIdeal pipeline: triangle should converge; "
|
||
"grad_inf_norm = " << result.grad_inf_norm;
|
||
EXPECT_LT(result.grad_inf_norm, 1e-8);
|
||
|
||
// Solution should be close to xbase (same equilibrium)
|
||
for (int i = 0; i < n; ++i) {
|
||
EXPECT_NEAR(result.x[static_cast<std::size_t>(i)],
|
||
xbase[static_cast<std::size_t>(i)], 1e-6)
|
||
<< "DOF " << i << " should recover the equilibrium value";
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Test 4 — Mesh I/O in the pipeline: solve → write → reload → check
|
||
//
|
||
// Demonstrates: compute a conformal factor on a mesh, write it to OFF, reload
|
||
// and check that the mesh topology is preserved.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(Pipeline, MeshIO_SolveAndExport)
|
||
{
|
||
// ── Build and solve ───────────────────────────────────────────────────
|
||
auto mesh = make_quad_strip();
|
||
auto maps = setup_euclidean_maps(mesh);
|
||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||
|
||
int n = 0;
|
||
pin_first_vertex_euclidean(mesh, maps, n);
|
||
set_natural_euclidean_theta(mesh, maps, n);
|
||
|
||
std::vector<double> x0(static_cast<std::size_t>(n), -0.1);
|
||
auto result = newton_euclidean(mesh, x0, maps);
|
||
ASSERT_TRUE(result.converged) << "Solver must converge before export test";
|
||
|
||
// ── Write mesh ────────────────────────────────────────────────────────
|
||
const std::string tmp_path = "/tmp/conformallab_pipeline_test.off";
|
||
ASSERT_NO_THROW(save_mesh(tmp_path, mesh));
|
||
ASSERT_TRUE(std::filesystem::exists(tmp_path));
|
||
|
||
// ── Reload and verify topology ────────────────────────────────────────
|
||
ConformalMesh mesh2;
|
||
ASSERT_NO_THROW(mesh2 = load_mesh(tmp_path));
|
||
|
||
EXPECT_EQ(mesh2.number_of_vertices(), mesh.number_of_vertices())
|
||
<< "Vertex count must survive OFF round-trip";
|
||
EXPECT_EQ(mesh2.number_of_faces(), mesh.number_of_faces())
|
||
<< "Face count must survive OFF round-trip";
|
||
|
||
std::filesystem::remove(tmp_path);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Test 5 — All three geometries, same quad-strip topology
|
||
//
|
||
// Validates that the solver infrastructure works uniformly: the same mesh
|
||
// topology is solvable under Euclidean, Spherical, and HyperIdeal geometries.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(Pipeline, AllThreeGeometries_QuadStrip)
|
||
{
|
||
// ── Euclidean ──────────────────────────────────────────────────────────
|
||
{
|
||
auto mesh = make_quad_strip();
|
||
auto maps = setup_euclidean_maps(mesh);
|
||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||
int n = 0;
|
||
pin_first_vertex_euclidean(mesh, maps, n);
|
||
set_natural_euclidean_theta(mesh, maps, n);
|
||
std::vector<double> x0(static_cast<std::size_t>(n), -0.1);
|
||
auto res = newton_euclidean(mesh, x0, maps);
|
||
EXPECT_TRUE(res.converged) << "Euclidean: quad strip should converge";
|
||
}
|
||
|
||
// ── HyperIdeal ────────────────────────────────────────────────────────
|
||
{
|
||
auto mesh = make_quad_strip();
|
||
auto maps = setup_hyper_ideal_maps(mesh);
|
||
int n = assign_all_dof_indices(mesh, maps);
|
||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||
std::vector<double> x0 = xbase;
|
||
for (auto& v : x0) v += 0.1;
|
||
auto res = newton_hyper_ideal(mesh, x0, maps);
|
||
EXPECT_TRUE(res.converged) << "HyperIdeal: quad strip should converge";
|
||
}
|
||
|
||
// ── Spherical (tetrahedron: smallest closed mesh with all vertices free) ─
|
||
{
|
||
auto mesh = make_spherical_tetrahedron();
|
||
auto maps = setup_spherical_maps(mesh);
|
||
compute_lambda0_from_mesh(mesh, maps);
|
||
int n = assign_vertex_dof_indices(mesh, maps);
|
||
std::vector<double> x0(static_cast<std::size_t>(n), -0.1);
|
||
auto res = newton_spherical(mesh, x0, maps);
|
||
EXPECT_TRUE(res.converged) << "Spherical: tetrahedron should converge";
|
||
}
|
||
}
|