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>
398 lines
19 KiB
C++
398 lines
19 KiB
C++
#pragma once
|
||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// cp_euclidean_functional.hpp
|
||
//
|
||
// Phase 9a.1 — Circle-Packing Euclidean functional (CP-Euclidean).
|
||
//
|
||
// Ported from de.varylab.discreteconformal.functional.CPEuclideanFunctional
|
||
// (Java, 260 lines). Mathematical reference:
|
||
// Bobenko, A. I., Pinkall, U. & Springborn, B. (2010)
|
||
// "Discrete conformal maps and ideal hyperbolic polyhedra"
|
||
// Geometry & Topology 14, 379-426.
|
||
//
|
||
// ┌──────────────────────────────────────────────────────────────────────────┐
|
||
// │ FACE-based circle packing │
|
||
// │ │
|
||
// │ Each face f of the mesh carries a circle of radius R_f. │
|
||
// │ The variable is ρ_f = log R_f. │
|
||
// │ Adjacent face-circles intersect at a prescribed angle θ_e per edge. │
|
||
// │ │
|
||
// │ This is the FACE-DUAL of the classical vertex-based Luo (2004) │
|
||
// │ inversive-distance circle packing implemented in │
|
||
// │ inversive_distance_functional.hpp (Phase 9a.2). The relation │
|
||
// │ I_ij = cos θ_e │
|
||
// │ identifies the two parametrisations (Glickenstein 2011 §5). │
|
||
// │ │
|
||
// │ DOFs │
|
||
// │ x[f_idx[f]] = ρ_f (face-dual log-radius) │
|
||
// │ f_idx[f] = −1 means f is pinned (ρ_f = 0, gauge fix) │
|
||
// │ │
|
||
// │ Constants │
|
||
// │ θ_e per edge intersection angle of the two face-circles │
|
||
// │ φ_f per face target sum of corner-angles inside the face │
|
||
// │ │
|
||
// │ Energy (BPS-2010 §6) │
|
||
// │ E(ρ) = Σ_f φ_f · ρ_f │
|
||
// │ + Σ_{(h,f=face(h)): │
|
||
// │ [ if opposite face exists ] │
|
||
// │ ½ p(θ*,Δρ)·Δρ + Λ(θ*+p) − θ*·ρ_left │
|
||
// │ [ else (boundary halfedge) ] │
|
||
// │ −2 θ*·ρ_left │
|
||
// │ ] │
|
||
// │ │
|
||
// │ where θ* = π − θ │
|
||
// │ Δρ = ρ_right − ρ_left │
|
||
// │ p(θ*, Δρ) = 2·atan( tan(θ*/2) · tanh(Δρ/2) ) │
|
||
// │ Λ = Clausen function (Lobachevsky) │
|
||
// │ │
|
||
// │ Gradient │
|
||
// │ Per face f: +φ_f │
|
||
// │ Per interior hf: −(p + θ*) added to G[face(h)] │
|
||
// │ Per boundary hf: −2 θ* added to G[face(h)] │
|
||
// │ │
|
||
// │ Hessian (analytic, BPS-2010 eq. 6.8; Java getHessian lines 127-166) │
|
||
// │ Per interior undirected edge e (connecting faces j and k): │
|
||
// │ h_jk = sin θ / (cosh Δρ − cos θ) │
|
||
// │ H[j,j] += h_jk, H[k,k] += h_jk, H[j,k] −= h_jk, H[k,j] −= h_jk │
|
||
// │ Pinned faces contribute nothing (their row/col is removed). │
|
||
// └──────────────────────────────────────────────────────────────────────────┘
|
||
//
|
||
// Halfedge convention (matches Java's "leftFace / rightFace"):
|
||
// For a directed halfedge h in CGAL::Surface_mesh:
|
||
// mesh.face(h) ≡ leftFace
|
||
// mesh.face(opposite(h)) ≡ rightFace (may be null on boundary)
|
||
// mesh.is_border(h) == true iff h has no face (h points outward).
|
||
// Property-map name prefix: "cf:" (face) and "ce:" (edge).
|
||
|
||
#include "conformal_mesh.hpp"
|
||
#include "constants.hpp"
|
||
#include "clausen.hpp"
|
||
#include <Eigen/Sparse>
|
||
#include <CGAL/boost/graph/iterator.h>
|
||
#include <vector>
|
||
#include <cmath>
|
||
#include <cstdint>
|
||
#include <iostream>
|
||
|
||
namespace conformallab {
|
||
|
||
// ── Property-map type aliases ────────────────────────────────────────────────
|
||
|
||
using CPFMapI = ConformalMesh::Property_map<Face_index, int>;
|
||
using CPFMapD = ConformalMesh::Property_map<Face_index, double>;
|
||
using CPEMapD = ConformalMesh::Property_map<Edge_index, double>;
|
||
|
||
// ── Persistent map bundle ─────────────────────────────────────────────────────
|
||
|
||
struct CPEuclideanMaps {
|
||
CPFMapI f_idx; ///< DOF index per face (−1 = pinned)
|
||
CPEMapD theta_e; ///< intersection angle per edge (default π/2 = orthogonal)
|
||
CPFMapD phi_f; ///< target face-angle sum (default 2π)
|
||
};
|
||
|
||
/// Attach the three CP-Euclidean property maps to `mesh` with default
|
||
/// values and return their handles.
|
||
///
|
||
/// Defaults:
|
||
/// * `theta_e[e] = π/2` for every edge — orthogonal circle packing
|
||
/// (Koebe-Andreev-Thurston).
|
||
/// * `phi_f[f] = 2π` for every face — flat target.
|
||
/// * `f_idx[f] = -1` for every face — all faces pinned initially;
|
||
/// call `assign_cp_euclidean_face_dof_indices()` next to assign
|
||
/// DOF indices to all faces except one gauge-pinned face.
|
||
///
|
||
/// The maps are named with the `"cf:"` / `"ce:"` prefixes
|
||
/// (cf = circle-packing-face, ce = circle-packing-edge) so they do
|
||
/// not collide with the Euclidean / Spherical / HyperIdeal maps.
|
||
///
|
||
/// \param mesh Input mesh. Modified in place: three property maps are
|
||
/// attached if not already present, otherwise the existing
|
||
/// maps are returned unchanged (CGAL property-map idempotence).
|
||
/// \returns A bundle of all three property maps for caller use.
|
||
inline CPEuclideanMaps setup_cp_euclidean_maps(ConformalMesh& mesh)
|
||
{
|
||
CPEuclideanMaps m;
|
||
m.f_idx = mesh.add_property_map<Face_index, int> ("cf:idx", -1 ).first;
|
||
m.theta_e = mesh.add_property_map<Edge_index, double>("ce:theta", PI / 2 ).first;
|
||
m.phi_f = mesh.add_property_map<Face_index, double>("cf:phi", TWO_PI ).first;
|
||
return m;
|
||
}
|
||
|
||
/// Assign sequential DOF indices `0..n-1` to all faces except `pinned`,
|
||
/// which receives the sentinel `-1` (gauge-fixed face, `ρ_pinned = 0`).
|
||
///
|
||
/// Mirrors the Java CPEuclideanFunctional's "skip face index 0"
|
||
/// convention from `evaluateEnergyAndGradient` (lines 184-185 of
|
||
/// CPEuclideanFunctional.java). The C++ port exposes the choice of
|
||
/// pinned face explicitly rather than hard-coding it.
|
||
///
|
||
/// \param mesh The mesh. Read for face iteration only; not modified.
|
||
/// \param m Map bundle whose `f_idx` is overwritten.
|
||
/// \param pinned The face whose DOF is fixed at zero (the gauge).
|
||
/// \returns The number of free DOFs assigned (`num_faces(mesh) - 1`).
|
||
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
||
CPEuclideanMaps& m,
|
||
Face_index pinned)
|
||
{
|
||
int idx = 0;
|
||
for (auto f : mesh.faces()) {
|
||
if (f == pinned) m.f_idx[f] = -1;
|
||
else m.f_idx[f] = idx++;
|
||
}
|
||
return idx;
|
||
}
|
||
|
||
/// Convenience overload: pin the **first** face in `mesh.faces()` order.
|
||
/// Use this when any face works as the gauge (typically true for
|
||
/// closed mesh experiments).
|
||
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
||
CPEuclideanMaps& m)
|
||
{
|
||
auto it = mesh.faces().begin();
|
||
if (it == mesh.faces().end()) return 0;
|
||
return assign_cp_euclidean_face_dof_indices(mesh, m, *it);
|
||
}
|
||
|
||
/// Count the free DOFs (faces with `f_idx >= 0`).
|
||
/// Equivalent to `num_faces(mesh) - <number of pinned faces>`.
|
||
inline int cp_euclidean_dimension(const ConformalMesh& mesh,
|
||
const CPEuclideanMaps& m)
|
||
{
|
||
int dim = 0;
|
||
for (auto f : mesh.faces()) if (m.f_idx[f] >= 0) ++dim;
|
||
return dim;
|
||
}
|
||
|
||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||
|
||
namespace cp_detail {
|
||
|
||
// p(θ*, Δρ) = 2·atan( tan(θ*/2) · tanh(Δρ/2) )
|
||
// Numerically stable form lifted directly from CPEuclideanFunctional.java
|
||
// (private method `p`, lines 243-247).
|
||
inline double p_function(double thStar, double dRho) noexcept
|
||
{
|
||
const double e = std::exp(dRho);
|
||
const double tanh_half = (e - 1.0) / (e + 1.0);
|
||
return 2.0 * std::atan(std::tan(0.5 * thStar) * tanh_half);
|
||
}
|
||
|
||
// DOF reader: returns 0 for the pinned face (idx = −1).
|
||
inline double dof_val(int idx, const std::vector<double>& x) noexcept
|
||
{
|
||
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
|
||
}
|
||
|
||
} // namespace cp_detail
|
||
|
||
// ── Energy ────────────────────────────────────────────────────────────────────
|
||
//
|
||
// Mirrors evaluateEnergyAndGradient in the Java code (lines 170-240) for the
|
||
// energy accumulation only. The gradient is computed in a dedicated function
|
||
// below for clarity.
|
||
inline double cp_euclidean_energy(const ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const CPEuclideanMaps& m)
|
||
{
|
||
using cp_detail::dof_val;
|
||
|
||
double E = 0.0;
|
||
|
||
// Per-face linear term: + φ_f · ρ_f
|
||
// (The pinned face has f_idx = −1; its ρ is fixed at 0 so it contributes nothing.)
|
||
for (auto f : mesh.faces()) {
|
||
const int i = m.f_idx[f];
|
||
if (i < 0) continue;
|
||
E += m.phi_f[f] * x[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
// Per directed halfedge term. Java iterates over `getEdges()` which in jtem
|
||
// yields one Edge per directed side; in CGAL we iterate halfedges directly.
|
||
for (auto h : mesh.halfedges()) {
|
||
if (mesh.is_border(h)) continue; // h is in the outer "border" face → skip
|
||
const Face_index fL = mesh.face(h);
|
||
const Halfedge_index ho = mesh.opposite(h);
|
||
const Face_index fR = mesh.is_border(ho) ? Face_index() : mesh.face(ho);
|
||
|
||
const double th = m.theta_e[mesh.edge(h)];
|
||
const double thStar = PI - th;
|
||
const double rho_L = dof_val(m.f_idx[fL], x);
|
||
|
||
if (fR == Face_index()) {
|
||
// Boundary halfedge: only the left face exists.
|
||
E += -2.0 * thStar * rho_L;
|
||
} else {
|
||
const double rho_R = dof_val(m.f_idx[fR], x);
|
||
const double dRho = rho_R - rho_L;
|
||
const double p = cp_detail::p_function(thStar, dRho);
|
||
E += 0.5 * p * dRho;
|
||
E += clausen2(thStar + p);
|
||
E += -thStar * rho_L;
|
||
}
|
||
}
|
||
|
||
return E;
|
||
}
|
||
|
||
// ── Gradient ──────────────────────────────────────────────────────────────────
|
||
//
|
||
// ∂E/∂ρ_f = φ_f − Σ_{h: face(h)=f, !is_border(h)} (p + θ*)
|
||
// OR (boundary): 2 θ*
|
||
inline std::vector<double> cp_euclidean_gradient(const ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const CPEuclideanMaps& m)
|
||
{
|
||
using cp_detail::dof_val;
|
||
|
||
const int n = cp_euclidean_dimension(mesh, m);
|
||
std::vector<double> G(static_cast<std::size_t>(n), 0.0);
|
||
|
||
// Per-face linear term.
|
||
for (auto f : mesh.faces()) {
|
||
const int i = m.f_idx[f];
|
||
if (i < 0) continue;
|
||
G[static_cast<std::size_t>(i)] += m.phi_f[f];
|
||
}
|
||
|
||
// Per directed halfedge term.
|
||
for (auto h : mesh.halfedges()) {
|
||
if (mesh.is_border(h)) continue;
|
||
const Face_index fL = mesh.face(h);
|
||
const int iL = m.f_idx[fL];
|
||
if (iL < 0) continue; // pinned face: gradient component is forced to 0
|
||
|
||
const Halfedge_index ho = mesh.opposite(h);
|
||
const Face_index fR = mesh.is_border(ho) ? Face_index() : mesh.face(ho);
|
||
|
||
const double th = m.theta_e[mesh.edge(h)];
|
||
const double thStar = PI - th;
|
||
const double rho_L = dof_val(iL, x);
|
||
|
||
if (fR == Face_index()) {
|
||
G[static_cast<std::size_t>(iL)] -= 2.0 * thStar;
|
||
} else {
|
||
const double rho_R = dof_val(m.f_idx[fR], x);
|
||
const double dRho = rho_R - rho_L;
|
||
const double p = cp_detail::p_function(thStar, dRho);
|
||
G[static_cast<std::size_t>(iL)] -= (p + thStar);
|
||
}
|
||
}
|
||
|
||
return G;
|
||
}
|
||
|
||
// ── Hessian (analytic) ────────────────────────────────────────────────────────
|
||
//
|
||
// Per interior undirected edge e with adjacent faces (j, k):
|
||
// h_jk = sin θ / (cosh(Δρ) − cos θ)
|
||
// Diagonal contributions on both endpoints; off-diagonal block is −h_jk.
|
||
// Pinned faces are skipped (their DOF index is −1 ⇒ excluded from the matrix).
|
||
inline Eigen::SparseMatrix<double> cp_euclidean_hessian(const ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const CPEuclideanMaps& m)
|
||
{
|
||
using cp_detail::dof_val;
|
||
|
||
const int n = cp_euclidean_dimension(mesh, m);
|
||
std::vector<Eigen::Triplet<double>> trips;
|
||
trips.reserve(static_cast<std::size_t>(4 * mesh.number_of_edges()));
|
||
|
||
for (auto e : mesh.edges()) {
|
||
const Halfedge_index h = mesh.halfedge(e);
|
||
const Halfedge_index ho = mesh.opposite(h);
|
||
if (mesh.is_border(h) || mesh.is_border(ho)) continue; // boundary edge
|
||
|
||
const int j = m.f_idx[mesh.face(h)];
|
||
const int k = m.f_idx[mesh.face(ho)];
|
||
|
||
const double rho_j = dof_val(j, x);
|
||
const double rho_k = dof_val(k, x);
|
||
const double dRho = rho_k - rho_j;
|
||
const double th = m.theta_e[e];
|
||
const double hjk = std::sin(th) / (std::cosh(dRho) - std::cos(th));
|
||
|
||
if (j >= 0) trips.emplace_back(j, j, hjk);
|
||
if (k >= 0) trips.emplace_back(k, k, hjk);
|
||
if (j >= 0 && k >= 0) {
|
||
trips.emplace_back(j, k, -hjk);
|
||
trips.emplace_back(k, j, -hjk);
|
||
}
|
||
}
|
||
|
||
Eigen::SparseMatrix<double> H(n, n);
|
||
H.setFromTriplets(trips.begin(), trips.end());
|
||
return H;
|
||
}
|
||
|
||
// ── Finite-difference gradient check ─────────────────────────────────────────
|
||
//
|
||
// Mirrors the Java FunctionalTest pattern:
|
||
// For each DOF i, compare analytic G[i] to (E(x+ε·e_i) − E(x−ε·e_i)) / (2ε).
|
||
// Default tolerance 1e-6 with ε = 1e-5 leaves ~3 digits of margin for sane meshes.
|
||
inline bool gradient_check_cp_euclidean(const ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const CPEuclideanMaps& m,
|
||
double eps = 1e-5,
|
||
double tol = 1e-6)
|
||
{
|
||
auto G = cp_euclidean_gradient(mesh, x, m);
|
||
const std::size_t n = G.size();
|
||
|
||
for (std::size_t i = 0; i < n; ++i) {
|
||
std::vector<double> xp = x, xm = x;
|
||
xp[i] += eps;
|
||
xm[i] -= eps;
|
||
const double Ep = cp_euclidean_energy(mesh, xp, m);
|
||
const double Em = cp_euclidean_energy(mesh, xm, m);
|
||
const double fd = (Ep - Em) / (2.0 * eps);
|
||
if (std::abs(G[i] - fd) > tol) {
|
||
std::cerr << "[cp-euclidean] FD gradient mismatch at DOF " << i
|
||
<< ": analytic=" << G[i]
|
||
<< " FD=" << fd
|
||
<< " diff=" << (G[i] - fd) << "\n";
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ── Finite-difference Hessian check ──────────────────────────────────────────
|
||
//
|
||
// Verifies analytic H against ( G(x+ε·e_i) − G(x−ε·e_i) ) / (2ε) column-wise.
|
||
// Symmetry is implicit in the analytic form; we check both off-diagonal entries.
|
||
inline bool hessian_check_cp_euclidean(const ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const CPEuclideanMaps& m,
|
||
double eps = 1e-5,
|
||
double tol = 1e-5)
|
||
{
|
||
const auto H = cp_euclidean_hessian(mesh, x, m);
|
||
const int n = static_cast<int>(H.rows());
|
||
|
||
for (int j = 0; j < n; ++j) {
|
||
std::vector<double> xp = x, xm = x;
|
||
xp[static_cast<std::size_t>(j)] += eps;
|
||
xm[static_cast<std::size_t>(j)] -= eps;
|
||
auto Gp = cp_euclidean_gradient(mesh, xp, m);
|
||
auto Gm = cp_euclidean_gradient(mesh, xm, m);
|
||
|
||
for (int i = 0; i < n; ++i) {
|
||
double fd = (Gp[static_cast<std::size_t>(i)] - Gm[static_cast<std::size_t>(i)])
|
||
/ (2.0 * eps);
|
||
double an = H.coeff(i, j);
|
||
if (std::abs(an - fd) > tol) {
|
||
std::cerr << "[cp-euclidean] FD Hessian mismatch at ("
|
||
<< i << "," << j << "): analytic=" << an
|
||
<< " FD=" << fd
|
||
<< " diff=" << (an - fd) << "\n";
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
} // namespace conformallab
|