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>
271 lines
13 KiB
C++
271 lines
13 KiB
C++
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// test_cp_euclidean_functional.cpp
|
||
//
|
||
// Phase 9a.1 — CPEuclideanFunctional (BPS 2010) tests.
|
||
//
|
||
// Replicates de.varylab.discreteconformal.functional.CPEuclideanFunctionalTest
|
||
// (88 lines) and adds boundary-edge coverage plus a closed-mesh case.
|
||
//
|
||
// Java test pattern (lines 50-87):
|
||
// 1. Build dodecahedron via HalfEdgeUtils.addDodecahedron.
|
||
// 2. Remove face 0 to produce an open mesh.
|
||
// 3. theta_e = π/2 for every edge. (orthogonal circle packing)
|
||
// 4. phi_f = 2π for every face. (flat target)
|
||
// 5. Random ρ ∈ [−0.5, 0.5] (seed 1).
|
||
// 6. FunctionalTest.setXGradient(ρ) → FD-vs-analytic gradient check.
|
||
// 7. FunctionalTest.setXHessian(ρ) → FD-vs-analytic Hessian check.
|
||
//
|
||
// C++ port uses the tetrahedron (4 faces) instead of the dodecahedron (12 faces)
|
||
// because the analytic structure is identical and the smaller mesh keeps the
|
||
// test fast and human-inspectable. We exercise the boundary-edge code path
|
||
// by additionally testing a tetrahedron with one face removed (3 faces, 3
|
||
// boundary edges, 3 interior edges).
|
||
|
||
#include "cp_euclidean_functional.hpp"
|
||
#include "mesh_builder.hpp"
|
||
#include "conformal_mesh.hpp"
|
||
|
||
#include <Eigen/Eigenvalues>
|
||
#include <gtest/gtest.h>
|
||
#include <vector>
|
||
#include <random>
|
||
|
||
using namespace conformallab;
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 1. Helper: explicit values for p(θ*, Δρ) at known inputs
|
||
//
|
||
// p(θ*, 0) = 0 (tanh 0 = 0)
|
||
// p(π, Δρ) = π·sign(Δρ) (tan(π/2) = ∞, atan saturates to ±π/2)
|
||
// p(0, Δρ) = 0 (tan(0) = 0)
|
||
// p odd in Δρ (tanh is odd).
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(CPEuclideanFunctional, PFunctionKnownValues)
|
||
{
|
||
using cp_detail::p_function;
|
||
constexpr double PI_ = 3.14159265358979323846;
|
||
|
||
// p(any, 0) = 0
|
||
EXPECT_NEAR(p_function(PI_ / 4, 0.0), 0.0, 1e-15);
|
||
EXPECT_NEAR(p_function(PI_ / 2, 0.0), 0.0, 1e-15);
|
||
|
||
// Odd in Δρ
|
||
const double thStar = PI_ / 3;
|
||
for (double dr : {0.1, 0.5, 1.0, 2.0}) {
|
||
EXPECT_NEAR(p_function(thStar, dr) + p_function(thStar, -dr), 0.0, 1e-12)
|
||
<< "p(θ*, Δρ) should be odd in Δρ";
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 2. Property-map setup defaults
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(CPEuclideanFunctional, SetupDefaults)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
|
||
constexpr double PI_ = 3.14159265358979323846;
|
||
for (auto e : mesh.edges()) EXPECT_NEAR(m.theta_e[e], PI_ / 2, 1e-15);
|
||
for (auto f : mesh.faces()) EXPECT_NEAR(m.phi_f[f], 2.0 * PI_, 1e-15);
|
||
for (auto f : mesh.faces()) EXPECT_EQ(m.f_idx[f], -1) << "all faces start pinned";
|
||
}
|
||
|
||
TEST(CPEuclideanFunctional, AssignDofIndices_PinsOneFace)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
EXPECT_EQ(n, 3) << "tetrahedron has 4 faces; 1 pinned ⇒ 3 free DOFs";
|
||
|
||
int pinned_count = 0;
|
||
int max_idx = -1;
|
||
for (auto f : mesh.faces()) {
|
||
if (m.f_idx[f] == -1) ++pinned_count;
|
||
else max_idx = std::max(max_idx, m.f_idx[f]);
|
||
}
|
||
EXPECT_EQ(pinned_count, 1);
|
||
EXPECT_EQ(max_idx, 2);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 3. Tangential limit (θ = 0): p = 0, energy collapses, gradient = φ_f
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(CPEuclideanFunctional, TangentialLimitGradientEqualsPhi)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
for (auto e : mesh.edges()) m.theta_e[e] = 0.0; // tangential limit
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
// At θ = 0: θ* = π. Interior edge contribution: −(p+θ*) where p = π·sign(Δρ).
|
||
// Boundary contribution: −2π. At ρ = 0, Δρ = 0 so p = 0; each interior face
|
||
// contributes −π per incident interior halfedge; for a tetrahedron each face
|
||
// has 3 interior halfedges ⇒ −3π. Net gradient: 2π − 3π = −π per free face.
|
||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||
auto G = cp_euclidean_gradient(mesh, x, m);
|
||
|
||
constexpr double PI_ = 3.14159265358979323846;
|
||
for (double g : G) EXPECT_NEAR(g, -PI_, 1e-10);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 4. FD gradient check on closed tetrahedron at random ρ
|
||
//
|
||
// Java parity: this is exactly the structure of CPEuclideanFunctionalTest.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(CPEuclideanFunctional, FDGradientCheck_ClosedTetrahedron_RandomRho)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
// Java: rnd.setSeed(1); rho_i = rnd.nextDouble() − 0.5
|
||
std::mt19937 rng(1);
|
||
std::uniform_real_distribution<double> u(-0.5, 0.5);
|
||
std::vector<double> rho(static_cast<std::size_t>(n));
|
||
for (auto& r : rho) r = u(rng);
|
||
|
||
EXPECT_TRUE(gradient_check_cp_euclidean(mesh, rho, m))
|
||
<< "FD vs analytic gradient mismatch on closed tetrahedron";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 5. FD Hessian check on closed tetrahedron at random ρ
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(CPEuclideanFunctional, FDHessianCheck_ClosedTetrahedron_RandomRho)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
std::mt19937 rng(1);
|
||
std::uniform_real_distribution<double> u(-0.5, 0.5);
|
||
std::vector<double> rho(static_cast<std::size_t>(n));
|
||
for (auto& r : rho) r = u(rng);
|
||
|
||
EXPECT_TRUE(hessian_check_cp_euclidean(mesh, rho, m))
|
||
<< "FD vs analytic Hessian mismatch on closed tetrahedron";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 6. Boundary-edge coverage: open mesh (tetrahedron with one face removed)
|
||
//
|
||
// Java test does this via `hds.removeFace(hds.getFace(0))`. In CGAL we get
|
||
// an equivalent open mesh by skipping the construction of one face.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
inline ConformalMesh make_open_tetrahedron()
|
||
{
|
||
ConformalMesh mesh;
|
||
auto v0 = mesh.add_vertex(Point3( 1, 1, 1));
|
||
auto v1 = mesh.add_vertex(Point3( 1, -1, -1));
|
||
auto v2 = mesh.add_vertex(Point3(-1, 1, -1));
|
||
auto v3 = mesh.add_vertex(Point3(-1, -1, 1));
|
||
// Three faces (omit the one opposite v0):
|
||
mesh.add_face(v0, v2, v1);
|
||
mesh.add_face(v0, v1, v3);
|
||
mesh.add_face(v0, v3, v2);
|
||
return mesh;
|
||
}
|
||
|
||
TEST(CPEuclideanFunctional, FDGradientCheck_OpenTetrahedron_RandomRho)
|
||
{
|
||
auto mesh = make_open_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
EXPECT_EQ(n, 2); // 3 faces, 1 pinned ⇒ 2 free DOFs
|
||
|
||
std::mt19937 rng(1);
|
||
std::uniform_real_distribution<double> u(-0.5, 0.5);
|
||
std::vector<double> rho(static_cast<std::size_t>(n));
|
||
for (auto& r : rho) r = u(rng);
|
||
|
||
EXPECT_TRUE(gradient_check_cp_euclidean(mesh, rho, m))
|
||
<< "FD vs analytic gradient mismatch on open tetrahedron";
|
||
}
|
||
|
||
TEST(CPEuclideanFunctional, FDHessianCheck_OpenTetrahedron_RandomRho)
|
||
{
|
||
auto mesh = make_open_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
std::mt19937 rng(1);
|
||
std::uniform_real_distribution<double> u(-0.5, 0.5);
|
||
std::vector<double> rho(static_cast<std::size_t>(n));
|
||
for (auto& r : rho) r = u(rng);
|
||
|
||
EXPECT_TRUE(hessian_check_cp_euclidean(mesh, rho, m))
|
||
<< "FD vs analytic Hessian mismatch on open tetrahedron";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 7. Hessian is symmetric positive-semidefinite (BPS-2010 §6 convexity)
|
||
//
|
||
// The energy is convex in ρ on its domain of validity. Hence H is PSD with
|
||
// a 1-dim null space (constant shift of all ρ, removed by gauge pin).
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(CPEuclideanFunctional, HessianIsPSD)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
std::vector<double> rho(static_cast<std::size_t>(n), 0.1);
|
||
auto H = cp_euclidean_hessian(mesh, rho, m);
|
||
|
||
// Symmetry
|
||
Eigen::MatrixXd Hd(H);
|
||
EXPECT_NEAR((Hd - Hd.transpose()).cwiseAbs().maxCoeff(), 0.0, 1e-15);
|
||
|
||
// Smallest eigenvalue ≥ 0 (PSD)
|
||
Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> es(Hd);
|
||
EXPECT_GE(es.eigenvalues().minCoeff(), -1e-12)
|
||
<< "Hessian must be PSD (BPS-2010 §6)";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 8. At equilibrium (Newton-converged ρ*), the gradient is zero by construction
|
||
//
|
||
// We do not run a full Newton solver here; we set up the "natural-theta" trick:
|
||
// adjust φ_f so that ρ = 0 is the equilibrium. This is the analog of the
|
||
// natural-theta convention already used in euclidean_functional tests
|
||
// (see test_euclidean_functional.cpp lines 159-189).
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(CPEuclideanFunctional, NaturalPhiMakesZeroTheEquilibrium)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
std::vector<double> rho(static_cast<std::size_t>(n), 0.0);
|
||
|
||
// Step 1: gradient at ρ = 0 with default φ.
|
||
auto G0 = cp_euclidean_gradient(mesh, rho, m);
|
||
|
||
// Step 2: adjust φ_f so the new gradient at ρ = 0 is zero.
|
||
// ∂E/∂ρ_f = φ_f − (sum of edge contributions)
|
||
// To zero G_f: subtract G_f from φ_f.
|
||
for (auto f : mesh.faces()) {
|
||
int i = m.f_idx[f];
|
||
if (i < 0) continue;
|
||
m.phi_f[f] -= G0[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
// Step 3: gradient at ρ = 0 should now be ~zero.
|
||
auto G_eq = cp_euclidean_gradient(mesh, rho, m);
|
||
for (double g : G_eq) EXPECT_NEAR(g, 0.0, 1e-13);
|
||
}
|