docs: 5-document meeting prep — tutorials + research note + status + architecture
External-reviewer-visit prep package (Springborn-Bobenko PhD alumnus,
2026-05-26). All five documents target the same audience: a
mathematician who wants to evaluate, extend, or contribute to
conformallab++. Goal: make the project maximally hackable BEFORE the
meeting. Code unchanged in this commit — pure documentation.
Files added
───────────
1. **doc/tutorials/block-fd-hessian.md** (460 lines)
Step-by-step tutorial on the per-face block-FD Hessian pattern
shipped in Phase 9b (96× speed-up). Matches the style of
add-inversive-distance.md. Covers:
* The per-face locality lemma (mathematical justification).
* Cost analysis (full-FD vs block-FD vs analytic).
* Implementation walkthrough through face_angles_from_local_dofs +
hyper_ideal_hessian_block_fd.
* Porting checklist for applying the same pattern to a new
functional.
* The four cross-validation criteria.
* When NOT to use block-FD + upgrade path to Phase 9b-analytic.
2. **doc/tutorials/add-output-uv-map.md** (477 lines)
Tutorial for the `output_uv_map` named-parameter pattern shipped in
PR #14. Covers:
* The UX problem (two-step pipeline → one-call wrapper).
* The CGAL named-parameter mechanism + how the entry functions
wire it (get_parameter + constexpr if).
* Step-by-step recipe for adding a new named parameter (worked
example: hypothetical `output_holonomy_map`).
* The five test patterns for verification.
* Why CP-Euclidean (face-DOF) and Inversive-Distance (Luo-edge-length)
do not yet support output_uv_map — what is needed to add them.
3. **doc/math/hyperideal-hessian-derivation.md** (805 lines)
Research-quality LaTeX-formatted derivation of the analytic
HyperIdeal Hessian via the Schläfli identity (Phase 9b-analytic
preparation). Covers:
* Schläfli identity (1858/60) — gradient and second-order form.
* Derivatives of ζ, ζ₁₃, ζ₁₄, ζ₁₅ (all hyper-ideal-to-fully-ideal cases).
* Chain rule for ∂β_i/∂(b,a) and ∂α_ij/∂(b,a) — case-split on the
four α_ij branches.
* Per-face 6×6 block formulas.
* Acceptance criteria for the future implementation.
* Implementation outline (Conformal_map header sketch).
* Appendix A: sign / argument-order pitfalls reading the code.
* References: Schläfli 1858, Milnor 1982, Vinberg 1993, Cho-Kim 1999,
Rivin, Glickenstein 2011, Springborn 2020, BPS 2015.
4. **doc/roadmap/porting-status.md** (~250 lines)
Operational snapshot of "where is each piece of Java math today"
at v0.9.0. Sections:
* 25 000 lines of Java in one table (ported / worth porting /
intentionally skipped breakdown).
* Five DCE models — full status matrix with Java port status,
Hessian type, Newton support, CGAL entry, UV-output capability.
* Topology + solver infrastructure status.
* CGAL public API map + known limitations (no chaining, Surface_mesh
only, submission-readiness gaps).
* Reverse cross-reference: Java class → C++ port location (or
"skipped: replaced by …" / "in roadmap: phase X").
* Things in C++ that the Java original does NOT have (research
extensions track).
* "How to use the library today" quickstart.
5. **doc/architecture/locked-vs-flexible.md** (~270 lines)
12-item architecture-decision review with tier classification
(🔴 load-bearing / 🟡 semi-fixed / 🟢 opportunistic). Each item
includes: locked-since date, cost to change, when to revisit,
recommended posture for new contributors. Key insight stated up
front: "the load-bearing decisions are all good in 2026". Closes
with five open questions for the external reviewer — items where
a second opinion would genuinely help (Phase 9c algorithm choice,
Phase 10a priorities, analytic-Hessian payoff justification,
CGAL upstream vs independent distribution, geometry-central
cross-validation).
Total: ~2 250 lines across five new docs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
477
doc/tutorials/add-output-uv-map.md
Normal file
477
doc/tutorials/add-output-uv-map.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# Tutorial: The `output_uv_map` Named Parameter
|
||||
|
||||
This tutorial documents the `output_uv_map` named parameter for the
|
||||
CGAL-style entry functions of conformallab++: how to use it (the "happy
|
||||
path"), how the CGAL named-parameter machinery threads a user-supplied
|
||||
property map through to the layout step, and how to extend the same
|
||||
pattern to add new named parameters to existing or new entry functions.
|
||||
|
||||
**Target audience.** A working mathematician using the C++ API who is
|
||||
comfortable with templates and CGAL property maps but wants a single
|
||||
authoritative reference for the pattern.
|
||||
|
||||
**Prerequisite reading.**
|
||||
|
||||
- `code/include/CGAL/Conformal_map/internal/parameters.h` — tag definitions
|
||||
- `code/include/CGAL/Discrete_conformal_map.h` — wiring in three entries
|
||||
- `code/tests/cgal/test_cgal_phase8b_lite.cpp` — the OutputUvMap_* tests
|
||||
- `doc/tutorials/add-inversive-distance.md` — sibling tutorial in this series
|
||||
|
||||
---
|
||||
|
||||
## 1. What this tutorial is and isn't
|
||||
|
||||
### The UX problem `output_uv_map` solves
|
||||
|
||||
Before Phase 8b-Lite, computing a flattening required **two separate
|
||||
calls**: the Newton wrapper, then a manual rebuild of `*_maps` plus a
|
||||
replay of the wrapper's gauge/DOF choices, then `*_layout()`. Any drift
|
||||
between the wrapper's gauge vertex and the caller's silently produced
|
||||
inconsistent UVs.
|
||||
|
||||
The `output_uv_map` named parameter collapses this into one call:
|
||||
|
||||
```cpp
|
||||
auto uv = mesh.add_property_map<Vertex_index, K::Point_2>("uv").first;
|
||||
CGAL::discrete_conformal_map_euclidean(
|
||||
mesh, CGAL::parameters::output_uv_map(uv));
|
||||
// uv[v] now holds the flattened UV coordinate of vertex v.
|
||||
```
|
||||
|
||||
This tutorial is **not** a derivation of the Newton solver (see
|
||||
`add-inversive-distance.md`), not a guide to writing a new layout
|
||||
routine, and not an introduction to CGAL named parameters in general
|
||||
(see `<CGAL/Named_function_parameters.h>`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Mathematical background
|
||||
|
||||
The package implements **two mathematically distinct stages**:
|
||||
|
||||
### Stage A — Newton solver (variational optimisation)
|
||||
|
||||
For the Euclidean DCE functional (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 <CGAL/Simple_cartesian.h>
|
||||
#include <CGAL/Surface_mesh.h>
|
||||
#include <CGAL/Discrete_conformal_map.h>
|
||||
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
using Mesh = CGAL::Surface_mesh<K::Point_3>;
|
||||
using Vertex_i = Mesh::Vertex_index;
|
||||
|
||||
Mesh mesh = /* load triangle mesh */;
|
||||
|
||||
// 1. Allocate a writable property map on the mesh.
|
||||
auto uv = mesh.add_property_map<Vertex_i, K::Point_2>(
|
||||
"v:uv", K::Point_2(0, 0)).first;
|
||||
|
||||
// 2. Ask the wrapper to populate it.
|
||||
auto res = CGAL::discrete_conformal_map_euclidean(
|
||||
mesh,
|
||||
CGAL::parameters::output_uv_map(uv));
|
||||
|
||||
// 3. Use the UVs.
|
||||
if (res.converged) {
|
||||
for (auto v : mesh.vertices())
|
||||
std::cout << v.idx() << ": " << uv[v] << '\n';
|
||||
}
|
||||
```
|
||||
|
||||
### Supported entries
|
||||
|
||||
| Entry | Value type per vertex |
|
||||
|--------------------------------------|--------------------------------------------------|
|
||||
| `discrete_conformal_map_euclidean` | `Point_2` — UV in ℝ² |
|
||||
| `discrete_conformal_map_spherical` | `Point_3` — point on S² ⊂ ℝ³ |
|
||||
| `discrete_conformal_map_hyper_ideal` | `Point_2` — point in the Poincaré disk (|p|≤1) |
|
||||
|
||||
### NOT supported (yet)
|
||||
|
||||
| Entry | Reason |
|
||||
|----------------------------------------|-------------------------------------------------------------|
|
||||
| `discrete_circle_packing_euclidean` | Face-based parametrisation — UV is per-face, not per-vertex |
|
||||
| `discrete_inversive_distance_map` | Requires `inversive_distance_layout()` using Luo's `ℓ²` formula — not yet written |
|
||||
|
||||
For the circle-packing entry a separate `output_circle_map` parameter
|
||||
(face → (centre, radius)) is the natural extension; for the inversive-
|
||||
distance entry the layout primitive must be written first. See §7.
|
||||
|
||||
### Optional companion: `normalise_layout`
|
||||
|
||||
```cpp
|
||||
CGAL::parameters::normalise_layout(true)
|
||||
```
|
||||
|
||||
When the layout writes into `output_uv_map`, this flag triggers a
|
||||
canonical post-processing pass:
|
||||
|
||||
- Euclidean: PCA — translate centroid to origin, rotate so the principal
|
||||
axis is along `x`.
|
||||
- Spherical: Rodrigues rotation so that the centroid is at the north pole.
|
||||
- Hyperbolic: Möbius centring of the Poincaré disk.
|
||||
|
||||
Default: `false`. Has no effect unless `output_uv_map` is also passed.
|
||||
|
||||
### Current limitation: no chaining
|
||||
|
||||
The CGAL machinery supports chaining named parameters via `.member()`
|
||||
syntax for the standard CGAL tags (e.g. `geom_traits`, `vertex_point_map`).
|
||||
**Package-local tags do not yet have member-function counterparts**, so
|
||||
|
||||
```cpp
|
||||
// DOES NOT COMPILE:
|
||||
CGAL::parameters::output_uv_map(uv).normalise_layout(true);
|
||||
```
|
||||
|
||||
is currently rejected. Pass one parameter per call instead. This is
|
||||
the same limitation noted in `test_cgal_phase8b_lite.cpp`:
|
||||
|
||||
```cpp
|
||||
// Named-parameter chaining (`a.b().c()`) is not currently supported on
|
||||
// the package-local tags; pass one parameter per call instead.
|
||||
```
|
||||
|
||||
Adding member-function chaining is a separate Phase 8b extension —
|
||||
requires writing a derived `Named_function_parameters` subclass that
|
||||
exposes `.output_uv_map(...)`, `.normalise_layout(...)`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 4. Named-parameter mechanism — how it works
|
||||
|
||||
CGAL's named parameters are a compile-time dispatch trick. Three pieces:
|
||||
|
||||
### 4.1 The tag (in `internal_np`)
|
||||
|
||||
```cpp
|
||||
namespace CGAL::Conformal_map::internal_np {
|
||||
enum output_uv_map_t { output_uv_map };
|
||||
}
|
||||
```
|
||||
|
||||
A single-value `enum` is the lightest possible type-level marker. The
|
||||
value `output_uv_map` is what callers will pass as the *key*; the type
|
||||
`output_uv_map_t` is what `get_parameter` will use for the lookup.
|
||||
|
||||
### 4.2 The user-facing helper (in `CGAL::parameters`)
|
||||
|
||||
```cpp
|
||||
template <typename PropertyMap>
|
||||
auto output_uv_map(const PropertyMap& pmap) {
|
||||
return CGAL::Named_function_parameters<
|
||||
PropertyMap,
|
||||
Conformal_map::internal_np::output_uv_map_t,
|
||||
CGAL::internal_np::No_property // no "next" parameter in chain
|
||||
>(pmap);
|
||||
}
|
||||
```
|
||||
|
||||
This wraps the property map in a `Named_function_parameters` object
|
||||
tagged with `output_uv_map_t`. The third template argument is the
|
||||
"previous" link in a chain; `No_property` means this is a singleton
|
||||
pack. (Chaining would substitute the previous pack's type here.)
|
||||
|
||||
### 4.3 The internal lookup (inside the entry function)
|
||||
|
||||
The full read-and-act pattern from `Discrete_conformal_map.h`:
|
||||
|
||||
```cpp
|
||||
// Step 1 — try to extract the parameter.
|
||||
auto uv_param = parameters::get_parameter(
|
||||
np, Conformal_map::internal_np::output_uv_map);
|
||||
|
||||
// Step 2 — at compile time, did we find one?
|
||||
constexpr bool has_uv = !std::is_same_v<
|
||||
decltype(uv_param), internal_np::Param_not_found>;
|
||||
|
||||
// Step 3 — conditionally do the extra work.
|
||||
if constexpr (has_uv) {
|
||||
if (nr.converged) {
|
||||
auto layout = ::conformallab::euclidean_layout(mesh, nr.x, maps);
|
||||
|
||||
const bool do_norm = parameters::choose_parameter(
|
||||
parameters::get_parameter(np, Conformal_map::internal_np::normalise_layout),
|
||||
false);
|
||||
if (do_norm) ::conformallab::normalise_euclidean(layout);
|
||||
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& uv = layout.uv[v.idx()];
|
||||
put(uv_param, v,
|
||||
typename Traits::Kernel::Point_2(uv.x(), uv.y()));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- `get_parameter` returns either the wrapped value or a sentinel
|
||||
`Param_not_found`. The check is `std::is_same_v<…, Param_not_found>`.
|
||||
- `choose_parameter(get_parameter(np, tag), default_value)` is the
|
||||
one-liner form for scalar parameters with a default.
|
||||
- `constexpr if` ensures that if the user did not pass `output_uv_map`,
|
||||
the layout call is **not even instantiated** — no runtime cost, and
|
||||
no need for the layout routine to be callable on the wrapper's
|
||||
signature (e.g. it can require a Point_2 traits type that the user
|
||||
hasn't supplied).
|
||||
- The layout step is gated on `nr.converged` for a reason: trilateration
|
||||
on a non-converged `u` produces garbage edge lengths and can throw
|
||||
on degenerate triangles. The current contract is **layout iff Newton
|
||||
converged**; on non-convergence the pmap is left at the value the
|
||||
caller initialised it with.
|
||||
|
||||
The same three-step pattern appears in `discrete_conformal_map_spherical`
|
||||
(writing `Point_3`) and `discrete_conformal_map_hyper_ideal` (writing
|
||||
`Point_2` in the Poincaré disk).
|
||||
|
||||
---
|
||||
|
||||
## 5. How to extend the pattern
|
||||
|
||||
Recipe for adding a new package-local named parameter — running example
|
||||
`output_holonomy_map` (a per-edge map carrying the Möbius/translation
|
||||
holonomy data that some downstream algorithms consume).
|
||||
|
||||
### Step 1 — Add the tag in `internal/parameters.h`
|
||||
|
||||
```cpp
|
||||
namespace CGAL::Conformal_map::internal_np {
|
||||
/// Property-map: edge_descriptor → 2x2 matrix of holonomy data.
|
||||
/// Default: not populated.
|
||||
enum output_holonomy_map_t { output_holonomy_map };
|
||||
}
|
||||
```
|
||||
|
||||
Two-line block. Use a meaningful Doxygen `\internal` comment because
|
||||
this is the canonical contract for the parameter's semantics.
|
||||
|
||||
### Step 2 — Add the user-facing helper in `parameters.h`
|
||||
|
||||
```cpp
|
||||
namespace CGAL::parameters {
|
||||
|
||||
/// `output_holonomy_map(pmap)` — write per-edge holonomy matrices
|
||||
/// into `pmap`. Only meaningful for closed surfaces with non-trivial
|
||||
/// π₁; ignored on simply-connected meshes.
|
||||
template <typename PropertyMap>
|
||||
auto output_holonomy_map(const PropertyMap& pmap) {
|
||||
return CGAL::Named_function_parameters<
|
||||
PropertyMap,
|
||||
Conformal_map::internal_np::output_holonomy_map_t,
|
||||
CGAL::internal_np::No_property
|
||||
>(pmap);
|
||||
}
|
||||
|
||||
} // namespace CGAL::parameters
|
||||
```
|
||||
|
||||
A Doxygen comment is **mandatory** here — this is the public surface.
|
||||
|
||||
### Step 3 — Use `get_parameter + constexpr if` in the entry function
|
||||
|
||||
```cpp
|
||||
auto hol_param = parameters::get_parameter(
|
||||
np, Conformal_map::internal_np::output_holonomy_map);
|
||||
constexpr bool has_hol = !std::is_same_v<
|
||||
decltype(hol_param), internal_np::Param_not_found>;
|
||||
if constexpr (has_hol) {
|
||||
auto H = ::conformallab::compute_holonomy(mesh, nr.x, maps);
|
||||
for (auto e : mesh.edges())
|
||||
put(hol_param, e, H[e.idx()]);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4 — Add tests
|
||||
|
||||
At minimum two:
|
||||
|
||||
1. **Takes-effect test.** Pass the parameter; verify the side effect
|
||||
actually happens (pmap is populated, values are non-default).
|
||||
2. **Sanity-when-absent test.** Call the wrapper without the parameter;
|
||||
verify the call still succeeds and produces the expected base result.
|
||||
|
||||
Both patterns are exemplified in `test_cgal_phase8b_lite.cpp` (next
|
||||
section).
|
||||
|
||||
### Optional Step 5 — Companion flag
|
||||
|
||||
If your new parameter has a Boolean tweak (analogous to
|
||||
`normalise_layout`), follow exactly the same recipe — `enum X_t { X }`
|
||||
+ helper + `choose_parameter(..., default)` at the read site.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests — walkthrough of `test_cgal_phase8b_lite.cpp`
|
||||
|
||||
The OutputUvMap_* tests live at the bottom of the file. Each codifies
|
||||
one acceptance criterion.
|
||||
|
||||
### 6.1 `OutputUvMap_Euclidean_PopulatesPmap`
|
||||
|
||||
Two assertions: (a) every UV is finite, (b) at least one vertex has
|
||||
moved off the origin. Test (b) rules out the false-positive where the
|
||||
layout silently returned the pmap's default at every vertex.
|
||||
|
||||
### 6.2 `OutputUvMap_Spherical_PopulatesXyz`
|
||||
|
||||
Geometric round-trip: every output point must lie on the unit sphere.
|
||||
|
||||
```cpp
|
||||
const double r = std::sqrt(p.x()*p.x() + p.y()*p.y() + p.z()*p.z());
|
||||
EXPECT_NEAR(r, 1.0, 1e-6);
|
||||
```
|
||||
|
||||
The spherical analogue of 6.1(b): the layout is correct iff the
|
||||
realisation lands on S².
|
||||
|
||||
### 6.3 `OutputUvMap_HyperIdeal_PointsInPoincareDisk`
|
||||
|
||||
The natural Θ/θ targets do not always reach Newton equilibrium inside
|
||||
the default `max_iterations(200)`. The test branches:
|
||||
|
||||
```cpp
|
||||
if (res.converged) {
|
||||
for (auto v : mesh.vertices()) {
|
||||
const double r2 = p.x()*p.x() + p.y()*p.y();
|
||||
EXPECT_LE(r2, 1.0 + 1e-6);
|
||||
}
|
||||
}
|
||||
// else: layout was skipped by the wrapper's `if (nr.converged)` guard.
|
||||
```
|
||||
|
||||
Documenting the wrapper contract ("layout iff converged") via the
|
||||
test keeps it resilient to PRNG-sensitive Newton paths.
|
||||
|
||||
### 6.4 `OutputUvMap_Absent_DoesNotRunLayout`
|
||||
|
||||
Sanity test: omitting `output_uv_map` must not change the existing
|
||||
behaviour. Catches the regression where adding the new `constexpr if`
|
||||
perturbs the wrapper's result type or consumes extra work.
|
||||
|
||||
### 6.5 `OutputUvMap_NormaliseLayout_TakesEffect`
|
||||
|
||||
Because chaining is not yet implemented (§3), this exercises the toggle
|
||||
indirectly: it runs the wrapper twice into two separate pmaps and
|
||||
verifies both populate finite values. When chaining lands, upgrade this
|
||||
to actually pass `normalise_layout(true)` on the second call and compare
|
||||
UV bounding boxes (the normalised one must be axis-aligned and
|
||||
centroid-zero).
|
||||
|
||||
---
|
||||
|
||||
## 7. Adding output to the two missing modes
|
||||
|
||||
### 7.1 CP-Euclidean (face-based) — proposed `output_circle_map`
|
||||
|
||||
The circle-packing entry parametrises **faces**: each face `f` has a
|
||||
radius `ρ_f`. The natural per-face output is `(centre, radius)`:
|
||||
|
||||
```cpp
|
||||
auto circles = mesh.add_property_map<Face_index,
|
||||
std::pair<K::Point_2, double>>("f:circle", ...).first;
|
||||
|
||||
CGAL::discrete_circle_packing_euclidean(
|
||||
mesh, CGAL::parameters::output_circle_map(circles));
|
||||
```
|
||||
|
||||
Effort: **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).
|
||||
- `<CGAL/Named_function_parameters.h>` — upstream machinery.
|
||||
460
doc/tutorials/block-fd-hessian.md
Normal file
460
doc/tutorials/block-fd-hessian.md
Normal file
@@ -0,0 +1,460 @@
|
||||
# Tutorial: The Per-Face Block-Finite-Difference Hessian (Phase 9b)
|
||||
|
||||
This tutorial documents the **block-finite-difference Hessian** scheme
|
||||
used for the hyper-ideal discrete-conformal functional of Bobenko–
|
||||
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<double> hyper_ideal_hessian_block_fd(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const HyperIdealMaps& m,
|
||||
double eps = 1e-5)
|
||||
{
|
||||
const int n = hyper_ideal_dimension(mesh, m);
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(36 * mesh.number_of_faces());
|
||||
|
||||
for (auto f : mesh.faces()) {
|
||||
// (1) Pull the three halfedges and their endpoints.
|
||||
Halfedge_index h0 = mesh.halfedge(f);
|
||||
Halfedge_index h1 = mesh.next(h0);
|
||||
Halfedge_index h2 = mesh.next(h1);
|
||||
Vertex_index v1 = mesh.source(h0), v2 = mesh.source(h1), v3 = mesh.source(h2);
|
||||
Edge_index e12 = mesh.edge(h0), e23 = mesh.edge(h1), e31 = mesh.edge(h2);
|
||||
|
||||
// (2) DOF indices: −1 = pinned.
|
||||
const int idx[6] = {
|
||||
m.v_idx[v1], m.v_idx[v2], m.v_idx[v3],
|
||||
m.e_idx[e12], m.e_idx[e23], m.e_idx[e31]
|
||||
};
|
||||
const bool v1b = idx[0] >= 0, v2b = idx[1] >= 0, v3b = idx[2] >= 0;
|
||||
|
||||
// (3) Read current DOF values (0 for pinned).
|
||||
const double vals[6] = {
|
||||
dof_val(idx[0], x), dof_val(idx[1], x), dof_val(idx[2], x),
|
||||
dof_val(idx[3], x), dof_val(idx[4], x), dof_val(idx[5], x)
|
||||
};
|
||||
|
||||
// (4) Per-local-column central-difference.
|
||||
for (int j = 0; j < 6; ++j) {
|
||||
if (idx[j] < 0) continue; // pinned column = no entry
|
||||
|
||||
double vp[6], vm[6];
|
||||
for (int k = 0; k < 6; ++k) vp[k] = vm[k] = vals[k];
|
||||
vp[j] += eps;
|
||||
vm[j] -= eps;
|
||||
|
||||
auto Op = face_angles_from_local_dofs(vp[0],vp[1],vp[2], vp[3],vp[4],vp[5], v1b,v2b,v3b);
|
||||
auto Om = face_angles_from_local_dofs(vm[0],vm[1],vm[2], vm[3],vm[4],vm[5], v1b,v2b,v3b);
|
||||
|
||||
const double Gp[6] = { Op.beta1,Op.beta2,Op.beta3, Op.alpha12,Op.alpha23,Op.alpha31 };
|
||||
const double Gm[6] = { Om.beta1,Om.beta2,Om.beta3, Om.alpha12,Om.alpha23,Om.alpha31 };
|
||||
|
||||
// (5) Scatter the 6 entries of this local column.
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
if (idx[i] < 0) continue; // pinned row = no entry
|
||||
const double val = (Gp[i] - Gm[i]) / (2.0 * eps);
|
||||
if (std::abs(val) > 1e-15)
|
||||
trips.emplace_back(idx[i], idx[j], val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Eigen::SparseMatrix<double> H(n, n);
|
||||
H.setFromTriplets(trips.begin(), trips.end()); // duplicates summed
|
||||
return H;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3 — the triplet/`setFromTriplets` pattern
|
||||
|
||||
Two design choices are worth highlighting:
|
||||
|
||||
1. **`Eigen::Triplet` accumulation.** Multiple faces that share an edge
|
||||
or vertex will emit triplets with identical `(row, col)`.
|
||||
`Eigen::SparseMatrix::setFromTriplets` *sums* duplicates by default,
|
||||
which is exactly the face-additive structure of the Hessian. No
|
||||
explicit hash-map keyed by `(row, col)` is needed.
|
||||
2. **Pinned-DOF handling.** A pinned (gauge-fixed) DOF has `idx = −1`.
|
||||
The inner `continue` statements simply skip its row and column. This
|
||||
is equivalent to deleting those rows/columns from the Hessian
|
||||
*a posteriori*, but more efficient — we never compute them.
|
||||
|
||||
---
|
||||
|
||||
## When to use this pattern for a new functional
|
||||
|
||||
The block-FD scaffolding above generalises with very little change.
|
||||
Checklist for porting:
|
||||
|
||||
1. **Identify the per-face DOFs.** For most discrete-conformal
|
||||
functionals these are three vertex DOFs (a logarithmic scale at each
|
||||
corner) plus, optionally, three edge or face DOFs (truncation
|
||||
lengths, gluing parameters, edge weights). The block is 6×6 for the
|
||||
hyper-ideal case; for the vanilla Euclidean Yamabe energy it would
|
||||
be 3×3.
|
||||
2. **Extract a pure-math local function.** Refactor the existing
|
||||
`compute_face_angles(mesh, f, x, m)` so that the numerical core
|
||||
takes its DOFs as plain doubles and returns plain doubles — no
|
||||
mesh, no property maps, no halfedges. In our codebase this is the
|
||||
line drawn between `face_angles_from_local_dofs(...)` (pure) and
|
||||
`compute_face_angles(...)` (mesh-aware wrapper).
|
||||
3. **Wrap in the `block_fd_hessian()` loop.** Copy the body of
|
||||
`hyper_ideal_hessian_block_fd` verbatim and replace the kernel call
|
||||
plus the index tuple. The triplet-scatter logic does not change.
|
||||
4. **Cross-validate against full-FD.** Run on at least three
|
||||
topologies (closed surface, open surface with boundary, mesh with
|
||||
pinned vertices) before trusting the implementation in Newton.
|
||||
|
||||
---
|
||||
|
||||
## Cross-validation criteria
|
||||
|
||||
Mirroring the four acceptance criteria from
|
||||
[`add-inversive-distance.md`](add-inversive-distance.md):
|
||||
|
||||
### C1 — Match against full-FD at machine precision
|
||||
|
||||
The block-FD Hessian must agree with the full-FD baseline up to
|
||||
double-perturbation rounding (~10⁻⁹ entrywise):
|
||||
|
||||
```cpp
|
||||
auto H_full = hyper_ideal_hessian (mesh, x, m, eps);
|
||||
auto H_block = hyper_ideal_hessian_block_fd (mesh, x, m, eps);
|
||||
Eigen::MatrixXd D = Eigen::MatrixXd(H_full) - Eigen::MatrixXd(H_block);
|
||||
EXPECT_LT(D.cwiseAbs().maxCoeff(), 1e-7);
|
||||
```
|
||||
|
||||
Required on **both** a closed mesh (e.g. tetrahedron) and an open mesh
|
||||
with boundary.
|
||||
|
||||
### C2 — Match with pinned DOFs (gauge fix)
|
||||
|
||||
Setting `m.v_idx[v0] = −1` for some reference vertex must produce the
|
||||
same `(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<Eigen::MatrixXd> es(Eigen::MatrixXd(H_sym));
|
||||
EXPECT_GT(es.eigenvalues().minCoeff(), -1e-9); // PSD modulo FD rounding
|
||||
```
|
||||
|
||||
### C4 — Sparsity pattern matches face adjacency
|
||||
|
||||
The non-zero pattern of `H_block` must be a subset of the
|
||||
"face-incidence" pattern: `H[i,j] ≠ 0` only if there exists a face `f`
|
||||
such that both DOFs `i` and `j` are among the 6 local DOFs of `f`.
|
||||
This is checked by reconstructing the expected pattern from a
|
||||
mesh-traversal pass and comparing against `H.nonZeros()`.
|
||||
|
||||
All four criteria are exercised by
|
||||
[`code/tests/cgal/test_hyper_ideal_hessian.cpp`](../../code/tests/cgal/test_hyper_ideal_hessian.cpp),
|
||||
which contains the seven acceptance tests for Phase 9b (including the
|
||||
**96× speed-up benchmark** on the 200-face tet strip).
|
||||
|
||||
---
|
||||
|
||||
## Limits and when NOT to use block-FD
|
||||
|
||||
The block-FD Hessian is the right default but has two weaknesses:
|
||||
|
||||
1. **Inner-loop overhead.** Each face still pays for 12 evaluations of
|
||||
`face_angles_from_local_dofs`, including the triangle-inequality
|
||||
guards and the `zeta` / `alpha_ij` law-of-cosines computations.
|
||||
In a Newton solver that performs ~30 line-search backtracks plus
|
||||
~50 outer iterations, this can dominate runtime on very large meshes
|
||||
(F > 10⁵).
|
||||
2. **Floating-point step coupling.** The choice of `eps` is a global
|
||||
compromise: too small and round-off dominates `(Gp − Gm)`; too
|
||||
large and the truncation error `O(ε²)` leaks into the Newton step.
|
||||
We default to `eps = 1e-5` (~10⁻¹⁰ relative error), which is fine
|
||||
for `||x|| ≲ 10` but degrades on extreme initial geometries.
|
||||
|
||||
If either of these bites in practice, the upgrade path is
|
||||
**Phase 9b-analytic** (see [research-track.md](../roadmap/research-track.md)).
|
||||
The analytic Hessian uses the Schläfli identity
|
||||
|
||||
```
|
||||
d(vol) = −½ Σ_e ℓ_e d(α_e)
|
||||
```
|
||||
|
||||
combined with closed-form differentiation through
|
||||
`(bᵢ, aₑ) → lᵢⱼ → ζ → β, α`. Acceptance criteria for that future PR
|
||||
include:
|
||||
|
||||
* Match against block-FD to 10⁻⁹ on the same test corpus.
|
||||
* Measured speed-up ≥ 3× over block-FD (asymptotically ~6×).
|
||||
* No `eps` parameter — the result is exact up to law-of-cosines
|
||||
conditioning.
|
||||
|
||||
Until 9b-analytic lands, **block-FD is the recommended path**.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance checklist
|
||||
|
||||
- [ ] `code/include/<functional>_hessian.hpp` compiles and exposes
|
||||
`block_fd` and `block_fd_sym` variants.
|
||||
- [ ] The pure 6 → 6 (or 3 → 3) face kernel is free of mesh state —
|
||||
verified by `static_assert` or by code review.
|
||||
- [ ] Full-FD baseline implemented in the same header for cross-checks.
|
||||
- [ ] C1 (entrywise match) passes on a closed mesh and on an open mesh.
|
||||
- [ ] C2 (pinned-DOF coherence) passes with at least one pinned vertex.
|
||||
- [ ] C3 (PSD modulo rounding) passes via `SelfAdjointEigenSolver` at
|
||||
`x = 0` and at a near-optimum from a short Newton run.
|
||||
- [ ] C4 (face-adjacency sparsity) passes — `H.nonZeros()` matches the
|
||||
expected pattern exactly.
|
||||
- [ ] Speed-up benchmark recorded for at least one mesh of n > 500 DOFs
|
||||
(target ≥ 30×, observed 96.5× on the 200-face tet strip).
|
||||
- [ ] Newton wrapper in `newton_solver.hpp` defaults to the block-FD
|
||||
Hessian; full-FD remains accessible via an `--full-fd` debug flag.
|
||||
- [ ] Registered in `code/tests/cgal/CMakeLists.txt`
|
||||
(`test_<functional>_hessian.cpp`).
|
||||
- [ ] `doc/roadmap/research-track.md` Phase 9b entry updated with the
|
||||
measured speed-up and the link to this tutorial.
|
||||
- [ ] Phase 9b-analytic entry in `research-track.md` cross-referenced
|
||||
as the next milestone.
|
||||
Reference in New Issue
Block a user