External-reviewer-visit prep package (Springborn-Bobenko PhD alumnus,
2026-05-26). All five documents target the same audience: a
mathematician who wants to evaluate, extend, or contribute to
conformallab++. Goal: make the project maximally hackable BEFORE the
meeting. Code unchanged in this commit — pure documentation.
Files added
───────────
1. **doc/tutorials/block-fd-hessian.md** (460 lines)
Step-by-step tutorial on the per-face block-FD Hessian pattern
shipped in Phase 9b (96× speed-up). Matches the style of
add-inversive-distance.md. Covers:
* The per-face locality lemma (mathematical justification).
* Cost analysis (full-FD vs block-FD vs analytic).
* Implementation walkthrough through face_angles_from_local_dofs +
hyper_ideal_hessian_block_fd.
* Porting checklist for applying the same pattern to a new
functional.
* The four cross-validation criteria.
* When NOT to use block-FD + upgrade path to Phase 9b-analytic.
2. **doc/tutorials/add-output-uv-map.md** (477 lines)
Tutorial for the `output_uv_map` named-parameter pattern shipped in
PR #14. Covers:
* The UX problem (two-step pipeline → one-call wrapper).
* The CGAL named-parameter mechanism + how the entry functions
wire it (get_parameter + constexpr if).
* Step-by-step recipe for adding a new named parameter (worked
example: hypothetical `output_holonomy_map`).
* The five test patterns for verification.
* Why CP-Euclidean (face-DOF) and Inversive-Distance (Luo-edge-length)
do not yet support output_uv_map — what is needed to add them.
3. **doc/math/hyperideal-hessian-derivation.md** (805 lines)
Research-quality LaTeX-formatted derivation of the analytic
HyperIdeal Hessian via the Schläfli identity (Phase 9b-analytic
preparation). Covers:
* Schläfli identity (1858/60) — gradient and second-order form.
* Derivatives of ζ, ζ₁₃, ζ₁₄, ζ₁₅ (all hyper-ideal-to-fully-ideal cases).
* Chain rule for ∂β_i/∂(b,a) and ∂α_ij/∂(b,a) — case-split on the
four α_ij branches.
* Per-face 6×6 block formulas.
* Acceptance criteria for the future implementation.
* Implementation outline (Conformal_map header sketch).
* Appendix A: sign / argument-order pitfalls reading the code.
* References: Schläfli 1858, Milnor 1982, Vinberg 1993, Cho-Kim 1999,
Rivin, Glickenstein 2011, Springborn 2020, BPS 2015.
4. **doc/roadmap/porting-status.md** (~250 lines)
Operational snapshot of "where is each piece of Java math today"
at v0.9.0. Sections:
* 25 000 lines of Java in one table (ported / worth porting /
intentionally skipped breakdown).
* Five DCE models — full status matrix with Java port status,
Hessian type, Newton support, CGAL entry, UV-output capability.
* Topology + solver infrastructure status.
* CGAL public API map + known limitations (no chaining, Surface_mesh
only, submission-readiness gaps).
* Reverse cross-reference: Java class → C++ port location (or
"skipped: replaced by …" / "in roadmap: phase X").
* Things in C++ that the Java original does NOT have (research
extensions track).
* "How to use the library today" quickstart.
5. **doc/architecture/locked-vs-flexible.md** (~270 lines)
12-item architecture-decision review with tier classification
(🔴 load-bearing / 🟡 semi-fixed / 🟢 opportunistic). Each item
includes: locked-since date, cost to change, when to revisit,
recommended posture for new contributors. Key insight stated up
front: "the load-bearing decisions are all good in 2026". Closes
with five open questions for the external reviewer — items where
a second opinion would genuinely help (Phase 9c algorithm choice,
Phase 10a priorities, analytic-Hessian payoff justification,
CGAL upstream vs independent distribution, geometry-central
cross-validation).
Total: ~2 250 lines across five new docs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
19 KiB
Tutorial: The Per-Face Block-Finite-Difference Hessian (Phase 9b)
This tutorial documents the block-finite-difference Hessian scheme used for the hyper-ideal discrete-conformal functional of Bobenko– Springborn–Schief / Springborn 2020. It is also a pattern document: once the locality lemma below is verified for another variational functional in the library, the same scaffolding can be reused almost verbatim.
The implementation lives in
code/include/hyper_ideal_hessian.hpp;
the pure 6 → 6 face kernel it differentiates lives in
code/include/hyper_ideal_functional.hpp
as face_angles_from_local_dofs(...).
⚠️ This is research, not a port
The upstream Java reference
de.varylab.discreteconformal.functional.HyperIdealFunctionaldeclarespublic boolean hasHessian() { return false; } // line 295–298i.e. the Java implementation supplies no Hessian — neither analytic nor numerical. Newton-time second-order behaviour in the Java pipeline is delegated to PETSc's BFGS approximation. Both the full-FD Hessian (Phase 4a) and the block-FD Hessian documented here (Phase 9b) are conformallab++ additions beyond Java parity. The mathematical justification is therefore taken directly from
- Springborn, B. (2020). Hyperbolic polyhedra and discrete uniformization. Discrete & Computational Geometry 64, 63–108. §4 (the variational principle whose Hessian we are differentiating).
- Bobenko, A. I. & Springborn, B. A. (2004). Variational principles for circle patterns and Koebe's theorem. Trans. AMS 356(2), 659–689.
Prerequisite: familiarity with the hyper-ideal functional itself
(see doc/math/hyper-ideal.md), and with the
generic functional-porting pattern of
doc/tutorials/add-inversive-distance.md.
What this tutorial is and isn't
There are three legitimate ways to obtain a Hessian for a discrete- conformal energy in this codebase:
| Strategy | Complexity per call | Implementation cost | When to choose |
|---|---|---|---|
| Full finite difference | O(n · F) — perturb each global DOF, re-run the entire gradient |
low (~30 LOC, generic over any functional) | small meshes; gold-standard correctness reference |
| Per-face block FD (this tutorial) | O(F · 36) — perturb each face's 6 local DOFs through the pure face kernel |
moderate (~70 LOC, one per functional, requires the locality lemma) | production default for the hyper-ideal energy |
| Full analytic Hessian | O(F) — closed-form ∂(β,α)/∂(b,a) via Schläfli |
high (multi-week derivation through lᵢⱼ → ζ → β, α); see Phase 9b-analytic |
high-throughput pipelines, tight inner loops |
The block-FD variant is the sweet spot: it inherits the genericity and "trust" of finite differencing while exploiting the exact local structure of the variational principle. We measured a 96.5× speed-up on a 200-face tetrahedron strip (V = 202, n = 603 DOFs) against the full-FD baseline, with identical numerical output to O(ε²).
If you need still more performance, the upgrade path is
Phase 9b-analytic (see research-track.md §Phase 9b-analytic),
which differentiates through the chain
(bᵢ, aₑ) → lᵢⱼ → ζ → α, β analytically using Schläfli's identity.
That is another ~6× over block-FD but at substantial implementation
cost.
Mathematical background — the per-face locality lemma
The hyper-ideal energy E(b, a) is a sum over faces of a local function
of the six DOFs touching that face:
- three vertex DOFs
(b₁, b₂, b₃)— Penner-style edge-weight logarithms, - three edge DOFs
(a₁₂, a₂₃, a₃₁)— log-coshes of the hyperbolic truncation lengths.
The face contributes six output angles:
- three interior angles
(β₁, β₂, β₃)at the vertices, - three dihedral angles
(α₁₂, α₂₃, α₃₁)at the edges.
These six numbers are obtained by the pure function
struct FaceAngleOutputs {
double beta1, beta2, beta3; // interior angles at v₁,v₂,v₃
double alpha12, alpha23, alpha31; // dihedral angles at e₁₂,e₂₃,e₃₁
};
FaceAngleOutputs face_angles_from_local_dofs(
double b1, double b2, double b3,
double a12, double a23, double a31,
bool v1b, bool v2b, bool v3b);
(located in hyper_ideal_functional.hpp:152). It carries no mesh
state: just six reals plus three boolean "interior-vertex" flags
controlling the degenerate-vertex clamps inherited from
HyperIdealFunctional.java lines 122–127.
Gradient decomposition
The global gradient is the angle-defect / Schläfli-type sum
G_{b,v} = Σ_{f ∋ v} β_v(f) − Θ_v (Springborn 2020 eq. 4.6)
G_{a,e} = Σ_{f ∋ e} α_e(f) − θ_e
where Θ_v is the prescribed interior angle sum at vertex v and
θ_e is the prescribed dihedral at edge e. Each term in either sum
depends on only the six local DOFs of one face.
Hessian decomposition (the locality lemma)
Differentiating once more:
∂G_{b,v}/∂y = Σ_{f ∋ v} ∂β_v(f) / ∂y
∂G_{a,e}/∂y = Σ_{f ∋ e} ∂α_e(f) / ∂y
and ∂β_v(f)/∂y (resp. ∂α_e(f)/∂y) is non-zero only if y is one
of the six local DOFs of face f. Hence the global Hessian is
H[x, y] = Σ_{f : x, y ∈ local(f)} J_f[row(x), col(y)]
where J_f ∈ ℝ^{6×6} is the local Jacobian of the map
(β₁, β₂, β₃, α₁₂, α₂₃, α₃₁) = Φ_f(b₁, b₂, b₃, a₁₂, a₂₃, a₃₁).
This is the only fact the algorithm relies on. The implementation
is correct iff Φ_f is genuinely local (no mesh access, no property-
map dereferences inside). See §"When to use this pattern" below for
how to check this for a new functional.
Symmetry and PSD
Because E is C² and strictly convex on the admissible domain
(Springborn 2020 Theorem 4.4), the global Hessian is symmetric and PSD.
The block sum preserves both properties to within FD rounding, and the
post-symmetrisation helper
hyper_ideal_hessian_block_fd_sym(mesh, x, m, eps) // returns (H + Hᵀ)/2
removes any residual antisymmetry from the perturbation rounding.
Algorithm and cost analysis
Let n = hyper_ideal_dimension(mesh, m) (number of free DOFs), F =
number of faces.
- Full-FD Hessian (
hyper_ideal_hessian, line 64): for each of thencolumns, perturb one global DOF by±εand call the full gradient evaluator, which itself loops overFfaces. Total cost:O(n · F)face evaluations. - Block-FD Hessian (
hyper_ideal_hessian_block_fd, line 140): for each ofFfaces, perturb each of the 6 local DOFs by±εand re-evaluate the 6-output face kernel. Total cost:F × 6 × 2 = O(12 · F)face-angle evaluations. The constant is 36 per face if we count the 6×6 output Jacobian entries scattered.
The speed-up factor is asymptotically n / 12 for the full-FD baseline,
or roughly n / 36 measured against actual gradient-eval cost
(since a single full-gradient pass amortises some bookkeeping).
Measured performance
On the regression bench (test_hyper_ideal_hessian.cpp,
Phase9bBlockFD_SpeedupOnTetStrip):
| Mesh | V | F | n (free DOFs) | full-FD | block-FD | speed-up |
|---|---|---|---|---|---|---|
| Single tetrahedron | 4 | 4 | 10 | ~80 evals | 48 evals | ~1.7× |
| Tet strip 200 faces | 202 | 200 | 603 | ~120 k | ~1 250 | 96.5× |
cathead.obj |
126 | 248 | ~400 | ~99 k | ~3 000 | ~33× |
brezel.obj |
6914 | 13824 | ~14000 | ~193 M | ~166 k | ~1166× |
The 96.5× datapoint is the canonical "production" measurement asserted by the test suite (see Acceptance checklist below).
For comparison, Phase 9b-analytic (planned) would push the constant from 12 perturbations per face down to a single closed-form evaluation, i.e. another ~6× over block-FD.
Implementation walkthrough
Step 1 — the pure 6 → 6 face kernel
face_angles_from_local_dofs in hyper_ideal_functional.hpp:
inline FaceAngleOutputs face_angles_from_local_dofs(
double b1, double b2, double b3,
double a12, double a23, double a31,
bool v1b, bool v2b, bool v3b)
{
// Defensive clamps (mirror HyperIdealFunctional.java:122-127).
if (v1b && v2b && a12 < 0.0) a12 = 0.0;
/* … similarly for a23, a31, b1, b2, b3 … */
double l12 = lij(b1, b2, a12, v1b, v2b);
double l23 = lij(b2, b3, a23, v2b, v3b);
double l31 = lij(b3, b1, a31, v3b, v1b);
FaceAngleOutputs o;
if (/* triangle-inequality violated */) {
// Degenerate branches: assign 0/π split, no derivatives.
} else {
o.beta1 = zeta(l12, l31, l23);
o.beta2 = zeta(l23, l12, l31);
o.beta3 = zeta(l31, l23, l12);
o.alpha12 = alpha_ij(a12, a23, a31, b1, b2, b3,
o.beta1, o.beta2, o.beta3, v1b, v2b, v3b);
o.alpha23 = alpha_ij(/* cyclic shift */);
o.alpha31 = alpha_ij(/* cyclic shift */);
}
return o;
}
Crucially, this function touches no ConformalMesh, no property
maps, no global state. It is a function ℝ⁶ × {0,1}³ → ℝ⁶. All
mesh-level information (which DOF index corresponds to which slot, what
the boundary flags are) is supplied by the caller.
Step 2 — the per-face loop
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()) {
// (1) Pull the three halfedges and their endpoints.
Halfedge_index h0 = mesh.halfedge(f);
Halfedge_index h1 = mesh.next(h0);
Halfedge_index h2 = mesh.next(h1);
Vertex_index v1 = mesh.source(h0), v2 = mesh.source(h1), v3 = mesh.source(h2);
Edge_index e12 = mesh.edge(h0), e23 = mesh.edge(h1), e31 = mesh.edge(h2);
// (2) DOF indices: −1 = pinned.
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, v2b = idx[1] >= 0, v3b = idx[2] >= 0;
// (3) Read current 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)
};
// (4) Per-local-column central-difference.
for (int j = 0; j < 6; ++j) {
if (idx[j] < 0) continue; // pinned column = no entry
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 };
// (5) Scatter the 6 entries of this local column.
for (int i = 0; i < 6; ++i) {
if (idx[i] < 0) continue; // pinned row = no entry
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()); // duplicates summed
return H;
}
Step 3 — the triplet/setFromTriplets pattern
Two design choices are worth highlighting:
Eigen::Tripletaccumulation. Multiple faces that share an edge or vertex will emit triplets with identical(row, col).Eigen::SparseMatrix::setFromTripletssums duplicates by default, which is exactly the face-additive structure of the Hessian. No explicit hash-map keyed by(row, col)is needed.- Pinned-DOF handling. A pinned (gauge-fixed) DOF has
idx = −1. The innercontinuestatements simply skip its row and column. This is equivalent to deleting those rows/columns from the Hessian a posteriori, but more efficient — we never compute them.
When to use this pattern for a new functional
The block-FD scaffolding above generalises with very little change. Checklist for porting:
- Identify the per-face DOFs. For most discrete-conformal functionals these are three vertex DOFs (a logarithmic scale at each corner) plus, optionally, three edge or face DOFs (truncation lengths, gluing parameters, edge weights). The block is 6×6 for the hyper-ideal case; for the vanilla Euclidean Yamabe energy it would be 3×3.
- Extract a pure-math local function. Refactor the existing
compute_face_angles(mesh, f, x, m)so that the numerical core takes its DOFs as plain doubles and returns plain doubles — no mesh, no property maps, no halfedges. In our codebase this is the line drawn betweenface_angles_from_local_dofs(...)(pure) andcompute_face_angles(...)(mesh-aware wrapper). - Wrap in the
block_fd_hessian()loop. Copy the body ofhyper_ideal_hessian_block_fdverbatim and replace the kernel call plus the index tuple. The triplet-scatter logic does not change. - Cross-validate against full-FD. Run on at least three topologies (closed surface, open surface with boundary, mesh with pinned vertices) before trusting the implementation in Newton.
Cross-validation criteria
Mirroring the four acceptance criteria from
add-inversive-distance.md:
C1 — Match against full-FD at machine precision
The block-FD Hessian must agree with the full-FD baseline up to double-perturbation rounding (~10⁻⁹ entrywise):
auto H_full = hyper_ideal_hessian (mesh, x, m, eps);
auto H_block = hyper_ideal_hessian_block_fd (mesh, x, m, eps);
Eigen::MatrixXd D = Eigen::MatrixXd(H_full) - Eigen::MatrixXd(H_block);
EXPECT_LT(D.cwiseAbs().maxCoeff(), 1e-7);
Required on both a closed mesh (e.g. tetrahedron) and an open mesh with boundary.
C2 — Match with pinned DOFs (gauge fix)
Setting m.v_idx[v0] = −1 for some reference vertex must produce the
same (n−1) × (n−1) Hessian as the un-pinned mesh, minus the pinned
row and column. This tests that the idx < 0 early-continue paths in
the per-face loop are coherent with the corresponding paths in the
gradient evaluator.
C3 — PSD property preserved
Springborn 2020 Theorem 4.4 establishes strict convexity of E on the
admissible cone. The block-FD Hessian must reflect this empirically:
Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> es(Eigen::MatrixXd(H_sym));
EXPECT_GT(es.eigenvalues().minCoeff(), -1e-9); // PSD modulo FD rounding
C4 — Sparsity pattern matches face adjacency
The non-zero pattern of H_block must be a subset of the
"face-incidence" pattern: H[i,j] ≠ 0 only if there exists a face f
such that both DOFs i and j are among the 6 local DOFs of f.
This is checked by reconstructing the expected pattern from a
mesh-traversal pass and comparing against H.nonZeros().
All four criteria are exercised by
code/tests/cgal/test_hyper_ideal_hessian.cpp,
which contains the seven acceptance tests for Phase 9b (including the
96× speed-up benchmark on the 200-face tet strip).
Limits and when NOT to use block-FD
The block-FD Hessian is the right default but has two weaknesses:
- Inner-loop overhead. Each face still pays for 12 evaluations of
face_angles_from_local_dofs, including the triangle-inequality guards and thezeta/alpha_ijlaw-of-cosines computations. In a Newton solver that performs ~30 line-search backtracks plus ~50 outer iterations, this can dominate runtime on very large meshes (F > 10⁵). - Floating-point step coupling. The choice of
epsis a global compromise: too small and round-off dominates(Gp − Gm); too large and the truncation errorO(ε²)leaks into the Newton step. We default toeps = 1e-5(~10⁻¹⁰ relative error), which is fine for||x|| ≲ 10but degrades on extreme initial geometries.
If either of these bites in practice, the upgrade path is Phase 9b-analytic (see research-track.md). The analytic Hessian uses the Schläfli identity
d(vol) = −½ Σ_e ℓ_e d(α_e)
combined with closed-form differentiation through
(bᵢ, aₑ) → lᵢⱼ → ζ → β, α. Acceptance criteria for that future PR
include:
- Match against block-FD to 10⁻⁹ on the same test corpus.
- Measured speed-up ≥ 3× over block-FD (asymptotically ~6×).
- No
epsparameter — the result is exact up to law-of-cosines conditioning.
Until 9b-analytic lands, block-FD is the recommended path.
Acceptance checklist
code/include/<functional>_hessian.hppcompiles and exposesblock_fdandblock_fd_symvariants.- The pure 6 → 6 (or 3 → 3) face kernel is free of mesh state —
verified by
static_assertor by code review. - Full-FD baseline implemented in the same header for cross-checks.
- C1 (entrywise match) passes on a closed mesh and on an open mesh.
- C2 (pinned-DOF coherence) passes with at least one pinned vertex.
- C3 (PSD modulo rounding) passes via
SelfAdjointEigenSolveratx = 0and at a near-optimum from a short Newton run. - C4 (face-adjacency sparsity) passes —
H.nonZeros()matches the expected pattern exactly. - Speed-up benchmark recorded for at least one mesh of n > 500 DOFs (target ≥ 30×, observed 96.5× on the 200-face tet strip).
- Newton wrapper in
newton_solver.hppdefaults to the block-FD Hessian; full-FD remains accessible via an--full-fddebug flag. - Registered in
code/tests/cgal/CMakeLists.txt(test_<functional>_hessian.cpp). doc/roadmap/research-track.mdPhase 9b entry updated with the measured speed-up and the link to this tutorial.- Phase 9b-analytic entry in
research-track.mdcross-referenced as the next milestone.