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

266 lines
12 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_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];
}
}