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>
236 lines
11 KiB
C++
236 lines
11 KiB
C++
#pragma once
|
||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// hyper_ideal_hessian.hpp
|
||
//
|
||
// Phase 4a — Hessian of the hyper-ideal discrete conformal functional.
|
||
// Phase 9b — Block-finite-difference Hessian (intermediate optimisation).
|
||
//
|
||
// ┌──────────────────────────────────────────────────────────────────────────┐
|
||
// │ Implementation strategy │
|
||
// │ │
|
||
// │ The hyper-ideal functional involves angle functions (ζ, σ, α, β) │
|
||
// │ composed through several nested layers (lij → ζ13/14/15 → β/α). │
|
||
// │ Deriving closed-form Hessian entries analytically through all these │
|
||
// │ layers is feasible but lengthy and is deferred to a future PR. │
|
||
// │ │
|
||
// │ TWO Hessian implementations are provided here: │
|
||
// │ │
|
||
// │ 1. `hyper_ideal_hessian` — full finite-difference baseline. │
|
||
// │ Cost ≈ n × (cost of full gradient evaluation) │
|
||
// │ = O(n · F) where n = #DOFs and F = #faces. │
|
||
// │ Used for correctness reference and small meshes. │
|
||
// │ │
|
||
// │ 2. `hyper_ideal_hessian_block_fd` — block-local finite-difference, │
|
||
// │ Phase 9b. Exploits the fact that each face contributes to the │
|
||
// │ gradient through exactly 6 DOFs (3 vertex b_i + 3 edge a_e). │
|
||
// │ Cost ≈ F × 6 × (cost of a single face-angle evaluation) │
|
||
// │ = O(36 · F). │
|
||
// │ Speed-up factor ≈ n / 36, i.e. typically 10–50× on V > 200. │
|
||
// │ │
|
||
// │ Both produce the same Hessian to O(ε²) and pass identical PSD checks. │
|
||
// │ The block-FD variant is the production default; the full-FD variant is │
|
||
// │ kept for cross-validation tests. │
|
||
// │ │
|
||
// │ An analytic Hessian via Schläfli-type differentiation through the chain │
|
||
// │ (bᵢ, aₑ) → lᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ / βᵢ │
|
||
// │ is deferred to a future PR (Phase 9b-analytic). Speed-up would be │
|
||
// │ another ~6×, taking the cost to O(F). │
|
||
// │ │
|
||
// │ The hyper-ideal energy is strictly convex (Springborn 2020), so H is │
|
||
// │ positive semi-definite everywhere and Eigen::SimplicialLDLT applies │
|
||
// │ directly to either Hessian variant. │
|
||
// └──────────────────────────────────────────────────────────────────────────┘
|
||
//
|
||
// Note on the Java reference: HyperIdealFunctional.java line 295-298 declares
|
||
// public boolean hasHessian() { return false; }
|
||
// — i.e. the upstream Java implementation supplies NO Hessian, analytic or
|
||
// numerical. Both `hyper_ideal_hessian` and `hyper_ideal_hessian_block_fd`
|
||
// are conformallab++ additions beyond Java parity.
|
||
|
||
#include "hyper_ideal_functional.hpp"
|
||
#include <Eigen/Sparse>
|
||
#include <vector>
|
||
#include <cmath>
|
||
#include <cstdint>
|
||
|
||
namespace conformallab {
|
||
|
||
// ── Full finite-difference Hessian (baseline, Phase 4a) ──────────────────────
|
||
//
|
||
// Returns the n×n sparse Hessian, where n = hyper_ideal_dimension(mesh, m).
|
||
// eps: finite-difference step size (default 1e-5 gives ~1e-10 relative error).
|
||
//
|
||
// Cost: n × full-gradient evaluations ≈ O(n·F). Use for small meshes or
|
||
// as a correctness reference for the block-FD variant below.
|
||
inline Eigen::SparseMatrix<double> hyper_ideal_hessian(
|
||
ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const HyperIdealMaps& m,
|
||
double eps = 1e-5)
|
||
{
|
||
const int n = hyper_ideal_dimension(mesh, m);
|
||
std::vector<Eigen::Triplet<double>> trips;
|
||
trips.reserve(static_cast<std::size_t>(n * n));
|
||
|
||
std::vector<double> xp = x, xm = x;
|
||
|
||
for (int j = 0; j < n; ++j) {
|
||
const std::size_t sj = static_cast<std::size_t>(j);
|
||
xp[sj] = x[sj] + eps;
|
||
xm[sj] = x[sj] - eps;
|
||
|
||
auto Gp = evaluate_hyper_ideal(mesh, xp, m, /*energy=*/false).gradient;
|
||
auto Gm = evaluate_hyper_ideal(mesh, xm, m, /*energy=*/false).gradient;
|
||
|
||
xp[sj] = xm[sj] = x[sj]; // restore
|
||
|
||
for (int i = 0; i < n; ++i) {
|
||
double val = (Gp[static_cast<std::size_t>(i)]
|
||
- Gm[static_cast<std::size_t>(i)]) / (2.0 * eps);
|
||
if (std::abs(val) > 1e-15)
|
||
trips.emplace_back(i, j, val);
|
||
}
|
||
}
|
||
|
||
Eigen::SparseMatrix<double> H(n, n);
|
||
H.setFromTriplets(trips.begin(), trips.end());
|
||
return H;
|
||
}
|
||
|
||
// ── Symmetrised Hessian (full-FD variant) ────────────────────────────────────
|
||
//
|
||
// The FD Hessian is symmetric in exact arithmetic; floating-point rounding
|
||
// can introduce tiny asymmetries. This helper returns (H + Hᵀ)/2.
|
||
inline Eigen::SparseMatrix<double> hyper_ideal_hessian_sym(
|
||
ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const HyperIdealMaps& m,
|
||
double eps = 1e-5)
|
||
{
|
||
auto H = hyper_ideal_hessian(mesh, x, m, eps);
|
||
Eigen::SparseMatrix<double> Ht = H.transpose();
|
||
return (H + Ht) * 0.5;
|
||
}
|
||
|
||
// ── Block-FD Hessian (Phase 9b) ──────────────────────────────────────────────
|
||
//
|
||
// Computes the Hessian by FD on each face's 6×6 local block. The 6 local
|
||
// DOFs of a face f are:
|
||
// (b_{v1}, b_{v2}, b_{v3}, a_{e12}, a_{e23}, a_{e31}).
|
||
// For each face we recompute the 6 output angles (β₁,β₂,β₃,α₁₂,α₂₃,α₃₁)
|
||
// at x ± ε along each local axis and read off the 6×6 Jacobian. The result
|
||
// scatters into the global Hessian via the DOF-index lookup.
|
||
//
|
||
// Why this is correct:
|
||
// ─────────────────────
|
||
// The global gradient decomposes by face:
|
||
// G_b_v = Σ_{f ∋ v} β_v(f) − Θ_v
|
||
// G_a_e = Σ_{f ∋ e} α_e(f) − θ_e
|
||
// Since β and α at face f depend ONLY on the 6 local DOFs of f, the
|
||
// Hessian also decomposes:
|
||
// ∂G_x/∂y = Σ_{f: x,y ∈ local(f)} ∂(β or α)/∂y at f.
|
||
// So accumulating per-face 6×6 blocks reproduces the full Hessian.
|
||
//
|
||
// Cost: F × 12 face-angle evaluations (6 DOFs × 2 directions).
|
||
// On a tetrahedron (F=4, n≈10): 48 face evaluations
|
||
// vs full-FD ≈ 80 → ~1.7× speed-up.
|
||
// On cathead.obj (F=248, n≈400): 2976 face evaluations
|
||
// vs full-FD ≈ 99,200 → ~33× speed-up.
|
||
// On brezel.obj (F=13824, n≈14000): 165 888 face evaluations
|
||
// vs full-FD ≈ 193 M → ~1166× speed-up.
|
||
inline Eigen::SparseMatrix<double> hyper_ideal_hessian_block_fd(
|
||
ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const HyperIdealMaps& m,
|
||
double eps = 1e-5)
|
||
{
|
||
const int n = hyper_ideal_dimension(mesh, m);
|
||
std::vector<Eigen::Triplet<double>> trips;
|
||
trips.reserve(36 * mesh.number_of_faces());
|
||
|
||
for (auto f : mesh.faces()) {
|
||
Halfedge_index h0 = mesh.halfedge(f);
|
||
Halfedge_index h1 = mesh.next(h0);
|
||
Halfedge_index h2 = mesh.next(h1);
|
||
|
||
Vertex_index v1 = mesh.source(h0);
|
||
Vertex_index v2 = mesh.source(h1);
|
||
Vertex_index v3 = mesh.source(h2);
|
||
Edge_index e12 = mesh.edge(h0);
|
||
Edge_index e23 = mesh.edge(h1);
|
||
Edge_index e31 = mesh.edge(h2);
|
||
|
||
// Local DOF indices: (b1, b2, b3, a12, a23, a31). Pinned slots = -1.
|
||
const int idx[6] = {
|
||
m.v_idx[v1], m.v_idx[v2], m.v_idx[v3],
|
||
m.e_idx[e12], m.e_idx[e23], m.e_idx[e31]
|
||
};
|
||
const bool v1b = idx[0] >= 0;
|
||
const bool v2b = idx[1] >= 0;
|
||
const bool v3b = idx[2] >= 0;
|
||
|
||
// Local DOF values (0 for pinned).
|
||
const double vals[6] = {
|
||
dof_val(idx[0], x), dof_val(idx[1], x), dof_val(idx[2], x),
|
||
dof_val(idx[3], x), dof_val(idx[4], x), dof_val(idx[5], x)
|
||
};
|
||
|
||
// For each free local DOF, evaluate the 6 outputs at ±ε.
|
||
// We never perturb a pinned DOF (its column would be physically zero
|
||
// because it is not part of the DOF vector at all).
|
||
for (int j = 0; j < 6; ++j) {
|
||
if (idx[j] < 0) continue;
|
||
|
||
double vp[6], vm[6];
|
||
for (int k = 0; k < 6; ++k) { vp[k] = vm[k] = vals[k]; }
|
||
vp[j] += eps;
|
||
vm[j] -= eps;
|
||
|
||
auto Op = face_angles_from_local_dofs(
|
||
vp[0], vp[1], vp[2], vp[3], vp[4], vp[5], v1b, v2b, v3b);
|
||
auto Om = face_angles_from_local_dofs(
|
||
vm[0], vm[1], vm[2], vm[3], vm[4], vm[5], v1b, v2b, v3b);
|
||
|
||
const double Gp[6] = {
|
||
Op.beta1, Op.beta2, Op.beta3,
|
||
Op.alpha12, Op.alpha23, Op.alpha31
|
||
};
|
||
const double Gm[6] = {
|
||
Om.beta1, Om.beta2, Om.beta3,
|
||
Om.alpha12, Om.alpha23, Om.alpha31
|
||
};
|
||
|
||
for (int i = 0; i < 6; ++i) {
|
||
if (idx[i] < 0) continue; // pinned: contributes nothing
|
||
const double val = (Gp[i] - Gm[i]) / (2.0 * eps);
|
||
if (std::abs(val) > 1e-15)
|
||
trips.emplace_back(idx[i], idx[j], val);
|
||
}
|
||
}
|
||
}
|
||
|
||
Eigen::SparseMatrix<double> H(n, n);
|
||
H.setFromTriplets(trips.begin(), trips.end());
|
||
return H;
|
||
}
|
||
|
||
// ── Symmetrised block-FD Hessian ─────────────────────────────────────────────
|
||
//
|
||
// The per-face block is symmetric to within FD rounding, but the
|
||
// accumulation may amplify tiny asymmetries. This helper returns
|
||
// (H + Hᵀ)/2, identical in spirit to `hyper_ideal_hessian_sym`.
|
||
inline Eigen::SparseMatrix<double> hyper_ideal_hessian_block_fd_sym(
|
||
ConformalMesh& mesh,
|
||
const std::vector<double>& x,
|
||
const HyperIdealMaps& m,
|
||
double eps = 1e-5)
|
||
{
|
||
auto H = hyper_ideal_hessian_block_fd(mesh, x, m, eps);
|
||
Eigen::SparseMatrix<double> Ht = H.transpose();
|
||
return (H + Ht) * 0.5;
|
||
}
|
||
|
||
} // namespace conformallab
|