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>
266 lines
12 KiB
C++
266 lines
12 KiB
C++
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// test_inversive_distance_functional.cpp
|
||
//
|
||
// Phase 9a.2 — Inversive-distance functional (Luo 2004) tests.
|
||
//
|
||
// Validation against three mathematical references:
|
||
//
|
||
// [Luo 2004] ℓ_ij² = exp(2u_i) + exp(2u_j) + 2 I_ij exp(u_i+u_j)
|
||
// ∂E/∂u_v = Θ_v − Σ α_v (Lemma 3.1)
|
||
//
|
||
// [BS 2004] I_ij = (ℓ² − r_i² − r_j²) / (2 r_i r_j)
|
||
// I = 1 ⇒ tangential circles
|
||
// I = 0 ⇒ orthogonal circles
|
||
//
|
||
// [Glickenstein 2011 §5]
|
||
// correspondence to BPS-2010 face-based CP:
|
||
// I_ij = cos θ_e on the face-dual mesh
|
||
//
|
||
// No Java reference exists for this functional in
|
||
// de.varylab.discreteconformal. Cross-validation is done via:
|
||
// 1. FD-vs-analytic gradient check (numerical),
|
||
// 2. Luo's edge-length identity check (mathematical),
|
||
// 3. Tangential-limit identity I=1 ⇒ ℓ = r_i+r_j (geometric).
|
||
|
||
#include "inversive_distance_functional.hpp"
|
||
#include "euclidean_functional.hpp"
|
||
#include "mesh_builder.hpp"
|
||
#include "conformal_mesh.hpp"
|
||
|
||
#include <gtest/gtest.h>
|
||
#include <vector>
|
||
#include <random>
|
||
#include <cmath>
|
||
|
||
using namespace conformallab;
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 1. Edge-length formula (Luo 2004 §3)
|
||
//
|
||
// ℓ² = exp(2u_i) + exp(2u_j) + 2 I exp(u_i+u_j)
|
||
// = r_i² + r_j² + 2 I r_i r_j
|
||
//
|
||
// Special cases:
|
||
// I = 1 ⇒ ℓ² = (r_i + r_j)² ⇒ ℓ = r_i + r_j (tangential)
|
||
// I = 0 ⇒ ℓ² = r_i² + r_j² (orthogonal — circles meet at 90°)
|
||
// I = −1 ⇒ ℓ² = (r_i − r_j)² ⇒ ℓ = |r_i − r_j| (inside-tangent)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(InversiveDistanceFunctional, EdgeLengthFormula_TangentialLimit)
|
||
{
|
||
// ui = 0 ⇒ ri = 1; uj = log(2) ⇒ rj = 2; I = 1 (tangential):
|
||
// ℓ² = 1 + 4 + 2·1·1·2 = 9 ⇒ ℓ = 3 = r_i + r_j ✓
|
||
double l2 = id_detail::edge_length_squared(0.0, std::log(2.0), 1.0);
|
||
EXPECT_NEAR(std::sqrt(l2), 3.0, 1e-12);
|
||
}
|
||
|
||
TEST(InversiveDistanceFunctional, EdgeLengthFormula_OrthogonalLimit)
|
||
{
|
||
// r_i = 3, r_j = 4, I = 0: ℓ² = 9 + 16 = 25 ⇒ ℓ = 5 (Pythagorean)
|
||
double l2 = id_detail::edge_length_squared(std::log(3.0), std::log(4.0), 0.0);
|
||
EXPECT_NEAR(std::sqrt(l2), 5.0, 1e-12);
|
||
}
|
||
|
||
TEST(InversiveDistanceFunctional, EdgeLengthFormula_InsideTangentLimit)
|
||
{
|
||
// r_i = 2, r_j = 5, I = −1: ℓ² = (5 − 2)² = 9 ⇒ ℓ = 3
|
||
double l2 = id_detail::edge_length_squared(std::log(2.0), std::log(5.0), -1.0);
|
||
EXPECT_NEAR(std::sqrt(l2), 3.0, 1e-12);
|
||
}
|
||
|
||
TEST(InversiveDistanceFunctional, EdgeLengthFormula_DegenerateReturnsMinusOne)
|
||
{
|
||
// r_i = r_j = 1, I = −2: ℓ² = 1 + 1 − 4 = −2 (impossible packing)
|
||
double l2 = id_detail::edge_length_squared(0.0, 0.0, -2.0);
|
||
EXPECT_EQ(l2, -1.0) << "should signal degenerate packing";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 2. Bowers-Stephenson identity round-trip
|
||
//
|
||
// Given (ℓ, r_i, r_j), the I_ij that compute_init produces must satisfy
|
||
// Luo's edge-length formula exactly: ℓ²(I_ij, r_i, r_j) = ℓ².
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(InversiveDistanceFunctional, BowersStephensonRoundTrip)
|
||
{
|
||
auto mesh = make_triangle(); // (0,0,0)-(1,0,0)-(0,1,0)
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
// At u = 0, exp(u) = r0. Reconstruct ℓ from (r_i, r_j, I_ij) and compare
|
||
// to the 3-D Euclidean edge length from the mesh.
|
||
for (auto e : mesh.edges()) {
|
||
auto h = mesh.halfedge(e);
|
||
auto p1 = mesh.point(mesh.source(h));
|
||
auto p2 = mesh.point(mesh.target(h));
|
||
double dx = p1.x() - p2.x();
|
||
double dy = p1.y() - p2.y();
|
||
double dz = p1.z() - p2.z();
|
||
double l_3d = std::sqrt(dx*dx + dy*dy + dz*dz);
|
||
|
||
double ri = m.r0[mesh.source(h)];
|
||
double rj = m.r0[mesh.target(h)];
|
||
double l2_reconstructed = ri*ri + rj*rj + 2.0 * m.I_e[e] * ri * rj;
|
||
EXPECT_NEAR(std::sqrt(l2_reconstructed), l_3d, 1e-12)
|
||
<< "Bowers-Stephenson round-trip failed for an edge";
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 3. Properties of the init step
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(InversiveDistanceFunctional, InitProducesValidPositiveRadii)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
for (auto v : mesh.vertices()) {
|
||
EXPECT_GT(m.r0[v], 0.0) << "init radius must be positive";
|
||
EXPECT_TRUE(std::isfinite(m.r0[v]));
|
||
}
|
||
for (auto e : mesh.edges()) {
|
||
EXPECT_TRUE(std::isfinite(m.I_e[e]));
|
||
// I > −1 is required for any valid inversive-distance packing.
|
||
EXPECT_GT(m.I_e[e], -1.0);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 4. Gradient at the "natural equilibrium" is zero by construction
|
||
//
|
||
// Same trick as in test_euclidean_functional.cpp:
|
||
// • Set u = 0 ⇒ r = r0 ⇒ ℓ = ℓ_3d (Bowers-Stephenson round-trip)
|
||
// • Compute G(0) — that's the angle defect Θ − Σ_actual.
|
||
// • Subtract G(0) from Θ → new G(0) is zero.
|
||
// This means u = 0 is now the Newton equilibrium of the functional, just
|
||
// like in the euclidean functional natural-theta trick.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(InversiveDistanceFunctional, NaturalThetaGivesZeroGradientAtU0)
|
||
{
|
||
auto mesh = make_triangle();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
// Assign DOFs to all vertices.
|
||
int n = 0;
|
||
for (auto v : mesh.vertices()) m.v_idx[v] = n++;
|
||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||
|
||
auto G0 = inversive_distance_gradient(mesh, x, m);
|
||
for (auto v : mesh.vertices()) {
|
||
int i = m.v_idx[v];
|
||
m.theta_v[v] -= G0[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
auto G_eq = inversive_distance_gradient(mesh, x, m);
|
||
for (double g : G_eq) EXPECT_NEAR(g, 0.0, 1e-13);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 5. FD-vs-analytic gradient check (the main acceptance test for the port)
|
||
//
|
||
// Pattern: identical to test_euclidean_functional.cpp's
|
||
// GradientCheck_TriangleVertex (lines 137-149). The energy is the path
|
||
// integral of the gradient (by construction); a consistent FD-vs-analytic
|
||
// match validates both energy and gradient implementations together.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(InversiveDistanceFunctional, FDGradientCheck_Triangle)
|
||
{
|
||
auto mesh = make_triangle();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
int n = 0;
|
||
for (auto v : mesh.vertices()) m.v_idx[v] = n++;
|
||
|
||
// Small perturbation u_v ≈ −0.1 keeps every triangle valid.
|
||
std::vector<double> x(static_cast<std::size_t>(n), -0.1);
|
||
EXPECT_TRUE(gradient_check_inversive_distance(mesh, x, m))
|
||
<< "FD gradient mismatch on single triangle (u = −0.1)";
|
||
}
|
||
|
||
TEST(InversiveDistanceFunctional, FDGradientCheck_QuadStrip)
|
||
{
|
||
auto mesh = make_quad_strip();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
int n = 0;
|
||
for (auto v : mesh.vertices()) m.v_idx[v] = n++;
|
||
|
||
std::vector<double> x(static_cast<std::size_t>(n), -0.15);
|
||
EXPECT_TRUE(gradient_check_inversive_distance(mesh, x, m))
|
||
<< "FD gradient mismatch on quad strip";
|
||
}
|
||
|
||
TEST(InversiveDistanceFunctional, FDGradientCheck_Tetrahedron)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
int n = 0;
|
||
for (auto v : mesh.vertices()) m.v_idx[v] = n++;
|
||
|
||
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
|
||
EXPECT_TRUE(gradient_check_inversive_distance(mesh, x, m))
|
||
<< "FD gradient mismatch on regular tetrahedron";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 6. Cross-validation with euclidean_functional.hpp
|
||
//
|
||
// The two functionals are DIFFERENT geometric models. At u = 0 with their
|
||
// natural inits both produce a valid triangulation, but the per-edge length
|
||
// is different:
|
||
// • Euclidean: ℓ = ℓ_3d (exact, by lambda0 init)
|
||
// • Inversive distance: ℓ = ℓ_3d (exact, by BS round-trip)
|
||
//
|
||
// HOWEVER the GRADIENT at u = 0 differs because the chain rule ∂ℓ/∂u is
|
||
// different. Specifically:
|
||
// • Euclidean: ∂(2 log ℓ)/∂u_i = 1
|
||
// • Inversive distance: ∂(2 log ℓ)/∂u_i = (r_i² + I r_i r_j) / ℓ²
|
||
//
|
||
// This test pins one quantitative consequence: at u = 0 both gradients have
|
||
// the SAME angle-defect structure Θ − Σ_actual. After applying the natural-
|
||
// theta trick on each, both must be at equilibrium with G(0) = 0.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(InversiveDistanceFunctional, AngleDefectAtU0_AgreesWithEuclideanAtU0)
|
||
{
|
||
auto mesh = make_quad_strip();
|
||
|
||
// ── Inversive distance side ────────────────────────────────────────────
|
||
auto m_id = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m_id);
|
||
int n_id = 0;
|
||
for (auto v : mesh.vertices()) m_id.v_idx[v] = n_id++;
|
||
std::vector<double> x_id(static_cast<std::size_t>(n_id), 0.0);
|
||
auto G_id = inversive_distance_gradient(mesh, x_id, m_id);
|
||
|
||
// ── Euclidean side (same mesh, same DOF order) ─────────────────────────
|
||
auto m_eu = setup_euclidean_maps(mesh);
|
||
compute_euclidean_lambda0_from_mesh(mesh, m_eu);
|
||
int n_eu = 0;
|
||
for (auto v : mesh.vertices()) m_eu.v_idx[v] = n_eu++;
|
||
std::vector<double> x_eu(static_cast<std::size_t>(n_eu), 0.0);
|
||
auto G_eu = euclidean_gradient(const_cast<ConformalMesh&>(mesh), x_eu, m_eu);
|
||
|
||
// Both should report the same actual angle sum per vertex at u = 0
|
||
// (since both reproduce ℓ = ℓ_3d at u = 0). Therefore Θ − Σ_actual
|
||
// is identical for the two functionals (Θ default 2π in both).
|
||
ASSERT_EQ(G_id.size(), G_eu.size());
|
||
for (std::size_t i = 0; i < G_id.size(); ++i) {
|
||
EXPECT_NEAR(G_id[i], G_eu[i], 1e-10)
|
||
<< "angle-defect mismatch at u=0, DOF " << i
|
||
<< ": id=" << G_id[i] << " eu=" << G_eu[i];
|
||
}
|
||
}
|