diff --git a/CLAUDE.md b/CLAUDE.md index 4139877..e5c0ea3 100644 --- a/CLAUDE.md +++ b/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 project has three distinct phases: -- **Phase 1–7 (done):** 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 10+ (research):** New mathematics beyond the Java original — holomorphic differentials, Siegel period matrix Ω ∈ H_g, full uniformization for genus g ≥ 2 +The project has four distinct phase blocks (updated 2026-05-22): +- **Phase 1–7 (done, v0.7.0):** Direct port of the Java library algorithms to C++. +- **Phase 8a MVP + 8b-Lite (done, v0.9.0):** CGAL public-API surface for all five DCE models via ``. Phase 8a.2 (generic FaceGraph), 8c (manuals), 8d (CGAL-test-format), 8e (YAML pipeline) deferred on-demand. +- **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 @@ -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` (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 | -|---|---|---|---|---| -| Euclidean | ℝ² | `EuclideanMaps` | `euclidean_functional.hpp`, `euclidean_hessian.hpp` | `newton_euclidean()` | -| Spherical | S² | `SphericalMaps` | `spherical_functional.hpp`, `spherical_hessian.hpp` | `newton_spherical()` | -| Hyper-ideal | H² (Poincaré disk) | `HyperIdealMaps` | `hyper_ideal_functional.hpp`, `hyper_ideal_hessian.hpp` | `newton_hyper_ideal()` | +| Model | Space | DOFs | Maps struct | Key headers | Newton function | CGAL entry | +|---|---|---|---|---|---|---| +| Euclidean | ℝ² | vertex | `EuclideanMaps` | `euclidean_functional.hpp`, `euclidean_hessian.hpp` | `newton_euclidean()` | `discrete_conformal_map_euclidean()` | +| Spherical | S² | vertex | `SphericalMaps` | `spherical_functional.hpp`, `spherical_hessian.hpp` | `newton_spherical()` | `discrete_conformal_map_spherical()` | +| 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 @@ -125,18 +132,19 @@ After `compute_*_lambda0_from_mesh()` the original vertex positions are no longe ### Newton solver (`newton_solver.hpp`) -The gradient sign convention differs between modes: -- **Euclidean/Spherical:** `G_v = Θ_v − actual_angle_sum` (target minus actual) -- **HyperIdeal:** `G_v = actual_angle_sum − Θ_v` (actual minus target) +Gradient sign convention differs across the five models: +- **Euclidean / Spherical / Inversive-Distance:** `G_v = Θ_v − actual_angle_sum` (target minus actual). +- **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: -- **Euclidean:** H is PSD (cotangent Laplacian) → `SimplicialLDLT(H)` -- **Spherical:** H is NSD (concave energy) → `SimplicialLDLT(−H)` (sign flip inside `newton_spherical`) -- **HyperIdeal:** H is PSD (strictly convex) → `SimplicialLDLT(H)` +Hessian sign and solver per model: +- **Euclidean:** H is PSD (cotangent Laplacian) → `SimplicialLDLT(H)`. +- **Spherical:** H is NSD (concave energy) → `SimplicialLDLT(−H)` (sign flip inside `newton_spherical`). +- **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)`. - -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. +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)`. ### 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`). -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 -Current release: **v0.7.0** (tag on `origin/dev`, PR to `main` open). -Phase 7 is complete. Phase 7.5 (Doxygen) and Phase 8 (CGAL package) are next. +Current release: **v0.9.0** (tag on `main`, released 2026-05-22). +Phases 1–9a complete, Phase 8b-Lite CGAL API surface complete (all 5 DCE models reachable via ``), 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) @@ -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` | | 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` | -| 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` | ### 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` | | 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` | +| Versioning rules + release process + single-source-of-truth list | `doc/release-policy.md` | ### geometry-central context @@ -386,7 +395,7 @@ Optional adoption roadmap (GC-1/2/3): `doc/roadmap/phases.md` (Optional section) ## 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. - **`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. diff --git a/README.md b/README.md index 9e9b201..5518b19 100644 --- a/README.md +++ b/README.md @@ -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 · > [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) | | **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) | -| **Test suites** — 39 suites, 227+23 tests, individual counts | [doc/api/tests.md](doc/api/tests.md) | +| **Public headers** — all public headers with descriptions | [doc/api/headers.md](doc/api/headers.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) | | **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) | diff --git a/code/include/cp_euclidean_functional.hpp b/code/include/cp_euclidean_functional.hpp index bbc326e..82efb31 100644 --- a/code/include/cp_euclidean_functional.hpp +++ b/code/include/cp_euclidean_functional.hpp @@ -89,9 +89,25 @@ struct CPEuclideanMaps { CPFMapD phi_f; ///< target face-angle sum (default 2π) }; -// Create the property maps with sensible defaults. -// θ_e = π/2 produces an orthogonal circle packing (Koebe-Andreev-Thurston). -// φ_f = 2π is the natural target for a flat triangle. +/// Attach the three CP-Euclidean property maps to `mesh` with default +/// values and return their handles. +/// +/// 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) { CPEuclideanMaps m; @@ -101,8 +117,18 @@ inline CPEuclideanMaps setup_cp_euclidean_maps(ConformalMesh& mesh) return m; } -// Assign DOF indices 0..n-1 to all faces except `pinned`, which gets −1. -// Mirrors Java CPEuclideanFunctional's convention "skip face index 0". +/// Assign sequential DOF indices `0..n-1` to all faces except `pinned`, +/// 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, CPEuclideanMaps& m, Face_index pinned) @@ -115,7 +141,9 @@ inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh, 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, 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); } +/// Count the free DOFs (faces with `f_idx >= 0`). +/// Equivalent to `num_faces(mesh) - `. inline int cp_euclidean_dimension(const ConformalMesh& mesh, const CPEuclideanMaps& m) { diff --git a/code/include/euclidean_functional.hpp b/code/include/euclidean_functional.hpp index cadbcbf..1d9fc86 100644 --- a/code/include/euclidean_functional.hpp +++ b/code/include/euclidean_functional.hpp @@ -63,8 +63,17 @@ struct EuclideanMaps { EuclEMapD lambda0; ///< base log-length λ°_e (default 0.0) }; -// Create and attach property maps with sensible defaults. -// theta_v = 2π (flat vertex), phi_e = π (interior edge, flat surface). +/// Attach the five Euclidean property maps to `mesh` with sensible +/// 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) { EuclideanMaps m; @@ -76,7 +85,12 @@ inline EuclideanMaps setup_euclidean_maps(ConformalMesh& mesh) 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) { int idx = 0; @@ -84,7 +98,10 @@ inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMap 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) { int idx = 0; @@ -93,7 +110,7 @@ inline int assign_euclidean_all_dof_indices(ConformalMesh& mesh, EuclideanMaps& 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) { int dim = 0; diff --git a/code/include/hyper_ideal_functional.hpp b/code/include/hyper_ideal_functional.hpp index 2c24e58..2a50868 100644 --- a/code/include/hyper_ideal_functional.hpp +++ b/code/include/hyper_ideal_functional.hpp @@ -53,8 +53,20 @@ struct HyperIdealMaps { EMapD theta_e; // target intersection angle θ_e }; -// Add all needed persistent property maps and return handles. -// Defaults: theta_v = 2π (regular cone), theta_e = π (orthogonal circles). +/// Attach the four HyperIdeal property maps to `mesh` and return their +/// 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) { HyperIdealMaps m; @@ -65,7 +77,7 @@ inline HyperIdealMaps setup_hyper_ideal_maps(ConformalMesh& mesh) 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) { int dim = 0; @@ -74,8 +86,15 @@ inline int hyper_ideal_dimension(const ConformalMesh& mesh, const HyperIdealMaps return dim; } -// Assign DOF indices 0..n-1: vertices first, then edges. -// All vertices and edges become variable. Returns total DOF count. +/// Make every vertex hyper-ideal and every edge variable, assigning +/// 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) { int idx = 0; diff --git a/code/include/inversive_distance_functional.hpp b/code/include/inversive_distance_functional.hpp index 69e6aed..a932fa5 100644 --- a/code/include/inversive_distance_functional.hpp +++ b/code/include/inversive_distance_functional.hpp @@ -89,7 +89,19 @@ struct InversiveDistanceMaps { 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) { InversiveDistanceMaps m; @@ -100,8 +112,16 @@ inline InversiveDistanceMaps setup_inversive_distance_maps(ConformalMesh& mesh) return m; } -// Assign sequential DOF indices to all vertices (no gauge pinning here — -// the caller should set one v_idx to −1 before assigning). +/// Assign sequential DOF indices `0..n-1` to every vertex. +/// +/// **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, InversiveDistanceMaps& m) { @@ -110,7 +130,7 @@ inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& m return idx; } -// Count free DOFs. +/// Count the free DOFs (vertices with `v_idx >= 0`). inline int inversive_distance_dimension(const ConformalMesh& mesh, const InversiveDistanceMaps& m) { @@ -119,18 +139,28 @@ inline int inversive_distance_dimension(const ConformalMesh& mesh, return dim; } -// ── Initialisation from initial mesh geometry ──────────────────────────────── -// -// Two-phase init mirroring "compute_lambda0" for euclidean_functional: -// 1. Choose r_i^(0). Simplest heuristic: r_i = (1/3)·(min adjacent ℓ). -// Other choices (max ℓ, mean ℓ, length-of-shortest-vertex-cycle) are -// possible; the user can override `m.r0[v]` between setup and init. -// 2. Compute I_ij = ( ℓ² − r_i² − r_j² ) / ( 2 r_i r_j ) for each edge. -// -// Note: a valid inversive-distance packing requires I_ij > −1 on every edge, -// and the triangle inequality must hold on every face under the resulting ℓ. -// The choice r_i = ⅓·min(ℓ_e adj v) keeps I_ij safely positive for most -// real meshes. +/// Two-phase initialisation from initial mesh geometry. Mirrors the +/// role of `compute_lambda0_from_mesh` in the Euclidean functional, but +/// adapted to Luo's vertex-based radius parametrisation. +/// +/// **Phase 1.** Pick a positive radius per vertex: +/// \code +/// r_i^(0) = (1/3) · min{ℓ_e : e adjacent to v_i} +/// \endcode +/// This is a heuristic — the user may override `m.r0[v]` for any +/// vertex between `setup_inversive_distance_maps()` and this call. +/// +/// **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, InversiveDistanceMaps& m) { diff --git a/code/include/mesh_utils.hpp b/code/include/mesh_utils.hpp index bbb3b07..bef7ce7 100644 --- a/code/include/mesh_utils.hpp +++ b/code/include/mesh_utils.hpp @@ -1,14 +1,37 @@ #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` (production) and with any CGAL +// `Kernel_d::Point_3` (test scaffolding). + #include #include #include 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 void cgal_to_eigen(CGAL::Surface_mesh& mesh, Eigen::MatrixXd& V, Eigen::MatrixXi& F) { - + CGAL::Polygon_mesh_processing::triangulate_faces(mesh); V.resize(mesh.num_vertices(), 3); @@ -30,13 +53,38 @@ void cgal_to_eigen(CGAL::Surface_mesh& mesh, 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(mesh, V, F); +/// mesh_utils::simple_visualize_mesh(V, F); +/// \endcode template void simple_visualize_mesh(Eigen::MatrixXd& V, Eigen::MatrixXi& F) { igl::opengl::glfw::Viewer viewer; viewer.data().set_mesh(V, F); 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 Eigen::Map> get_vertex_map(CGAL::Surface_mesh& mesh) { diff --git a/code/include/spherical_functional.hpp b/code/include/spherical_functional.hpp index 36ee0aa..7ae020b 100644 --- a/code/include/spherical_functional.hpp +++ b/code/include/spherical_functional.hpp @@ -57,8 +57,14 @@ struct SphericalMaps { // Defaults: theta_v = 2π, theta_e = π, lambda0 = 0. // lambda0 = 0 means exp(λ°/2)=1, i.e., l=π — degenerate unless u_i<0. -// For real meshes, set lambda0 from mesh geometry via -// compute_lambda0_from_mesh() below. +/// Attach the five spherical property maps to `mesh` and return their +/// 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) { SphericalMaps m; @@ -70,7 +76,8 @@ inline SphericalMaps setup_spherical_maps(ConformalMesh& mesh) 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) { int idx = 0; @@ -78,7 +85,9 @@ inline int assign_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m) 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) { int idx = 0; @@ -87,7 +96,7 @@ inline int assign_all_spherical_dof_indices(ConformalMesh& mesh, SphericalMaps& return idx; } -// Count variable DOFs. +/// Count the free DOFs (vertices + edges with index `≥ 0`). inline int spherical_dimension(const ConformalMesh& mesh, const SphericalMaps& m) { int dim = 0; @@ -96,9 +105,14 @@ inline int spherical_dimension(const ConformalMesh& mesh, const SphericalMaps& m return dim; } -// Set lambda0 from mesh vertex positions (unit-sphere assumed): -// λ°_e = 2·log(sin(l_e / 2)) where l_e = arccos(p_i · p_j). -// Requires vertices to lie on the unit sphere. +/// Compute `λ°_e` for every edge from the input vertex positions, +/// assuming `mesh` has vertices 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) { for (auto e : mesh.edges()) { diff --git a/code/tests/cgal/CMakeLists.txt b/code/tests/cgal/CMakeLists.txt index b26795c..529b835 100644 --- a/code/tests/cgal/CMakeLists.txt +++ b/code/tests/cgal/CMakeLists.txt @@ -53,8 +53,9 @@ add_executable(conformallab_cgal_tests # ── Java parity: geometry utility tests ────────────────────────────────── # Ported from CuttinUtilityTest, UnwrapUtilityTest, - # ConvergenceUtilityTests, HomologyTest (tests 1–6). - # Test 7 (genus-2 homology) as GTEST_SKIP stub until Phase 8. + # ConvergenceUtilityTests, HomologyTest. All tests active — + # the v0.7.0 genus-2 homology stub was implemented in Phase 7 + # (HomologyGenerators.Genus2_FourCutEdges, brezel2.obj). test_geometry_utils.cpp # ── Scalability smoke tests ──────────────────────────────────────────────── diff --git a/doc/api/headers.md b/doc/api/headers.md index cf02b63..41b06c7 100644 --- a/doc/api/headers.md +++ b/doc/api/headers.md @@ -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_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 | 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 @@ -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 | | `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()` | + +## 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` (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` and `Hyper_ideal_map_result` | +| `CGAL/Discrete_circle_packing.h` | `Default_cp_euclidean_traits` + `discrete_circle_packing_euclidean()`. Result: `Circle_packing_result` with `rho_per_face` | +| `CGAL/Discrete_inversive_distance.h` | `Default_inversive_distance_traits` + `discrete_inversive_distance_map()`. Result: `Conformal_map_result` 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::*` | diff --git a/doc/contributing.md b/doc/contributing.md index f3800c8..47003a3 100644 --- a/doc/contributing.md +++ b/doc/contributing.md @@ -29,8 +29,8 @@ Two jobs run on push to `dev`/`main` or on pull requests: | Job | What it tests | Trigger | |---|---|---| -| `test-fast` | 23 non-CGAL tests, no Boost | all branches | -| `test-cgal` | 227 CGAL tests, 0 skipped | `main`, `dev`, PRs only | +| `test-fast` | pure-math tests, no Boost | all branches | +| `test-cgal` | full CGAL test suite | `main`, `dev`, PRs only | 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`. -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)). --- diff --git a/doc/getting-started.md b/doc/getting-started.md index aea5d93..c5100a0 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -48,7 +48,7 @@ cmake --build build --target conformallab_tests -j$(nproc) 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) @@ -60,7 +60,7 @@ cmake --build build --target conformallab_cgal_tests -j$(nproc) 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) @@ -146,7 +146,7 @@ sets x* = 0, so a small perturbation (-0.05) needs only one Newton step. bash scripts/try_it.sh ``` 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. --- diff --git a/doc/math/validation-protocol.md b/doc/math/validation-protocol.md index 04d3493..5d5a80c 100644 --- a/doc/math/validation-protocol.md +++ b/doc/math/validation-protocol.md @@ -199,7 +199,7 @@ inner/outer edge lengths). Use `torus_8x8.off` for a finer approximation. ## 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 2: FD gradient < 1e-6 for all 3 geometries [ ] Check 3: Newton convergence < 50 iterations diff --git a/doc/math/validation.md b/doc/math/validation.md index 40bc692..463ba57 100644 --- a/doc/math/validation.md +++ b/doc/math/validation.md @@ -14,7 +14,7 @@ cmake --build build --target conformallab_cgal_tests 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: -- [ ] `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.EuclideanFunctional.GradientCheck_*` pass → energy = integral of gradient - [ ] `cgal.PeriodMatrix.TauInFundamentalDomain_*` pass → SL(2,ℤ) reduction correct diff --git a/doc/release-policy.md b/doc/release-policy.md new file mode 100644 index 0000000..4a53633 --- /dev/null +++ b/doc/release-policy.md @@ -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 — `. Push to a feature branch. + +### Open the release PR + +7. **Open one PR** with all prep commits. Title: + `release: vX.Y.Z ()`. 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. diff --git a/scripts/check-test-counts.sh b/scripts/check-test-counts.sh new file mode 100755 index 0000000..0394f1f --- /dev/null +++ b/scripts/check-test-counts.sh @@ -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: tests, 0 skipped.** (non-CGAL section) +# ... +# **Total: 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 <