docs: 5-document meeting prep — tutorials + research note + status + architecture

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>
This commit is contained in:
Tarik Moussa
2026-05-22 13:38:53 +02:00
parent ff9c9ec11b
commit eb393537f3
5 changed files with 2270 additions and 0 deletions

View File

@@ -0,0 +1,477 @@
# Tutorial: The `output_uv_map` Named Parameter
This tutorial documents the `output_uv_map` named parameter for the
CGAL-style entry functions of conformallab++: how to use it (the "happy
path"), how the CGAL named-parameter machinery threads a user-supplied
property map through to the layout step, and how to extend the same
pattern to add new named parameters to existing or new entry functions.
**Target audience.** A working mathematician using the C++ API who is
comfortable with templates and CGAL property maps but wants a single
authoritative reference for the pattern.
**Prerequisite reading.**
- `code/include/CGAL/Conformal_map/internal/parameters.h` — tag definitions
- `code/include/CGAL/Discrete_conformal_map.h` — wiring in three entries
- `code/tests/cgal/test_cgal_phase8b_lite.cpp` — the OutputUvMap_* tests
- `doc/tutorials/add-inversive-distance.md` — sibling tutorial in this series
---
## 1. What this tutorial is and isn't
### The UX problem `output_uv_map` solves
Before Phase 8b-Lite, computing a flattening required **two separate
calls**: the Newton wrapper, then a manual rebuild of `*_maps` plus a
replay of the wrapper's gauge/DOF choices, then `*_layout()`. Any drift
between the wrapper's gauge vertex and the caller's silently produced
inconsistent UVs.
The `output_uv_map` named parameter collapses this into one call:
```cpp
auto uv = mesh.add_property_map<Vertex_index, K::Point_2>("uv").first;
CGAL::discrete_conformal_map_euclidean(
mesh, CGAL::parameters::output_uv_map(uv));
// uv[v] now holds the flattened UV coordinate of vertex v.
```
This tutorial is **not** a derivation of the Newton solver (see
`add-inversive-distance.md`), not a guide to writing a new layout
routine, and not an introduction to CGAL named parameters in general
(see `<CGAL/Named_function_parameters.h>`).
---
## 2. Mathematical background
The package implements **two mathematically distinct stages**:
### Stage A — Newton solver (variational optimisation)
For the Euclidean DCE functional (SpringbornSchmiesBobenko 2008), the
solver finds the conformal scale factor vector `u ∈ ℝⁿ` that satisfies
```
G(u)_v := Θ_v Σ_{T ∋ v} α_v(T; u) = 0 ∀ v ∈ V \ {pinned}
```
i.e. the actual cone angles match the target curvature `Θ_v` at every
non-pinned vertex. The gradient `G` is the variational derivative of
the convex energy
```
E(u) = ∫_0^1 ⟨ G(t u), u ⟩ dt (path integral on the closed Yamabe 1-form)
```
The output is **purely combinatorial-metric**: the converged `u_v`
determines an intrinsic flat metric `_ij(u) = exp(½(u_i + u_j)) · _ij^(0)`
on the abstract triangulation. **No embedding yet exists.**
### Stage B — Layout (geometric realisation)
The layout step is a separate **trilateration** turning intrinsic edge
lengths into a `Point_2` (or `Point_3`) per vertex:
1. Pick a seed triangle `T₀`; place its three vertices canonically.
2. BFS over the dual graph from `T₀`. Each newly visited triangle has
two vertices already placed; the third is the unique solution of
two distance constraints (intersection of two circles).
3. The BFS uses a **priority queue keyed on BFS depth**
(`euclidean_layout` in `code/include/layout.hpp`) to avoid the
numerical drift of pure DFS on long thin meshes.
Spherical / hyperbolic variants substitute spherical-cap or Poincaré-
disk trilateration respectively.
Stage A is convex optimisation in ℝⁿ; Stage B is deterministic
realisation with no degrees of freedom. The named parameter chains them.
---
## 3. The user-facing API
### Happy path
```cpp
#include <CGAL/Simple_cartesian.h>
#include <CGAL/Surface_mesh.h>
#include <CGAL/Discrete_conformal_map.h>
using K = CGAL::Simple_cartesian<double>;
using Mesh = CGAL::Surface_mesh<K::Point_3>;
using Vertex_i = Mesh::Vertex_index;
Mesh mesh = /* load triangle mesh */;
// 1. Allocate a writable property map on the mesh.
auto uv = mesh.add_property_map<Vertex_i, K::Point_2>(
"v:uv", K::Point_2(0, 0)).first;
// 2. Ask the wrapper to populate it.
auto res = CGAL::discrete_conformal_map_euclidean(
mesh,
CGAL::parameters::output_uv_map(uv));
// 3. Use the UVs.
if (res.converged) {
for (auto v : mesh.vertices())
std::cout << v.idx() << ": " << uv[v] << '\n';
}
```
### Supported entries
| Entry | Value type per vertex |
|--------------------------------------|--------------------------------------------------|
| `discrete_conformal_map_euclidean` | `Point_2` — UV in ℝ² |
| `discrete_conformal_map_spherical` | `Point_3` — point on S² ⊂ ℝ³ |
| `discrete_conformal_map_hyper_ideal` | `Point_2` — point in the Poincaré disk (|p|≤1) |
### NOT supported (yet)
| Entry | Reason |
|----------------------------------------|-------------------------------------------------------------|
| `discrete_circle_packing_euclidean` | Face-based parametrisation — UV is per-face, not per-vertex |
| `discrete_inversive_distance_map` | Requires `inversive_distance_layout()` using Luo's `ℓ²` formula — not yet written |
For the circle-packing entry a separate `output_circle_map` parameter
(face → (centre, radius)) is the natural extension; for the inversive-
distance entry the layout primitive must be written first. See §7.
### Optional companion: `normalise_layout`
```cpp
CGAL::parameters::normalise_layout(true)
```
When the layout writes into `output_uv_map`, this flag triggers a
canonical post-processing pass:
- Euclidean: PCA — translate centroid to origin, rotate so the principal
axis is along `x`.
- Spherical: Rodrigues rotation so that the centroid is at the north pole.
- Hyperbolic: Möbius centring of the Poincaré disk.
Default: `false`. Has no effect unless `output_uv_map` is also passed.
### Current limitation: no chaining
The CGAL machinery supports chaining named parameters via `.member()`
syntax for the standard CGAL tags (e.g. `geom_traits`, `vertex_point_map`).
**Package-local tags do not yet have member-function counterparts**, so
```cpp
// DOES NOT COMPILE:
CGAL::parameters::output_uv_map(uv).normalise_layout(true);
```
is currently rejected. Pass one parameter per call instead. This is
the same limitation noted in `test_cgal_phase8b_lite.cpp`:
```cpp
// Named-parameter chaining (`a.b().c()`) is not currently supported on
// the package-local tags; pass one parameter per call instead.
```
Adding member-function chaining is a separate Phase 8b extension —
requires writing a derived `Named_function_parameters` subclass that
exposes `.output_uv_map(...)`, `.normalise_layout(...)`, etc.
---
## 4. Named-parameter mechanism — how it works
CGAL's named parameters are a compile-time dispatch trick. Three pieces:
### 4.1 The tag (in `internal_np`)
```cpp
namespace CGAL::Conformal_map::internal_np {
enum output_uv_map_t { output_uv_map };
}
```
A single-value `enum` is the lightest possible type-level marker. The
value `output_uv_map` is what callers will pass as the *key*; the type
`output_uv_map_t` is what `get_parameter` will use for the lookup.
### 4.2 The user-facing helper (in `CGAL::parameters`)
```cpp
template <typename PropertyMap>
auto output_uv_map(const PropertyMap& pmap) {
return CGAL::Named_function_parameters<
PropertyMap,
Conformal_map::internal_np::output_uv_map_t,
CGAL::internal_np::No_property // no "next" parameter in chain
>(pmap);
}
```
This wraps the property map in a `Named_function_parameters` object
tagged with `output_uv_map_t`. The third template argument is the
"previous" link in a chain; `No_property` means this is a singleton
pack. (Chaining would substitute the previous pack's type here.)
### 4.3 The internal lookup (inside the entry function)
The full read-and-act pattern from `Discrete_conformal_map.h`:
```cpp
// Step 1 — try to extract the parameter.
auto uv_param = parameters::get_parameter(
np, Conformal_map::internal_np::output_uv_map);
// Step 2 — at compile time, did we find one?
constexpr bool has_uv = !std::is_same_v<
decltype(uv_param), internal_np::Param_not_found>;
// Step 3 — conditionally do the extra work.
if constexpr (has_uv) {
if (nr.converged) {
auto layout = ::conformallab::euclidean_layout(mesh, nr.x, maps);
const bool do_norm = parameters::choose_parameter(
parameters::get_parameter(np, Conformal_map::internal_np::normalise_layout),
false);
if (do_norm) ::conformallab::normalise_euclidean(layout);
for (auto v : mesh.vertices()) {
const auto& uv = layout.uv[v.idx()];
put(uv_param, v,
typename Traits::Kernel::Point_2(uv.x(), uv.y()));
}
}
}
```
Key points:
- `get_parameter` returns either the wrapped value or a sentinel
`Param_not_found`. The check is `std::is_same_v<…, Param_not_found>`.
- `choose_parameter(get_parameter(np, tag), default_value)` is the
one-liner form for scalar parameters with a default.
- `constexpr if` ensures that if the user did not pass `output_uv_map`,
the layout call is **not even instantiated** — no runtime cost, and
no need for the layout routine to be callable on the wrapper's
signature (e.g. it can require a Point_2 traits type that the user
hasn't supplied).
- The layout step is gated on `nr.converged` for a reason: trilateration
on a non-converged `u` produces garbage edge lengths and can throw
on degenerate triangles. The current contract is **layout iff Newton
converged**; on non-convergence the pmap is left at the value the
caller initialised it with.
The same three-step pattern appears in `discrete_conformal_map_spherical`
(writing `Point_3`) and `discrete_conformal_map_hyper_ideal` (writing
`Point_2` in the Poincaré disk).
---
## 5. How to extend the pattern
Recipe for adding a new package-local named parameter — running example
`output_holonomy_map` (a per-edge map carrying the Möbius/translation
holonomy data that some downstream algorithms consume).
### Step 1 — Add the tag in `internal/parameters.h`
```cpp
namespace CGAL::Conformal_map::internal_np {
/// Property-map: edge_descriptor → 2x2 matrix of holonomy data.
/// Default: not populated.
enum output_holonomy_map_t { output_holonomy_map };
}
```
Two-line block. Use a meaningful Doxygen `\internal` comment because
this is the canonical contract for the parameter's semantics.
### Step 2 — Add the user-facing helper in `parameters.h`
```cpp
namespace CGAL::parameters {
/// `output_holonomy_map(pmap)` — write per-edge holonomy matrices
/// into `pmap`. Only meaningful for closed surfaces with non-trivial
/// π₁; ignored on simply-connected meshes.
template <typename PropertyMap>
auto output_holonomy_map(const PropertyMap& pmap) {
return CGAL::Named_function_parameters<
PropertyMap,
Conformal_map::internal_np::output_holonomy_map_t,
CGAL::internal_np::No_property
>(pmap);
}
} // namespace CGAL::parameters
```
A Doxygen comment is **mandatory** here — this is the public surface.
### Step 3 — Use `get_parameter + constexpr if` in the entry function
```cpp
auto hol_param = parameters::get_parameter(
np, Conformal_map::internal_np::output_holonomy_map);
constexpr bool has_hol = !std::is_same_v<
decltype(hol_param), internal_np::Param_not_found>;
if constexpr (has_hol) {
auto H = ::conformallab::compute_holonomy(mesh, nr.x, maps);
for (auto e : mesh.edges())
put(hol_param, e, H[e.idx()]);
}
```
### Step 4 — Add tests
At minimum two:
1. **Takes-effect test.** Pass the parameter; verify the side effect
actually happens (pmap is populated, values are non-default).
2. **Sanity-when-absent test.** Call the wrapper without the parameter;
verify the call still succeeds and produces the expected base result.
Both patterns are exemplified in `test_cgal_phase8b_lite.cpp` (next
section).
### Optional Step 5 — Companion flag
If your new parameter has a Boolean tweak (analogous to
`normalise_layout`), follow exactly the same recipe — `enum X_t { X }`
+ helper + `choose_parameter(..., default)` at the read site.
---
## 6. Tests — walkthrough of `test_cgal_phase8b_lite.cpp`
The OutputUvMap_* tests live at the bottom of the file. Each codifies
one acceptance criterion.
### 6.1 `OutputUvMap_Euclidean_PopulatesPmap`
Two assertions: (a) every UV is finite, (b) at least one vertex has
moved off the origin. Test (b) rules out the false-positive where the
layout silently returned the pmap's default at every vertex.
### 6.2 `OutputUvMap_Spherical_PopulatesXyz`
Geometric round-trip: every output point must lie on the unit sphere.
```cpp
const double r = std::sqrt(p.x()*p.x() + p.y()*p.y() + p.z()*p.z());
EXPECT_NEAR(r, 1.0, 1e-6);
```
The spherical analogue of 6.1(b): the layout is correct iff the
realisation lands on S².
### 6.3 `OutputUvMap_HyperIdeal_PointsInPoincareDisk`
The natural Θ/θ targets do not always reach Newton equilibrium inside
the default `max_iterations(200)`. The test branches:
```cpp
if (res.converged) {
for (auto v : mesh.vertices()) {
const double r2 = p.x()*p.x() + p.y()*p.y();
EXPECT_LE(r2, 1.0 + 1e-6);
}
}
// else: layout was skipped by the wrapper's `if (nr.converged)` guard.
```
Documenting the wrapper contract ("layout iff converged") via the
test keeps it resilient to PRNG-sensitive Newton paths.
### 6.4 `OutputUvMap_Absent_DoesNotRunLayout`
Sanity test: omitting `output_uv_map` must not change the existing
behaviour. Catches the regression where adding the new `constexpr if`
perturbs the wrapper's result type or consumes extra work.
### 6.5 `OutputUvMap_NormaliseLayout_TakesEffect`
Because chaining is not yet implemented (§3), this exercises the toggle
indirectly: it runs the wrapper twice into two separate pmaps and
verifies both populate finite values. When chaining lands, upgrade this
to actually pass `normalise_layout(true)` on the second call and compare
UV bounding boxes (the normalised one must be axis-aligned and
centroid-zero).
---
## 7. Adding output to the two missing modes
### 7.1 CP-Euclidean (face-based) — proposed `output_circle_map`
The circle-packing entry parametrises **faces**: each face `f` has a
radius `ρ_f`. The natural per-face output is `(centre, radius)`:
```cpp
auto circles = mesh.add_property_map<Face_index,
std::pair<K::Point_2, double>>("f:circle", ...).first;
CGAL::discrete_circle_packing_euclidean(
mesh, CGAL::parameters::output_circle_map(circles));
```
Effort: **12 days**. The face-graph BFS-packing is Stephenson's
`circlepack` algorithm; radii come from Newton, new work is the
geometric loop plus the §5 plumbing.
### 7.2 Inversive-Distance — needs new layout routine first
The inversive-distance functional yields edge lengths
```
_ij(u)² = r_i² + r_j² + 2 I_ij r_i r_j (Luo 2004 §3)
```
with `r_i = exp(u_i)`. Required work: write
`inversive_distance_layout()` in `code/include/layout.hpp` mirroring
`euclidean_layout()` but consuming the above `_ij(u)` instead of
`exp(½(u_i + u_j)) · _ij^(0)`. Then wire the parameter through
`discrete_inversive_distance_map` exactly as §4.3 shows.
Effort: **~3 days**. Status: **TBD**.
---
## 8. Acceptance checklist for a new named parameter
Use this checklist when extending the package:
- [ ] **Tag declared** in `code/include/CGAL/Conformal_map/internal/parameters.h`
(`enum X_t { X };`) with a `\internal` Doxygen comment specifying
the property-map key/value types and default behaviour.
- [ ] **User-facing helper** in the same file's `CGAL::parameters`
namespace, with a public Doxygen comment.
- [ ] **`get_parameter + constexpr if` pattern** in every entry function
that supports the parameter (do not leak the tag into entries that
ignore it).
- [ ] **At least two tests** in `test_cgal_phase8b_lite.cpp` (or a new
test file if introducing a new functional): a
*parameter-takes-effect* test and a *sanity-when-absent* test.
- [ ] **Limitation documented.** If the parameter is mode-specific
(e.g. only meaningful for closed meshes) or cannot be chained
with existing helpers, say so in the user-facing Doxygen and in
a comment near the read site.
- [ ] **No silent regressions.** If the parameter changes the wrapper's
observable behaviour even when absent (e.g. via a new default),
add a regression test pinning the old default.
---
## 9. Further reading
- Springborn, Schmies, Bobenko (2008). *Conformal equivalence of
triangle meshes.* SIGGRAPH. — Euclidean DCE functional.
- Bobenko, Pinkall, Springborn (2015). *Discrete conformal maps and
ideal hyperbolic polyhedra.* Geom. Topol. — hyper-ideal variant.
- Glickenstein (2011). *Discrete conformal variations and scalar
curvature on piecewise flat manifolds.* JDG 87(2).
- `<CGAL/Named_function_parameters.h>` — upstream machinery.

View File

@@ -0,0 +1,460 @@
# 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
SpringbornSchief / 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`](../../code/include/hyper_ideal_hessian.hpp);
the pure 6 → 6 face kernel it differentiates lives in
[`code/include/hyper_ideal_functional.hpp`](../../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.HyperIdealFunctional`
> declares
>
> ```java
> public boolean hasHessian() { return false; } // line 295298
> ```
>
> i.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
>
> 1. **Springborn, B.** (2020). *Hyperbolic polyhedra and discrete uniformization.*
> Discrete & Computational Geometry 64, 63108. §4 (the variational
> principle whose Hessian we are differentiating).
> 2. **Bobenko, A. I. & Springborn, B. A.** (2004). *Variational principles
> for circle patterns and Koebe's theorem.* Trans. AMS 356(2), 659689.
**Prerequisite:** familiarity with the hyper-ideal functional itself
(see [`doc/math/hyper-ideal.md`](../math/hyper-ideal.md)), and with the
generic functional-porting pattern of
[`doc/tutorials/add-inversive-distance.md`](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](../roadmap/research-track.md)),
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
```cpp
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 122127.
### 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
```cpp
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 the `n` columns, perturb one global DOF by `±ε` and call
the full gradient evaluator, which itself loops over `F` faces.
Total cost: `O(n · F)` face evaluations.
* **Block-FD Hessian** (`hyper_ideal_hessian_block_fd`, line 140):
for each of `F` faces, 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`:
```cpp
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
```cpp
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:
1. **`Eigen::Triplet` accumulation.** Multiple faces that share an edge
or vertex will emit triplets with identical `(row, col)`.
`Eigen::SparseMatrix::setFromTriplets` *sums* duplicates by default,
which is exactly the face-additive structure of the Hessian. No
explicit hash-map keyed by `(row, col)` is needed.
2. **Pinned-DOF handling.** A pinned (gauge-fixed) DOF has `idx = 1`.
The inner `continue` statements 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:
1. **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.
2. **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 between `face_angles_from_local_dofs(...)` (pure) and
`compute_face_angles(...)` (mesh-aware wrapper).
3. **Wrap in the `block_fd_hessian()` loop.** Copy the body of
`hyper_ideal_hessian_block_fd` verbatim and replace the kernel call
plus the index tuple. The triplet-scatter logic does not change.
4. **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`](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):
```cpp
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 `(n1) × (n1)` 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:
```cpp
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`](../../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:
1. **Inner-loop overhead.** Each face still pays for 12 evaluations of
`face_angles_from_local_dofs`, including the triangle-inequality
guards and the `zeta` / `alpha_ij` law-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⁵).
2. **Floating-point step coupling.** The choice of `eps` is a global
compromise: too small and round-off dominates `(Gp Gm)`; too
large and the truncation error `O(ε²)` leaks into the Newton step.
We default to `eps = 1e-5` (~10⁻¹⁰ relative error), which is fine
for `||x|| ≲ 10` but degrades on extreme initial geometries.
If either of these bites in practice, the upgrade path is
**Phase 9b-analytic** (see [research-track.md](../roadmap/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 `eps` parameter — 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.hpp` compiles and exposes
`block_fd` and `block_fd_sym` variants.
- [ ] The pure 6 → 6 (or 3 → 3) face kernel is free of mesh state —
verified by `static_assert` or 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 `SelfAdjointEigenSolver` at
`x = 0` and 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.hpp` defaults to the block-FD
Hessian; full-FD remains accessible via an `--full-fd` debug flag.
- [ ] Registered in `code/tests/cgal/CMakeLists.txt`
(`test_<functional>_hessian.cpp`).
- [ ] `doc/roadmap/research-track.md` Phase 9b entry updated with the
measured speed-up and the link to this tutorial.
- [ ] Phase 9b-analytic entry in `research-track.md` cross-referenced
as the next milestone.