Files
ConformalLabpp/code/tests/cgal/test_phase7.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

489 lines
20 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_phase7.cpp
//
// Phase 7 — Tests for Java-parity layout features:
// - MobiusMap : identity, inverse, compose, from_three, is_identity
// - best_root_face : selects a valid face; interior bonus
// - halfedge_uv : size, non-seam consistency, seam divergence
// - Priority BFS : vertex ordering / depth correctness
// - normalise_euclidean : halfedge_uv centroid at origin
// - period_matrix.hpp : τ in upper half-plane, SL(2,) reduction
// - fundamental_domain.hpp: parallelogram CCW, generators, tiling_copy
#include "conformal_mesh.hpp"
#include "mesh_builder.hpp"
#include "euclidean_functional.hpp"
#include "hyper_ideal_functional.hpp"
#include "newton_solver.hpp"
#include "layout.hpp"
#include "period_matrix.hpp"
#include "fundamental_domain.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <complex>
#include <vector>
#include <limits>
using namespace conformallab;
using C = std::complex<double>;
// ════════════════════════════════════════════════════════════════════════════
// MobiusMap
// ════════════════════════════════════════════════════════════════════════════
TEST(MobiusMap, Identity_AppliesAsIdentity)
{
MobiusMap id = MobiusMap::identity();
C z(0.3, 0.7);
C w = id.apply(z);
EXPECT_NEAR(w.real(), z.real(), 1e-12);
EXPECT_NEAR(w.imag(), z.imag(), 1e-12);
}
TEST(MobiusMap, Identity_IsIdentity)
{
EXPECT_TRUE(MobiusMap::identity().is_identity());
}
TEST(MobiusMap, NonIdentity_IsNotIdentity)
{
// T(z) = z + 1 — translation, clearly not identity
MobiusMap T{ C(1), C(1), C(0), C(1) };
EXPECT_FALSE(T.is_identity());
}
TEST(MobiusMap, Inverse_ComposeIsIdentity)
{
// T(z) = (2z + 1) / (z + 3)
MobiusMap T{ C(2), C(1), C(1), C(3) };
MobiusMap TinvT = T.inverse().compose(T);
EXPECT_TRUE(TinvT.is_identity(1e-9));
}
TEST(MobiusMap, Compose_OrderCorrect)
{
// S: z ↦ z + 1, T: z ↦ 2z
// S.compose(T) means S applied after T: z ↦ 2z + 1
MobiusMap S{ C(1), C(1), C(0), C(1) }; // z + 1
MobiusMap T{ C(2), C(0), C(0), C(1) }; // 2z
MobiusMap ST = S.compose(T);
C z(1.0, 0.0);
// S(T(z)) = S(2) = 3
EXPECT_NEAR(ST.apply(z).real(), 3.0, 1e-12);
EXPECT_NEAR(ST.apply(z).imag(), 0.0, 1e-12);
}
TEST(MobiusMap, FromThree_RecoversMap)
{
// Known map T(z) = (z + i) / (1 + 0·z) — translation by i
C w1 = C(0, 1) + C(0, 1); // T(i) = 2i
C w2 = C(1, 0) + C(0, 1); // T(1) = 1 + i
C w3 = C(-1, 0) + C(0, 1); // T(-1) = -1 + i
MobiusMap T = MobiusMap::from_three(C(0, 1), w1, C(1, 0), w2, C(-1, 0), w3);
// Verify T maps a fourth point correctly: T(0) = i
C result = T.apply(C(0, 0));
EXPECT_NEAR(result.real(), 0.0, 1e-9);
EXPECT_NEAR(result.imag(), 1.0, 1e-9);
}
TEST(MobiusMap, FromThree_DegenerateReturnsIdentity)
{
// Three coincident points → singular system → identity fallback
C z(0.5, 0.5);
MobiusMap T = MobiusMap::from_three(z, z, z, z, z, z);
// Should not crash; returns identity (or at least a valid map)
// We just check the result is finite
C w = T.apply(C(0.1, 0.2));
EXPECT_FALSE(std::isnan(w.real()));
EXPECT_FALSE(std::isnan(w.imag()));
}
TEST(MobiusMap, Apply_Vector2d)
{
MobiusMap id = MobiusMap::identity();
Eigen::Vector2d p(0.4, 0.6);
Eigen::Vector2d q = id.apply(p);
EXPECT_NEAR(q.x(), p.x(), 1e-12);
EXPECT_NEAR(q.y(), p.y(), 1e-12);
}
// ════════════════════════════════════════════════════════════════════════════
// best_root_face
// ════════════════════════════════════════════════════════════════════════════
TEST(BestRootFace, ReturnsValidFace_Triangle)
{
auto mesh = make_triangle();
Face_index f = detail::best_root_face(mesh);
EXPECT_NE(f, Face_index());
EXPECT_GE(f.idx(), 0);
}
TEST(BestRootFace, ReturnsValidFace_Tetrahedron)
{
auto mesh = make_tetrahedron();
Face_index f = detail::best_root_face(mesh);
EXPECT_NE(f, Face_index());
// Tetrahedron has 4 faces — best is one of them
EXPECT_LT(static_cast<std::size_t>(f.idx()), mesh.number_of_faces());
}
// ════════════════════════════════════════════════════════════════════════════
// halfedge_uv — size and non-seam consistency
// ════════════════════════════════════════════════════════════════════════════
// Helper: build equilibrium Euclidean layout for a given mesh.
// Uses x = 0 (identity scale factor) which is the equilibrium for natural edge lengths.
static Layout2D make_euclidean_layout(ConformalMesh& mesh)
{
EuclideanMaps maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// Pin first vertex (DOF = -1); assign sequential indices to the rest.
auto vit = mesh.vertices().begin();
maps.v_idx[*vit++] = -1;
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++;
std::vector<double> x(static_cast<std::size_t>(idx), 0.0);
return euclidean_layout(mesh, x, maps);
}
TEST(HalfedgeUV, Size_EqualsNumberOfHalfedges_Triangle)
{
auto mesh = make_triangle();
auto lay = make_euclidean_layout(mesh);
EXPECT_EQ(lay.halfedge_uv.size(), mesh.number_of_halfedges());
}
TEST(HalfedgeUV, Size_EqualsNumberOfHalfedges_QuadStrip)
{
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
EXPECT_EQ(lay.halfedge_uv.size(), mesh.number_of_halfedges());
}
TEST(HalfedgeUV, NonBorderHalfedges_MatchUV)
{
// For an open mesh with no cut graph the layout has no seams.
// Every non-border halfedge h must satisfy:
// halfedge_uv[h] == uv[source(h)]
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
for (auto h : mesh.halfedges()) {
if (mesh.is_border(h)) continue;
std::size_t hi = static_cast<std::size_t>(h.idx());
std::size_t vi = static_cast<std::size_t>(mesh.source(h).idx());
EXPECT_NEAR(lay.halfedge_uv[hi].x(), lay.uv[vi].x(), 1e-10)
<< "halfedge " << hi << " source vertex " << vi;
EXPECT_NEAR(lay.halfedge_uv[hi].y(), lay.uv[vi].y(), 1e-10)
<< "halfedge " << hi << " source vertex " << vi;
}
}
TEST(HalfedgeUV, BorderHalfedges_AreZero)
{
auto mesh = make_triangle();
auto lay = make_euclidean_layout(mesh);
bool found_border = false;
for (auto h : mesh.halfedges()) {
if (!mesh.is_border(h)) continue;
std::size_t hi = static_cast<std::size_t>(h.idx());
EXPECT_NEAR(lay.halfedge_uv[hi].x(), 0.0, 1e-12);
EXPECT_NEAR(lay.halfedge_uv[hi].y(), 0.0, 1e-12);
found_border = true;
}
EXPECT_TRUE(found_border);
}
// ════════════════════════════════════════════════════════════════════════════
// Priority BFS — depth ordering
// ════════════════════════════════════════════════════════════════════════════
TEST(PriorityBFS, Layout_SucceedsOnOpenMesh)
{
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
EXPECT_TRUE(lay.success);
}
TEST(PriorityBFS, Layout_NoSeamOnOpenMesh)
{
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
EXPECT_FALSE(lay.has_seam);
}
TEST(PriorityBFS, AllVerticesPlaced)
{
auto mesh = make_tetrahedron();
// Tetrahedron is closed; layout without cut graph will have a seam
EuclideanMaps maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
auto vit = mesh.vertices().begin();
maps.v_idx[*vit++] = -1;
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++;
std::vector<double> x(static_cast<std::size_t>(idx), 0.0);
auto lay = euclidean_layout(mesh, x, maps);
EXPECT_TRUE(lay.success);
// All UVs must be finite
for (auto& p : lay.uv) {
EXPECT_FALSE(std::isnan(p.x()));
EXPECT_FALSE(std::isnan(p.y()));
}
}
// ════════════════════════════════════════════════════════════════════════════
// normalise_euclidean — centroid + PCA applied to both uv and halfedge_uv
// ════════════════════════════════════════════════════════════════════════════
TEST(NormaliseEuclidean, UVCentroidAtOrigin)
{
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
normalise_euclidean(lay);
Eigen::Vector2d mean = Eigen::Vector2d::Zero();
for (auto& p : lay.uv) mean += p;
mean /= static_cast<double>(lay.uv.size());
EXPECT_NEAR(mean.x(), 0.0, 1e-10);
EXPECT_NEAR(mean.y(), 0.0, 1e-10);
}
TEST(NormaliseEuclidean, HalfedgeUVCentroidAlsoShifted)
{
// After normalisation: the non-border halfedge_uv entries should also be
// centred (since they are shifted by the same mean as uv).
// We verify that the mean of non-border halfedge_uv is near (0,0).
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
normalise_euclidean(lay);
Eigen::Vector2d mean = Eigen::Vector2d::Zero();
int count = 0;
for (auto h : mesh.halfedges()) {
if (mesh.is_border(h)) continue;
mean += lay.halfedge_uv[static_cast<std::size_t>(h.idx())];
++count;
}
if (count > 0) mean /= static_cast<double>(count);
EXPECT_NEAR(mean.x(), 0.0, 1e-9);
EXPECT_NEAR(mean.y(), 0.0, 1e-9);
}
// ════════════════════════════════════════════════════════════════════════════
// PeriodMatrix — reduce_to_fundamental_domain
// ════════════════════════════════════════════════════════════════════════════
TEST(PeriodMatrix, ReduceToFD_AlreadyInFD)
{
// τ = i is in F (|i|=1, Re(i)=0, Im(i)=1>0)
C tau(0.0, 1.0);
C reduced = reduce_to_fundamental_domain(tau);
EXPECT_TRUE(is_in_fundamental_domain(reduced));
EXPECT_NEAR(reduced.real(), 0.0, 1e-10);
EXPECT_NEAR(reduced.imag(), 1.0, 1e-10);
}
TEST(PeriodMatrix, ReduceToFD_ShiftsRealPart)
{
// τ = 2 + 3i → T step: τ -= 2 → 3i (|3i|=3≥1, Re=0)
C tau(2.0, 3.0);
C reduced = reduce_to_fundamental_domain(tau);
EXPECT_TRUE(is_in_fundamental_domain(reduced, 1e-9));
EXPECT_NEAR(reduced.real(), 0.0, 1e-10);
EXPECT_NEAR(reduced.imag(), 3.0, 1e-10);
}
TEST(PeriodMatrix, ReduceToFD_InvertsSmallTau)
{
// τ = 0.5i → |0.5i|=0.5<1 → S: τ↦-1/(0.5i) = 2i
C tau(0.0, 0.5);
C reduced = reduce_to_fundamental_domain(tau);
EXPECT_TRUE(is_in_fundamental_domain(reduced, 1e-9));
EXPECT_NEAR(reduced.real(), 0.0, 1e-10);
EXPECT_NEAR(reduced.imag(), 2.0, 1e-10);
}
TEST(PeriodMatrix, ReduceToFD_ThrowsForNonUpperHalfPlane)
{
C tau(0.5, -1.0); // Im < 0 → not in upper half-plane
EXPECT_THROW(reduce_to_fundamental_domain(tau), std::domain_error);
}
TEST(PeriodMatrix, IsInFundamentalDomain_Square)
{
EXPECT_TRUE(is_in_fundamental_domain(C(0.0, 1.0))); // i
EXPECT_TRUE(is_in_fundamental_domain(C(0.3, 1.5))); // inside
EXPECT_FALSE(is_in_fundamental_domain(C(0.6, 1.5))); // Re > 1/2
EXPECT_FALSE(is_in_fundamental_domain(C(0.0, 0.5))); // |τ| < 1
}
TEST(PeriodMatrix, ComputePeriodMatrix_UnitSquare)
{
// ω_1 = (1, 0), ω_2 = (0, 1) → τ = i
HolonomyData hol;
hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.0, 1.0) };
PeriodData pd = compute_period_matrix(hol, /*reduce=*/false);
EXPECT_EQ(pd.genus(), 1);
EXPECT_GT(pd.tau.imag(), 0.0);
EXPECT_NEAR(pd.tau.real(), 0.0, 1e-10);
EXPECT_NEAR(pd.tau.imag(), 1.0, 1e-10);
}
TEST(PeriodMatrix, ComputePeriodMatrix_ReducedTau_InFD)
{
// ω_1 = (1, 0), ω_2 = (0.5, 0.25) → τ = 0.5 + 0.25i
// |τ| = sqrt(0.25 + 0.0625) ≈ 0.559 < 1 → needs S step
HolonomyData hol;
hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.5, 0.25) };
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
EXPECT_TRUE(pd.in_fundamental_domain);
EXPECT_TRUE(is_in_fundamental_domain(pd.tau, 1e-9));
}
// ════════════════════════════════════════════════════════════════════════════
// FundamentalDomain — genus-1 parallelogram
// ════════════════════════════════════════════════════════════════════════════
TEST(FundamentalDomain, Genus1_HasFourVertices)
{
HolonomyData hol;
hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.0, 1.0) };
FundamentalDomain fd = compute_fundamental_domain_genus1(hol);
EXPECT_EQ(fd.vertices.size(), 4u);
EXPECT_TRUE(fd.is_valid());
}
TEST(FundamentalDomain, Genus1_VerticesMatchGenerators_UnitSquare)
{
Eigen::Vector2d w1(1.0, 0.0), w2(0.0, 1.0);
HolonomyData hol;
hol.translations = { w1, w2 };
FundamentalDomain fd = compute_fundamental_domain_genus1(hol);
// Expected (CCW): origin, w1, w1+w2, w2
EXPECT_NEAR(fd.vertices[0].x(), 0.0, 1e-12);
EXPECT_NEAR(fd.vertices[0].y(), 0.0, 1e-12);
EXPECT_NEAR(fd.vertices[1].x(), w1.x(), 1e-12);
EXPECT_NEAR(fd.vertices[1].y(), w1.y(), 1e-12);
EXPECT_NEAR(fd.vertices[2].x(), (w1 + w2).x(), 1e-12);
EXPECT_NEAR(fd.vertices[2].y(), (w1 + w2).y(), 1e-12);
EXPECT_NEAR(fd.vertices[3].x(), w2.x(), 1e-12);
EXPECT_NEAR(fd.vertices[3].y(), w2.y(), 1e-12);
}
TEST(FundamentalDomain, Genus1_CCWOrientation)
{
// After possible swap, the signed area = cross(v1-v0, v3-v0) > 0 (CCW)
HolonomyData hol;
hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.0, 1.0) };
FundamentalDomain fd = compute_fundamental_domain_genus1(hol);
Eigen::Vector2d v0 = fd.vertices[0], v1 = fd.vertices[1], v3 = fd.vertices[3];
double cross = (v1 - v0).x() * (v3 - v0).y() - (v1 - v0).y() * (v3 - v0).x();
EXPECT_GT(cross, 0.0);
}
TEST(FundamentalDomain, Genus1_CCWEnforced_WhenInputIsCW)
{
// If we give CW generators (w2 × w1 < 0), the polygon must still be CCW.
// w1 = (0,1), w2 = (1,0): cross w1×w2 = 0*0 - 1*1 = -1 < 0 → should swap
HolonomyData hol;
hol.translations = { Eigen::Vector2d(0.0, 1.0), Eigen::Vector2d(1.0, 0.0) };
FundamentalDomain fd = compute_fundamental_domain_genus1(hol);
Eigen::Vector2d v0 = fd.vertices[0], v1 = fd.vertices[1], v3 = fd.vertices[3];
double cross = (v1 - v0).x() * (v3 - v0).y() - (v1 - v0).y() * (v3 - v0).x();
EXPECT_GT(cross, 0.0);
}
TEST(FundamentalDomain, Genus1_EdgeIdentifications)
{
HolonomyData hol;
hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.0, 1.0) };
FundamentalDomain fd = compute_fundamental_domain_genus1(hol);
EXPECT_EQ(fd.edge_identifications.size(), 2u);
// bottom ≡ top: (0,2)
EXPECT_EQ(fd.edge_identifications[0].first, 0);
EXPECT_EQ(fd.edge_identifications[0].second, 2);
// right ≡ left: (1,3)
EXPECT_EQ(fd.edge_identifications[1].first, 1);
EXPECT_EQ(fd.edge_identifications[1].second, 3);
}
TEST(FundamentalDomain, Genus1_GeneratorsStored)
{
Eigen::Vector2d w1(2.0, 1.0), w2(-1.0, 3.0);
HolonomyData hol;
hol.translations = { w1, w2 };
FundamentalDomain fd = compute_fundamental_domain_genus1(hol);
EXPECT_EQ(fd.generators.size(), 2u);
// Generators are w1 and w2 (possibly swapped to ensure CCW)
// Their sum of norms matches the originals
double norm_gen = fd.generators[0].norm() + fd.generators[1].norm();
double norm_in = w1.norm() + w2.norm();
EXPECT_NEAR(norm_gen, norm_in, 1e-10);
}
TEST(FundamentalDomain, HigherGenus_ReturnsEmpty)
{
HolonomyData hol;
hol.translations = {
Eigen::Vector2d(1, 0), Eigen::Vector2d(0, 1),
Eigen::Vector2d(2, 0), Eigen::Vector2d(0, 2) // g=2, 4 generators
};
FundamentalDomain fd = compute_fundamental_domain(hol);
// g > 1 returns empty (TODO Phase 8)
EXPECT_FALSE(fd.is_valid());
}
// ════════════════════════════════════════════════════════════════════════════
// tiling_copy / tiling_neighbourhood
// ════════════════════════════════════════════════════════════════════════════
TEST(TilingCopy, ShiftAppliedToAllUV)
{
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
Eigen::Vector2d w1(3.0, 0.0), w2(0.0, 2.0);
// m=1, n=2 → expected shift = w1 + 2*w2 = (3, 4)
Layout2D copy = tiling_copy(lay, w1, w2, 1, 2);
Eigen::Vector2d expected_shift(3.0, 4.0);
for (std::size_t i = 0; i < lay.uv.size(); ++i) {
EXPECT_NEAR(copy.uv[i].x(), lay.uv[i].x() + expected_shift.x(), 1e-12);
EXPECT_NEAR(copy.uv[i].y(), lay.uv[i].y() + expected_shift.y(), 1e-12);
}
}
TEST(TilingCopy, ZeroShift_IsSameAsCopy)
{
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
Eigen::Vector2d w1(1, 0), w2(0, 1);
Layout2D copy = tiling_copy(lay, w1, w2, 0, 0);
for (std::size_t i = 0; i < lay.uv.size(); ++i) {
EXPECT_NEAR(copy.uv[i].x(), lay.uv[i].x(), 1e-12);
EXPECT_NEAR(copy.uv[i].y(), lay.uv[i].y(), 1e-12);
}
}
TEST(TilingNeighbourhood, CorrectCount)
{
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
HolonomyData hol;
hol.translations = { Eigen::Vector2d(1, 0), Eigen::Vector2d(0, 1) };
// m_max=1, n_max=1 → (2*1+1) * (2*1+1) = 9 tiles
auto tiles = tiling_neighbourhood(lay, hol, 1, 1);
EXPECT_EQ(tiles.size(), 9u);
}
TEST(TilingNeighbourhood, EmptyHolonomy_ReturnsSingleTile)
{
auto mesh = make_quad_strip();
auto lay = make_euclidean_layout(mesh);
HolonomyData hol; // no translations
auto tiles = tiling_neighbourhood(lay, hol);
EXPECT_EQ(tiles.size(), 1u);
}