feat(phase5): Layout, CLI, JSON/XML serialisation — 95 tests

Phase 5 complete:

layout.hpp
  - euclidean_layout(): BFS unfolding in ℝ² using trilaterate_2d
  - spherical_layout(): BFS on S² using trilaterate_sph (spherical law of cosines)
  - hyper_ideal_layout(): BFS in Poincaré disk (tanh(d/2) Euclidean approx)
  - save_layout_off(): convenience OFF writer for 2-D and 3-D layouts

serialization.hpp
  - save/load_result_json(): nlohmann/json; stores DOF vector + uv/pos layout
  - save/load_result_xml(): hand-written writer/parser; same schema

conformallab_cli.cpp (rewritten)
  - CLI11 interface: -i/-o/-g/-j/-x/-s/-v
  - Dispatches to euclidean / spherical / hyper_ideal pipeline
  - Runs Newton, computes layout, saves OFF + JSON + XML

examples/example_layout.cpp
  - Full round-trip demo: solve → layout → JSON/XML → reload → verify

tests/cgal/test_layout.cpp (8 tests)
  - Euclidean_PreservesEdgeLengths, CorrectVertexCount, TriangleIsNonDegenerate
  - Spherical_PreservesArcLengths, PositionsOnUnitSphere
  - HyperIdeal_SuccessAndFinitePositions
  - Serialization.JSON_RoundTrip, XML_RoundTrip

All 95 CGAL tests pass (2 skipped — Hessian stubs unchanged).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-13 00:44:33 +02:00
parent 3f124eb071
commit b7593e3f6d
8 changed files with 1707 additions and 116 deletions

135
README.md
View File

@@ -4,7 +4,7 @@ conformallab++ is a modern C++ reimplementation of the [ConformalLab](https://gi
The long-term goal is a **CGAL package** that brings discrete conformal maps (hyper-ideal, spherical, Euclidean) to the CGAL ecosystem using `CGAL::Surface_mesh` as the underlying half-edge data structure.
> **Status:** Phase 4 vollständig abgeschlossen. Alle drei Geometrien lösbar via Newton-Solver (SimplicialLDLT + SparseQR-Fallback). Interaktiver Viewer-Beispiel inklusive. **87 Tests, 2 skipped**. Nächster Schritt: Phase 5 (CLI-App + Serialisierung).
> **Status:** Phase 5 vollständig abgeschlossen. Alle drei Geometrien lösbar via Newton-Solver (SimplicialLDLT + SparseQR-Fallback). BFS-Layout in ℝ²/S²/Poincaré-Disk, JSON/XML-Serialisierung, vollständige CLI-App. **95 Tests, 2 skipped**.
---
@@ -26,29 +26,43 @@ The long-term goal is a **CGAL package** that brings discrete conformal maps (hy
| Mesh I/O (CGAL::IO — OFF / OBJ / PLY) | ✅ Phase 4b |
| End-to-end pipeline tests | ✅ Phase 4c |
| **Example programs** (headless + interactive viewer) | ✅ Phase 4d |
| CLI app + XML serialisation | 🔜 Phase 5 |
| **BFS Layout** (ℝ², S², Poincaré disk) | Phase 5 |
| **CLI app** (`conformallab_core`) | ✅ Phase 5 |
| **JSON + XML serialisation** | ✅ Phase 5 |
---
## Quick start — running the examples
## Quick start — CLI app
```bash
cmake -S code -B build -DWITH_CGAL=ON
cmake --build build --target example_euclidean example_hyper_ideal
cmake --build build -j4
# Euclidean conformal map (headless)
./build/examples/example_euclidean [input.off] [output.off]
# Run the conformal map CLI on any OFF/OBJ/PLY mesh
./bin/conformallab_core -i input.off -g euclidean -o layout.off -j result.json -x result.xml
# HyperIdeal conformal map (headless)
./build/examples/example_hyper_ideal [input.off] [output.off]
# Available geometries: euclidean | spherical | hyper_ideal
./bin/conformallab_core -i input.off -g spherical -o sphere.off
# Interactive viewer (requires -DWITH_VIEWER=ON)
cmake -S code -B build -DWITH_CGAL=ON -DWITH_VIEWER=ON
cmake --build build --target example_viewer
# Show input mesh in interactive viewer (built-in)
./bin/conformallab_core -i input.off -s
```
### Example programs
```bash
# Layout + JSON/XML round-trip demo
./build/examples/example_layout [input.off] [layout.off] [result.json] [result.xml]
# Headless pipelines
./build/examples/example_euclidean [input.off] [output.off]
./build/examples/example_hyper_ideal [input.off] [output.off]
# Interactive viewer (requires -DWITH_CGAL=ON, viewer is built automatically)
./build/examples/example_viewer [input.off]
```
If no input file is given each example uses a built-in test mesh.
If no input file is given each example uses a built-in quad-strip mesh.
---
@@ -107,6 +121,39 @@ int n = assign_all_dof_indices(mesh, maps);
auto result = newton_hyper_ideal(mesh, x0, maps);
```
### Layout (embedding into target space)
After solving, convert scale factors into actual vertex coordinates:
```cpp
#include "layout.hpp"
#include "serialization.hpp"
// Euclidean: flat 2-D positions in ℝ²
Layout2D layout = euclidean_layout(mesh, result.x, maps);
// layout.uv[v.idx()] = Eigen::Vector2d
// Spherical: unit vectors on S² ⊂ ℝ³
Layout3D slayout = spherical_layout(mesh, result.x, smaps);
// slayout.pos[v.idx()] = Eigen::Vector3d
// HyperIdeal: Poincaré disk coordinates in ℝ²
Layout2D hlayout = hyper_ideal_layout(mesh, result.x, hmaps);
// Save layout as OFF
save_layout_off("layout.off", mesh, layout);
// Serialise to JSON / XML
save_result_json("result.json", result, "euclidean",
mesh.number_of_vertices(), mesh.number_of_faces(), &layout);
save_result_xml("result.xml", result, "euclidean",
mesh.number_of_vertices(), mesh.number_of_faces(), &layout);
// Load back
NewtonResult res2; std::string geom; Layout2D uv2;
auto x = load_result_json("result.json", &res2, &geom, &uv2);
```
Using **`solve_linear_system`** directly (with fallback detection):
```cpp
@@ -125,8 +172,8 @@ if (used_fallback)
| Mode | CMake flags | What gets built | CI |
|------|------------|-----------------|-----|
| **Tests only** (default) | *(none)* | `conformallab_tests` — Eigen + GTest only | ✅ automatic |
| **CGAL tests + examples** | `-DWITH_CGAL=ON` | `conformallab_cgal_tests`, `example_euclidean`, `example_hyper_ideal` | local only |
| **Interactive viewer** | `-DWITH_CGAL=ON -DWITH_VIEWER=ON` | above + `example_viewer`, `conformallab_core` | local only |
| **CGAL tests + examples** | `-DWITH_CGAL=ON` | `conformallab_cgal_tests`, `example_euclidean`, `example_hyper_ideal`, `example_layout`, `conformallab_core` (CLI) | local only |
| **Interactive viewer** | `-DWITH_CGAL=ON` *(implied)* | above + `example_viewer` + viewer linked into CLI | local only |
External dependencies are bundled as tarballs in `code/deps/tarballs/` and extracted lazily at CMake configure time (GTest is fetched via `FetchContent`).
@@ -163,19 +210,21 @@ ctest --test-dir build --output-on-failure
```bash
cmake -S code -B build -DWITH_CGAL=ON
cmake --build build --target conformallab_cgal_tests example_euclidean example_hyper_ideal -j$(nproc)
cmake --build build -j$(nproc)
ctest --test-dir build -R "^cgal\." --output-on-failure
./build/examples/example_euclidean
./build/examples/example_hyper_ideal
./build/examples/example_layout
./bin/conformallab_core -i input.off -g euclidean -o layout.off -j result.json
```
Expected: **87 tests pass, 2 skipped** (the two `@Ignore` Hessian stubs).
Expected: **95 tests pass, 2 skipped** (the two `@Ignore` Hessian stubs).
### Interactive viewer
```bash
cmake -S code -B build -DWITH_CGAL=ON -DWITH_VIEWER=ON
cmake --build build --target example_viewer -j$(nproc)
# WITH_CGAL=ON already implies viewer; example_viewer is built automatically
cmake -S code -B build -DWITH_CGAL=ON && cmake --build build -t example_viewer -j$(nproc)
./build/examples/example_viewer data/off/example.off
```
@@ -201,6 +250,8 @@ cmake --build build --target example_viewer -j$(nproc)
| `mesh_io.hpp` | `read_mesh` / `write_mesh` / `load_mesh` / `save_mesh` |
| `conformal_mesh.hpp` | `ConformalMesh` = `CGAL::Surface_mesh<Point3>` + property-map helpers |
| `mesh_builder.hpp` | `make_triangle` / `make_tetrahedron` / `make_quad_strip` / `make_fan` / `make_spherical_tetrahedron` |
| `layout.hpp` | `euclidean_layout` / `spherical_layout` / `hyper_ideal_layout``Layout2D/3D`; BFS unfolding |
| `serialization.hpp` | `save/load_result_json` + `save/load_result_xml` |
| `mesh_utils.hpp` | CGAL → Eigen conversion (`cgal_to_eigen`) |
| `constants.hpp` | `conformallab::PI`, `TWO_PI` |
@@ -216,6 +267,8 @@ code/
│ ├── mesh_io.hpp
│ ├── mesh_utils.hpp
│ ├── newton_solver.hpp # ← public solve_linear_system + 3 Newton solvers
│ ├── layout.hpp # ← BFS layout (euclidean/spherical/hyper_ideal)
│ ├── serialization.hpp # ← JSON + XML save/load
│ ├── hyper_ideal_{functional,hessian,geometry,utility,visualization_utility}.hpp
│ ├── spherical_{functional,hessian,geometry}.hpp
│ ├── euclidean_{functional,hessian,geometry}.hpp
@@ -225,9 +278,10 @@ code/
│ ├── CMakeLists.txt
│ ├── example_euclidean.cpp # Headless Euclidean pipeline
│ ├── example_hyper_ideal.cpp # Headless HyperIdeal pipeline
│ ├── example_layout.cpp # Solve → layout → OFF/JSON/XML round-trip
│ └── example_viewer.cpp # Interactive libigl viewer (WITH_VIEWER)
├── src/
│ ├── apps/v0/conformallab_cli.cpp # CLI skeleton (Phase 5)
│ ├── apps/v0/conformallab_cli.cpp # Full CLI app (Phase 5)
│ └── viewer/simple_viewer.cpp
├── tests/
│ ├── CMakeLists.txt
@@ -242,7 +296,8 @@ code/
│ ├── test_spherical_hessian.cpp # 8 tests
│ ├── test_newton_solver.cpp # 14 tests (incl. 3 SparseQR tests)
│ ├── test_mesh_io.cpp # 6 tests
── test_pipeline.cpp # 5 tests
── test_pipeline.cpp # 5 tests
│ └── test_layout.cpp # 8 tests (layout + JSON/XML)
└── deps/
├── eigen-3.4.0/ # always extracted
├── CGAL-6.1.1/ # extracted with WITH_CGAL
@@ -277,7 +332,9 @@ Pure-math tests requiring only Eigen: Clausen / Lobachevsky / ImLi₂, hyper-ide
| `SparseQRFallback` | 3 | Full-rank LDLT path · singular matrix triggers QR · closed-mesh gauge-mode |
| `MeshIO` | 6 | OFF/OBJ round-trips, error handling |
| `Pipeline` | 5 | End-to-end: build → setup → solve → export → reload, all three geometries |
| **Total** | **87** | 2 skipped (Hessian stubs) |
| `Layout` | 6 | Edge-length preservation (Euclidean/Spherical), arc-lengths on S², Poincaré disk |
| `Serialization` | 2 | JSON and XML round-trips (DOF + layout) |
| **Total** | **95** | 2 skipped (Hessian stubs) |
---
@@ -334,7 +391,7 @@ The three core functionals are fully equivalent to the Java original at the leve
| SparseQR fallback for gauge-mode null spaces | ? | ✅ |
| **Cone metrics** — prescribed Θ_v ≠ 2π | ✅ full pipeline | ⚠️ data structure only |
| **Boundary conditions** — Dirichlet u=f, Neumann, free boundary | ✅ | ⚠️ pin-only |
| **Layout / embedding** — DOF vector → vertex coordinates in ℝ² / H² / S² | ✅ | ❌ not implemented |
| **Layout / embedding** — DOF vector → vertex coordinates in ℝ² / H² / S² | ✅ | ✅ BFS unfolding (all three geometries) |
| **GaussBonnet consistency check** on target angles | ✅ | ❌ |
| **Global uniformization** for genus g ≥ 1 | ✅ | ❌ |
| **Period matrices** — Teichmüller parameters for g ≥ 2 | ✅ | ❌ |
@@ -343,7 +400,7 @@ The three core functionals are fully equivalent to the Java original at the leve
| Clausen / Lobachevsky / ImLi₂ special functions | ✅ | ✅ |
| Discrete elliptic utility (modular normalisation of τ) | ✅ | ✅ (not yet wired up) |
| Poincaré disk / Lorentz boost visualisation helpers | ✅ | ✅ |
| Mesh I/O | ✅ XML/CoHDS | ✅ OFF/OBJ/PLY |
| Mesh I/O + serialisation | ✅ XML/CoHDS | ✅ OFF/OBJ/PLY + JSON/XML |
| Interactive viewer | ✅ jReality | ✅ libigl/GLFW |
### Key numerical difference — HyperIdeal Hessian
@@ -354,7 +411,7 @@ The analytical Hessian of the HyperIdeal functional requires differentiating thr
(b_i, a_e) → l_ij → ζ₁₃/ζ₁₄/ζ₁₅ → α_ij / β_i
```
which is feasible but involves many nested cases (four vertex-type combinations per edge). Until Phase 5 delivers the analytical version, conformallab++ uses a symmetric finite-difference Hessian:
which is feasible but involves many nested cases (four vertex-type combinations per edge). Until Phase 6 delivers the analytical version, conformallab++ uses a symmetric finite-difference Hessian:
```
H[i,j] = ( G(x + ε·eⱼ)[i] G(x ε·eⱼ)[i] ) / (2ε)
@@ -362,11 +419,13 @@ H[i,j] = ( G(x + ε·eⱼ)[i] G(x ε·eⱼ)[i] ) / (2ε)
This is O(ε²) accurate (≈ 10⁻¹⁰ relative error at ε = 10⁻⁵), positive semi-definite by strict convexity of the HyperIdeal energy (Springborn 2020), and costs n extra gradient evaluations per Newton step instead of O(n). For meshes with fewer than ~500 DOFs the difference in wall time is negligible.
### What "cone metrics" and "layout" would require
### What "cone metrics" still requires
**Cone metrics** — the property map `theta_v` is already subtracted in the gradient (`G_v = Σα_v Θ_v`), so prescribing a cone angle is a one-liner: `maps.theta_v[v] = desired_angle`. What is missing is the *application layer*: checking GaussBonnet consistency (Σ (2π Θ_v) = 2π·χ), distributing angle defects sensibly, and special handling at boundary vertices.
**Layout** — after solving you have the conformal scale factors u_i but the vertex positions in the mesh are unchanged. Recovering the actual flat / hyperbolic / spherical coordinates requires integrating the discrete holomorphic differential (discrete SchwarzChristoffel for the Euclidean case, or geodesic development for the hyperbolic case). This is the single biggest missing step for a complete uniformization pipeline.
**Layout (Phase 5 ✅)**`layout.hpp` implements BFS unfolding for all three geometries via `euclidean_layout`, `spherical_layout`, and `hyper_ideal_layout`. For open meshes the embedding is globally consistent; for closed meshes the first BFS visit wins and `has_seam = true` is set. To get a proper parameterisation of a closed mesh, cut it to a disk first (not yet implemented).
**Next steps** — global uniformization (cutting closed meshes, period matrices, holonomy), GaussBonnet checking, analytical HyperIdeal Hessian.
---
@@ -547,8 +606,8 @@ Property maps are reference-counted and cheap to copy. Give them unique string n
### Quick-start experiment checklist
1. **Read** `examples/example_euclidean.cpp` — it shows the full pipeline in ~80 lines with comments at every step.
2. **Build** without a viewer first: `cmake -S code -B build -DWITH_CGAL=ON && cmake --build build --target example_euclidean`.
1. **Read** `examples/example_layout.cpp` — it shows the full pipeline (load → setup → solve → layout → JSON/XML save → reload) in ~120 lines with comments at every step.
2. **Build** with `cmake -S code -B build -DWITH_CGAL=ON && cmake --build build --target example_layout` and run `./build/examples/example_layout`.
3. **Add a gradient check test** in `tests/cgal/` — copy any `TEST(…, GradientCheck_…)` block and swap out the functional. Run with `ctest -R your_test_name`.
4. **Try different target angles** — set `maps.theta_v[v] = M_PI / 3` for all interior vertices and see how the solver responds. The constraint `Σ(2π Θ_v) = 2π·χ(M)` (GaussBonnet) must hold for a solution to exist.
5. **Inspect convergence**`NewtonResult` carries `iterations`, `grad_inf_norm`, and the full `x` at termination. Plot `||G(xₖ)||` per iteration to verify quadratic convergence near the solution.
@@ -573,7 +632,7 @@ Property maps are reference-counted and cheap to copy. Give them unique string n
**DOF vector convention.** All functionals use `x` indexed by `v_idx[v]` / `e_idx[e]` (1 = pinned). This matches the Java `FunctionalTest` gradient-check convention and is uniform across all three geometries.
**HyperIdeal Hessian via FD.** The analytical Hessian through `ζ13/14/15 → lij → β/α` is left for Phase 5. A symmetric FD Hessian `H[i,j] = (G(x+ε·eⱼ)[i] G(xε·eⱼ)[i]) / (2ε)` is O(ε²) accurate, PSD by strict convexity, and sufficient for Newton on < 500 DOFs.
**HyperIdeal Hessian via FD.** The analytical Hessian through `ζ13/14/15 → lij → β/α` is deferred to Phase 6. A symmetric FD Hessian `H[i,j] = (G(x+ε·eⱼ)[i] G(xε·eⱼ)[i]) / (2ε)` is O(ε²) accurate, PSD by strict convexity, and sufficient for Newton on < 500 DOFs.
**Spherical Hessian sign.** The spherical energy is **concave** (not convex) the Hessian **H** is NSD at equilibrium. Newton solves `(H)·Δx = G`, so the sign flip is handled transparently inside `newton_spherical`.
@@ -632,11 +691,19 @@ Phase 4d SparseQR-Fallback + Beispiel-Programme ✅ abgeschlossen
→ examples/example_hyper_ideal.cpp (headless)
→ examples/example_viewer.cpp (interaktiver Viewer, WITH_VIEWER)
Phase 5 CLI-App + Serialisierung (23 Tage)
conformallab_core CLI: --input, --geometry, --output
JSON/XML-Export für DOF-Vektoren und Solver-Ergebnisse
Analytischer HyperIdeal-Hessian (direkte Ableitung)
Integration aller drei Geometrien hinter einheitlicher CLI
Phase 5 Layout + CLI + Serialisierung ✅ abgeschlossen
layout.hpp: BFS-Einbettung in ℝ² (Euclidean/HyperIdeal) und S² (Spherical)
serialization.hpp: JSON (nlohmann/json) + XML (hand-written) save/load
conformallab_core CLI: -i/-o/-g/-j/-x/-s/-v Flags, alle drei Geometrien
example_layout.cpp: Solve → Layout → OFF/JSON/XML + Round-Trip-Check
→ test_layout.cpp: 8 Tests (Eucl./Sphär./HyperIdeal + JSON/XML)
→ 95 Tests gesamt (2 skipped)
Phase 6 (geplant)
→ Analytischer HyperIdeal-Hessian (direkte Ableitung durch ζ-Kette)
→ GaussBonnet Konsistenzprüfung für Kegelmetriken
→ Mesh-Cut für geschlossene Flächen → globale Parameterisierung
→ Inversive-Distance-Funktional (Luo 2004)
```
---