From eb393537f301c0cef9cec73c2be9c01b304bcde6 Mon Sep 17 00:00:00 2001 From: Tarik Moussa Date: Fri, 22 May 2026 13:38:53 +0200 Subject: [PATCH] =?UTF-8?q?docs:=205-document=20meeting=20prep=20=E2=80=94?= =?UTF-8?q?=20tutorials=20+=20research=20note=20+=20status=20+=20architect?= =?UTF-8?q?ure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- doc/architecture/locked-vs-flexible.md | 281 ++++++++ doc/math/hyperideal-hessian-derivation.md | 805 ++++++++++++++++++++++ doc/roadmap/porting-status.md | 247 +++++++ doc/tutorials/add-output-uv-map.md | 477 +++++++++++++ doc/tutorials/block-fd-hessian.md | 460 +++++++++++++ 5 files changed, 2270 insertions(+) create mode 100644 doc/architecture/locked-vs-flexible.md create mode 100644 doc/math/hyperideal-hessian-derivation.md create mode 100644 doc/roadmap/porting-status.md create mode 100644 doc/tutorials/add-output-uv-map.md create mode 100644 doc/tutorials/block-fd-hessian.md diff --git a/doc/architecture/locked-vs-flexible.md b/doc/architecture/locked-vs-flexible.md new file mode 100644 index 0000000..c30caaf --- /dev/null +++ b/doc/architecture/locked-vs-flexible.md @@ -0,0 +1,281 @@ +# Locked-in vs flexible architecture decisions + +> **Purpose.** External collaborators (especially mathematicians +> evaluating whether to extend the library) need to know which +> design choices are **load-bearing** (changing them is expensive +> across the whole codebase) and which are **opportunistic** (made +> on first principles and easy to revisit). +> +> **Why this matters now.** A v0.9.0 + Springborn-Bobenko-alumnus +> external review (May 2026) is the right moment to surface these +> decisions before they ossify further. If any of the *locked* +> decisions need revisiting, this is the cheapest moment in the +> project's life to do so. + +Three tiers: + +``` +🔴 LOAD-BEARING changing requires repo-wide refactoring +🟡 SEMI-FIXED changing affects multiple subsystems; possible but not casual +🟢 OPPORTUNISTIC changing is one-PR work +``` + +--- + +## 1. Mesh data structure → `CGAL::Surface_mesh

` + +| | | +|--|--| +| Status | 🔴 load-bearing | +| Locked since | Phase 3 (2025) | +| Alternative considered | OpenMesh / pmp-library / custom halfedge / Java `CoHDS` literal port | +| Why locked | Every header `code/include/*.hpp` uses `ConformalMesh = CGAL::Surface_mesh` and CGAL property maps explicitly. Phase 8a Traits provide a *concept* abstraction but the Default model is `Surface_mesh`-only. | +| Cost to change | ~3 weeks: rewrite traits, every functional, every test mesh factory. Touched in ~30 headers + ~25 test files. | +| Mitigation | Traits-based design (Phase 8a) means user-supplied mesh types CAN be added as Default-traits specialisations without touching algorithms — Phase 8a.2 plan documents this. But the Default model is Surface_mesh. | +| When to revisit | If a major user wants Polyhedron_3 or OpenMesh as default. Currently no concrete request → keep. | + +**Recommended posture for an external contributor:** use the existing +Surface_mesh-based API for any new functional. Adding generic-FaceGraph +support is a single architectural step that doesn't need to be repeated +per functional — wait for one user to need it. + +--- + +## 2. Floating-point kernel → `CGAL::Simple_cartesian` + +| | | +|--|--| +| Status | 🟡 semi-fixed | +| Locked since | Phase 3 (deliberate decision: conformal geometry doesn't need exact predicates) | +| Cost to change | Per-functional template parameterisation. The Phase 8 MVP wrapper already deduces the kernel from the mesh point type, so user code can specify any CGAL kernel — but the **legacy** `code/include/*.hpp` headers are hardcoded. | +| When to revisit | If a user reports floating-point catastrophic cancellation in the half-tangent angle formula on extreme meshes. Hasn't happened in 250 tests over 9 phases. | + +**Recommended posture:** stay with `Simple_cartesian`. If a +specific algorithm needs `Exact_predicates_inexact_constructions_kernel` +for robustness, parameterise that one algorithm — don't refactor the +whole codebase. + +--- + +## 3. Header-only, no compiled library + +| | | +|--|--| +| Status | 🔴 load-bearing | +| Locked since | Phase 1 | +| Cost to change | Significant: would need to introduce `.cpp` files, link order, ABI compatibility decisions. But everything in `code/include/*.hpp` is `inline` or template, so a header-only-to-compiled migration is mechanical. | +| Why locked | CGAL package convention is also header-only. Forking would break that goal. | +| When to revisit | If compilation times become prohibitive (currently < 30 s clean build with all 5 functionals). Or if a future cyclic dependency between functionals forces it. Neither is on the horizon. | + +**Recommended posture:** stay header-only. This is also the de-facto +norm for CGAL packages of comparable scope. + +--- + +## 4. Five DCE models on the same mesh + +| | | +|--|--| +| Status | 🟡 semi-fixed | +| Locked since | Phase 9a (2026-05) — when CP-Euclidean introduced face-DOFs | +| Why semi-fixed | Each model has its own `*Maps` bundle with its own property-map name prefix (`ev:`, `sv:`, `v:`, `cf:`/`ce:`, `iv:`/`ie:`) so all five can coexist on the same `CGAL::Surface_mesh`. Adding a sixth model means picking a new prefix + writing a new `*Maps` struct + Default trait. | +| Cost to add a sixth model | ~1 week (CP-Euclidean took ~3 days, Inversive-Distance ~3 days, Hessian + Newton + CGAL entry add another ~3 days). | +| When to revisit | If a unified base-Maps abstraction would actually win something (currently it would not — the property-map sets differ in *kind*, not just in name). | + +**Recommended posture for adding a new functional:** +1. Pick a 2-letter prefix not in `{ev, sv, v, cf, ce, iv, ie}`. +2. Define your `*Maps` struct with that prefix. +3. Define your `Default_*_traits` class in a new + header `code/include/CGAL/Discrete_*.h`. +4. Wire it into `newton_solver.hpp` (template-copy from + `newton_inversive_distance` is the closest pattern for vertex-DOFs; + `newton_cp_euclidean` for face-DOFs). +5. Add a CGAL entry in the new header. +6. Add tests following [`add-inversive-distance.md`](../tutorials/add-inversive-distance.md). + +This recipe has been validated three times now (CP-Euclidean, Inversive +Distance, and the four Phase-8b-Lite wrappers). + +--- + +## 5. Newton solver with line search + SparseQR fallback + +| | | +|--|--| +| Status | 🟡 semi-fixed | +| Locked since | Phase 4 | +| Cost to change | Each `newton_*` function is ~50-80 lines; replacing the solver across all five is ~2 days. | +| When to revisit | If a future functional needs trust-region or BFGS. None of the five currently does — Newton converges quadratically near the optimum and the line search handles bad initial points. | + +**Recommended posture:** Newton with line search is enough for any +strictly-convex variational problem. For non-convex variants, consider +adding a `newton_with_trust_region()` helper alongside, not replacing. + +--- + +## 6. Eigen as the linear-algebra back-end + +| | | +|--|--| +| Status | 🔴 load-bearing | +| Locked since | Phase 4 | +| Alternative considered | PETSc/Tao (Java original) / Boost.uBLAS / Blaze | +| Why locked | Eigen is header-only (no external dependency at build time), bundled as a CGAL dependency, fast, and offers `SimplicialLDLT + SparseQR` which the gauge-singular-mesh case needs. | +| Cost to change | Significant — every Hessian header (`*_hessian.hpp`) and every Newton solver uses `Eigen::SparseMatrix` and `Eigen::VectorXd` directly. ~2 weeks repo-wide. | +| When to revisit | If a sparse-solver feature (e.g. parallel Cholesky) is needed that Eigen doesn't offer. | + +**Recommended posture:** stay with Eigen. + +--- + +## 7. CGAL public-API surface layout + +| | | +|--|--| +| Status | 🟡 semi-fixed (Phase 8a-MVP design decision, 2026-05-19) | +| Locked since | PR #6 (v0.9.0) | +| Strategy chosen | "Strategy C" — functional-specific Default traits, one entry function per functional, no fat unified trait. | +| Cost to change to unified trait | ~1 week — refactor `Default_*_traits<>` into a single `Default_conformal_map_traits<>` with all property-map fields. Existing 8 tests would need updating. | +| When to revisit | When the first cross-functional algorithm (e.g. a hybrid functional that uses both face and vertex DOFs) lands. Speculation today. | + +**Recommended posture:** stay with Strategy C. CGAL's own +`Polygon_mesh_processing` package follows the same convention — one +default trait per algorithm family. + +--- + +## 8. Named-parameter mechanism + +| | | +|--|--| +| Status | 🟢 opportunistic | +| Locked since | Phase 8 MVP (2026-05-19) | +| Current state | Six tags: `vertex_curvature_map`, `fixed_vertex_map`, `gradient_tolerance`, `max_iterations`, `output_uv_map`, `normalise_layout`. No chaining yet. | +| Cost to extend with chaining | ~2 days. Generate CGAL-style member-function chainers via macros (CGAL has a `CGAL_add_named_parameter` macro for this). | +| When to revisit | At any time; this is the lowest-risk change in the codebase. Tutorials [`add-output-uv-map.md`](../tutorials/add-output-uv-map.md) §4 explains the mechanism. | + +**Recommended posture:** add chaining when the first user complains. Or +when adding the 8th-10th named parameter (still 6 today — manageable). + +--- + +## 9. Property-map name conventions + +| | | +|--|--| +| Status | 🟢 opportunistic | +| Locked since | Phase 3 + 9a (prefix `ev:`/`sv:`/`v:`/`cf:`/`ce:`/`iv:`/`ie:` set when each functional was introduced) | +| Cost to change | One sed-replace + recompile. No user-visible effect because the names are an *internal* convention; the CGAL public API never exposes them. | +| When to revisit | If a future functional reuses an existing letter prefix. Already discussed in [`locked-vs-flexible.md`](#4-five-dce-models-on-the-same-mesh). | + +--- + +## 10. Tests: GTest, not CGAL's own test format + +| | | +|--|--| +| Status | 🟡 semi-fixed | +| Locked since | Phase 1 | +| Cost to change | ~1 week — rewrite test harnesses to CGAL's `test/Conformal_map/` convention. This is Phase 8d (planned for CGAL submission). | +| Why GTest now | Faster development cycle, IDE-friendly (Xcode / VSCode / CLion all have native GTest support). No CGAL submission is in progress yet. | +| When to revisit | When committing to CGAL submission (Phase 8c-d, decided to be a future commitment, see [`release-policy.md`](../release-policy.md)). | + +**Recommended posture:** keep GTest as primary. When/if CGAL submission +happens, add a `test/Conformal_map/` shim that calls into the GTest +suite — both formats can coexist. + +--- + +## 11. License: MIT + +| | | +|--|--| +| Status | 🔴 load-bearing | +| Locked since | Project inception | +| Cost to change | High organisational cost (requires consent of all contributors); ~no code cost. | +| Why locked | MIT was chosen for academic friendliness (citing, modifying, embedding). CGAL upstream requires LGPL for submitted packages. | +| Trade-off | Submitting to CGAL upstream is not possible without re-licensing. The codebase architecture is "CGAL-style" but the project would publish independently. | +| When to revisit | If/when a concrete CGAL upstream submission is decided. See [`release-policy.md`](../release-policy.md) for the formal policy. | + +**Recommended posture:** stay with MIT. Build the "CGAL-style package +for external distribution" as the primary deliverable. Re-license only +when the CGAL editorial board commits to accepting the submission. + +--- + +## 12. Documentation pattern: Markdown + Doxygen + +| | | +|--|--| +| Status | 🟢 opportunistic | +| Locked since | Phase 7.5 (2026-05) | +| Current state | `code/include/*.hpp` carry Doxygen-style `///` comments (87% coverage); `doc/*.md` for prose; `Doxyfile` generates HTML in `doc/doxygen/`. | +| Cost to add more | Per-file basis; ~1 hour per header for full Doxygen. | +| When to revisit | When chasing CGAL-submission readiness (need `PackageDescription.txt` + `User_manual.md`). | + +**Recommended posture:** keep adding `///` comments incrementally with +each new public function. + +--- + +## Summary table + +| Decision | Tier | Cost to change | +|-----------------------------------------|----------------|----------------------| +| 1. CGAL::Surface_mesh as default mesh | 🔴 load-bearing | ~3 weeks | +| 2. Simple_cartesian kernel | 🟡 semi-fixed | per-functional | +| 3. Header-only architecture | 🔴 load-bearing | medium (mechanical) | +| 4. Five DCE models, separate Maps | 🟡 semi-fixed | ~1 week per new model | +| 5. Newton + line search + SparseQR | 🟡 semi-fixed | ~2 days | +| 6. Eigen back-end | 🔴 load-bearing | ~2 weeks | +| 7. Strategy C (per-functional traits) | 🟡 semi-fixed | ~1 week | +| 8. Named-parameter mechanism | 🟢 opportunistic | ~2 days (chaining) | +| 9. Property-map name conventions | 🟢 opportunistic | ~1 hour | +| 10. GTest, not CGAL test format | 🟡 semi-fixed | ~1 week | +| 11. MIT license | 🔴 load-bearing | organisational | +| 12. Markdown + Doxygen | 🟢 opportunistic | per-file | + +**Key insight:** the **load-bearing decisions are all good in 2026**. +Surface_mesh + Eigen + header-only + MIT are the right defaults for a +research-quality CGAL-style package. The **semi-fixed decisions are +all behind one concrete blocker** (single user request, CGAL submission +commitment, etc.). The **opportunistic decisions are cheap to revisit +any time**. + +The architecture is in a good place for the v0.9.0 → v0.10.0 transition. +No "expensive corner" has been painted into; every locked decision +matches the project's three-goal hierarchy in +[`research-track.md`](../roadmap/research-track.md). + +--- + +## Open questions for the external reviewer + +Items where the project would benefit from a second opinion: + +1. **Phase 9c (4g-polygon) algorithm choice.** Two routes: + * Port the Java `FundamentalPolygonUtility` + `CanonicalFormUtility` + literally (~2 weeks). + * Or: re-derive from Springborn 2020 §5 using the existing + `cut_graph.hpp` + holonomy infrastructure (~3 weeks, cleaner + architecture). + Which is preferred? See [`phases.md`](../roadmap/phases.md) §Phase 9c. + +2. **Phase 10a (forms) priorities.** Three sub-items + (`DiscreteHarmonicFormUtility`, `DiscreteHolomorphicFormUtility`, + `CanonicalBasisUtility`) interlock. Which to start with? + +3. **Analytic Hessian payoff.** The Schläfli-based analytic HyperIdeal + Hessian (Phase 9b-analytic — derivation already written: + [`hyperideal-hessian-derivation.md`](../math/hyperideal-hessian-derivation.md)) + would add another ~6× over block-FD. Is that worth ~2 weeks of + implementation effort for a working-mesh size on which? + +4. **CGAL upstream vs independent distribution.** Does the reviewer + know a CGAL editor / has personal opinion on the LGPL-vs-MIT + trade-off? + +5. **geometry-central cross-validation (GC-1).** Two libraries solve + the same DCE problem from different algorithmic directions + (Newton-on-mesh vs Ptolemaic-flips-on-intrinsic-triangulation). An + independent comparison would be a nice paper. Interested? diff --git a/doc/math/hyperideal-hessian-derivation.md b/doc/math/hyperideal-hessian-derivation.md new file mode 100644 index 0000000..029a950 --- /dev/null +++ b/doc/math/hyperideal-hessian-derivation.md @@ -0,0 +1,805 @@ +# Analytic Hessian of the Hyper-Ideal Discrete Conformal Energy + +**Status:** research note, mathematical preparation for **Phase 9b-analytic** +(see `doc/roadmap/research-track.md`). The current shipped Hessian +(`hyper_ideal_hessian_block_fd`, v0.9.0) is block finite-difference; this note +derives the closed-form replacement via the Schläfli identity and the chain +rule through the building blocks `zeta`, `zeta13`, `zeta14`, `zeta15`, `lij`, +`alpha_ij`, `sigma_i`, `sigma_ij` declared in `code/include/hyper_ideal_geometry.hpp` +and assembled by `face_angles_from_local_dofs(...)` in +`code/include/hyper_ideal_functional.hpp`. + +**Target reader.** A mathematician fluent with Springborn (2020) and the +Bobenko-school discrete-conformal apparatus; the goal is verifiability of +each derivative line against the source papers. + +**Conventions.** +- $b_i \in \mathbb{R}$ — log scale factor at hyper-ideal vertex $i$ (the DOF). + An "ideal" vertex has no $b_i$ DOF; we encode that by `vi_var = false`. +- $a_{ij} \in \mathbb{R}$ — intersection-angle DOF on edge $ij$ (in the + Springborn $a$-parametrisation, edges may be conventional, ideal–ideal, + or hyper-ideal–hyper-ideal). +- $\ell_{ij}$ — effective hyperbolic length of edge $ij$ in the auxiliary + truncated tetrahedron. +- $\beta_i^{(f)}$ — interior angle of the auxiliary hyperbolic triangle on + face $f$ at vertex $i$. +- $\alpha_{ij}^{(f)}$ — dihedral angle of the truncated tetrahedron at edge + $ij$, contributed by face $f$. +- $\Theta_v$, $\theta_e$ — prescribed cone / intersection-angle targets. +- $V(f)$ — hyperbolic volume of the truncated tetrahedron associated with + face $f$. + +--- + +## 1. The Schläfli identity and the gradient + +### 1.1 Energy + +The Springborn (2020, §4) hyper-ideal energy on a triangulated surface $M$ +with DOF vector $x = (b, a)$ is +$$ +E(b, a) \;=\; \sum_{f \in F} U(f) \;-\; \sum_{e \in E} \theta_e\, a_e \;-\; + \sum_{v \in V} \Theta_v\, b_v , +$$ +where the per-face contribution is +$$ +U(f) \;=\; \sum_{e \in f} a_e\, \alpha_e^{(f)} \;+\; + \sum_{i \in f} b_i\, \beta_i^{(f)} \;+\; 2\, V(f) . +$$ +(Each face contributes its three edges and three vertices; ideal vertices +contribute $b_i = 0$ trivially.) This matches the `face_energy(...)` routine +in `hyper_ideal_functional.hpp` line by line. + +### 1.2 First-order Schläfli (gradient) + +The classical Schläfli differential identity (Schläfli 1858/60; Milnor 1982; +Vinberg 1993, Ch. 7) for the volume of any compact hyperbolic polyhedron +$P \subset \mathbb{H}^3$ reads +$$ +\boxed{\;\; -2\, dV \;=\; \sum_{e \subset P} \ell_e \, d\alpha_e \;\;} +$$ +in the closed (finite-vertex) case, where $\ell_e, \alpha_e$ are edge length +and dihedral angle. For the truncated / hyper-ideal extension we follow +Bobenko–Springborn–Schief and write the identity in the *mixed* form that +respects the truncation: +$$ +\boxed{\;\; 2\, dV \;=\; \sum_e a_e\, d\alpha_e \;+\; \sum_v b_v\, d\beta_v + \;-\; \sum_e \alpha_e\, da_e \;-\; \sum_v \beta_v\, db_v . \;\;} +\tag{S1} +$$ +Combined with the trivial $d(a_e \alpha_e) = a_e\, d\alpha_e + \alpha_e\, da_e$ +identity, (S1) is equivalent to +$$ +d\!\left(\sum_e a_e \alpha_e + \sum_v b_v \beta_v + 2V\right) + \;=\; \sum_e \alpha_e\, da_e \;+\; \sum_v \beta_v\, db_v , +$$ +i.e. $dU(f) = \sum \alpha_e\, da_e + \sum \beta_v\, db_v$ on each face. +Summing over faces and subtracting the linear $\theta, \Theta$ terms, +$$ +\frac{\partial E}{\partial b_v} \;=\; \Big(\!\!\sum_{f \ni v} \beta_v^{(f)}\Big) - \Theta_v, +\qquad +\frac{\partial E}{\partial a_e} \;=\; \Big(\!\!\sum_{f \supset e} \alpha_e^{(f)}\Big) - \theta_e , +$$ +which is precisely the gradient implemented in +`evaluate_hyper_ideal(...)` (pass 3). + +### 1.3 Second-order Schläfli (Hessian) + +Differentiating (S1) once more (Cho–Kim 1999, Lemma 2.1; Rivin 1994) +yields the second Schläfli identity +$$ +\boxed{\;\; 0 \;=\; \sum_e da_e \wedge d\alpha_e \;+\; \sum_v db_v \wedge d\beta_v . \;\;} +\tag{S2} +$$ +Since the wedge product is antisymmetric in the differential factors, (S2) +forces the bilinear form +$$ +H(f) := \begin{pmatrix} + \partial \beta_i / \partial b_j & \partial \beta_i / \partial a_e \\ + \partial \alpha_e / \partial b_j & \partial \alpha_e / \partial a_{e'} +\end{pmatrix} +$$ +to be **symmetric** on each face (and so on the whole mesh). This is the +deep reason that one may compute the Hessian +$$ +H \;=\; \frac{\partial^2 E}{\partial x^2} \;=\; +\frac{\partial (\beta - \Theta,\; \alpha - \theta)}{\partial (b, a)} +$$ +by accumulating only the *upper triangle* face by face, and that the +mesh-level matrix inherits PSD-ness from the per-face $6 \times 6$ blocks +(Springborn 2020 §4.3). + +### 1.4 Strategy + +The full chain through `face_angles_from_local_dofs(...)` is +$$ +(b_i, a_e) \;\xrightarrow{\;\text{lij}\;}\; \ell_e + \;\xrightarrow{\;\zeta\;}\; \beta_i + \;\xrightarrow{\;\zeta,\sigma\;}\; \alpha_e . +$$ +We derive each arrow in Sections 2–3 and assemble the $6 \times 6$ face +Jacobian in Sections 4–5. Symmetry is then a consequence of (S2) and a +useful numerical check. + +--- + +## 2. Derivative of $\zeta$ (interior angle from edge lengths) + +### 2.1 Definition + +For an auxiliary hyperbolic triangle with side lengths $x, y, z$ (opposite +to vertices $X, Y, Z$), +$$ +\zeta(x, y, z) \;=\; \arccos\!\left(\frac{\cosh x \cosh y - \cosh z}{\sinh x \sinh y}\right) , +$$ +returning the interior angle at the vertex *between* the sides of length +$x$ and $y$ (so $z$ is opposite). This is the hyperbolic law of cosines +solved for the included angle, and it is exactly `zeta(...)` in +`hyper_ideal_geometry.hpp`. + +### 2.2 Partial derivatives + +Let +$$ +N := \cosh x \cosh y - \cosh z, +\qquad D := \sinh x \sinh y, +\qquad N / D = \cos \beta . +$$ +Then $\sin \beta \cdot d\beta = -d(N/D) = (D\, dN - N\, dD)/D^2 \cdot (-1)$. +Computing the partials, +$$ +\partial_x N = \sinh x \cosh y,\qquad +\partial_y N = \cosh x \sinh y,\qquad +\partial_z N = -\sinh z , +$$ +$$ +\partial_x D = \cosh x \sinh y,\qquad +\partial_y D = \sinh x \cosh y,\qquad +\partial_z D = 0 . +$$ +Hence +$$ +\partial_x (\cos \beta) \;=\; \frac{(\sinh x \cosh y)\,\sinh x \sinh y - (\cosh x \cosh y - \cosh z)\, \cosh x \sinh y}{\sinh^2 x \sinh^2 y} +$$ +$$ += \;\frac{\sinh y \big[ \sinh^2 x \cosh y - \cosh x (\cosh x \cosh y - \cosh z) \big]}{\sinh^2 x \sinh^2 y} +\;=\;\frac{\cosh x \cosh z - \cosh y}{\sinh^2 x \sinh y} +$$ +(using $\sinh^2 x - \cosh^2 x = -1$, which gives the cancellation +$\sinh^2 x \cosh y - \cosh^2 x \cosh y = -\cosh y$). + +Combined with $-\sin \beta\, \partial_x \beta = \partial_x (\cos\beta)$ this +yields the **hyperbolic dual law of cosines** in derivative form +(Cho–Kim 1999, Lemma 3.1): +$$ +\boxed{\;\; \frac{\partial \beta}{\partial x} + \;=\; \frac{\cosh y - \cosh x \cosh z}{\sin \beta \cdot \sinh^2 x \sinh y} + \;=\; -\frac{\cos \beta_y}{\sinh x \sin \beta} \,\cdot\, \frac{1}{?} \;\;} +$$ +We prefer the *normalised* form (sine rule). Recall the hyperbolic sine +rule on a triangle $\beta, \beta_y, \beta_z$: +$\sinh x / \sin \beta_x' = \sinh y / \sin \beta_y' = \sinh z / \sin \beta_z'$, +where $\beta_x'$ is opposite to side $x$. In our convention $\beta = \beta_z'$ +(the angle opposite $z$ — wait: $\zeta(x,y,z)$ returns the angle *between* +sides $x$ and $y$, i.e. **opposite to side $z$**), so let +$\beta := \beta_z'$, $\beta_x' := $ angle opposite $x$, $\beta_y' := $ angle +opposite $y$. Then by the dual law of cosines applied to side $y$, +$\cosh y = \cosh x \cosh z - \sinh x \sinh z \cos \beta_y'$, whence +$\cosh y - \cosh x \cosh z = -\sinh x \sinh z \cos \beta_y'$. +Substituting, +$$ +\frac{\partial \beta}{\partial x} + \;=\; \frac{-\sinh x \sinh z \cos \beta_y'}{\sin\beta \cdot \sinh^2 x \sinh y} + \;=\; \frac{-\sinh z \cos \beta_y'}{\sin \beta \cdot \sinh x \sinh y} . +$$ +Using $\sin \beta / \sinh z = \sin \beta_y'/ \sinh y$ (sine rule) once more, +$\sin \beta \sinh y = \sin \beta_y' \sinh z$, and we obtain the **clean +Cho–Kim form**: +$$ +\boxed{\;\; \frac{\partial \beta}{\partial x} \;=\; -\frac{\cos \beta_y'}{\sinh x}, \quad + \frac{\partial \beta}{\partial y} \;=\; -\frac{\cos \beta_x'}{\sinh y}, \quad + \frac{\partial \beta}{\partial z} \;=\; +\frac{1}{\sinh z} \cdot \frac{\sinh z}{\sin\beta\,\sinh x \sinh y} \cdot \sinh z \;\;} +$$ +For the third partial, $\partial_z N = -\sinh z$, $\partial_z D = 0$, so +$$ +-\sin \beta \cdot \partial_z \beta = \frac{-\sinh z}{\sinh x \sinh y} +\quad \Longrightarrow \quad +\boxed{\;\; \frac{\partial \beta}{\partial z} + \;=\; \frac{\sinh z}{\sin \beta \cdot \sinh x \sinh y} + \;=\; \frac{1}{\sin \beta} \cdot \frac{\sinh z}{\sinh x \sinh y} . \;\;} +$$ +Equivalently, $\partial \beta / \partial z = \sinh z / (\sin \beta \sinh x \sinh y)$, +which by the sine rule is also $1 / (\sinh x \sin \beta_y')$, recovering a +form symmetric to the first two. + +**Summary (operational form used in code).** For +$\beta = \zeta(x, y, z)$ (opposite to $z$): +$$ +\begin{aligned} +\partial_x \beta &= \frac{1}{\sin \beta} \cdot \frac{\cosh y - \cosh x \cosh z}{\sinh^2 x \sinh y}, \\ +\partial_y \beta &= \frac{1}{\sin \beta} \cdot \frac{\cosh x - \cosh y \cosh z}{\sinh x \sinh^2 y}, \\ +\partial_z \beta &= \frac{1}{\sin \beta} \cdot \frac{\sinh z}{\sinh x \sinh y} . +\end{aligned} +\tag{Z} +$$ +These three lines are what the analytic kernel will evaluate (they are +finite as long as no edge degenerates and $\sin \beta \neq 0$, which is +guaranteed inside the triangle-inequality regime gated by +`face_angles_from_local_dofs`). + +--- + +## 3. Derivatives of $\ell_{ij}$ (edge-length building blocks) + +The length dispatcher `lij(b_i, b_j, a_{ij}, v_i, v_j)` in +`hyper_ideal_geometry.hpp` selects among $\zeta_{13}, \zeta_{14}, \zeta_{15}$ +based on which vertices are hyper-ideal. We treat each branch separately. + +### 3.1 Both vertices hyper-ideal: $\ell_{ij} = \zeta_{13}(b_i, b_j, a_{ij})$ + +$$ +\zeta_{13}(x, y, z) = \operatorname{arcosh}\!\left( \frac{\cosh x \cosh y + \cosh z}{\sinh x \sinh y} \right) . +$$ +Let $L = \zeta_{13}$, $\Phi := \cosh L = (\cosh x \cosh y + \cosh z)/(\sinh x \sinh y)$. +Then $\sinh L \cdot \partial L = \partial \Phi$. With +$$ +\partial_x \Phi = \frac{\sinh x \cosh y \cdot \sinh x \sinh y - (\cosh x \cosh y + \cosh z) \cosh x \sinh y}{\sinh^2 x \sinh^2 y} += \frac{-\cosh x \cosh z - \cosh y}{\sinh^2 x \sinh y}, +$$ +(using $\sinh^2 x - \cosh^2 x = -1$), and analogously for $y$, $z$: +$$ +\boxed{\;\; +\begin{aligned} +\partial_x \zeta_{13} &= -\frac{1}{\sinh L} \cdot \frac{\cosh y + \cosh x \cosh z}{\sinh^2 x \sinh y}, \\ +\partial_y \zeta_{13} &= -\frac{1}{\sinh L} \cdot \frac{\cosh x + \cosh y \cosh z}{\sinh x \sinh^2 y}, \\ +\partial_z \zeta_{13} &= +\frac{1}{\sinh L} \cdot \frac{\sinh z}{\sinh x \sinh y} . +\end{aligned} +\;\;} +\tag{Z13} +$$ +Compare (Z) and (Z13): they differ only in (i) the sign in front of +$\cosh z$ in the numerator of the first two partials, and (ii) the +prefactor ($1/\sin \beta$ vs $1/\sinh L$) — which mirrors the +$\arccos / \operatorname{arcosh}$ duality. This is the dual hyperbolic +law of cosines for a right-angled hexagon +(Buser 1992, *Geometry and Spectra of Compact Riemann Surfaces*, Thm 2.4.1). + +### 3.2 One ideal vertex: $\ell_{ij} = \zeta_{14}(a_{ij}, b_j)$ + +If vertex $i$ is ideal (i.e. only $v_j$ is hyper-ideal), +$$ +\zeta_{14}(x, y) = \operatorname{arcosh}\!\left( \frac{e^x + \cosh y}{\sinh y} \right) . +$$ +Let $L = \zeta_{14}$, $\Phi = \cosh L = (e^x + \cosh y)/\sinh y$. Then +$$ +\partial_x \Phi = \frac{e^x}{\sinh y}, +\qquad +\partial_y \Phi = \frac{\sinh y \cdot \sinh y - (e^x + \cosh y)\cosh y}{\sinh^2 y} + = \frac{-1 - e^x \cosh y}{\sinh^2 y} . +$$ +Hence +$$ +\boxed{\;\; +\partial_x \zeta_{14} \;=\; \frac{e^x}{\sinh L \cdot \sinh y}, +\qquad +\partial_y \zeta_{14} \;=\; -\frac{1 + e^x \cosh y}{\sinh L \cdot \sinh^2 y} . +\;\;} +\tag{Z14} +$$ +Note that `lij` invokes $\zeta_{14}$ with argument order *(edge, vertex)*, +so when $v_i$ is the ideal one the call is `zeta14(a_{ij}, b_j)` and we +must remember that $\partial / \partial a_{ij} = \partial_x \zeta_{14}$, +$\partial / \partial b_j = \partial_y \zeta_{14}$. The symmetric case +(when $v_j$ is the ideal one) flips the roles of $b_i / b_j$. + +### 3.3 Both vertices ideal: $\ell_{ij} = \zeta_{15}(a_{ij})$ + +$$ +\zeta_{15}(x) = 2\, \operatorname{arsinh}\!\big(e^{x/2}\big), \qquad +\partial_x \zeta_{15} = \frac{2 \cdot \tfrac{1}{2} e^{x/2}}{\sqrt{1 + e^x}} + = \frac{e^{x/2}}{\sqrt{1 + e^x}} . +$$ +A more numerically symmetric form uses +$\cosh(\zeta_{15}/2) = \sqrt{1 + e^x}$, $\sinh(\zeta_{15}/2) = e^{x/2}$, +so +$$ +\boxed{\;\; +\partial_x \zeta_{15} \;=\; \tanh\!\big(\zeta_{15}(x)/2\big) . \;\;} +\tag{Z15} +$$ +This is the cleanest form for cross-checks against the FD reference. + +### 3.4 Combined edge-length partials + +We package the per-edge $\partial \ell_{ij}$ as a *length differential* +$$ +d\ell_{ij} \;=\; L^b_{ij,i}\, db_i \;+\; L^b_{ij,j}\, db_j \;+\; L^a_{ij}\, da_{ij} +$$ +with case-by-case coefficients: + +| Case ($v_i$, $v_j$) | $L^b_{ij,i}$ | $L^b_{ij,j}$ | $L^a_{ij}$ | +|---|---|---|---| +| (hyp, hyp) | $\partial_x \zeta_{13}$ at $(b_i, b_j, a_{ij})$ | $\partial_y \zeta_{13}$ | $\partial_z \zeta_{13}$ | +| (ideal, hyp) | — | $\partial_y \zeta_{14}$ at $(a_{ij}, b_j)$ | $\partial_x \zeta_{14}$ | +| (hyp, ideal) | $\partial_y \zeta_{14}$ at $(a_{ij}, b_i)$ | — | $\partial_x \zeta_{14}$ | +| (ideal, ideal) | — | — | $\partial_x \zeta_{15}$ at $a_{ij}$ | + +A "—" entry means the corresponding DOF does not exist; the partial is +identically zero on the constraint surface. + +--- + +## 4. Chain-rule assembly (interior angles and dihedrals) + +### 4.1 Interior angles $\beta_i$ + +On face $f = (1, 2, 3)$ with edges $\ell_{12}, \ell_{23}, \ell_{31}$, +the code computes +$$ +\beta_1 = \zeta(\ell_{12}, \ell_{31}, \ell_{23}),\quad +\beta_2 = \zeta(\ell_{23}, \ell_{12}, \ell_{31}),\quad +\beta_3 = \zeta(\ell_{31}, \ell_{23}, \ell_{12}) . +$$ +With (Z) we get, for any DOF $\xi \in \{b_1, b_2, b_3, a_{12}, a_{23}, a_{31}\}$, +$$ +\frac{\partial \beta_1}{\partial \xi} + \;=\; \zeta_x(\ell_{12}, \ell_{31}, \ell_{23})\, \partial_\xi \ell_{12} + \;+\; \zeta_y(\ell_{12}, \ell_{31}, \ell_{23})\, \partial_\xi \ell_{31} + \;+\; \zeta_z(\ell_{12}, \ell_{31}, \ell_{23})\, \partial_\xi \ell_{23} , +\tag{B1} +$$ +and cyclically for $\beta_2, \beta_3$. Each $\partial_\xi \ell_{e}$ is read +from the table in §3.4: of the six DOFs only the three that touch edge $e$ +contribute (i.e. $b_{e^-}, b_{e^+}, a_e$). + +### 4.2 Dihedral angles $\alpha_{ij}$ — general structure + +`alpha_ij(...)` has four branches depending on $(v_i, v_j, v_k)$. +Define the "$\sigma$-triangle" attached to vertex $i$: +$$ +s_i \;=\; \sigma_i(a_{ij}, a_{ki}, a_{jk}; v_j, v_k), \qquad +s_{ij} \;=\; \sigma_{ij}(a_{ij}, b_i, b_j; v_j), \qquad +s_{ik} \;=\; \sigma_{ij}(a_{ki}, b_i, b_k; v_k) . +$$ +Then in the *hyper-ideal $v_i$* branch +$$ +\alpha_{ij} \;=\; \zeta(s_i, s_{ij}, s_{ik}) +\tag{A.v_i} +$$ +— note that the dihedral $\alpha_{ij}$ is computed as an interior angle in +the half-triangle at vertex $i$, with $s_i$ playing the role of side $x$, +$s_{ij}$ of $y$ and $s_{ik}$ of $z$. + +The branches $v_j$ (hyper-ideal but $v_i$ ideal), $v_k$ (one level of +recursion), and *all ideal* (closed form) follow the same pattern. + +### 4.3 Derivatives of $\sigma_i$ and $\sigma_{ij}$ + +Both are themselves dispatchers over $\zeta_{13}, \zeta_{14}, \zeta_{15}$. + +**$\sigma_{ij}(a_{ij}, b_i, b_j; v_j)$.** +- If $v_j$ hyper-ideal: $\sigma_{ij} = \zeta_{13}(a_{ij}, b_i, b_j)$ + with partials $(\partial_x \zeta_{13}, \partial_y \zeta_{13}, \partial_z \zeta_{13})$ + evaluated at $(a_{ij}, b_i, b_j)$. **Note the argument order:** the + first slot of $\zeta_{13}$ is $a_{ij}$, not $b_i$. +- If $v_j$ ideal: $\sigma_{ij} = \zeta_{14}(-a_{ij}, b_i)$. Then + $\partial_{a_{ij}} \sigma_{ij} = -\partial_x \zeta_{14}(-a_{ij}, b_i)$, + $\partial_{b_i} \sigma_{ij} = \partial_y \zeta_{14}(-a_{ij}, b_i)$, + $\partial_{b_j} \sigma_{ij} = 0$. + +**$\sigma_i(a_{ij}, a_{ki}, a_{jk}; v_j, v_k)$.** +- $(v_j, v_k) = $ (hyp, hyp): $\sigma_i = \zeta_{13}(a_{ij}, a_{ki}, a_{jk})$. + Partials direct. +- $(v_j, v_k) = $ (hyp, ideal): $\sigma_i = \zeta_{14}(a_{jk} - a_{ki}, a_{ij})$. + Then $\partial_{a_{jk}} = \partial_x \zeta_{14}$, + $\partial_{a_{ki}} = -\partial_x \zeta_{14}$, + $\partial_{a_{ij}} = \partial_y \zeta_{14}$. +- $(v_j, v_k) = $ (ideal, hyp): $\sigma_i = \zeta_{14}(a_{jk} - a_{ij}, a_{ki})$. + Sign pattern is the mirror image. +- $(v_j, v_k) = $ (ideal, ideal): $\sigma_i = \zeta_{15}(a_{jk} - a_{ij} - a_{ki})$. + Partials are $(\partial_x \zeta_{15}, -\partial_x \zeta_{15}, -\partial_x \zeta_{15})$ + along $(a_{jk}, a_{ij}, a_{ki})$. + +These nine sub-branches are the bulk of the per-face symbolic work. + +### 4.4 Chain rule for $\alpha_{ij}$ in the $v_i$-branch + +By (A.v_i) and (Z) evaluated at $(s_i, s_{ij}, s_{ik})$, +$$ +d\alpha_{ij} \;=\; +\zeta_x|_{(s_i, s_{ij}, s_{ik})} ds_i +\;+\; \zeta_y|_{(s_i, s_{ij}, s_{ik})} ds_{ij} +\;+\; \zeta_z|_{(s_i, s_{ij}, s_{ik})} ds_{ik} , +\tag{A1} +$$ +and each $ds_i, ds_{ij}, ds_{ik}$ expands via §4.3. The result is a +linear combination of the six face DOFs with closed-form coefficients. + +For the $v_j$-branch swap the roles $(i \leftrightarrow j)$; the +formulas are identical up to a permutation of $\sigma$-indices. + +### 4.5 The recursive $v_k$-branch + +When $v_i, v_j$ are both ideal but $v_k$ is hyper-ideal, the code returns +$$ +\alpha_{ij} \;=\; \pi - \alpha_{jk}' - \beta_j , +$$ +where $\alpha_{jk}'$ is a recursive call into the $v_j$-branch (since the +cyclic role rotation $\, (i, j, k) \mapsto (j, k, i)$ makes the second- +position vertex $v_k$, which is hyper-ideal, trigger the $v_i$-branch on +the recursion). Differentiating, +$$ +d\alpha_{ij} \;=\; -\, d\alpha_{jk}' \;-\; d\beta_j , +\tag{A2} +$$ +so we obtain $d\alpha_{ij}$ by computing $d\alpha_{jk}'$ via (A1) (with +the *rotated* DOF identification) and subtracting $d\beta_j$ from §4.1. +This single-level recursion is **finite** because the recursive call +descends into the $v_i$-branch (whose $v_i$ is now the original $v_k$, +which is hyper-ideal by hypothesis); see the comment "never more than one +level deep" in `hyper_ideal_geometry.hpp` line 106. + +### 4.6 The all-ideal branch (closed form) + +When all three vertices are ideal, +$\alpha_{ij} = \tfrac{1}{2}(\pi + \beta_k - \beta_i - \beta_j)$. Hence +$$ +d\alpha_{ij} \;=\; \tfrac{1}{2}(d\beta_k - d\beta_i - d\beta_j) , +\tag{A3} +$$ +with each $d\beta_\bullet$ from (B1). In this case the DOF vector +collapses to $(a_{12}, a_{23}, a_{31})$ (the $b$'s do not exist), and the +six-by-six block reduces to a non-trivial $3 \times 3$ block embedded +along the $a$-axes. + +--- + +## 5. The per-face $6 \times 6$ block + +### 5.1 Layout + +Order the inputs and outputs of `face_angles_from_local_dofs` as +$$ +x_{\text{loc}} \;=\; (b_1, b_2, b_3, a_{12}, a_{23}, a_{31})^\top, +\qquad +y_{\text{loc}} \;=\; (\beta_1, \beta_2, \beta_3, \alpha_{12}, \alpha_{23}, \alpha_{31})^\top . +$$ +The face Jacobian is +$$ +J(f) \;=\; \frac{\partial y_{\text{loc}}}{\partial x_{\text{loc}}} + \;=\; +\begin{pmatrix} +J^{\beta b} & J^{\beta a} \\ +J^{\alpha b} & J^{\alpha a} +\end{pmatrix} \in \mathbb{R}^{6 \times 6} . +$$ +The $3 \times 3$ sub-blocks are: + +- $J^{\beta b}_{ij} = \partial \beta_i / \partial b_j$, computed from (B1) + using the $L^b$ coefficients from §3.4. Sparse: only the two edges + $(i, \cdot)$ adjacent to vertex $i$ contribute the $b_j$ derivative + (via $\partial \ell_{ij} / \partial b_j$), so $J^{\beta b}_{ij}$ has at + most two nonzero edge-length terms per $(i, j)$ pair. +- $J^{\beta a}_{ie} = \partial \beta_i / \partial a_e$, also from (B1): + exactly **one** $\zeta$-derivative slot per $(i, e)$ pair, since each + $a_e$ affects exactly one edge length and each $\ell_e$ enters $\beta_i$ + in exactly one slot. +- $J^{\alpha b}_{e j} = \partial \alpha_e / \partial b_j$: computed from + the branch-appropriate identity among (A1), (A2), (A3). For (A1), only + $s_{ij}$ and $s_{ik}$ depend on $b$'s, so the row collapses to + $\zeta_y \cdot \partial_{b_j} s_{ij} + \zeta_z \cdot \partial_{b_j} s_{ik}$. +- $J^{\alpha a}_{e e'} = \partial \alpha_e / \partial a_{e'}$: this is the + densest block; both $s_i$ and $s_{ij}, s_{ik}$ depend on $a$'s, so all + three $\zeta$-slots contribute. + +### 5.2 Closed-form formulas, hyper-ideal triangle (all $v_i$ variable) + +Let $\beta_1 = \zeta(\ell_{12}, \ell_{31}, \ell_{23})$ (and cyclic), and +write the $\zeta$-partials at vertex $1$ as +$\zeta_x^{(1)} := \zeta_x(\ell_{12}, \ell_{31}, \ell_{23})$, etc.; and the +length partials from (Z13) as +$L^b_{e, i}, L^a_e$ etc. Then explicitly: +$$ +\begin{aligned} +\frac{\partial \beta_1}{\partial b_1} &= + \zeta_x^{(1)} L^b_{12,1} \;+\; \zeta_y^{(1)} L^b_{31,1} , + &\quad +\frac{\partial \beta_1}{\partial b_2} &= \zeta_x^{(1)} L^b_{12,2} , \\ +\frac{\partial \beta_1}{\partial b_3} &= \zeta_y^{(1)} L^b_{31,3} , + &\quad +\frac{\partial \beta_1}{\partial a_{12}} &= \zeta_x^{(1)} L^a_{12} , \\ +\frac{\partial \beta_1}{\partial a_{31}} &= \zeta_y^{(1)} L^a_{31} , + &\quad +\frac{\partial \beta_1}{\partial a_{23}} &= \zeta_z^{(1)} L^a_{23} . +\end{aligned} +\tag{Bblock} +$$ +(Note: $\beta_1$ does **not** depend on $a_{23}$ via the $b$'s — only the +direct $\ell_{23}$-dependence through $\zeta_z^{(1)}$ — so the last line +is the only $a_{23}$-coupling of $\beta_1$.) + +The full $J^{\beta b}$ row for vertex $1$ is therefore: +$$ +J^{\beta b}_{1,*} \;=\; +\big(\; + \zeta_x^{(1)} L^b_{12,1} + \zeta_y^{(1)} L^b_{31,1} ,\;\; + \zeta_x^{(1)} L^b_{12,2} ,\;\; + \zeta_y^{(1)} L^b_{31,3} +\;\big) , +$$ +and similarly for rows 2 and 3 by cyclic permutation. + +### 5.3 Closed-form formulas, dihedral row in $v_1$-branch + +Let $\beta := \alpha_{12} = \zeta(s_1, s_{12}, s_{13})$ (in the $v_1$ +branch, the $i$ of $\sigma$ is vertex 1, and the two edges through +vertex 1 are $12$ and $31 = 13$). With $\zeta_x^{(\alpha_{12})}$ etc. +denoting the three partials of $\zeta$ evaluated at $(s_1, s_{12}, s_{13})$: +$$ +\frac{\partial \alpha_{12}}{\partial \xi} + \;=\; \zeta_x^{(\alpha_{12})} \frac{\partial s_1}{\partial \xi} + \;+\; \zeta_y^{(\alpha_{12})} \frac{\partial s_{12}}{\partial \xi} + \;+\; \zeta_z^{(\alpha_{12})} \frac{\partial s_{13}}{\partial \xi} , +$$ +where $\xi$ ranges over the six face DOFs. Substituting +$s_1 = \zeta_{13}(a_{12}, a_{31}, a_{23})$ (assuming all +$v_j, v_k$ hyper-ideal so we are in the $(\text{hyp, hyp})$ sub-branch of +$\sigma_1$), +$s_{12} = \zeta_{13}(a_{12}, b_1, b_2)$, +$s_{13} = \zeta_{13}(a_{31}, b_1, b_3)$, the six entries are: +$$ +\begin{array}{l|l} +\xi & \partial \alpha_{12} / \partial \xi \\\hline +b_1 & \zeta_y^{(\alpha_{12})} \partial_y \zeta_{13}|_{s_{12}} + \zeta_z^{(\alpha_{12})} \partial_y \zeta_{13}|_{s_{13}} \\ +b_2 & \zeta_y^{(\alpha_{12})} \partial_z \zeta_{13}|_{s_{12}} \\ +b_3 & \zeta_z^{(\alpha_{12})} \partial_z \zeta_{13}|_{s_{13}} \\ +a_{12} & \zeta_x^{(\alpha_{12})} \partial_x \zeta_{13}|_{s_1} + \zeta_y^{(\alpha_{12})} \partial_x \zeta_{13}|_{s_{12}} \\ +a_{31} & \zeta_x^{(\alpha_{12})} \partial_y \zeta_{13}|_{s_1} + \zeta_z^{(\alpha_{12})} \partial_x \zeta_{13}|_{s_{13}} \\ +a_{23} & \zeta_x^{(\alpha_{12})} \partial_z \zeta_{13}|_{s_1} +\end{array} +\tag{Ablock} +$$ +The rows for $\alpha_{23}$ and $\alpha_{31}$ follow by cyclic permutation +$(1, 2, 3) \mapsto (2, 3, 1) \mapsto (3, 1, 2)$. + +### 5.4 Symmetry check (Schläfli) + +By (S2), the $6 \times 6$ block $H(f) = J(f)$ — interpreted as a +*Hessian-of-energy* block via the Springborn identification +$\beta_i = \partial U(f)/\partial b_i$, $\alpha_e = \partial U(f)/\partial a_e$ — +satisfies $J(f) = J(f)^\top$. Equivalently the four sub-blocks obey +$$ +J^{\beta b} = (J^{\beta b})^\top, \quad J^{\alpha a} = (J^{\alpha a})^\top, +\quad J^{\alpha b} = (J^{\beta a})^\top . +\tag{Sym} +$$ +This is a powerful numerical sanity check: any analytic formula that +violates (Sym) by more than rounding error has a derivation error +somewhere in §3–§4. + +The off-diagonal cross-check $\partial \alpha_{12} / \partial b_3 \;\stackrel{!}{=}\; \partial \beta_3 / \partial a_{12}$ +is particularly instructive: the left-hand side is computed via the +$\sigma$-triangle at vertex 1 (last row of `Ablock`-style table for +$\alpha_{12}$), the right-hand side via the auxiliary triangle at vertex 3. +The two routes coincide only because of (S2). + +### 5.5 Scatter to global Hessian + +Once $J(f)$ is built, the scatter step is **identical** to the existing +`hyper_ideal_hessian_block_fd`: for each $(i, j) \in \{1, \dots, 6\}^2$, +add $J_{ij}(f)$ to $H[\text{glb}(i), \text{glb}(j)]$, where $\text{glb}$ +maps the local face slot to its global DOF index via `v_idx` / `e_idx`. +Pinned slots (`-1`) are skipped, exactly as in the existing block-FD +implementation. + +--- + +## 6. Acceptance criteria + +These mirror `doc/roadmap/research-track.md` Phase 9b-analytic: + +1. **Per-case derivative cross-checks against block-FD.** For each of the + four building blocks $(\partial \beta / \partial \ell, \partial \ell / \partial \xi, + \partial \sigma / \partial \xi, \partial \alpha / \partial \xi)$, sample + 100 random DOF vectors in + - all-hyper-ideal regime ($v_1, v_2, v_3$ variable), + - one-ideal regime (each of three rotations of $v_i$ pinned), + - two-ideal regime (each of three rotations of two $v_i$ pinned). + Require $\max_{ij} |J_{ij}^{\text{analytic}} - J_{ij}^{\text{FD}}| \le 10^{-6}$ + with central-difference step $\varepsilon = 10^{-5}$. + +2. **Schläfli symmetry (gauge-free).** For each face $f$ at random $x$, + verify $\|J(f) - J(f)^\top\|_\infty \le 10^{-10}$. Symbolically this is + automatic by (S2); numerically it pins down sign/branch bugs. + +3. **Gauge null space.** Let $\mathbf{1}_b$ be the constant-$b$ Möbius + dilation mode (i.e. the vector with $1$ in every $b$-slot and $0$ in + every $a$-slot). Require $\|H \mathbf{1}_b\|_\infty \le 10^{-10}$ on + the all-hyper-ideal mesh after assembly (this is the only Möbius mode + that survives in the closed-surface case). + +4. **PSD on the interior.** For random DOFs inside the convex domain + (Springborn 2020 §4.3), verify $\lambda_{\min}(H) \ge -10^{-12}$ + (numerical zero). Closed-form: $E$ is convex on the admissible cone + by Springborn 2020 Theorem 4.2. + +5. **Speed-up.** Wall-clock benchmark on tetrahedron, octahedron, + icosahedron, and a 200-vertex genus-2 test mesh: analytic Hessian + assembly $\ge 3\times$ faster than `hyper_ideal_hessian_block_fd`, + with target $\sim 6\times$ asymptotically (one face costs + $O(6 \cdot 36)$ FD evaluations of `face_angles_from_local_dofs` + currently; analytic costs $\sim 100$ scalar transcendentals per face). + +--- + +## 7. Implementation outline + +Header: `code/include/hyper_ideal_hessian_analytic.hpp` (new). + +```cpp +// hyper_ideal_hessian_analytic.hpp — Phase 9b-analytic +// +// Closed-form 6x6 per-face Hessian via Schläfli + chain rule. +// See doc/math/hyperideal-hessian-derivation.md for the derivation. + +#include "hyper_ideal_geometry.hpp" +#include "hyper_ideal_functional.hpp" + +namespace conformallab { + +// Building-block derivatives (Section 3 of the note). +struct ZetaPartials { double dx, dy, dz; }; // d zeta(x, y, z) +struct Zeta13Partials { double dx, dy, dz; }; // d zeta13 +struct Zeta14Partials { double dx, dy; }; // d zeta14(x, y) + +inline ZetaPartials d_zeta (double x, double y, double z, double beta); +inline Zeta13Partials d_zeta13(double x, double y, double z, double L); +inline Zeta14Partials d_zeta14(double x, double y, double L); +inline double d_zeta15(double x); // returns tanh(L/2) + +// Edge-length differential coefficients (table in 3.4). +struct EdgeLenDiff { + double dbi; // d l_ij / d b_i (zero if v_i ideal) + double dbj; // d l_ij / d b_j (zero if v_j ideal) + double daij; // d l_ij / d a_ij +}; +EdgeLenDiff d_lij(double bi, double bj, double aij, bool vi, bool vj); + +// 6x6 face Jacobian: rows (beta1, beta2, beta3, alpha12, alpha23, alpha31), +// cols (b1, b2, b3, a12, a23, a31). Same input contract as +// face_angles_from_local_dofs. +struct FaceJacobian6 { double M[6][6]; }; + +FaceJacobian6 analytic_face_jacobian( + double b1, double b2, double b3, + double a12, double a23, double a31, + bool v1b, bool v2b, bool v3b); + +// Drop-in replacement for hyper_ideal_hessian_block_fd. +// Same scatter loop; only the inner block changes from FD to closed form. +Eigen::SparseMatrix hyper_ideal_hessian_analytic( + const ConformalMesh& mesh, + const std::vector& x, + const HyperIdealMaps& m); + +} // namespace conformallab +``` + +The body of `analytic_face_jacobian` follows the case dispatch: + +```text +1. clamp inputs same as face_angles_from_local_dofs +2. compute l12, l23, l31 via lij; bail out on triangle-inequality break +3. compute beta1, beta2, beta3 via zeta +4. for each edge e, compute EdgeLenDiff via d_lij(...) +5. for each vertex i, fill row i of J^{beta b} and J^{beta a} + using (B1) with d_zeta(...) evaluated at the angle of vertex i +6. for each edge e, dispatch on (v_i, v_j, v_k) and compute alpha_e row: + - (v_i hyp branch): use (A.v_i), expand s_i, s_ij, s_ik via 4.3 + - (v_j hyp branch): symmetric + - (v_k recursive branch): compute alpha_jk row first, then + subtract beta_j row and negate (A2) + - (all-ideal branch): combine three beta rows via (A3) +7. assert |J - J^T|_inf < 1e-10 (DEBUG only) +8. return J +``` + +The global assembler then reuses the existing scatter loop from +`hyper_ideal_hessian_block_fd`; only the inner *per-face block* +construction changes. + +--- + +## 8. References + +- **Schläfli, L.** (1858/60). *On the multiple integral + $\int dx\, dy \cdots dz$ whose limits are + $p_1 = a_1 x + b_1 y + \cdots + h_1 z > 0$, $p_2 > 0, \dots, p_n > 0$ + and $x^2 + y^2 + \cdots + z^2 < 1$.* Quarterly Journal of Pure and + Applied Mathematics 2, 269–301; 3, 54–68, 97–108. Reprinted in + *Gesammelte Mathematische Abhandlungen*, Band I, Birkhäuser 1950. + → first-order Schläfli identity, equation (S1) above. + +- **Milnor, J.** (1982). *Hyperbolic geometry: the first 150 years.* + Bulletin AMS 6, 9–24. → modern restatement of (S1) and discussion of + its role in hyperbolic volume. + +- **Vinberg, E. B.** (ed.) (1993). *Geometry II: Spaces of constant + curvature.* Encyclopaedia of Mathematical Sciences vol. 29, Springer. + Ch. 7 covers the polyhedral Schläfli identity and its derivation via + the Gauss–Bonnet formula for hyperbolic polytopes. + +- **Cho, Y. & Kim, H.** (1999). *On the volume formula for hyperbolic + tetrahedra.* Discrete & Computational Geometry 22, 347–366. + → explicit $\partial \alpha / \partial a$, $\partial \beta / \partial b$, + etc. for hyperbolic tetrahedra (used in §2.2 above). + +- **Rivin, I.** (1994). *Euclidean structures on simplicial surfaces and + hyperbolic volume.* Annals of Math. 139, 553–580. → second-order + Schläfli (S2) in the convex-polyhedron setting. + +- **Buser, P.** (1992). *Geometry and Spectra of Compact Riemann + Surfaces.* Birkhäuser. Thm 2.4.1 — right-angled hexagon identities + (used in §3.1 to interpret $\zeta_{13}$). + +- **Glickenstein, D.** (2011). *Discrete conformal variations and scalar + curvature on piecewise flat manifolds.* J. Diff. Geom. 87, 201–238. + Equation (4.6) and §4 — cone-vertex variant of the angle-length + derivative formulas (mirror Hessian for inversive distance). + +- **Springborn, B.** (2020). *Ideal Hyperbolic Polyhedra and Discrete + Uniformization.* Discrete & Computational Geometry 64, 63–108. + §4 — hyper-ideal energy and its gradient/Hessian; §4.3 — convexity + (PSD criterion). + +- **Bobenko, A. I., Pinkall, U. & Springborn, B.** (2015). *Discrete + conformal maps and ideal hyperbolic polyhedra.* Geometry & Topology + 19, 2155–2215. — earlier exposition of the truncated-tetrahedron + construction that `face_angles_from_local_dofs` realises. + +--- + +## Appendix A. Sign conventions and pitfalls + +A few places where the derivation interacts subtly with the code: + +- `zeta(l_jk, l_ki, l_ij)` returns the interior angle **at the vertex + between sides $l_{jk}$ and $l_{ki}$**, hence opposite to $l_{ij}$. So + when reading off $\zeta_x, \zeta_y, \zeta_z$ for $\beta_1 = + \zeta(\ell_{12}, \ell_{31}, \ell_{23})$, the slot $x$ matches + $\ell_{12}$, $y$ matches $\ell_{31}$, $z$ matches $\ell_{23}$. The + Cho–Kim formulas (Z) must be applied with **exactly this slot mapping**. + +- `sigma_ij(a_{ij}, b_i, b_j, v_j)` — when $v_j$ is ideal, the call is + `zeta14(-a_{ij}, b_i)`, so the partial w.r.t. $a_{ij}$ carries a + **negative** sign. This is a frequent source of off-by-sign bugs. + +- The recursive $v_k$-branch in `alpha_ij` returns $\pi - \alpha_{jk}' - \beta_j$, + **not** $\pi - \alpha_{jk}' - \beta_k$. This reflects the geometry of + the truncated tetrahedron and is *not* the cyclically-symmetric + formula one might guess; see Springborn 2020 Fig. 3. + +- The "all-ideal" closed form + $\alpha_{ij} = \tfrac{1}{2}(\pi + \beta_k - \beta_i - \beta_j)$ + is dual: $\alpha + \beta$ on each ideal vertex always sums to $\pi/2$ + for a planar triangle, so the dihedral degenerates to the half-angle + identity. Differentiating loses any explicit $\ell$ dependence, hence + (A3) is the simplest of the four branches. + +## Appendix B. Numerical guard zones + +The block-FD currently used as reference clamps: +- $b_i < 0 \;\to\; 0.01$ on variable vertices, +- $a_e < 0 \;\to\; 0$ on edges between two variable vertices. + +The analytic kernel must reproduce these clamps *before* computing any +derivatives; otherwise the FD-vs-analytic cross-check (Acceptance §1) +will fail at boundary inputs. Inside the clamped region, all +$\sinh \ell, \sin \beta$ denominators in (Z), (Z13), (Z14), (Z15) are +bounded away from zero, so the closed-form expressions are numerically +stable. The degenerate triangle-inequality branches in +`face_angles_from_local_dofs` (lines 174–183) set angles to $0$ or +$\pi$, which makes some $\zeta$-partials infinite; in those branches the +Jacobian is undefined and we return the all-zero block (matching the +non-smooth boundary of the domain). This is also the policy of the +block-FD reference, so the cross-check stays consistent. diff --git a/doc/roadmap/porting-status.md b/doc/roadmap/porting-status.md new file mode 100644 index 0000000..4018d99 --- /dev/null +++ b/doc/roadmap/porting-status.md @@ -0,0 +1,247 @@ +# Porting status overview + +> **Snapshot date:** 2026-05-22 (commit graph at v0.9.0 + 2 open PRs). +> +> **Audience.** External collaborators evaluating whether to use, extend, +> or contribute to conformallab++. This document is the **operational +> truth** of which Java mathematics is reachable from C++ today, with +> what API, and with which caveats. For the longer plan see +> [`phases.md`](phases.md), for the formal port-vs-research classification +> see [`research-track.md`](research-track.md), for the cross-reference +> with the Java original see [`java-parity.md`](java-parity.md). + +--- + +## 1. The 25 000 lines of Java in one table + +``` +de.varylab.discreteconformal +├── functional/ ~3 000 LoC ████████████░░░░ Phase 3 + 9a ✅ +├── unwrapper/ ~3 500 LoC ████████░░░░░░░░ Phase 5/6/7 (basic 3 modes) ✅ +│ residual: Stereographic, CircleDomain, Koebe, CirclePattern +├── util/ ~5 000 LoC ████░░░░░░░░░░░░ partial — main utilities ported +│ residual: Homology, Holomorphic / Harmonic forms, +│ FundamentalPolygon, Surgery, Cutting +├── heds/ (Co* data struct) ~3 000 LoC ░░░░░░░░░░░░░░░░ REPLACED by CGAL::Surface_mesh ❌ intentional +├── plugin/ ~7 000 LoC ░░░░░░░░░░░░░░░░ SKIP (Swing UI, GLSL viewer) ❌ intentional +├── numerics/ (PETSc glue) ~1 500 LoC ░░░░░░░░░░░░░░░░ REPLACED by Eigen ❌ intentional +├── math/, logging/, ... ~2 000 LoC ░░░░░░░░░░░░░░░░ REPLACED by std::* / Eigen ❌ intentional +└── total ~25 000 LoC +``` + +**Coverage of the mathematics:** about **4 500 of the 11 000 LoC** of +genuinely mathematical Java code is portrayed in C++ — **~41% of the +maths, ~75% of the "core math we actually want"** (Phase 1–7 + 9a + 9b +together cover the algorithmically central mass). + +The remaining ~6 500 LoC of port-worthy maths is catalogued in +[`java-parity.md`](java-parity.md) and broken into Phases 9c / 10a / +10b / 10b' / 11. + +--- + +## 2. Five DCE models — operational status matrix + +The library supports **five** discrete-conformal-equivalence models on +the same `CGAL::Surface_mesh

`. All can be used from both the +legacy API (`code/include/*.hpp`) and the CGAL public API +(``). + +| Model | Native space | DOF location | Java port? | Hessian | Newton | CGAL entry | UV out | +|------------------------|---------------|--------------|-----------|------------------|-----------|---------------------------------------------|----------| +| **Euclidean** | ℝ² | vertex | EuclideanCyclic 530 LoC | analytic (cot-Laplace) | ✅ | `discrete_conformal_map_euclidean` | ✅ Point_2 | +| **Spherical** | S² | vertex | Spherical 458 LoC | analytic | ✅ | `discrete_conformal_map_spherical` | ✅ Point_3 | +| **Hyper-ideal** | H² (Poincaré) | vertex + edge | HyperIdeal 311 LoC (no Hess) | block-FD (Phase 9b, 96× speed-up); analytic planned | ✅ | `discrete_conformal_map_hyper_ideal` | ✅ Point_2 | +| **CP-Euclidean** (BPS) | face circles | **face** | CPEuclidean 260 LoC | analytic 2×2-per-edge | ✅ | `discrete_circle_packing_euclidean` | ⛔ N/A | +| **Inversive Distance** | vertex circles| vertex | ❌ no Java (Luo 2004 + Glickenstein 2011 from literature) | FD (analytic planned) | ✅ | `discrete_inversive_distance_map` | ⛔ pending | + +Total: 250+ tests covering all five models, 0 skipped. Per-suite +breakdown: [`doc/api/tests.md`](../api/tests.md). + +### What "UV out" means + +The `output_uv_map(pmap)` named parameter, when supplied, runs the +relevant `*_layout()` step internally and writes per-vertex coordinates +into `pmap` — no separate user code needed. See +[`doc/tutorials/add-output-uv-map.md`](../tutorials/add-output-uv-map.md). + +* **Euclidean / Spherical / HyperIdeal**: shipped. +* **CP-Euclidean**: conceptually not applicable (face-based packing + produces face-circles, not per-vertex UV — needs a separate + `output_circle_map` design). +* **Inversive Distance**: requires `inversive_distance_layout()` using + Luo's edge-length formula `ℓ² = r_i² + r_j² + 2 I_ij r_i r_j`. + ~3 days work, on the wishlist. + +--- + +## 3. Topology infrastructure + +| Feature | Status | Notes | +|------------------------------------------------|:-----:|-------| +| Mesh I/O (OFF / OBJ / PLY) | ✅ | `mesh_io.hpp` via CGAL::IO | +| Gauss–Bonnet check & enforce | ✅ | `gauss_bonnet.hpp` | +| Cut graph (tree-cotree, Erickson–Whittlesey) | ✅ | `cut_graph.hpp`, produces 2g seam edges | +| Layout / embedding (priority-BFS trilateration) | ✅ | ℝ² / S² / Poincaré disk | +| Halfedge UV (seam-aware texture atlas) | ✅ | `layout.hpp` | +| Möbius holonomy SU(1,1) | ✅ | for hyperbolic mode, genus 1 | +| Period matrix τ ∈ ℍ + SL(2,ℤ) reduction | ✅ | genus 1 only | +| Fundamental domain (parallelogram) | ✅ | genus 1 only | +| Fundamental domain (4g-polygon) | 🔲 | Phase 9c — biggest remaining Java-port item | +| Real mesh cuts (CuttingUtility ops) | 🔲 | Phase 9c prerequisite | + +--- + +## 4. Solver infrastructure + +| Feature | Status | Notes | +|-------------------------------------------------|:-----:|-------| +| Newton with line search | ✅ | `newton_solver.hpp`, all five solvers | +| SimplicialLDLT + SparseQR fallback | ✅ | gauge-singular meshes handled automatically | +| Block-FD Hessian framework | ✅ | shipped for HyperIdeal (Phase 9b); 96× speed-up | +| Analytic Hessian via Schläfli | 🔲 | Phase 9b-analytic; derivation in [`hyperideal-hessian-derivation.md`](../math/hyperideal-hessian-derivation.md) | + +--- + +## 5. CGAL public API (``) + +After Phase 8a MVP + 8b-Lite (both shipped in v0.9.0): + +| Public header | Provides | +|--------------------------------------------------------------|----------| +| `` | `discrete_conformal_map_euclidean / _spherical / _hyper_ideal` + `Conformal_map_result` | +| `` | `Default_cp_euclidean_traits` + `discrete_circle_packing_euclidean` + `Circle_packing_result` | +| `` | `Default_inversive_distance_traits` + `discrete_inversive_distance_map` | +| `` | Thin re-exports of layout functions in the `CGAL::` namespace | +| `` | `ConformalMapTraits` concept + `Default_conformal_map_traits` | +| `` (private) | Named-parameter tags | + +Named parameters available: + +| Parameter | Effect | +|------------------------------------|--------| +| `vertex_curvature_map(pmap)` | Per-vertex Θ targets; if omitted, "natural-theta" makes x = 0 the equilibrium | +| `fixed_vertex_map(pmap)` | Gauge pin; if omitted, first vertex is pinned | +| `gradient_tolerance(eps)` | Newton stop threshold (default 1e-10) | +| `max_iterations(n)` | Newton iteration cap (default 200) | +| `output_uv_map(pmap)` | Run layout + write per-vertex coordinates | +| `normalise_layout(flag)` | Canonical post-layout normalisation (PCA / north-pole / Möbius) | + +**Known limitations** (documented for the meeting): + +1. **No chaining yet.** `.gradient_tolerance(eps).max_iterations(n)` does + not work — pass one named parameter per call. Fix is mechanical + (~2 days, generate CGAL-style chaining helpers via macros). + +2. **Generic FaceGraph not yet supported.** All Default traits + specialise on `CGAL::Surface_mesh

` only. Adding `Polyhedron_3`, + `OpenMesh-adapter`, `pmp::SurfaceMesh` is Phase 8a.2 — speculative, + waiting on a concrete user request. + +3. **CGAL submission-readiness incomplete.** User_manual / + PackageDescription.txt / CGAL-format tests are not yet written + (Phase 8c / 8d). Required for upstream submission, not for current + users. + +--- + +## 6. What's reachable from the Java original today + +Cross-reference between Java classes and their C++ port status. For +the full list see [`java-parity.md`](java-parity.md); the table below +gives the operational summary for a Java user wondering "where is X?". + +### Direct ports + +| Java | C++ | +|----------------------------------------------|-------------------------------------------------------------| +| `EuclideanCyclicFunctional` | `euclidean_functional.hpp` | +| `SphericalFunctional` | `spherical_functional.hpp` | +| `HyperIdealFunctional` | `hyper_ideal_functional.hpp` | +| `HyperIdealUtility`, `…Geometry` | `hyper_ideal_utility.hpp`, `hyper_ideal_geometry.hpp` | +| `HyperIdealHyperellipticUtility` | `hyper_ideal_visualization_utility.hpp` | +| `CPEuclideanFunctional` | `cp_euclidean_functional.hpp` | +| `Clausen` | `clausen.hpp` | +| `MeshIO` (jReality JRS) | `mesh_io.hpp` (CGAL::IO, OFF/OBJ/PLY) | +| `CuttingUtility::point_in_triangle_2d` | `geometry_utils.hpp` partial port | +| `ConvergenceUtility::circumradius` | `geometry_utils.hpp` | +| `HomologyUtility::compute_generators` | `cut_graph.hpp` (different algorithm: tree-cotree) | +| `PeriodMatrixUtility` (genus 1) | `period_matrix.hpp` | +| `UnwrapperUtility::EuclideanUnwrapper` | `newton_euclidean()` + `euclidean_layout()` | +| `UnwrapperUtility::SphericalUnwrapper` | `newton_spherical()` + `spherical_layout()` | +| `UnwrapperUtility::HyperbolicUnwrapper` | `newton_hyper_ideal()` + `hyper_ideal_layout()` | + +### Not-yet-ported, in roadmap + +| Java | Suggested phase | Lines | Notes | +|---------------------------------------------------|:----:|:----:|-------| +| `FundamentalPolygonUtility` + `CanonicalFormUtility` | 9c | 698+532 | genus > 1 fundamental domain | +| `CuttingUtility` + `SurgeryUtility` (full ops) | 9c | 584+217 | real mesh-cut operations | +| `DiscreteHarmonicFormUtility` | 10a | 657 | harmonic 1-forms | +| `DiscreteHolomorphicFormUtility` | 10a | 285 | holomorphic differentials | +| `CanonicalBasisUtility` | 10a | 337 | symplectic homology basis | +| `DualityUtility`, `HomologyUtility` (full) | 10a | 308+122 | primal/dual cohomology | +| `DiscreteRiemannUtility` | 10b | 186 | period matrix for genus > 1 | +| `HyperbolicCyclicFunctional` | 10b–c | 530 | hyperbolic energy (different from hyper-ideal) | +| `QuasiisothermicUtility` + `SinConditionApplication` | 10b | ~1200 | Lawson correspondence | +| `StereographicUnwrapper` | 10b' | 266 | S² → ℂ atlas | +| `KoebePolyhedron` | 10c | 321 | KAT circle packing | +| `MobiusCenteringFunctional` | 10c | 289 | sphere pre-processing | +| `CircleDomainUnwrapper` | 11c | 570 | multiply-connected planar regions | + +### Intentionally not ported + +* **`CoHDS` (half-edge data structure)** — replaced by `CGAL::Surface_mesh`. ~3 000 Java LoC saved. +* **`plugin/*` Swing UI** — out of scope; C++ project is a library, not an application. +* **`unwrapper/numerics/*` PETSc/Tao wrappers** — replaced by Eigen, which is header-only and bundled. +* **`math/CP1`, `math/Cn`, …** — redundant with `std::complex`, `Eigen::Matrix`. + +--- + +## 7. Things in C++ that the Java original does NOT have + +These are conformallab++ contributions beyond porting — the +"research extensions" track ([`research-track.md`](research-track.md)). + +| Item | Status | +|----------------------------------------------|:-----:| +| Block-FD HyperIdeal Hessian (96× speed-up) | ✅ shipped (Phase 9b) | +| HyperIdeal Hessian (any kind) | ✅ shipped — Java has `hasHessian()==false` | +| Inversive-Distance functional (Luo 2004) | ✅ shipped — implemented from paper, not from Java | +| `` public-API surface | ✅ shipped (Phase 8a + 8b-Lite) | +| Cross-validation framework (4 acceptance criteria) | ✅ documented in `add-inversive-distance.md` | +| Genus-2 test mesh + brezel2.obj scalability | ✅ shipped | +| Memory-safe layout via `halfedge_uv` storage | ✅ shipped | +| `doc/release-policy.md` formal release policy | ✅ shipped (PR #13) | +| Analytic HyperIdeal Hessian via Schläfli | 🔲 derivation written (`hyperideal-hessian-derivation.md`); implementation Phase 9b-analytic | +| Output UV map integrated into wrappers | ✅ shipped (PR #14) for 3 of 5 models | +| Full uniformisation for genus g ≥ 2 | 🔲 Phase 10c — fully new research | + +--- + +## 8. How to "use the library today" + +The fastest path for a mathematician evaluating the library: + +```bash +# Build + run the full test suite +git clone && cd ConformalLabpp +cmake -S code -B build -DWITH_CGAL_TESTS=ON +cmake --build build --target conformallab_cgal_tests -j$(nproc) +ctest --test-dir build -R "^cgal\." --output-on-failure + +# Try the CGAL API on a mesh +./build/examples/example_layout my_mesh.off +``` + +To experiment with a new functional, follow +[`doc/tutorials/add-inversive-distance.md`](../tutorials/add-inversive-distance.md). + +To extend the CGAL API with new named parameters, follow +[`doc/tutorials/add-output-uv-map.md`](../tutorials/add-output-uv-map.md). + +To optimise an FD Hessian, follow +[`doc/tutorials/block-fd-hessian.md`](../tutorials/block-fd-hessian.md). + +For the architectural commitments that constrain future contributions, +see [`doc/architecture/locked-vs-flexible.md`](../architecture/locked-vs-flexible.md). diff --git a/doc/tutorials/add-output-uv-map.md b/doc/tutorials/add-output-uv-map.md new file mode 100644 index 0000000..2334e0d --- /dev/null +++ b/doc/tutorials/add-output-uv-map.md @@ -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("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 ``). + +--- + +## 2. Mathematical background + +The package implements **two mathematically distinct stages**: + +### Stage A — Newton solver (variational optimisation) + +For the Euclidean DCE functional (Springborn–Schmies–Bobenko 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 +#include +#include + +using K = CGAL::Simple_cartesian; +using Mesh = CGAL::Surface_mesh; +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( + "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 +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 +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>("f:circle", ...).first; + +CGAL::discrete_circle_packing_euclidean( + mesh, CGAL::parameters::output_circle_map(circles)); +``` + +Effort: **1–2 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). +- `` — upstream machinery. diff --git a/doc/tutorials/block-fd-hessian.md b/doc/tutorials/block-fd-hessian.md new file mode 100644 index 0000000..81778d5 --- /dev/null +++ b/doc/tutorials/block-fd-hessian.md @@ -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– +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`](../../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 295–298 +> ``` +> +> 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, 63–108. §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), 659–689. + +**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 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 + +```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 hyper_ideal_hessian_block_fd( + ConformalMesh& mesh, + const std::vector& x, + const HyperIdealMaps& m, + double eps = 1e-5) +{ + const int n = hyper_ideal_dimension(mesh, m); + std::vector> 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 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 `(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: + +```cpp +Eigen::SelfAdjointEigenSolver 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/_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__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.