Some checks failed
C++ Tests / test-fast (pull_request) Successful in 1m55s
API Docs / doc-build (pull_request) Successful in 53s
Markdown link check / check (pull_request) Successful in 50s
C++ Tests / test-cgal (pull_request) Has been skipped
C++ Tests / quality-gates (pull_request) Failing after 1m56s
External reviewer pass over the literature references. Verified entries against arXiv/DOI/publisher and corrected misattributions that had propagated across the docs. Corrected citations (consistent across all docs): - Bowers-Bowers-Lutz 2026: title was the 2017 paper's -> "Rigidity of Koebe Polyhedra and Inversive Distance Circle Packings" - Liouville theorem: "Springborn 2019" -> Pinkall & Springborn, Geom. Dedicata 214 (2021) - Bobenko-Pinkall-Springborn: "G&T 14 (2010)" -> G&T 19(4) (2015), 2155-2215 - Optimal Cone Singularities: "Crane, Soliman, Ben-Chen, Schroeder" -> Soliman, Slepcev, Crane, ACM TOG 37(4) - Schlaefli formula: "Rivin, Springborn 1999" -> Rivin, Schlenker - Quasiconformal distortion: "Springborn, Veselov" -> Born, Buecking, Springborn (arXiv:1505.01341) - Period matrices: "Bobenko, Buecking 2009" (was Bobenko-Mercat-Schmies' title) -> Bobenko-Mercat-Schmies 2011 + genuine Bobenko-Buecking 2021 - Fabricated entry: "Alexa 2020, DOI 10.1145/3414685.3417840" pointed to an unrelated paper (Pixelor) -> Bunge, Herholz, Kazhdan, Botsch 2020 - Stripe Patterns: "Bonneel et al. 2015" -> Knoeppel, Crane, Pinkall, Schroeder 2015 Equation-number corrections (verified against the PDFs): - Glickenstein 2011 "eq. 4.6" -> "§5.2" (no such equation label exists) - Springborn 2020 "eq. 4.6" -> "§4 variational gradient" - inversive-distance attribution softened to classical inversive distance Other: - DBFEnergy bibliography (separate repo) and convergence half-sentence in novelty-statement.md §3.3 (Bobenko-Buecking 2021) - Status legend (implemented vs planned) at top of references.md - New Phase 12 (decorated DCE & geometric transition, Chain A, near-term) and Phase 13 (canonical tessellations & polyhedral realisation, Chain B capstone) in phases.md + research-track.md; 10c scope-boundary note clarifying infrastructure vs Lutz-specific algorithms Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
301 lines
11 KiB
Markdown
301 lines
11 KiB
Markdown
# Tutorial: Implementing the Inversive-Distance Functional (Phase 9a.2)
|
||
|
||
This tutorial walks through adding a **new** discrete-conformal functional
|
||
to conformallab++. The running example is the **vertex-based inversive-
|
||
distance functional** of Luo (2004), used as Phase 9a.2 of the roadmap.
|
||
|
||
> ## ⚠️ This is research, not a port
|
||
>
|
||
> An earlier draft of this document claimed this functional was a port of
|
||
> `de.varylab.discreteconformal.functional.InversiveDistanceFunctional`.
|
||
> **That Java class does not exist.** Verified empirically:
|
||
>
|
||
> ```bash
|
||
> $ find /Users/tarikmoussa/Desktop/conformallab -iname "*nversive*"
|
||
> (zero results)
|
||
> $ grep -r "InversiveDistance" /Users/tarikmoussa/Desktop/conformallab/src
|
||
> (zero matches)
|
||
> ```
|
||
>
|
||
> The closest Java cousin is `CPEuclideanFunctional.java`, which implements
|
||
> the **face-based** circle-packing variant (Phase 9a.1). The vertex-based
|
||
> inversive-distance functional (this tutorial) is built **from the
|
||
> literature**, not from a Java reference, and the correctness validation
|
||
> is cross-checked against three sources:
|
||
>
|
||
> 1. **Luo, F.** (2004). *Combinatorial Yamabe Flow on Surfaces.* Comm. Contemp. Math. 6(5), 765–780.
|
||
> 2. **Bowers, P. L. & Stephenson, K.** (2004). *Uniformizing dessins and Belyĭ maps via circle packing.* Mem. AMS 170(805).
|
||
> 3. **Glickenstein, D.** (2011). *Discrete conformal variations and scalar curvature on piecewise flat manifolds.* J. Differential Geometry 87(2), 201–238.
|
||
>
|
||
> The tutorial below has been re-written to match this reality.
|
||
|
||
**Prerequisite:** Read [doc/api/extending.md](../api/extending.md) first
|
||
for the general functional-porting pattern. This tutorial fills in the
|
||
mathematical and code details for one specific case.
|
||
|
||
---
|
||
|
||
## Mathematical background
|
||
|
||
Inversive-distance circle packing parametrises **each vertex** by a circle
|
||
of radius `r_i = exp(u_i)`. Two adjacent circles have an *inversive
|
||
distance* `I_ij` that is a fixed constant of the edge, derived once from
|
||
the initial geometry via the Bowers–Stephenson identity:
|
||
|
||
```
|
||
I_ij = ( ℓ_ij² − r_i² − r_j² ) / ( 2 r_i r_j ) (Bowers-Stephenson 2004)
|
||
```
|
||
|
||
Geometric interpretation of `I_ij`:
|
||
|
||
| Range | Configuration |
|
||
|---|---|
|
||
| `I_ij = +1` | tangent circles (Koebe-style) |
|
||
| `I_ij ∈ (0, 1)` | overlapping with intersection angle `φ`, `I = cos φ` |
|
||
| `I_ij = 0` | orthogonal circles |
|
||
| `I_ij ∈ (−1, 0)` | disjoint circles, inversive distance > 1 |
|
||
| `I_ij ≤ −1` | impossible packing |
|
||
|
||
The edge length under a state `u` is then determined by Luo's formula:
|
||
|
||
```
|
||
ℓ_ij(u)² = exp(2 u_i) + exp(2 u_j) + 2 I_ij exp(u_i + u_j)
|
||
= r_i² + r_j² + 2 I_ij r_i r_j (Luo 2004 §3)
|
||
```
|
||
|
||
The angle formula is the same numerically-stable half-tangent law of
|
||
cosines used by `euclidean_functional.hpp`; only the way `ℓ_ij` is
|
||
computed from `u` is different.
|
||
|
||
The gradient is the standard Yamabe-flow gradient:
|
||
|
||
```
|
||
∂E/∂u_v = Θ_v − Σ_{T ∋ v} α_v(T) (Luo 2004 Lemma 3.1)
|
||
```
|
||
|
||
The energy is a path integral of the gradient (Luo's 1-form is closed on
|
||
the domain where every triangle is valid); we use the same 10-point
|
||
Gauss-Legendre quadrature as `euclidean_functional.hpp`.
|
||
|
||
The Hessian is finite-difference for the MVP; an analytic form
|
||
(Glickenstein 2011 §5.2) is documented in the research-track roadmap.
|
||
|
||
---
|
||
|
||
## Step 1 — Create the header
|
||
|
||
```bash
|
||
cp code/include/euclidean_functional.hpp \
|
||
code/include/inversive_distance_functional.hpp
|
||
```
|
||
|
||
Modify the maps struct: replace `lambda0` (Euclidean log-length) with the
|
||
inversive-distance constant `I_e` and the initial radius `r0` (used for
|
||
the Bowers-Stephenson init).
|
||
|
||
```cpp
|
||
struct InversiveDistanceMaps {
|
||
ConformalMesh::Property_map<Vertex_index, int> v_idx; // DOF index (−1 = pinned)
|
||
ConformalMesh::Property_map<Vertex_index, double> theta_v; // target cone angle
|
||
ConformalMesh::Property_map<Vertex_index, double> r0; // initial radius r_i^(0)
|
||
ConformalMesh::Property_map<Edge_index, double> I_e; // inversive distance per edge
|
||
};
|
||
```
|
||
|
||
Then implement the four entry points that any functional needs in
|
||
conformallab++:
|
||
|
||
- `setup_inversive_distance_maps(mesh)` — create maps with defaults.
|
||
- `compute_inversive_distance_init_from_mesh(mesh, m)` — choose `r_i^(0)`
|
||
from the input geometry, then compute `I_ij` via Bowers-Stephenson.
|
||
- `inversive_distance_gradient(mesh, x, m)` — Luo's `Θ − Σ α`.
|
||
- `inversive_distance_energy(mesh, x, m)` — 10-point Gauss-Legendre path integral.
|
||
|
||
For the full implementation, see `code/include/inversive_distance_functional.hpp`
|
||
(part of PR #8).
|
||
|
||
---
|
||
|
||
## Step 2 — Edge-length kernel
|
||
|
||
The single new pure-math primitive is the Luo edge-length formula. Wrap
|
||
it in a small detail helper so the gradient function reads cleanly:
|
||
|
||
```cpp
|
||
namespace id_detail {
|
||
|
||
// ℓ² = exp(2u_i) + exp(2u_j) + 2 I exp(u_i + u_j)
|
||
// Returns -1 on degenerate input (no valid packing).
|
||
inline double edge_length_squared(double u_i, double u_j, double I_ij) {
|
||
double ri = std::exp(u_i);
|
||
double rj = std::exp(u_j);
|
||
double l2 = ri*ri + rj*rj + 2.0 * I_ij * ri * rj;
|
||
return l2 > 0.0 ? l2 : -1.0;
|
||
}
|
||
|
||
} // namespace id_detail
|
||
```
|
||
|
||
This is the only place where the inversive-distance model differs from
|
||
the Euclidean one. All downstream code (angle computation, gradient
|
||
accumulation, energy integration) is structurally identical.
|
||
|
||
---
|
||
|
||
## Step 3 — Reuse `euclidean_angles()`
|
||
|
||
The half-tangent law of cosines is independent of how lengths were
|
||
obtained. Feed `log(ℓ²)` to the existing helper to compute the three
|
||
corner angles per face:
|
||
|
||
```cpp
|
||
auto fa = euclidean_angles(std::log(l12sq), std::log(l23sq), std::log(l31sq));
|
||
```
|
||
|
||
This is the **non-trivial reuse** that justifies the structural similarity
|
||
to the Euclidean functional — we get the law-of-cosines numerics for free,
|
||
and only the edge-length input changes.
|
||
|
||
---
|
||
|
||
## Step 4 — Validation tests
|
||
|
||
The acceptance criteria for this functional are stricter than for a Java
|
||
port because there is no reference implementation to compare against. We
|
||
need **three independent validations**:
|
||
|
||
### 4.1 Limit-case edge lengths
|
||
|
||
Each of Luo's special cases (`I = 1` tangent, `I = 0` orthogonal,
|
||
`I = −1` inside-tangent) gives a closed-form `ℓ` that must be reproduced
|
||
to machine precision:
|
||
|
||
```cpp
|
||
TEST(InversiveDistanceFunctional, EdgeLengthFormula_TangentialLimit) {
|
||
// r_i=1, r_j=2, I=1: ℓ² = 1 + 4 + 2·1·1·2 = 9 ⇒ ℓ = 3 = r_i + r_j
|
||
double l2 = id_detail::edge_length_squared(0.0, std::log(2.0), 1.0);
|
||
EXPECT_NEAR(std::sqrt(l2), 3.0, 1e-12);
|
||
}
|
||
```
|
||
|
||
Three such tests cover the diagnostic special cases.
|
||
|
||
### 4.2 Bowers-Stephenson round-trip
|
||
|
||
The initialisation `compute_inversive_distance_init_from_mesh` must be
|
||
self-consistent: starting from `(ℓ_3d, r_i, r_j)` and computing `I_ij`,
|
||
the round-trip back through Luo's formula must give the original `ℓ`.
|
||
|
||
```cpp
|
||
TEST(InversiveDistanceFunctional, BowersStephensonRoundTrip) {
|
||
auto mesh = make_triangle();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
for (auto e : mesh.edges()) {
|
||
double l_3d = /* mesh 3-D edge length */;
|
||
double ri = m.r0[mesh.source(mesh.halfedge(e))];
|
||
double rj = m.r0[mesh.target(mesh.halfedge(e))];
|
||
double l_rec = std::sqrt(ri*ri + rj*rj + 2.0 * m.I_e[e] * ri * rj);
|
||
EXPECT_NEAR(l_rec, l_3d, 1e-12);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.3 FD-vs-analytic gradient check
|
||
|
||
Standard pattern from every functional in conformallab++ — see
|
||
`test_euclidean_functional.cpp`. Compare the analytic gradient
|
||
to a symmetric finite difference of the energy.
|
||
|
||
### 4.4 Cross-validation against `euclidean_functional.hpp`
|
||
|
||
At `u = 0`, both functionals reconstruct the input 3-D edge length
|
||
exactly (Euclidean via `compute_lambda0`, inversive distance via
|
||
Bowers-Stephenson). Therefore the actual angle sums are identical,
|
||
and the two gradients (with default `Θ_v = 2π`) must match component-wise:
|
||
|
||
```cpp
|
||
TEST(InversiveDistanceFunctional, AngleDefectAtU0_AgreesWithEuclideanAtU0) {
|
||
auto G_id = inversive_distance_gradient(mesh, /*x=0*/, m_id);
|
||
auto G_eu = euclidean_gradient (mesh, /*x=0*/, m_eu);
|
||
for (size_t i = 0; i < G_id.size(); ++i)
|
||
EXPECT_NEAR(G_id[i], G_eu[i], 1e-10);
|
||
}
|
||
```
|
||
|
||
This is the empirical statement of Glickenstein 2011 §5: different
|
||
parametrisations of the same initial discrete metric produce the same
|
||
Newton-time-zero gradient.
|
||
|
||
---
|
||
|
||
## Step 5 — Register the tests
|
||
|
||
In `code/tests/cgal/CMakeLists.txt`:
|
||
|
||
```cmake
|
||
# ── Phase 9a.2: InversiveDistance (Luo 2004 + Glickenstein 2011) ─────────
|
||
# Vertex-based inversive-distance circle-packing functional. No Java
|
||
# reference; implemented from the literature. Cross-validated against
|
||
# EuclideanCyclicFunctional at the natural initial geometry (u = 0).
|
||
test_inversive_distance_functional.cpp
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
ctest --test-dir build -R "InversiveDistance" --output-on-failure
|
||
```
|
||
|
||
---
|
||
|
||
## Step 6 — Newton solver
|
||
|
||
Once the functional passes its tests, wire a Newton wrapper into
|
||
`newton_solver.hpp`:
|
||
|
||
```cpp
|
||
inline NewtonResult newton_inversive_distance(
|
||
ConformalMesh& mesh,
|
||
std::vector<double> x0,
|
||
const InversiveDistanceMaps& m,
|
||
double tol = 1e-8,
|
||
int max_iter = 200);
|
||
```
|
||
|
||
The body is structurally identical to `newton_euclidean()` — same
|
||
SimplicialLDLT + SparseQR fallback, same termination test. Only the
|
||
inner gradient / Hessian calls differ.
|
||
|
||
---
|
||
|
||
## Checklist for a new functional
|
||
|
||
- [ ] `code/include/<name>_functional.hpp` compiles
|
||
- [ ] Limit-case edge-length tests pass at machine precision
|
||
- [ ] Round-trip identity (init ⇄ length formula) verified
|
||
- [ ] FD-vs-analytic gradient check passes on triangle, quad strip, tetra
|
||
- [ ] Cross-validation test against an existing functional at `u = 0`
|
||
- [ ] Newton wrapper added to `newton_solver.hpp`
|
||
- [ ] Registered in `code/tests/cgal/CMakeLists.txt`
|
||
- [ ] `doc/roadmap/java-parity.md` updated (port status or research note)
|
||
- [ ] `doc/math/references.md` extended with the primary paper(s)
|
||
- [ ] If this is *new research* beyond Java: add an entry in
|
||
`doc/roadmap/research-track.md` with citations and acceptance criteria
|
||
|
||
---
|
||
|
||
## How to know if it's a port or research
|
||
|
||
Run the local Java-repo check **first** before writing any tutorial doc:
|
||
|
||
```bash
|
||
find /Users/tarikmoussa/Desktop/conformallab -iname "*<feature>*"
|
||
grep -r "<ClassName>" /Users/tarikmoussa/Desktop/conformallab/src
|
||
```
|
||
|
||
If both return zero matches, the feature is **not** in Java and any C++
|
||
implementation is **new research**, not a port. The tutorial framing and
|
||
the `doc/roadmap/research-track.md` entry should reflect this from day one.
|