Files
ConformalLabpp/code/tests/cgal/test_phase7.cpp
Tarik Moussa 135bcf0bba
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 2m16s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped
feat(p1): CLI extensions + quality measures + stereographic layout
Implement Phase-Session P1 quick wins (4 independent additions):

9h.1: Add --tol and --max-iter CLI options to conformallab_core
  - Newton solver tolerance [default 1e-8]
  - Newton iteration limit [default 200]
  - Thread both through run_euclidean / run_spherical / run_hyper_ideal
  - Update CLI parameter table in documentation

9h.2: Add -g cp_euclidean and -g inversive_distance geometry routes
  - run_cp_euclidean() & run_inversive_distance() pipelines (~60 lines each)
  - Face-based DOF assignment for CP-Euclidean
  - Vertex-based DOF assignment for Inversive-Distance
  - Both integrated into CLI geometry validator (IsMember)

9g.1: Create conformal_quality.hpp with validation measures
  - IsothermicityMeasure: metric anisotropy (conformality deviation)
  - DiscreteConformalEquivalenceMeasure: length-cross-ratio residuals
  - FlippedTriangles: detects inverted/degenerate triangles
  - LengthCrossRatio: discrete conformal invariant computation
  - ConvergenceUtility: aggregated convergence statistics (max/mean/sum)
  - Ported from Java: plugin/visualizer + convergence utilities
  - Includes sanity tests validating finite outputs on valid layouts

9d.3: Create stereographic_layout.hpp for S² → ℂ projection
  - Stereographic projection from north pole: S² → ℂ ∪ {∞}
  - Inverse projection: ℂ → S² for round-trip validation
  - Möbius centring: centres the 2-D point cloud at origin
  - stereographic_layout(Layout3D) -> Layout2D conversion
  - Round-trip tests: south pole, equator, random sphere points
  - Tests: projection/inverse consistency, north pole handling

Test results: 336/336 CGAL tests pass (272 pre-existing + 64 new from all phases)
- conformal_quality.cpp: 13 new tests (measures, isothermic, dce, convergence)
- stereographic_layout.cpp: 10 new tests (projection, inverse, round-trip, layout)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 08:58:46 +02:00

768 lines
34 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 "mesh_io.hpp"
#include "cut_graph.hpp"
#include "gauss_bonnet.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);
}
// H4 (test-coverage audit, 2026-06-01): the guard is `Im(τ) <= 0.0`, so
// the exact boundary Im(τ) == 0.0 (the real axis) must also throw.
// The previous test only checked Im(τ) < 0; this covers the boundary.
TEST(PeriodMatrix, ReduceToFD_ThrowsForRealAxisBoundary)
{
// Im(τ) == 0.0 exactly — on the real axis, not in the upper half-plane.
C tau_real_axis(1.0, 0.0);
EXPECT_THROW(reduce_to_fundamental_domain(tau_real_axis), std::domain_error)
<< "tau with Im == 0.0 is on the real axis and must throw domain_error";
// Additional boundary variants to be thorough.
EXPECT_THROW(reduce_to_fundamental_domain(C(0.0, 0.0)), std::domain_error);
EXPECT_THROW(reduce_to_fundamental_domain(C(-0.5, 0.0)), std::domain_error);
EXPECT_THROW(reduce_to_fundamental_domain(C(0.5, 0.0)), 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.
// compute_period_matrix reduces with normalizeModulus (Finding 6), whose
// mirror-folded target domain is { 0 ≤ Re ≤ ½, Im > 0, |τ| ≥ 1 } — note the
// RIGHT boundary Re = +½ is CLOSED here. This is NOT the half-open SL(2,)
// domain of is_in_fundamental_domain (−½ ≤ Re < ½), which excludes Re = +½
// because +½ ≡ −½ under T. For this input normalizeModulus lands exactly on
// τ = ½ + i, so we must check the normalizeModulus domain, not the SL(2,)
// one (asserting is_in_fundamental_domain here would wrongly fail on +½).
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);
const double tol = 1e-9;
EXPECT_GT(pd.tau.imag(), 0.0);
EXPECT_GE(pd.tau.real(), 0.0 - tol); // 0 ≤ Re (mirror fold)
EXPECT_LE(pd.tau.real(), 0.5 + tol); // Re ≤ ½ (closed right edge)
EXPECT_GE(std::abs(pd.tau), 1.0 - tol); // |τ| ≥ 1
// Concretely: τ = ½ + i.
EXPECT_NEAR(pd.tau.real(), 0.5, 1e-12);
EXPECT_NEAR(pd.tau.imag(), 1.0, 1e-12);
}
// ─────────────────────────────────────────────────────────────────────────────
// Golden-value oracle — pin normalizeModulus (the τ reduction used by
// compute_period_matrix, Finding 6) bit-for-bit against the upstream Java
// reference (de.varylab.discreteconformal.util.DiscreteEllipticUtility.
// normalizeModulus), captured by calling the compiled Java method (openjdk 17)
// on these exact τ. This locks the sign/fold conventions of the SL(2,)+mirror
// reduction (0 ≤ Re ≤ ½, Im ≥ 0, |τ| ≥ 1) against an independent implementation,
// catching drift the existing in-FD membership checks cannot (they only assert
// the result lies in F, not that it is the SAME representative Java picks).
//
// To regenerate: /tmp/oracle/TauOracle.java. Values are Java printf %.17g.
// ─────────────────────────────────────────────────────────────────────────────
TEST(PeriodMatrix, NormalizeModulus_GoldenJava)
{
auto chk = [](double re, double im, double re_g, double im_g) {
C n = conformallab::normalizeModulus(C(re, im));
EXPECT_NEAR(n.real(), re_g, 1e-12);
EXPECT_NEAR(n.imag(), im_g, 1e-12);
};
chk(0.3, 0.5, 0.11764705882352933, 1.4705882352941178); // |τ|<1 → S + folds
chk(-0.4, 1.3, 0.40000000000000000, 1.3000000000000000); // Re<0 mirror fold
chk(2.7, 0.8, 0.41095890410958880, 1.0958904109589043); // large Re → T
chk(0.1, 2.0, 0.10000000000000000, 2.0000000000000000); // already in F
chk(-1.6, 0.9, 0.41237113402061850, 0.92783505154639180); // T + S + mirror
}
// ════════════════════════════════════════════════════════════════════════════
// End-to-end holonomy → τ on real genus-1 torus meshes
//
// Regression test for the holonomy-extraction bug: euclidean_holonomy() developed
// the cut surface along a BFS dual tree that crossed the primal-tree edges freely.
// Relative to that tree the cut graph's 2g generator edges were NOT generators —
// some were null-homotopic — so the two developed copies of a "cut" edge landed
// on top of each other and compute_period_matrix() got ω ≈ 0 (→ τ = 0 / NaN /
// huge). The fix develops across the cut graph's OWN dual spanning tree T* only
// (CutGraph::is_dual_tree), unfolding the surface onto a true disk so the cut
// edges become the boundary identifications that carry the lattice generators.
//
// Analytic target. The bundled meshes are tori of REVOLUTION (major radius R,
// minor radius r, R > r), not abstract square/hexagonal flat tori. Their
// conformal modulus is purely imaginary,
//
// τ = i · √(R² r²) / r (reduced so |τ| ≥ 1)
//
// derived from the flat-conformal change of variable dψ = r/(R + r cos φ) dφ on
// the induced metric ds² = (R + r cos φ)² dθ² + r² dφ²; the ψ-period is
// 2πr/√(R²r²), giving the rectangular lattice ratio above. Re(τ) = 0 follows
// from the meridian ⟂ longitude reflection symmetry. The coarse polygonal cross
// sections (square/hex/octagon) approximate the circular value from above; the
// gap shrinks as the cross section gains sides.
// ════════════════════════════════════════════════════════════════════════════
namespace {
// Run the full pipeline solve → cut → layout → period matrix on a torus mesh and
// return the reduced τ together with the two raw holonomy generators.
struct TorusTau {
std::complex<double> tau;
std::vector<Eigen::Vector2d> omega;
bool converged = false;
};
TorusTau run_torus_pipeline(const std::string& file)
{
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/off/" + file;
ConformalMesh mesh = load_mesh(path);
EuclideanMaps maps = setup_euclidean_maps(mesh); // Θ_v = 2π (flat target)
compute_euclidean_lambda0_from_mesh(mesh, maps);
int idx = 0;
bool pinned = false;
for (auto v : mesh.vertices()) {
if (!pinned) { maps.v_idx[v] = -1; pinned = true; }
else maps.v_idx[v] = idx++;
}
enforce_gauss_bonnet(mesh, maps);
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
auto res = newton_euclidean(mesh, x0, maps);
CutGraph cg = compute_cut_graph(mesh);
HolonomyData hol;
euclidean_layout(mesh, res.x, maps, &cg, &hol, /*normalise=*/false);
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
return TorusTau{pd.tau, hol.translations, res.converged};
}
// Reduced conformal modulus of a torus of revolution (major R, minor r).
double revolution_tau_imag(double R, double r)
{
return std::sqrt(R * R - r * r) / r; // ≥ 1 form (|τ| ≥ 1)
}
void check_torus(const std::string& file, double R, double r, double rel_tol)
{
TorusTau t = run_torus_pipeline(file);
ASSERT_TRUE(t.converged) << file << ": Newton did not converge";
// Generators must be non-degenerate (the bug collapsed them to ~0).
ASSERT_EQ(t.omega.size(), 2u);
EXPECT_GT(t.omega[0].norm(), 1e-3) << file << ": ω₁ degenerate";
EXPECT_GT(t.omega[1].norm(), 1e-3) << file << ": ω₂ degenerate";
EXPECT_TRUE(std::isfinite(t.tau.real()) && std::isfinite(t.tau.imag()))
<< file << ": τ is not finite (" << t.tau.real() << "+" << t.tau.imag() << "i)";
EXPECT_GT(t.tau.imag(), 0.0) << file << ": τ must lie in the upper half-plane";
EXPECT_TRUE(is_in_fundamental_domain(t.tau, 1e-6))
<< file << ": τ = " << t.tau.real() << "+" << t.tau.imag() << "i not in F";
// Re(τ) = 0 by the meridian ⟂ longitude reflection symmetry.
EXPECT_NEAR(t.tau.real(), 0.0, 0.05)
<< file << ": Re(τ) should vanish for a torus of revolution";
const double expected = revolution_tau_imag(R, r);
EXPECT_NEAR(t.tau.imag(), expected, rel_tol * expected)
<< file << ": Im(τ) = " << t.tau.imag()
<< " vs analytic i·√(R²r²)/r = " << expected;
}
} // namespace
// 4×4 torus of revolution: R = 2, r = 1 → τ = i√3 ≈ 1.732i.
// Square (4-gon) cross section → coarsest circle approximation, looser tolerance.
TEST(HolonomyEndToEnd, Torus4x4_TauMatchesRevolutionModulus)
{
check_torus("torus_4x4.off", /*R=*/2.0, /*r=*/1.0, /*rel_tol=*/0.10);
}
// Hexagonal 6×6 torus of revolution: R = 3, r = 1 → τ = i√8 ≈ 2.828i.
TEST(HolonomyEndToEnd, TorusHex6x6_TauMatchesRevolutionModulus)
{
check_torus("torus_hex_6x6.off", /*R=*/3.0, /*r=*/1.0, /*rel_tol=*/0.05);
}
// Octagonal 8×8 torus of revolution: R = 3, r = 1 → τ = i√8 ≈ 2.828i.
TEST(HolonomyEndToEnd, Torus8x8_TauMatchesRevolutionModulus)
{
check_torus("torus_8x8.off", /*R=*/3.0, /*r=*/1.0, /*rel_tol=*/0.05);
}
// ════════════════════════════════════════════════════════════════════════════
// Finding-H (java-port-audit item 7, external-audit-2026-05-30):
// End-to-end torus with Re(τ) < 0 before normalizeModulus
//
// torus_skewed_4x4.off is a flat torus on a parallelogram lattice
// ω₁ = (4, 0) ω₂ = (1, 4)
// The raw τ = ω₂/ω₁ = (0.25 + i), Re < 0.
// After normalizeModulus the mirror fold gives τ = (0.25 + i), Re ≥ 0.
//
// This guards against a regression where compute_period_matrix uses
// reduce_to_fundamental_domain (old code, no mirror fold) instead of
// normalizeModulus (Java-faithful, finding 6 fix) — in that case the
// pipeline would silently report τ with Re < 0 instead of Re ≥ 0.
// ════════════════════════════════════════════════════════════════════════════
TEST(HolonomyEndToEnd, SkewedTorus_ReTauNegativeBeforeNorm_FoldedToPositive)
{
// ── Load the skewed flat torus ────────────────────────────────────────
const std::string path =
std::string(CONFORMALLAB_DATA_DIR) + "/off/torus_skewed_4x4.off";
ConformalMesh mesh = load_mesh(path);
ASSERT_GT(mesh.number_of_vertices(), 0u) << "Failed to load torus_skewed_4x4.off";
ASSERT_EQ(conformallab::euler_characteristic(mesh), 0)
<< "Mesh must be a torus (χ=0)";
// ── Run the full pipeline ─────────────────────────────────────────────
EuclideanMaps maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int idx = 0;
bool pinned = false;
for (auto v : mesh.vertices()) {
if (!pinned) { maps.v_idx[v] = -1; pinned = true; }
else maps.v_idx[v] = idx++;
}
enforce_gauss_bonnet(mesh, maps);
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
auto res = newton_euclidean(mesh, x0, maps);
ASSERT_TRUE(res.converged) << "Newton did not converge on skewed flat torus";
CutGraph cg = compute_cut_graph(mesh);
HolonomyData hol;
euclidean_layout(mesh, res.x, maps, &cg, &hol, /*normalise=*/false);
ASSERT_EQ(hol.translations.size(), 2u) << "Expected exactly 2 holonomy generators";
// ── Raw τ (no normalization) must have Re < 0 ─────────────────────────
// This confirms the mesh geometry does produce a τ with negative real
// part, making the normalizeModulus step non-trivial.
PeriodData pd_raw = compute_period_matrix(hol, /*reduce=*/false);
EXPECT_LT(pd_raw.tau.real(), 0.0)
<< "Raw τ must have Re < 0 for this skewed lattice"
<< " (got Re = " << pd_raw.tau.real() << ")";
// ── Normalized τ must have Re ≥ 0 (normalizeModulus was applied) ─────
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
EXPECT_GE(pd.tau.real(), -1e-10)
<< "Normalized τ must have Re ≥ 0 (normalizeModulus mirror fold)"
<< " (got Re = " << pd.tau.real() << ")";
EXPECT_GT(pd.tau.imag(), 0.0)
<< "τ must lie in the upper half-plane";
EXPECT_GE(std::abs(pd.tau), 1.0 - 1e-9)
<< "|τ| ≥ 1 (fundamental domain condition)";
// ── Additional fundamental-domain conditions ───────────────────────────
// These are the normalizeModulus guarantees (Finding 6 / java-port-audit).
EXPECT_LE(pd.tau.real(), 0.5 + 1e-9)
<< "normalizeModulus must produce Re(τ) ≤ ½";
// The exact value depends on which generators tree-cotree finds;
// we do NOT assert a specific numeric value here (generator choice is
// an implementation detail of the tree-cotree algorithm, not of
// normalizeModulus). The assertions above are sufficient to confirm
// that the mirror fold was applied.
}
// ════════════════════════════════════════════════════════════════════════════
// Finding-H synthetic sanity: compute_period_matrix with explicit Re(τ)<0
// holonomy verifies the mirror fold numerically (no mesh, no tree-cotree).
// ════════════════════════════════════════════════════════════════════════════
TEST(HolonomyEndToEnd, SyntheticHolonomy_NegativeReTau_NormalizedToPositive)
{
// Lattice: ω₁=(4,0), ω₂=(-1,4) → τ_raw = (-1+4i)/4 = -0.25+i
// normalizeModulus: Re=-0.25 < 0 → mirror: τ = -conj(τ) = +0.25+i
HolonomyData hol;
hol.translations = {
Eigen::Vector2d(4.0, 0.0),
Eigen::Vector2d(-1.0, 4.0)
};
PeriodData pd_raw = compute_period_matrix(hol, /*reduce=*/false);
EXPECT_NEAR(pd_raw.tau.real(), -0.25, 1e-10) << "Raw Re(τ) must be -0.25";
EXPECT_NEAR(pd_raw.tau.imag(), 1.0, 1e-10) << "Raw Im(τ) must be 1.0";
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
EXPECT_GE(pd.tau.real(), 0.0 - 1e-9) << "Normalized Re(τ) ≥ 0";
EXPECT_LE(pd.tau.real(), 0.5 + 1e-9) << "Normalized Re(τ) ≤ ½";
EXPECT_NEAR(pd.tau.real(), 0.25, 1e-9) << "Mirror fold: Re = -0.25 → +0.25";
EXPECT_NEAR(pd.tau.imag(), 1.0, 1e-9) << "Im(τ) preserved by mirror fold";
EXPECT_GE(std::abs(pd.tau), 1.0 - 1e-9) << "|τ| ≥ 1";
}
// ════════════════════════════════════════════════════════════════════════════
// 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);
}