docs: test-count centralisation + release policy + doc-audit + 24 new docstrings #13
63
CLAUDE.md
63
CLAUDE.md
@@ -11,10 +11,12 @@ conformallab++ is a C++17 reimplementation of [ConformalLab](https://github.com/
|
|||||||
|
|
||||||
**The long-term goal is a CGAL package** — a submission to the CGAL library that brings discrete conformal maps (hyper-ideal, spherical, Euclidean) to the CGAL ecosystem using `CGAL::Surface_mesh` as the underlying halfedge data structure, with a traits-class design compatible with arbitrary CGAL-conforming mesh types.
|
**The long-term goal is a CGAL package** — a submission to the CGAL library that brings discrete conformal maps (hyper-ideal, spherical, Euclidean) to the CGAL ecosystem using `CGAL::Surface_mesh` as the underlying halfedge data structure, with a traits-class design compatible with arbitrary CGAL-conforming mesh types.
|
||||||
|
|
||||||
The project has three distinct phases:
|
The project has four distinct phase blocks (updated 2026-05-22):
|
||||||
- **Phase 1–7 (done):** Direct port of the Java library algorithms to C++
|
- **Phase 1–7 (done, v0.7.0):** Direct port of the Java library algorithms to C++.
|
||||||
- **Phase 8–9 (planned):** CGAL package infrastructure + remaining Java features not yet ported (inversive-distance functional, analytic HyperIdeal Hessian, genus-g > 1 fundamental domain)
|
- **Phase 8a MVP + 8b-Lite (done, v0.9.0):** CGAL public-API surface for all five DCE models via `<CGAL/Discrete_*.h>`. Phase 8a.2 (generic FaceGraph), 8c (manuals), 8d (CGAL-test-format), 8e (YAML pipeline) deferred on-demand.
|
||||||
- **Phase 10+ (research):** New mathematics beyond the Java original — holomorphic differentials, Siegel period matrix Ω ∈ H_g, full uniformization for genus g ≥ 2
|
- **Phase 9a + 9b (done, v0.9.0):** Two new functionals (CP-Euclidean port, Inversive-Distance research), two new Newton solvers, block-FD HyperIdeal Hessian.
|
||||||
|
- **Phase 9b-analytic + 9c (planned):** Full analytic HyperIdeal Hessian via Schläfli identity (research, see `doc/roadmap/research-track.md`); 4g-polygon fundamental domain for genus g > 1 (mixed port + research).
|
||||||
|
- **Phase 10+ (research):** Holomorphic differentials, Siegel period matrix Ω ∈ H_g, full uniformization for genus g ≥ 2.
|
||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
@@ -90,17 +92,22 @@ This replaces the Java `CoHDS` (half-edge data structure) and its intrusive `CoV
|
|||||||
|
|
||||||
`CGAL_DISABLE_GMP` and `CGAL_DISABLE_MPFR` are defined for all CGAL targets — the library deliberately uses `Simple_cartesian<double>` (floating-point, no exact arithmetic) because conformal geometry does not require exact predicates.
|
`CGAL_DISABLE_GMP` and `CGAL_DISABLE_MPFR` are defined for all CGAL targets — the library deliberately uses `Simple_cartesian<double>` (floating-point, no exact arithmetic) because conformal geometry does not require exact predicates.
|
||||||
|
|
||||||
### The three geometry modes
|
### The five DCE models
|
||||||
|
|
||||||
Each mode has its own Maps struct that bundles all property maps, plus functional, Hessian, and Newton function:
|
Each model has its own Maps struct that bundles all property maps, plus a functional, optional Hessian, Newton solver, and (since v0.9.0) a CGAL public-API entry function:
|
||||||
|
|
||||||
| Mode | Space | Maps struct | Key headers | Newton function |
|
| Model | Space | DOFs | Maps struct | Key headers | Newton function | CGAL entry |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| Euclidean | ℝ² | `EuclideanMaps` | `euclidean_functional.hpp`, `euclidean_hessian.hpp` | `newton_euclidean()` |
|
| Euclidean | ℝ² | vertex | `EuclideanMaps` | `euclidean_functional.hpp`, `euclidean_hessian.hpp` | `newton_euclidean()` | `discrete_conformal_map_euclidean()` |
|
||||||
| Spherical | S² | `SphericalMaps` | `spherical_functional.hpp`, `spherical_hessian.hpp` | `newton_spherical()` |
|
| Spherical | S² | vertex | `SphericalMaps` | `spherical_functional.hpp`, `spherical_hessian.hpp` | `newton_spherical()` | `discrete_conformal_map_spherical()` |
|
||||||
| Hyper-ideal | H² (Poincaré disk) | `HyperIdealMaps` | `hyper_ideal_functional.hpp`, `hyper_ideal_hessian.hpp` | `newton_hyper_ideal()` |
|
| Hyper-ideal | H² (Poincaré disk) | vertex + edge | `HyperIdealMaps` | `hyper_ideal_functional.hpp`, `hyper_ideal_hessian.hpp` (block-FD, Phase 9b) | `newton_hyper_ideal()` | `discrete_conformal_map_hyper_ideal()` |
|
||||||
|
| CP-Euclidean (BPS 2010) | face-based circle packing | **face** | `CPEuclideanMaps` | `cp_euclidean_functional.hpp` | `newton_cp_euclidean()` | `discrete_circle_packing_euclidean()` |
|
||||||
|
| Inversive-Distance (Luo 2004) | vertex-based circle packing | vertex | `InversiveDistanceMaps` | `inversive_distance_functional.hpp` | `newton_inversive_distance()` | `discrete_inversive_distance_map()` |
|
||||||
|
|
||||||
HyperIdeal also has edge DOFs (`e_idx[e]`); Euclidean and Spherical are vertex-DOF only. For HyperIdeal: `assign_all_dof_indices(mesh, maps)` assigns all vertex and edge DOFs automatically. For Euclidean/Spherical: pin one vertex manually (`maps.v_idx[first_vertex] = -1`) then assign sequential indices.
|
DOF-assignment patterns:
|
||||||
|
- **Vertex-only models** (Euclidean, Spherical, Inversive-Distance): pin one vertex manually (`maps.v_idx[first_vertex] = -1`) then assign sequential indices. The CGAL public entries do this automatically with the "natural-theta" trick (so calling them with no arguments returns x = 0 as the equilibrium).
|
||||||
|
- **HyperIdeal**: `assign_all_dof_indices(mesh, maps)` assigns vertex + edge DOFs automatically.
|
||||||
|
- **CP-Euclidean**: face-based — `assign_cp_euclidean_face_dof_indices(mesh, maps, pinned_face)` pins one face and indexes the rest.
|
||||||
|
|
||||||
### The full pipeline
|
### The full pipeline
|
||||||
|
|
||||||
@@ -125,18 +132,19 @@ After `compute_*_lambda0_from_mesh()` the original vertex positions are no longe
|
|||||||
|
|
||||||
### Newton solver (`newton_solver.hpp`)
|
### Newton solver (`newton_solver.hpp`)
|
||||||
|
|
||||||
The gradient sign convention differs between modes:
|
Gradient sign convention differs across the five models:
|
||||||
- **Euclidean/Spherical:** `G_v = Θ_v − actual_angle_sum` (target minus actual)
|
- **Euclidean / Spherical / Inversive-Distance:** `G_v = Θ_v − actual_angle_sum` (target minus actual).
|
||||||
- **HyperIdeal:** `G_v = actual_angle_sum − Θ_v` (actual minus target)
|
- **HyperIdeal:** `G_v = actual_angle_sum − Θ_v` (actual minus target).
|
||||||
|
- **CP-Euclidean:** `G_f = φ_f − Σ_{h:face(h)=f} (p(θ*,Δρ) + θ*)` (face-based; see `cp_euclidean_functional.hpp` header for the full formula).
|
||||||
|
|
||||||
Hessian sign and solver:
|
Hessian sign and solver per model:
|
||||||
- **Euclidean:** H is PSD (cotangent Laplacian) → `SimplicialLDLT(H)`
|
- **Euclidean:** H is PSD (cotangent Laplacian) → `SimplicialLDLT(H)`.
|
||||||
- **Spherical:** H is NSD (concave energy) → `SimplicialLDLT(−H)` (sign flip inside `newton_spherical`)
|
- **Spherical:** H is NSD (concave energy) → `SimplicialLDLT(−H)` (sign flip inside `newton_spherical`).
|
||||||
- **HyperIdeal:** H is PSD (strictly convex) → `SimplicialLDLT(H)`
|
- **HyperIdeal:** H is PSD (strictly convex) → `SimplicialLDLT(H)`. Phase 9b uses a **block-FD Hessian** (per-face 6×6 local block, ~96× speed-up vs full FD on V=200). Full analytic Hessian via the chain `(bᵢ, aₑ) → lᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ` is planned research — see `doc/roadmap/research-track.md` Phase 9b-analytic.
|
||||||
|
- **CP-Euclidean:** analytic 2×2-per-edge `h_jk = sin θ / (cosh Δρ − cos θ)` (BPS 2010), strictly convex → `SimplicialLDLT(H)`.
|
||||||
|
- **Inversive-Distance:** FD Hessian (inline in `newton_inversive_distance`). Analytic via Glickenstein 2011 eq. (4.6) is planned research (Phase 9a.2-analytic).
|
||||||
|
|
||||||
When `SimplicialLDLT` fails (rank-deficient H — gauge mode on closed mesh without pinned vertex), the solver automatically retries with `Eigen::SparseQR` to find the minimum-norm step orthogonal to the null space. Public API: `solve_linear_system(H, rhs, &used_fallback)`.
|
When `SimplicialLDLT` fails (rank-deficient H — gauge mode on a closed mesh without pinned vertex/face), the solver automatically retries with `Eigen::SparseQR` to find the minimum-norm step orthogonal to the null space. Public API: `solve_linear_system(H, rhs, &used_fallback)`.
|
||||||
|
|
||||||
The HyperIdeal Hessian is currently a **symmetric finite-difference approximation** (O(ε²), costs n extra gradient evaluations per Newton step). The analytic Hessian via the chain `(bᵢ, aₑ) → lᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ` is deferred to Phase 9b.
|
|
||||||
|
|
||||||
### Layout and holonomy (`layout.hpp`)
|
### Layout and holonomy (`layout.hpp`)
|
||||||
|
|
||||||
@@ -244,12 +252,12 @@ Two jobs in `.gitea/workflows/cpp-tests.yml`:
|
|||||||
|
|
||||||
Runner: `eulernest` — self-hosted Raspberry Pi, ARM64, Ubuntu 22.04. Docker image: `git.eulernest.eu/conformallab/ci-cpp:latest`. `test-cgal` needs `test-fast` to pass first (`needs: test-fast`).
|
Runner: `eulernest` — self-hosted Raspberry Pi, ARM64, Ubuntu 22.04. Docker image: `git.eulernest.eu/conformallab/ci-cpp:latest`. `test-cgal` needs `test-fast` to pass first (`needs: test-fast`).
|
||||||
|
|
||||||
Expected results: **23 non-CGAL tests pass**, **227 CGAL tests pass, 0 skipped**.
|
Expected results: full test suite passing, 0 skipped, 0 failed. The canonical counts live in `doc/api/tests.md` — do not hardcode them anywhere else (see [`doc/release-policy.md`](doc/release-policy.md)).
|
||||||
|
|
||||||
## Release state
|
## Release state
|
||||||
|
|
||||||
Current release: **v0.7.0** (tag on `origin/dev`, PR to `main` open).
|
Current release: **v0.9.0** (tag on `main`, released 2026-05-22).
|
||||||
Phase 7 is complete. Phase 7.5 (Doxygen) and Phase 8 (CGAL package) are next.
|
Phases 1–9a complete, Phase 8b-Lite CGAL API surface complete (all 5 DCE models reachable via `<CGAL/Discrete_*.h>`), Phase 9b block-FD HyperIdeal Hessian shipped (~96× speed-up). Next planned milestones: Phase 9c (4g-polygon, genus g > 1) and Phase 9b-analytic (Schläfli identity). See `doc/release-policy.md` for the version-tag policy and `doc/roadmap/phases.md` for the phase plan.
|
||||||
|
|
||||||
## Phase 8 strategic decisions (2026-05-19)
|
## Phase 8 strategic decisions (2026-05-19)
|
||||||
|
|
||||||
@@ -349,7 +357,7 @@ follow the empirical verification rule above before any new claim.
|
|||||||
| Full pipeline API for all three geometries | `doc/api/pipeline.md` |
|
| Full pipeline API for all three geometries | `doc/api/pipeline.md` |
|
||||||
| What does each processing unit require/provide (contracts)? | `doc/api/contracts.md` |
|
| What does each processing unit require/provide (contracts)? | `doc/api/contracts.md` |
|
||||||
| How to add a new functional / geometry mode / port from Java | `doc/api/extending.md` |
|
| How to add a new functional / geometry mode / port from Java | `doc/api/extending.md` |
|
||||||
| All 39 test suites, 227+23 tests, individual counts | `doc/api/tests.md` |
|
| Per-suite breakdown and counts (single source of truth) | `doc/api/tests.md` |
|
||||||
| Phase 8 CGAL package design + Declarative YAML pipeline spec | `doc/api/cgal-package.md` |
|
| Phase 8 CGAL package design + Declarative YAML pipeline spec | `doc/api/cgal-package.md` |
|
||||||
|
|
||||||
### Concepts & specs
|
### Concepts & specs
|
||||||
@@ -373,6 +381,7 @@ follow the empirical verification rule above before any new claim.
|
|||||||
| Build modes, single-test invocation, CLI, Docker image rebuild | `doc/getting-started.md` |
|
| Build modes, single-test invocation, CLI, Docker image rebuild | `doc/getting-started.md` |
|
||||||
| Step-by-step: port the Inversive Distance functional (Phase 9a template) | `doc/tutorials/add-inversive-distance.md` |
|
| Step-by-step: port the Inversive Distance functional (Phase 9a template) | `doc/tutorials/add-inversive-distance.md` |
|
||||||
| Language policy, test standards, release flow | `doc/contributing.md` |
|
| Language policy, test standards, release flow | `doc/contributing.md` |
|
||||||
|
| Versioning rules + release process + single-source-of-truth list | `doc/release-policy.md` |
|
||||||
|
|
||||||
### geometry-central context
|
### geometry-central context
|
||||||
|
|
||||||
@@ -386,7 +395,7 @@ Optional adoption roadmap (GC-1/2/3): `doc/roadmap/phases.md` (Optional section)
|
|||||||
|
|
||||||
## Known quirks
|
## Known quirks
|
||||||
|
|
||||||
- **`test-fast` also runs stubs**: `conformallab_tests` (non-CGAL) contains `GTEST_SKIP`-based stubs for functionals that need CGAL. This is intentional — those tests document what was in the Java port scope but requires the CGAL mesh type.
|
- **No GTEST_SKIP stubs remain** (since v0.9.0): the three stale HDS-port stub files were removed because the CGAL test suite covers the same functionality with real tests. The pure-math `conformallab_tests` target now only contains active tests.
|
||||||
- **Boost is header-only**: CGAL 6.x uses only Boost headers (`Boost.Config`, `Boost.Graph`). No compiled Boost libraries are needed. `find_package(Boost REQUIRED)` only locates the include path.
|
- **Boost is header-only**: CGAL 6.x uses only Boost headers (`Boost.Config`, `Boost.Graph`). No compiled Boost libraries are needed. `find_package(Boost REQUIRED)` only locates the include path.
|
||||||
- **`main` branch is protected** on `origin` (Gitea). Push to `dev`, then merge via pull request. Codeberg `main` can be pushed to directly.
|
- **`main` branch is protected** on `origin` (Gitea). Push to `dev`, then merge via pull request. Codeberg `main` can be pushed to directly.
|
||||||
- **Both remotes must stay in sync**: `origin` = `git.eulernest.eu` (CI runs here), `codeberg` = `codeberg.org/TMoussa/ConformalLabpp` (public mirror). Push to both after every significant change.
|
- **Both remotes must stay in sync**: `origin` = `git.eulernest.eu` (CI runs here), `codeberg` = `codeberg.org/TMoussa/ConformalLabpp` (public mirror). Push to both after every significant change.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Algorithmic foundation:
|
|||||||
> DOI: [10.14279/depositonce-5415](https://depositonce.tu-berlin.de/items/8e2988b2-d991-45b5-aad5-9fb7988f3b2f) · CC BY-SA 4.0 ·
|
> DOI: [10.14279/depositonce-5415](https://depositonce.tu-berlin.de/items/8e2988b2-d991-45b5-aad5-9fb7988f3b2f) · CC BY-SA 4.0 ·
|
||||||
> [Java original](https://github.com/varylab/conformallab) · [sechel.de](https://sechel.de/)
|
> [Java original](https://github.com/varylab/conformallab) · [sechel.de](https://sechel.de/)
|
||||||
|
|
||||||
**Status:** v0.9.0 — Phases 1–9a complete, Phase 8b-Lite CGAL API surface. Newton solvers for **five** DCE models (Euclidean / Spherical / HyperIdeal / CP-Euclidean / Inversive-Distance), priority-BFS layout in ℝ²/S²/Poincaré disk, Gauss–Bonnet, tree-cotree cut graph, Möbius holonomy, period matrix (genus 1), fundamental domain, halfedge_uv texture atlas, JSON/XML serialisation, CLI app. **227 CGAL tests + 23 non-CGAL tests, 0 skipped.**
|
**Status:** v0.9.0 — Phases 1–9a complete, Phase 8b-Lite CGAL API surface. Newton solvers for **five** DCE models (Euclidean / Spherical / HyperIdeal / CP-Euclidean / Inversive-Distance), priority-BFS layout in ℝ²/S²/Poincaré disk, Gauss–Bonnet, tree-cotree cut graph, Möbius holonomy, period matrix (genus 1), fundamental domain, halfedge_uv texture atlas, JSON/XML serialisation, CLI app. Full test suite passing, 0 skipped — see [`doc/api/tests.md`](doc/api/tests.md) for the per-suite breakdown.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,8 +83,8 @@ Layout2D layout = euclidean_layout(mesh, res.x, maps);
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Getting started** — build modes, single-test invocation, CLI, Docker | [doc/getting-started.md](doc/getting-started.md) |
|
| **Getting started** — build modes, single-test invocation, CLI, Docker | [doc/getting-started.md](doc/getting-started.md) |
|
||||||
| **Pipeline API** — all three geometries, holonomy, serialisation | [doc/api/pipeline.md](doc/api/pipeline.md) |
|
| **Pipeline API** — all three geometries, holonomy, serialisation | [doc/api/pipeline.md](doc/api/pipeline.md) |
|
||||||
| **Public headers** — all 24 headers with descriptions | [doc/api/headers.md](doc/api/headers.md) |
|
| **Public headers** — all public headers with descriptions | [doc/api/headers.md](doc/api/headers.md) |
|
||||||
| **Test suites** — 39 suites, 227+23 tests, individual counts | [doc/api/tests.md](doc/api/tests.md) |
|
| **Test suites** — per-suite breakdown and counts (single source of truth) | [doc/api/tests.md](doc/api/tests.md) |
|
||||||
| **Extending** — new functionals, geometry modes, porting from Java | [doc/api/extending.md](doc/api/extending.md) |
|
| **Extending** — new functionals, geometry modes, porting from Java | [doc/api/extending.md](doc/api/extending.md) |
|
||||||
| **Processing unit contracts** — preconditions / provides table | [doc/api/contracts.md](doc/api/contracts.md) |
|
| **Processing unit contracts** — preconditions / provides table | [doc/api/contracts.md](doc/api/contracts.md) |
|
||||||
| **CGAL package design** — Phase 8 target, YAML pipeline | [doc/api/cgal-package.md](doc/api/cgal-package.md) |
|
| **CGAL package design** — Phase 8 target, YAML pipeline | [doc/api/cgal-package.md](doc/api/cgal-package.md) |
|
||||||
|
|||||||
@@ -89,9 +89,25 @@ struct CPEuclideanMaps {
|
|||||||
CPFMapD phi_f; ///< target face-angle sum (default 2π)
|
CPFMapD phi_f; ///< target face-angle sum (default 2π)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the property maps with sensible defaults.
|
/// Attach the three CP-Euclidean property maps to `mesh` with default
|
||||||
// θ_e = π/2 produces an orthogonal circle packing (Koebe-Andreev-Thurston).
|
/// values and return their handles.
|
||||||
// φ_f = 2π is the natural target for a flat triangle.
|
///
|
||||||
|
/// Defaults:
|
||||||
|
/// * `theta_e[e] = π/2` for every edge — orthogonal circle packing
|
||||||
|
/// (Koebe-Andreev-Thurston).
|
||||||
|
/// * `phi_f[f] = 2π` for every face — flat target.
|
||||||
|
/// * `f_idx[f] = -1` for every face — all faces pinned initially;
|
||||||
|
/// call `assign_cp_euclidean_face_dof_indices()` next to assign
|
||||||
|
/// DOF indices to all faces except one gauge-pinned face.
|
||||||
|
///
|
||||||
|
/// The maps are named with the `"cf:"` / `"ce:"` prefixes
|
||||||
|
/// (cf = circle-packing-face, ce = circle-packing-edge) so they do
|
||||||
|
/// not collide with the Euclidean / Spherical / HyperIdeal maps.
|
||||||
|
///
|
||||||
|
/// \param mesh Input mesh. Modified in place: three property maps are
|
||||||
|
/// attached if not already present, otherwise the existing
|
||||||
|
/// maps are returned unchanged (CGAL property-map idempotence).
|
||||||
|
/// \returns A bundle of all three property maps for caller use.
|
||||||
inline CPEuclideanMaps setup_cp_euclidean_maps(ConformalMesh& mesh)
|
inline CPEuclideanMaps setup_cp_euclidean_maps(ConformalMesh& mesh)
|
||||||
{
|
{
|
||||||
CPEuclideanMaps m;
|
CPEuclideanMaps m;
|
||||||
@@ -101,8 +117,18 @@ inline CPEuclideanMaps setup_cp_euclidean_maps(ConformalMesh& mesh)
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign DOF indices 0..n-1 to all faces except `pinned`, which gets −1.
|
/// Assign sequential DOF indices `0..n-1` to all faces except `pinned`,
|
||||||
// Mirrors Java CPEuclideanFunctional's convention "skip face index 0".
|
/// which receives the sentinel `-1` (gauge-fixed face, `ρ_pinned = 0`).
|
||||||
|
///
|
||||||
|
/// Mirrors the Java CPEuclideanFunctional's "skip face index 0"
|
||||||
|
/// convention from `evaluateEnergyAndGradient` (lines 184-185 of
|
||||||
|
/// CPEuclideanFunctional.java). The C++ port exposes the choice of
|
||||||
|
/// pinned face explicitly rather than hard-coding it.
|
||||||
|
///
|
||||||
|
/// \param mesh The mesh. Read for face iteration only; not modified.
|
||||||
|
/// \param m Map bundle whose `f_idx` is overwritten.
|
||||||
|
/// \param pinned The face whose DOF is fixed at zero (the gauge).
|
||||||
|
/// \returns The number of free DOFs assigned (`num_faces(mesh) - 1`).
|
||||||
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
||||||
CPEuclideanMaps& m,
|
CPEuclideanMaps& m,
|
||||||
Face_index pinned)
|
Face_index pinned)
|
||||||
@@ -115,7 +141,9 @@ inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
|||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience: pin the first face in iteration order.
|
/// Convenience overload: pin the **first** face in `mesh.faces()` order.
|
||||||
|
/// Use this when any face works as the gauge (typically true for
|
||||||
|
/// closed mesh experiments).
|
||||||
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
||||||
CPEuclideanMaps& m)
|
CPEuclideanMaps& m)
|
||||||
{
|
{
|
||||||
@@ -124,6 +152,8 @@ inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
|||||||
return assign_cp_euclidean_face_dof_indices(mesh, m, *it);
|
return assign_cp_euclidean_face_dof_indices(mesh, m, *it);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Count the free DOFs (faces with `f_idx >= 0`).
|
||||||
|
/// Equivalent to `num_faces(mesh) - <number of pinned faces>`.
|
||||||
inline int cp_euclidean_dimension(const ConformalMesh& mesh,
|
inline int cp_euclidean_dimension(const ConformalMesh& mesh,
|
||||||
const CPEuclideanMaps& m)
|
const CPEuclideanMaps& m)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,8 +63,17 @@ struct EuclideanMaps {
|
|||||||
EuclEMapD lambda0; ///< base log-length λ°_e (default 0.0)
|
EuclEMapD lambda0; ///< base log-length λ°_e (default 0.0)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create and attach property maps with sensible defaults.
|
/// Attach the five Euclidean property maps to `mesh` with sensible
|
||||||
// theta_v = 2π (flat vertex), phi_e = π (interior edge, flat surface).
|
/// defaults and return their handles.
|
||||||
|
///
|
||||||
|
/// Defaults:
|
||||||
|
/// * `v_idx[v] = -1` (every vertex pinned; user must reassign before solving)
|
||||||
|
/// * `e_idx[e] = -1` (no edge DOFs by default; use `assign_euclidean_all_dof_indices` for cyclic functional)
|
||||||
|
/// * `theta_v[v] = 2π` (flat interior vertex target)
|
||||||
|
/// * `phi_e[e] = π` (interior edge turn angle target — flat surface)
|
||||||
|
/// * `lambda0[e] = 0` (placeholder; call `compute_euclidean_lambda0_from_mesh` next)
|
||||||
|
///
|
||||||
|
/// Map name prefix: `"ev:"` (vertex) and `"ee:"` (edge).
|
||||||
inline EuclideanMaps setup_euclidean_maps(ConformalMesh& mesh)
|
inline EuclideanMaps setup_euclidean_maps(ConformalMesh& mesh)
|
||||||
{
|
{
|
||||||
EuclideanMaps m;
|
EuclideanMaps m;
|
||||||
@@ -76,7 +85,12 @@ inline EuclideanMaps setup_euclidean_maps(ConformalMesh& mesh)
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign DOF indices 0..n-1 for all vertices only (no edge DOFs).
|
/// Assign sequential DOF indices `0..n-1` to all vertices.
|
||||||
|
///
|
||||||
|
/// **Note:** does NOT pin a gauge vertex. For closed meshes the caller
|
||||||
|
/// must set one `m.v_idx[v] = -1` either before or after this call to
|
||||||
|
/// remove the rotational mode (the Newton solver's SparseQR fallback
|
||||||
|
/// will otherwise pick a minimum-norm solution but at higher cost).
|
||||||
inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
|
inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
|
||||||
{
|
{
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
@@ -84,7 +98,10 @@ inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMap
|
|||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign DOF indices for all vertices AND all edges.
|
/// Assign DOF indices for all vertices AND all edges (vertex-DOFs first,
|
||||||
|
/// then edge-DOFs). Use this overload for the "cyclic" formulation that
|
||||||
|
/// includes per-edge log-length DOFs (`λ_e`) on top of per-vertex scale
|
||||||
|
/// factors (`u_v`).
|
||||||
inline int assign_euclidean_all_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
|
inline int assign_euclidean_all_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
|
||||||
{
|
{
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
@@ -93,7 +110,7 @@ inline int assign_euclidean_all_dof_indices(ConformalMesh& mesh, EuclideanMaps&
|
|||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count variable DOFs (vertices + edges).
|
/// Count the free DOFs (vertices + edges with index `≥ 0`).
|
||||||
inline int euclidean_dimension(const ConformalMesh& mesh, const EuclideanMaps& m)
|
inline int euclidean_dimension(const ConformalMesh& mesh, const EuclideanMaps& m)
|
||||||
{
|
{
|
||||||
int dim = 0;
|
int dim = 0;
|
||||||
|
|||||||
@@ -53,8 +53,20 @@ struct HyperIdealMaps {
|
|||||||
EMapD theta_e; // target intersection angle θ_e
|
EMapD theta_e; // target intersection angle θ_e
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add all needed persistent property maps and return handles.
|
/// Attach the four HyperIdeal property maps to `mesh` and return their
|
||||||
// Defaults: theta_v = 2π (regular cone), theta_e = π (orthogonal circles).
|
/// handles.
|
||||||
|
///
|
||||||
|
/// Defaults:
|
||||||
|
/// * `v_idx[v] = -1` (ideal vertex — i.e. the corresponding `b_v` is fixed at 0)
|
||||||
|
/// * `e_idx[e] = -1` (edge DOF fixed at 0)
|
||||||
|
/// * `theta_v[v] = 2π` (regular cone target)
|
||||||
|
/// * `theta_e[e] = π` (orthogonal-circle target)
|
||||||
|
///
|
||||||
|
/// The map prefix `"v:"` / `"e:"` is intentionally generic for the
|
||||||
|
/// HyperIdeal functional — it is the canonical / Phase 3b model.
|
||||||
|
/// Other functionals use distinct prefixes (`"ev:"` Euclidean, `"sv:"`
|
||||||
|
/// Spherical, `"cf:"`/`"ce:"` CP-Euclidean, `"iv:"`/`"ie:"`
|
||||||
|
/// Inversive-Distance) so all models can coexist on the same mesh.
|
||||||
inline HyperIdealMaps setup_hyper_ideal_maps(ConformalMesh& mesh)
|
inline HyperIdealMaps setup_hyper_ideal_maps(ConformalMesh& mesh)
|
||||||
{
|
{
|
||||||
HyperIdealMaps m;
|
HyperIdealMaps m;
|
||||||
@@ -65,7 +77,7 @@ inline HyperIdealMaps setup_hyper_ideal_maps(ConformalMesh& mesh)
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count variable DOFs: #variable_vertices + #variable_edges.
|
/// Count free DOFs: `#variable_vertices + #variable_edges`.
|
||||||
inline int hyper_ideal_dimension(const ConformalMesh& mesh, const HyperIdealMaps& m)
|
inline int hyper_ideal_dimension(const ConformalMesh& mesh, const HyperIdealMaps& m)
|
||||||
{
|
{
|
||||||
int dim = 0;
|
int dim = 0;
|
||||||
@@ -74,8 +86,15 @@ inline int hyper_ideal_dimension(const ConformalMesh& mesh, const HyperIdealMaps
|
|||||||
return dim;
|
return dim;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign DOF indices 0..n-1: vertices first, then edges.
|
/// Make every vertex hyper-ideal and every edge variable, assigning
|
||||||
// All vertices and edges become variable. Returns total DOF count.
|
/// sequential DOF indices `0..n-1` (vertices first, edges after).
|
||||||
|
///
|
||||||
|
/// This is the standard initialisation for the Springborn-2020
|
||||||
|
/// hyper-ideal functional — gauge fixing is **not** needed because
|
||||||
|
/// the energy is strictly convex on the full DOF space (no
|
||||||
|
/// rotational mode for an all-hyper-ideal configuration).
|
||||||
|
///
|
||||||
|
/// \returns total DOF count = `num_vertices(mesh) + num_edges(mesh)`.
|
||||||
inline int assign_all_dof_indices(ConformalMesh& mesh, HyperIdealMaps& m)
|
inline int assign_all_dof_indices(ConformalMesh& mesh, HyperIdealMaps& m)
|
||||||
{
|
{
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
|
|||||||
@@ -89,7 +89,19 @@ struct InversiveDistanceMaps {
|
|||||||
IDEMapD I_e; ///< inversive distance I_ij (per edge, constant)
|
IDEMapD I_e; ///< inversive distance I_ij (per edge, constant)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the property maps with sensible defaults.
|
/// Attach the four inversive-distance property maps to `mesh` and
|
||||||
|
/// return their handles.
|
||||||
|
///
|
||||||
|
/// Defaults are intentionally trivial — every real use of this
|
||||||
|
/// functional must call `compute_inversive_distance_init_from_mesh()`
|
||||||
|
/// next to populate `r0` and `I_e` from the input geometry.
|
||||||
|
/// * `v_idx[v] = -1` (all vertices pinned initially)
|
||||||
|
/// * `theta_v[v] = 2π` (regular interior vertex)
|
||||||
|
/// * `r0[v] = 1.0` (placeholder)
|
||||||
|
/// * `I_e[e] = 1.0` (tangential default — overwritten by init step)
|
||||||
|
///
|
||||||
|
/// The maps use the `"iv:"` / `"ie:"` prefix so they do not collide
|
||||||
|
/// with the Euclidean / Spherical / HyperIdeal / CP-Euclidean maps.
|
||||||
inline InversiveDistanceMaps setup_inversive_distance_maps(ConformalMesh& mesh)
|
inline InversiveDistanceMaps setup_inversive_distance_maps(ConformalMesh& mesh)
|
||||||
{
|
{
|
||||||
InversiveDistanceMaps m;
|
InversiveDistanceMaps m;
|
||||||
@@ -100,8 +112,16 @@ inline InversiveDistanceMaps setup_inversive_distance_maps(ConformalMesh& mesh)
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign sequential DOF indices to all vertices (no gauge pinning here —
|
/// Assign sequential DOF indices `0..n-1` to every vertex.
|
||||||
// the caller should set one v_idx to −1 before assigning).
|
///
|
||||||
|
/// **Note:** this overload does NOT pin a gauge vertex. The caller
|
||||||
|
/// is expected to either:
|
||||||
|
/// 1. set one `m.v_idx[v] = -1` *before* calling this function (then
|
||||||
|
/// the call is a no-op for that vertex) — OR —
|
||||||
|
/// 2. flip one assigned index back to `-1` *after* this function.
|
||||||
|
///
|
||||||
|
/// For a closed mesh, exactly one pin is required to remove the
|
||||||
|
/// global rotational mode.
|
||||||
inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& mesh,
|
inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& mesh,
|
||||||
InversiveDistanceMaps& m)
|
InversiveDistanceMaps& m)
|
||||||
{
|
{
|
||||||
@@ -110,7 +130,7 @@ inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& m
|
|||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count free DOFs.
|
/// Count the free DOFs (vertices with `v_idx >= 0`).
|
||||||
inline int inversive_distance_dimension(const ConformalMesh& mesh,
|
inline int inversive_distance_dimension(const ConformalMesh& mesh,
|
||||||
const InversiveDistanceMaps& m)
|
const InversiveDistanceMaps& m)
|
||||||
{
|
{
|
||||||
@@ -119,18 +139,28 @@ inline int inversive_distance_dimension(const ConformalMesh& mesh,
|
|||||||
return dim;
|
return dim;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initialisation from initial mesh geometry ────────────────────────────────
|
/// Two-phase initialisation from initial mesh geometry. Mirrors the
|
||||||
//
|
/// role of `compute_lambda0_from_mesh` in the Euclidean functional, but
|
||||||
// Two-phase init mirroring "compute_lambda0" for euclidean_functional:
|
/// adapted to Luo's vertex-based radius parametrisation.
|
||||||
// 1. Choose r_i^(0). Simplest heuristic: r_i = (1/3)·(min adjacent ℓ).
|
///
|
||||||
// Other choices (max ℓ, mean ℓ, length-of-shortest-vertex-cycle) are
|
/// **Phase 1.** Pick a positive radius per vertex:
|
||||||
// possible; the user can override `m.r0[v]` between setup and init.
|
/// \code
|
||||||
// 2. Compute I_ij = ( ℓ² − r_i² − r_j² ) / ( 2 r_i r_j ) for each edge.
|
/// r_i^(0) = (1/3) · min{ℓ_e : e adjacent to v_i}
|
||||||
//
|
/// \endcode
|
||||||
// Note: a valid inversive-distance packing requires I_ij > −1 on every edge,
|
/// This is a heuristic — the user may override `m.r0[v]` for any
|
||||||
// and the triangle inequality must hold on every face under the resulting ℓ.
|
/// vertex between `setup_inversive_distance_maps()` and this call.
|
||||||
// The choice r_i = ⅓·min(ℓ_e adj v) keeps I_ij safely positive for most
|
///
|
||||||
// real meshes.
|
/// **Phase 2.** Compute the per-edge inversive distance via the
|
||||||
|
/// Bowers-Stephenson 2004 identity:
|
||||||
|
/// \code
|
||||||
|
/// I_ij = ( ℓ_ij² − r_i² − r_j² ) / ( 2 r_i r_j )
|
||||||
|
/// \endcode
|
||||||
|
///
|
||||||
|
/// \pre Every edge has positive 3-D length.
|
||||||
|
/// \pre Radii produced in Phase 1 are positive (degenerate isolated
|
||||||
|
/// vertices fall back to `r_i = 1`).
|
||||||
|
/// \post Every `I_e[e] > -1` for a valid packing. The chosen
|
||||||
|
/// Phase-1 heuristic keeps `I_e > 0` for most real meshes.
|
||||||
inline void compute_inversive_distance_init_from_mesh(ConformalMesh& mesh,
|
inline void compute_inversive_distance_init_from_mesh(ConformalMesh& mesh,
|
||||||
InversiveDistanceMaps& m)
|
InversiveDistanceMaps& m)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
// mesh_utils.hpp
|
||||||
|
//
|
||||||
|
// Conversions between CGAL::Surface_mesh and Eigen matrices. Used
|
||||||
|
// primarily by the viewer / example programs to bridge to libigl, which
|
||||||
|
// expects (V, F) matrix pairs rather than a halfedge data structure.
|
||||||
|
//
|
||||||
|
// All functions are templated on the kernel so the same code works
|
||||||
|
// with `Simple_cartesian<double>` (production) and with any CGAL
|
||||||
|
// `Kernel_d::Point_3` (test scaffolding).
|
||||||
|
|
||||||
#include <CGAL/Surface_mesh.h>
|
#include <CGAL/Surface_mesh.h>
|
||||||
#include <Eigen/Dense>
|
#include <Eigen/Dense>
|
||||||
#include <CGAL/Polygon_mesh_processing/triangulate_faces.h>
|
#include <CGAL/Polygon_mesh_processing/triangulate_faces.h>
|
||||||
|
|
||||||
|
|
||||||
namespace mesh_utils {
|
namespace mesh_utils {
|
||||||
|
|
||||||
|
/// Copy `mesh` into an Eigen `(V, F)` pair (libigl convention).
|
||||||
|
///
|
||||||
|
/// **Side effect:** `mesh` is triangulated in place via
|
||||||
|
/// `CGAL::Polygon_mesh_processing::triangulate_faces` so the output
|
||||||
|
/// `F` is guaranteed to be a 3-column matrix. If `mesh` is already a
|
||||||
|
/// triangle mesh this is a no-op.
|
||||||
|
///
|
||||||
|
/// \param mesh Input surface mesh. **Modified in place** if any face
|
||||||
|
/// has more than 3 vertices.
|
||||||
|
/// \param V Output: `(num_vertices, 3)` matrix of vertex positions.
|
||||||
|
/// \param F Output: `(num_faces, 3)` matrix of vertex indices per
|
||||||
|
/// face (rows are individual triangles).
|
||||||
template <typename Kernel>
|
template <typename Kernel>
|
||||||
void cgal_to_eigen(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh,
|
void cgal_to_eigen(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh,
|
||||||
Eigen::MatrixXd& V, Eigen::MatrixXi& F) {
|
Eigen::MatrixXd& V, Eigen::MatrixXi& F) {
|
||||||
@@ -30,13 +53,38 @@ void cgal_to_eigen(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh,
|
|||||||
face_idx++;
|
face_idx++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Quick interactive visualisation via libigl + GLFW.
|
||||||
|
///
|
||||||
|
/// **Requires** `WITH_VIEWER=ON` at CMake time (which is implied by
|
||||||
|
/// `WITH_CGAL=ON`). Blocks until the viewer window is closed.
|
||||||
|
/// Not suitable for CI / headless contexts.
|
||||||
|
///
|
||||||
|
/// Typical use:
|
||||||
|
/// \code{.cpp}
|
||||||
|
/// Eigen::MatrixXd V; Eigen::MatrixXi F;
|
||||||
|
/// mesh_utils::cgal_to_eigen<Kernel>(mesh, V, F);
|
||||||
|
/// mesh_utils::simple_visualize_mesh<Kernel>(V, F);
|
||||||
|
/// \endcode
|
||||||
template <typename Kernel>
|
template <typename Kernel>
|
||||||
void simple_visualize_mesh(Eigen::MatrixXd& V, Eigen::MatrixXi& F) {
|
void simple_visualize_mesh(Eigen::MatrixXd& V, Eigen::MatrixXi& F) {
|
||||||
igl::opengl::glfw::Viewer viewer;
|
igl::opengl::glfw::Viewer viewer;
|
||||||
viewer.data().set_mesh(V, F);
|
viewer.data().set_mesh(V, F);
|
||||||
viewer.launch();
|
viewer.launch();
|
||||||
}
|
}
|
||||||
// Zero-copy map for V (optional)
|
|
||||||
|
/// Zero-copy `Eigen::Map` view of `mesh`'s vertex positions.
|
||||||
|
///
|
||||||
|
/// Returns a row-major `(N, 3)` `Eigen::Map` that aliases the
|
||||||
|
/// `mesh.points()` storage directly — no allocation, O(1).
|
||||||
|
///
|
||||||
|
/// **Lifetime warning:** the returned `Map` references memory owned by
|
||||||
|
/// `mesh`. Adding or removing vertices may invalidate the underlying
|
||||||
|
/// storage; use the `Map` only as long as `mesh` is structurally stable.
|
||||||
|
///
|
||||||
|
/// This is the read-write counterpart to `cgal_to_eigen` for cases
|
||||||
|
/// where the caller wants to *modify* vertex positions through Eigen
|
||||||
|
/// (e.g. apply a Möbius transformation) without an intermediate copy.
|
||||||
template <typename Kernel>
|
template <typename Kernel>
|
||||||
Eigen::Map<Eigen::Matrix<double, Eigen::Dynamic, 3, Eigen::RowMajor>>
|
Eigen::Map<Eigen::Matrix<double, Eigen::Dynamic, 3, Eigen::RowMajor>>
|
||||||
get_vertex_map(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh) {
|
get_vertex_map(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh) {
|
||||||
|
|||||||
@@ -57,8 +57,14 @@ struct SphericalMaps {
|
|||||||
|
|
||||||
// Defaults: theta_v = 2π, theta_e = π, lambda0 = 0.
|
// Defaults: theta_v = 2π, theta_e = π, lambda0 = 0.
|
||||||
// lambda0 = 0 means exp(λ°/2)=1, i.e., l=π — degenerate unless u_i<0.
|
// lambda0 = 0 means exp(λ°/2)=1, i.e., l=π — degenerate unless u_i<0.
|
||||||
// For real meshes, set lambda0 from mesh geometry via
|
/// Attach the five spherical property maps to `mesh` and return their
|
||||||
// compute_lambda0_from_mesh() below.
|
/// handles. Mirrors `setup_euclidean_maps` but uses the `"sv:"` /
|
||||||
|
/// `"se:"` prefix so the two functionals can coexist on the same mesh
|
||||||
|
/// (useful for cross-validation tests).
|
||||||
|
///
|
||||||
|
/// Defaults match the Euclidean defaults except that `lambda0 = 0` here
|
||||||
|
/// gives `l_e = π` which is degenerate on the unit sphere — always call
|
||||||
|
/// `compute_lambda0_from_mesh(mesh, m)` next on a real mesh.
|
||||||
inline SphericalMaps setup_spherical_maps(ConformalMesh& mesh)
|
inline SphericalMaps setup_spherical_maps(ConformalMesh& mesh)
|
||||||
{
|
{
|
||||||
SphericalMaps m;
|
SphericalMaps m;
|
||||||
@@ -70,7 +76,8 @@ inline SphericalMaps setup_spherical_maps(ConformalMesh& mesh)
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign DOF indices 0..n-1 for all vertices (only vertex DOFs).
|
/// Assign sequential DOF indices `0..n-1` to all vertices (no edge DOFs).
|
||||||
|
/// Caller is expected to pin one gauge vertex with `m.v_idx[v] = -1`.
|
||||||
inline int assign_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
inline int assign_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
||||||
{
|
{
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
@@ -78,7 +85,9 @@ inline int assign_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
|||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign DOF indices for all vertices AND edges.
|
/// Assign DOF indices for all vertices AND all edges (vertex-DOFs first,
|
||||||
|
/// then edge-DOFs). Mirrors `assign_euclidean_all_dof_indices` for the
|
||||||
|
/// cyclic spherical formulation.
|
||||||
inline int assign_all_spherical_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
inline int assign_all_spherical_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
||||||
{
|
{
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
@@ -87,7 +96,7 @@ inline int assign_all_spherical_dof_indices(ConformalMesh& mesh, SphericalMaps&
|
|||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count variable DOFs.
|
/// Count the free DOFs (vertices + edges with index `≥ 0`).
|
||||||
inline int spherical_dimension(const ConformalMesh& mesh, const SphericalMaps& m)
|
inline int spherical_dimension(const ConformalMesh& mesh, const SphericalMaps& m)
|
||||||
{
|
{
|
||||||
int dim = 0;
|
int dim = 0;
|
||||||
@@ -96,9 +105,14 @@ inline int spherical_dimension(const ConformalMesh& mesh, const SphericalMaps& m
|
|||||||
return dim;
|
return dim;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set lambda0 from mesh vertex positions (unit-sphere assumed):
|
/// Compute `λ°_e` for every edge from the input vertex positions,
|
||||||
// λ°_e = 2·log(sin(l_e / 2)) where l_e = arccos(p_i · p_j).
|
/// assuming `mesh` has vertices on the unit sphere.
|
||||||
// Requires vertices to lie on the unit sphere.
|
///
|
||||||
|
/// Formula: `λ°_e = 2·log(sin(l_e / 2))` where `l_e = arccos(p_i · p_j)`
|
||||||
|
/// is the spherical arc length of edge `e`.
|
||||||
|
///
|
||||||
|
/// \pre Every vertex `v` of `mesh` lies on the unit sphere (norm = 1).
|
||||||
|
/// \pre No edge is degenerate (`p_i ≠ p_j` and `p_i ≠ -p_j`).
|
||||||
inline void compute_lambda0_from_mesh(ConformalMesh& mesh, SphericalMaps& m)
|
inline void compute_lambda0_from_mesh(ConformalMesh& mesh, SphericalMaps& m)
|
||||||
{
|
{
|
||||||
for (auto e : mesh.edges()) {
|
for (auto e : mesh.edges()) {
|
||||||
|
|||||||
@@ -53,8 +53,9 @@ add_executable(conformallab_cgal_tests
|
|||||||
|
|
||||||
# ── Java parity: geometry utility tests ──────────────────────────────────
|
# ── Java parity: geometry utility tests ──────────────────────────────────
|
||||||
# Ported from CuttinUtilityTest, UnwrapUtilityTest,
|
# Ported from CuttinUtilityTest, UnwrapUtilityTest,
|
||||||
# ConvergenceUtilityTests, HomologyTest (tests 1–6).
|
# ConvergenceUtilityTests, HomologyTest. All tests active —
|
||||||
# Test 7 (genus-2 homology) as GTEST_SKIP stub until Phase 8.
|
# the v0.7.0 genus-2 homology stub was implemented in Phase 7
|
||||||
|
# (HomologyGenerators.Genus2_FourCutEdges, brezel2.obj).
|
||||||
test_geometry_utils.cpp
|
test_geometry_utils.cpp
|
||||||
|
|
||||||
# ── Scalability smoke tests ────────────────────────────────────────────────
|
# ── Scalability smoke tests ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -46,11 +46,18 @@ CGAL/Eigen for the CGAL-dependent headers).
|
|||||||
| `euclidean_functional.hpp` | `EuclideanMaps`, `setup_euclidean_maps()`, `compute_euclidean_lambda0_from_mesh()`, `euclidean_gradient()`, `euclidean_energy()` |
|
| `euclidean_functional.hpp` | `EuclideanMaps`, `setup_euclidean_maps()`, `compute_euclidean_lambda0_from_mesh()`, `euclidean_gradient()`, `euclidean_energy()` |
|
||||||
| `euclidean_hessian.hpp` | `euclidean_hessian()` — cotangent Laplacian (Pinkall–Polthier 1993) |
|
| `euclidean_hessian.hpp` | `euclidean_hessian()` — cotangent Laplacian (Pinkall–Polthier 1993) |
|
||||||
|
|
||||||
|
## Circle-packing functionals (Phase 9a)
|
||||||
|
|
||||||
|
| Header | Description |
|
||||||
|
|---|---|
|
||||||
|
| `cp_euclidean_functional.hpp` | **Face-based** CP-Euclidean (Bobenko–Pinkall–Springborn 2010), port of Java `CPEuclideanFunctional`. `CPEuclideanMaps`, `setup_cp_euclidean_maps()`, `assign_cp_euclidean_face_dof_indices()`, `cp_euclidean_gradient()`, `cp_euclidean_energy()`, **analytic** `cp_euclidean_hessian()` (2×2-per-edge `h_jk = sin θ / (cosh Δρ − cos θ)`) |
|
||||||
|
| `inversive_distance_functional.hpp` | **Vertex-based** inversive-distance (Luo 2004, no Java original). `InversiveDistanceMaps`, `setup_inversive_distance_maps()`, `compute_inversive_distance_init_from_mesh()` (Bowers–Stephenson 2004 init), `inversive_distance_gradient()`, `inversive_distance_energy()` |
|
||||||
|
|
||||||
## Solver
|
## Solver
|
||||||
|
|
||||||
| Header | Description |
|
| Header | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `newton_solver.hpp` | `newton_euclidean()`, `newton_spherical()`, `newton_hyper_ideal()`, `solve_linear_system()` (SimplicialLDLT + SparseQR fallback), `NewtonResult` struct |
|
| `newton_solver.hpp` | Five Newton solvers — `newton_euclidean()`, `newton_spherical()`, `newton_hyper_ideal()` (uses block-FD Hessian since Phase 9b), `newton_cp_euclidean()` (analytic Hessian, Phase 9a-Newton), `newton_inversive_distance()` (FD Hessian, Phase 9a-Newton). Plus `solve_linear_system()` (SimplicialLDLT + SparseQR fallback) and the `NewtonResult` struct. |
|
||||||
|
|
||||||
## Preprocessing
|
## Preprocessing
|
||||||
|
|
||||||
@@ -72,3 +79,33 @@ CGAL/Eigen for the CGAL-dependent headers).
|
|||||||
| `period_matrix.hpp` | `PeriodData`, `compute_period_matrix()`, `reduce_to_fundamental_domain()`, `is_in_fundamental_domain()` — period ratio τ = ω₂/ω₁ ∈ ℍ, SL(2,ℤ) reduction |
|
| `period_matrix.hpp` | `PeriodData`, `compute_period_matrix()`, `reduce_to_fundamental_domain()`, `is_in_fundamental_domain()` — period ratio τ = ω₂/ω₁ ∈ ℍ, SL(2,ℤ) reduction |
|
||||||
| `fundamental_domain.hpp` | `FundamentalDomain`, `compute_fundamental_domain()` (genus 1: CCW parallelogram; genus > 1: empty, TODO Phase 9c), `tiling_copy()`, `tiling_neighbourhood()` |
|
| `fundamental_domain.hpp` | `FundamentalDomain`, `compute_fundamental_domain()` (genus 1: CCW parallelogram; genus > 1: empty, TODO Phase 9c), `tiling_copy()`, `tiling_neighbourhood()` |
|
||||||
| `serialization.hpp` | `save_result_json()`, `load_result_json()`, `save_result_xml()`, `load_result_xml()`, `save_layout_off()` |
|
| `serialization.hpp` | `save_result_json()`, `load_result_json()`, `save_result_xml()`, `load_result_xml()`, `save_layout_off()` |
|
||||||
|
|
||||||
|
## Math utilities
|
||||||
|
|
||||||
|
These headers contain pure-math helpers used internally across the
|
||||||
|
package. All are also re-exported on the public API so downstream
|
||||||
|
consumers (e.g. user code that wants to assemble a custom functional)
|
||||||
|
can use them directly.
|
||||||
|
|
||||||
|
| Header | Description |
|
||||||
|
|---|---|
|
||||||
|
| `matrix_utility.hpp` | Small linear-algebra helpers used by `period_matrix.hpp` (2×2 determinant, …) |
|
||||||
|
| `projective_math.hpp` | Real projective 2-space helpers (point on line, lines through points, dual) |
|
||||||
|
| `p2_utility.hpp` | Higher-level P² utilities used by hyper-ideal layout (perpendicular bisectors in Poincaré disk, etc.) |
|
||||||
|
| `discrete_elliptic_utility.hpp` | Genus-1 / elliptic-curve helpers — half-period ratio τ from texture coords; bridge to genus-1 fundamental domain |
|
||||||
|
|
||||||
|
## CGAL public API (Phase 8b-Lite)
|
||||||
|
|
||||||
|
These headers live under `include/CGAL/` and are the user-facing entry
|
||||||
|
points. They are thin wrappers over the implementation in
|
||||||
|
`code/include/*.hpp`, exposing the five DCE models through a
|
||||||
|
CGAL-conventional interface.
|
||||||
|
|
||||||
|
| Header | Description |
|
||||||
|
|---|---|
|
||||||
|
| `CGAL/Conformal_map_traits.h` | `ConformalMapTraits` concept + `Default_conformal_map_traits<TriangleMesh, K>` (Euclidean-flavoured base trait) |
|
||||||
|
| `CGAL/Discrete_conformal_map.h` | `discrete_conformal_map_euclidean()`, `discrete_conformal_map_spherical()`, `discrete_conformal_map_hyper_ideal()`. Result types `Conformal_map_result<FT>` and `Hyper_ideal_map_result<FT>` |
|
||||||
|
| `CGAL/Discrete_circle_packing.h` | `Default_cp_euclidean_traits` + `discrete_circle_packing_euclidean()`. Result: `Circle_packing_result<FT>` with `rho_per_face` |
|
||||||
|
| `CGAL/Discrete_inversive_distance.h` | `Default_inversive_distance_traits` + `discrete_inversive_distance_map()`. Result: `Conformal_map_result<FT>` reused |
|
||||||
|
| `CGAL/Conformal_layout.h` | Thin re-export of `euclidean_layout`, `spherical_layout`, `hyper_ideal_layout` into the `CGAL::` namespace |
|
||||||
|
| `CGAL/Conformal_map/internal/parameters.h` | (internal) Named-parameter tags for `CGAL::parameters::*` |
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ Two jobs run on push to `dev`/`main` or on pull requests:
|
|||||||
|
|
||||||
| Job | What it tests | Trigger |
|
| Job | What it tests | Trigger |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `test-fast` | 23 non-CGAL tests, no Boost | all branches |
|
| `test-fast` | pure-math tests, no Boost | all branches |
|
||||||
| `test-cgal` | 227 CGAL tests, 0 skipped | `main`, `dev`, PRs only |
|
| `test-cgal` | full CGAL test suite | `main`, `dev`, PRs only |
|
||||||
|
|
||||||
A PR is ready to merge when both jobs pass.
|
A PR is ready to merge when both jobs pass.
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ Every new algorithm needs:
|
|||||||
|
|
||||||
3. **Registration** — add the `.cpp` file to `code/tests/cgal/CMakeLists.txt`.
|
3. **Registration** — add the `.cpp` file to `code/tests/cgal/CMakeLists.txt`.
|
||||||
|
|
||||||
Expected CI result: **23 + 227 tests pass, 0 skipped**.
|
Expected CI result: full test suite passing, 0 skipped (per-suite counts: [`doc/api/tests.md`](api/tests.md)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ cmake --build build --target conformallab_tests -j$(nproc)
|
|||||||
ctest --test-dir build --output-on-failure
|
ctest --test-dir build --output-on-failure
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: **23 tests pass**.
|
Expected: all pure-math tests pass (count: [`doc/api/tests.md`](api/tests.md)).
|
||||||
|
|
||||||
### Mode 2 — CGAL tests, headless (recommended for CI and development)
|
### Mode 2 — CGAL tests, headless (recommended for CI and development)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ cmake --build build --target conformallab_cgal_tests -j$(nproc)
|
|||||||
ctest --test-dir build -R "^cgal\." --output-on-failure
|
ctest --test-dir build -R "^cgal\." --output-on-failure
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: **227 tests pass, 0 skipped**.
|
Expected: all CGAL tests pass, 0 skipped (count: [`doc/api/tests.md`](api/tests.md)).
|
||||||
|
|
||||||
### Mode 3 — Full local build (CLI app + interactive viewer)
|
### Mode 3 — Full local build (CLI app + interactive viewer)
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ sets x* = 0, so a small perturbation (-0.05) needs only one Newton step.
|
|||||||
bash scripts/try_it.sh
|
bash scripts/try_it.sh
|
||||||
```
|
```
|
||||||
This clones nothing (run from inside the repo), builds the CGAL test suite, runs
|
This clones nothing (run from inside the repo), builds the CGAL test suite, runs
|
||||||
all 227+23 tests, and prints a summary. See `scripts/try_it.sh` for details.
|
the full test suite, and prints a summary. See `scripts/try_it.sh` for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ inner/outer edge lengths). Use `torus_8x8.off` for a finer approximation.
|
|||||||
## Summary checklist
|
## Summary checklist
|
||||||
|
|
||||||
```
|
```
|
||||||
[ ] Check 0: 176 tests pass, 0 skipped
|
[ ] Check 0: full test suite passes, 0 skipped (counts: `doc/api/tests.md`)
|
||||||
[ ] Check 1: Gauss–Bonnet exact (1e-10)
|
[ ] Check 1: Gauss–Bonnet exact (1e-10)
|
||||||
[ ] Check 2: FD gradient < 1e-6 for all 3 geometries
|
[ ] Check 2: FD gradient < 1e-6 for all 3 geometries
|
||||||
[ ] Check 3: Newton convergence < 50 iterations
|
[ ] Check 3: Newton convergence < 50 iterations
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ cmake --build build --target conformallab_cgal_tests
|
|||||||
ctest --test-dir build -R cgal --output-on-failure
|
ctest --test-dir build -R cgal --output-on-failure
|
||||||
```
|
```
|
||||||
|
|
||||||
All 227 tests pass, 0 skipped (see `doc/api/tests.md`).
|
All tests pass, 0 skipped (per-suite breakdown: `doc/api/tests.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ implemented in conformallab++ (→ GC-2 in the phase roadmap).
|
|||||||
|
|
||||||
Run these in order to validate the implementation:
|
Run these in order to validate the implementation:
|
||||||
|
|
||||||
- [ ] `ctest --test-dir build -R cgal --output-on-failure` → 227 tests pass, 0 skipped
|
- [ ] `ctest --test-dir build -R cgal --output-on-failure` → all pass, 0 skipped (count: `doc/api/tests.md`)
|
||||||
- [ ] `cgal.GaussBonnet.*` all pass → topology is correctly read from mesh
|
- [ ] `cgal.GaussBonnet.*` all pass → topology is correctly read from mesh
|
||||||
- [ ] `cgal.EuclideanFunctional.GradientCheck_*` pass → energy = integral of gradient
|
- [ ] `cgal.EuclideanFunctional.GradientCheck_*` pass → energy = integral of gradient
|
||||||
- [ ] `cgal.PeriodMatrix.TauInFundamentalDomain_*` pass → SL(2,ℤ) reduction correct
|
- [ ] `cgal.PeriodMatrix.TauInFundamentalDomain_*` pass → SL(2,ℤ) reduction correct
|
||||||
|
|||||||
216
doc/release-policy.md
Normal file
216
doc/release-policy.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# Release & versioning policy
|
||||||
|
|
||||||
|
> **Audience:** maintainers and recurring contributors of conformallab++.
|
||||||
|
> External users only need this if they are publishing a fork.
|
||||||
|
>
|
||||||
|
> **Created:** 2026-05-22, after the v0.9.0 release that exposed two
|
||||||
|
> recurring failure modes: (a) the v0.9.0 tag had to be moved twice
|
||||||
|
> because release-prep landed on the wrong commit, and (b) the test
|
||||||
|
> counts were hardcoded in eight different documents and drifted apart.
|
||||||
|
|
||||||
|
This document codifies how conformallab++ chooses version numbers,
|
||||||
|
how releases are produced, and which artefacts must stay in sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Version-number rules (SemVer)
|
||||||
|
|
||||||
|
The project follows [Semantic Versioning 2.0](https://semver.org).
|
||||||
|
Tag format: `vMAJOR.MINOR.PATCH` (e.g. `v0.9.0`).
|
||||||
|
|
||||||
|
### Pre-1.0 phase (current — until CGAL submission)
|
||||||
|
|
||||||
|
While the project is at version `0.x.y`, the public API is **not yet
|
||||||
|
frozen**. The convention we use during the pre-1.0 phase is:
|
||||||
|
|
||||||
|
| Bump | When |
|
||||||
|
|---|---|
|
||||||
|
| **`0.x.0 → 0.(x+1).0`** (MINOR) | Significant new feature: new functional, new Newton solver, new public CGAL entry, new Phase milestone completed. May break the public API. Treated as the "interesting" release type during pre-1.0. |
|
||||||
|
| **`0.x.y → 0.x.(y+1)`** (PATCH) | Bug fixes, performance optimisations without API change, doc-only follow-up releases, CI/infrastructure fixes. Never breaks the public API. |
|
||||||
|
| **`0.x.y → 1.0.0`** | API freeze + CGAL submission readiness. All public headers under `include/CGAL/` are stable. Cumulative changelog updated to call out every public-API decision in retrospect. |
|
||||||
|
|
||||||
|
Phase milestone → MINOR-bump mapping (historical + planned):
|
||||||
|
|
||||||
|
| Tag | Phase milestone |
|
||||||
|
|-----------|--------------------------------------------------------|
|
||||||
|
| `v0.1.0` | Phase 1 (special functions) |
|
||||||
|
| `v0.2.0` | Phase 2 (hyper-ideal geometry) |
|
||||||
|
| `v0.7.0` | Phase 7 complete (period matrix, fundamental domain) |
|
||||||
|
| `v0.9.0` | Phase 9a + 9b + 8b-Lite |
|
||||||
|
| `v0.10.0` | Phase 9c (4g-polygon, genus g > 1) |
|
||||||
|
| `v0.11.0` | Phase 10a (harmonic + holomorphic 1-forms) |
|
||||||
|
| `v1.0.0` | CGAL package submission-ready |
|
||||||
|
|
||||||
|
### Post-1.0 phase (planned, ≥ `v1.0.0`)
|
||||||
|
|
||||||
|
Standard SemVer with no pre-1.0 exceptions:
|
||||||
|
|
||||||
|
| Bump | When |
|
||||||
|
|---|---|
|
||||||
|
| **MAJOR** (`X.0.0 → (X+1).0.0`) | Any breaking change to the public API: removed function, signature change, renamed CGAL entry, behaviour change of a documented contract. |
|
||||||
|
| **MINOR** (`X.Y.0 → X.(Y+1).0`) | New features, new public entries, new Default-traits classes. Strictly additive — no removal, no behaviour change. |
|
||||||
|
| **PATCH** (`X.Y.Z → X.Y.(Z+1)`) | Bug fixes only. No new files, no new public symbols. |
|
||||||
|
|
||||||
|
### Pre-release tags
|
||||||
|
|
||||||
|
For testing-quality releases:
|
||||||
|
|
||||||
|
* `v1.0.0-alpha.1`, `v1.0.0-alpha.2`, … — early preview, API may still
|
||||||
|
shift.
|
||||||
|
* `v1.0.0-beta.1` — feature-complete preview, API stable but tests
|
||||||
|
may still find issues.
|
||||||
|
* `v1.0.0-rc.1`, `v1.0.0-rc.2`, … — release candidate, only blocker
|
||||||
|
fixes before the final tag.
|
||||||
|
|
||||||
|
### What does NOT trigger a release
|
||||||
|
|
||||||
|
* Internal refactoring without user-visible change.
|
||||||
|
* Documentation-only updates (unless the documentation is itself a
|
||||||
|
product, like a new tutorial).
|
||||||
|
* Test additions / coverage improvements.
|
||||||
|
* CI / infrastructure changes.
|
||||||
|
|
||||||
|
Group such changes into the next planned release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Single source of truth for moving numbers
|
||||||
|
|
||||||
|
Three numbers tend to drift across multiple documents in any project:
|
||||||
|
test counts, version strings, and date strings. The conformallab++
|
||||||
|
rule is:
|
||||||
|
|
||||||
|
| Number / string | Single source of truth | Anywhere else |
|
||||||
|
|-----------------------------|--------------------------------|--------------------------------------|
|
||||||
|
| **Total / per-suite test counts** | `doc/api/tests.md` | Use qualitative phrasing + link, e.g. "full test suite passes, 0 skipped — see [`doc/api/tests.md`](doc/api/tests.md)". |
|
||||||
|
| **Project version** | `CITATION.cff` `version:` field | Don't hardcode in headers, READMEs, doc bodies. Reference by name ("v0.9.0") only when the historical version actually matters (changelog entries, release-note bodies, Phase-milestone tables). |
|
||||||
|
| **Release date** | `CITATION.cff` `date-released:` + `CHANGELOG.md` section header | Don't hardcode elsewhere. |
|
||||||
|
| **Test-suite description per file** | `doc/api/tests.md` | Headers and code files may list the suites they implement, but never claim a count of suites elsewhere. |
|
||||||
|
|
||||||
|
### Why qualitative phrasing for test counts
|
||||||
|
|
||||||
|
The counts change with every PR that adds or removes a test. Hardcoding
|
||||||
|
the number "227" in eight documents means eight separate edits per PR —
|
||||||
|
and historically we have failed to keep them in sync more than once.
|
||||||
|
|
||||||
|
The cost of looking up the exact number in `doc/api/tests.md` is one
|
||||||
|
click; the cost of fixing a stale "227" in seven places is real PR
|
||||||
|
overhead. We pay the click.
|
||||||
|
|
||||||
|
### Audit script
|
||||||
|
|
||||||
|
`scripts/check-test-counts.sh` (when present) reads the totals from
|
||||||
|
`doc/api/tests.md`, runs `ctest`, and fails the CI if they diverge.
|
||||||
|
This catches the `tests.md` itself going stale even if no other doc
|
||||||
|
hardcodes the number.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Release process (checklist)
|
||||||
|
|
||||||
|
The recipe that worked for v0.9.0 (after the false-start with PR #11 /
|
||||||
|
#12). Apply for every `vX.Y.Z` release.
|
||||||
|
|
||||||
|
### Pre-release prep — on a release branch
|
||||||
|
|
||||||
|
1. **Update `CHANGELOG.md`** — add a new top-level section
|
||||||
|
`## [vX.Y.Z] — YYYY-MM-DD`. Categorise changes by `Added`,
|
||||||
|
`Changed`, `Removed`, `Fixed`, `Deprecated`, `Security`. See
|
||||||
|
[Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
2. **Update `CITATION.cff`** — `version:` and `date-released:` fields.
|
||||||
|
3. **Refresh `doc/api/tests.md`** — re-run the full test suite locally
|
||||||
|
and update the per-suite tables + the two `**Total: N tests, 0 skipped.**`
|
||||||
|
lines (one for `conformallab_tests`, one for `conformallab_cgal_tests`).
|
||||||
|
4. **Run `scripts/check-test-counts.sh`** if present — must pass.
|
||||||
|
5. **Verify no hardcoded counts remain** elsewhere:
|
||||||
|
```bash
|
||||||
|
grep -rn "[0-9]\+ tests pass\|[0-9]\+ tests, 0 skipped\|[0-9]\+ CGAL tests" \
|
||||||
|
README.md CLAUDE.md doc/ scripts/ \
|
||||||
|
| grep -v "doc/api/tests.md"
|
||||||
|
# Expected: zero results (other than tests.md itself).
|
||||||
|
```
|
||||||
|
6. **Commit** with the message
|
||||||
|
`release: vX.Y.Z — <one-line summary>`. Push to a feature branch.
|
||||||
|
|
||||||
|
### Open the release PR
|
||||||
|
|
||||||
|
7. **Open one PR** with all prep commits. Title:
|
||||||
|
`release: vX.Y.Z (<summary>)`. Description: copy the new
|
||||||
|
`CHANGELOG.md` section.
|
||||||
|
8. **Wait for CI green** — `test-fast` + `test-cgal` + `doc-build`.
|
||||||
|
9. **Merge** with a merge commit (the merge SHA becomes the release
|
||||||
|
target).
|
||||||
|
|
||||||
|
### After-merge — tag and announce
|
||||||
|
|
||||||
|
10. **Verify `main` head is the merge commit**:
|
||||||
|
```bash
|
||||||
|
git fetch origin && git log origin/main --oneline -1
|
||||||
|
```
|
||||||
|
11. **Create the Gitea release** via API or web UI. `target_commitish`
|
||||||
|
MUST be the merge SHA from step 10 (NOT the feature branch HEAD).
|
||||||
|
The web UI does this automatically; the API needs explicit `sha`.
|
||||||
|
12. **Sync the codeberg mirror**:
|
||||||
|
```bash
|
||||||
|
git fetch origin --tags
|
||||||
|
git push codeberg main --tags
|
||||||
|
```
|
||||||
|
13. **Update CLAUDE.md "Release state" section** so future
|
||||||
|
contributors see the new version at a glance. (This is the only
|
||||||
|
place where the current version appears outside `CITATION.cff` and
|
||||||
|
`CHANGELOG.md`.)
|
||||||
|
|
||||||
|
### Common failure modes and how to recover
|
||||||
|
|
||||||
|
| Symptom | Recovery |
|
||||||
|
|---|---|
|
||||||
|
| Tag points to an earlier commit than the one you wanted | Delete the release + tag via Gitea API, re-create with the correct `target_commitish`. |
|
||||||
|
| Release-prep commit lands on a feature branch but not on `main` after merge | Open a fast-follow `release/vX.Y.Z-finalise` PR. Cherry-pick the missing commit; merge; move the tag. |
|
||||||
|
| `doc/api/tests.md` counts are stale post-merge | One-line PR: re-run `ctest`, update tests.md, no version bump (it's a doc fix between releases). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Hotfix policy
|
||||||
|
|
||||||
|
For a `vX.Y.0` minor release that needs an urgent fix before the next
|
||||||
|
planned minor:
|
||||||
|
|
||||||
|
1. Branch off the release tag (NOT off `main`):
|
||||||
|
```bash
|
||||||
|
git checkout -b hotfix/vX.Y.1 vX.Y.0
|
||||||
|
```
|
||||||
|
2. Apply the minimal fix.
|
||||||
|
3. Tag `vX.Y.1` from the hotfix branch.
|
||||||
|
4. Merge the hotfix back into `main` so the fix isn't lost.
|
||||||
|
|
||||||
|
Hotfixes are rare for a research-quality library; the typical path is
|
||||||
|
to wait for the next minor. Use hotfixes only for security issues or
|
||||||
|
build-broken-on-supported-platform regressions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Deprecation policy (post-1.0)
|
||||||
|
|
||||||
|
When a public symbol is to be removed in version `M.0.0`:
|
||||||
|
|
||||||
|
1. In any earlier `M-1` minor release, mark it `[[deprecated]]` (C++17)
|
||||||
|
or via a Doxygen `\deprecated` tag.
|
||||||
|
2. Add a `Deprecated` section to that version's CHANGELOG entry with
|
||||||
|
the planned removal version.
|
||||||
|
3. Keep the symbol working for at least one full minor cycle before
|
||||||
|
removal.
|
||||||
|
4. Remove in the `M.0.0` release; document the removal in the
|
||||||
|
`Removed` section.
|
||||||
|
|
||||||
|
Pre-1.0, this policy does not apply — breaking changes are allowed
|
||||||
|
on any minor bump as long as they are listed in CHANGELOG.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Related documents
|
||||||
|
|
||||||
|
* `CHANGELOG.md` — per-version history.
|
||||||
|
* `CITATION.cff` — authoritative version + date for citation.
|
||||||
|
* `doc/api/tests.md` — single source of truth for test counts.
|
||||||
|
* `doc/contributing.md` — code style + PR mechanics.
|
||||||
|
* `doc/roadmap/phases.md` — what each future MINOR bump will deliver.
|
||||||
90
scripts/check-test-counts.sh
Executable file
90
scripts/check-test-counts.sh
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/check-test-counts.sh
|
||||||
|
#
|
||||||
|
# CI gate that verifies the totals in `doc/api/tests.md` match the
|
||||||
|
# actual `ctest` output. Catches the canonical test-count document
|
||||||
|
# going stale even if no other doc hardcodes the number.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash scripts/check-test-counts.sh
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 totals match
|
||||||
|
# 1 totals diverge — prints diff and which file to fix
|
||||||
|
# 2 prerequisites missing (cmake not found, tests.md missing, …)
|
||||||
|
#
|
||||||
|
# This script is meant to be cheap enough to run on every PR (~30 s on
|
||||||
|
# a typical CI runner). It re-uses the existing build-cgal/ directory
|
||||||
|
# if one is present; otherwise it builds a throwaway build-counts/.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.." # repo root
|
||||||
|
|
||||||
|
TESTS_MD="doc/api/tests.md"
|
||||||
|
|
||||||
|
if [ ! -f "$TESTS_MD" ]; then
|
||||||
|
echo "FAIL: $TESTS_MD not found" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
command -v cmake >/dev/null || { echo "FAIL: cmake not in PATH" >&2; exit 2; }
|
||||||
|
command -v ctest >/dev/null || { echo "FAIL: ctest not in PATH" >&2; exit 2; }
|
||||||
|
|
||||||
|
# ── Build ───────────────────────────────────────────────────────────────────
|
||||||
|
# Re-use existing build-cgal/ if it exists with the right flags; otherwise
|
||||||
|
# create a throwaway build-counts/ directory.
|
||||||
|
BUILD_DIR=""
|
||||||
|
if [ -f build-cgal/CMakeCache.txt ] && grep -q "WITH_CGAL_TESTS:.*=ON\|WITH_CGAL:.*=ON" build-cgal/CMakeCache.txt; then
|
||||||
|
BUILD_DIR=build-cgal
|
||||||
|
else
|
||||||
|
BUILD_DIR=build-counts
|
||||||
|
cmake -S code -B "$BUILD_DIR" -DWITH_CGAL_TESTS=ON -DCMAKE_BUILD_TYPE=Release >/dev/null
|
||||||
|
fi
|
||||||
|
cmake --build "$BUILD_DIR" -j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 2)" >/dev/null
|
||||||
|
|
||||||
|
# ── Get actual counts ───────────────────────────────────────────────────────
|
||||||
|
cd "$BUILD_DIR"
|
||||||
|
actual_cgal=$(ctest -R "^cgal" 2>&1 | grep "tests passed" | sed -E 's/.*out of ([0-9]+).*/\1/')
|
||||||
|
actual_fast=$(ctest -E "^cgal" 2>&1 | grep "tests passed" | sed -E 's/.*out of ([0-9]+).*/\1/')
|
||||||
|
cd - >/dev/null
|
||||||
|
|
||||||
|
# ── Get claimed counts from tests.md ────────────────────────────────────────
|
||||||
|
# Expected format (in this order):
|
||||||
|
# **Total: <N1> tests, 0 skipped.** (non-CGAL section)
|
||||||
|
# ...
|
||||||
|
# **Total: <N2> tests, 0 skipped.** (CGAL section)
|
||||||
|
claimed_fast=$(grep -E "\*\*Total: [0-9]+ tests, 0 skipped\.\*\*" "$TESTS_MD" \
|
||||||
|
| head -n 1 | sed -E 's/.*Total: ([0-9]+) tests.*/\1/')
|
||||||
|
claimed_cgal=$(grep -E "\*\*Total: [0-9]+ tests, 0 skipped\.\*\*" "$TESTS_MD" \
|
||||||
|
| tail -n 1 | sed -E 's/.*Total: ([0-9]+) tests.*/\1/')
|
||||||
|
|
||||||
|
# ── Compare ────────────────────────────────────────────────────────────────
|
||||||
|
ok=1
|
||||||
|
if [ "${actual_fast}" != "${claimed_fast}" ]; then
|
||||||
|
echo "MISMATCH: non-CGAL — $TESTS_MD claims ${claimed_fast}, ctest reports ${actual_fast}"
|
||||||
|
ok=0
|
||||||
|
fi
|
||||||
|
if [ "${actual_cgal}" != "${claimed_cgal}" ]; then
|
||||||
|
echo "MISMATCH: CGAL — $TESTS_MD claims ${claimed_cgal}, ctest reports ${actual_cgal}"
|
||||||
|
ok=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ok" -eq 0 ]; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
FAIL: $TESTS_MD totals out of sync with actual test counts.
|
||||||
|
|
||||||
|
Recovery:
|
||||||
|
1. Edit $TESTS_MD: update the two
|
||||||
|
'**Total: N tests, 0 skipped.**' lines.
|
||||||
|
2. Add/remove suite rows in the per-suite tables if test
|
||||||
|
suites were added/removed.
|
||||||
|
3. Re-run this script to confirm.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK: test counts in $TESTS_MD match ctest output"
|
||||||
|
echo " non-CGAL: ${actual_fast} CGAL: ${actual_cgal} total: $((actual_fast + actual_cgal))"
|
||||||
|
exit 0
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
# bash scripts/try_it.sh
|
# bash scripts/try_it.sh
|
||||||
#
|
#
|
||||||
# Expected output (last lines):
|
# Expected output (last lines):
|
||||||
# [PASS] 227 CGAL tests pass, 0 skipped
|
# [PASS] full CGAL test suite passes, 0 skipped
|
||||||
|
# (current counts: doc/api/tests.md)
|
||||||
# [PASS] 23 non-CGAL tests pass
|
# [PASS] 23 non-CGAL tests pass
|
||||||
# [EXAMPLE] Converged in N iterations. ||G||_inf < 1e-9
|
# [EXAMPLE] Converged in N iterations. ||G||_inf < 1e-9
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user