diff --git a/.gitea/workflows/cpp-tests.yml b/.gitea/workflows/cpp-tests.yml index 4bc4977..e72c505 100644 --- a/.gitea/workflows/cpp-tests.yml +++ b/.gitea/workflows/cpp-tests.yml @@ -3,11 +3,13 @@ name: C++ Tests # Trigger keywords in commit message (checked via head_commit.message): # /test-cgal — full CGAL test suite (277 tests, ~5 min build) # /quality-gates — license, codespell, shellcheck, CGAL conventions -# /ci-all — all of the above + /docs + /links (across all workflows) +# +# ⚠ /ci-all was removed: the Pi runner (3-4 GB RAM) cannot sustain +# multiple Docker containers simultaneously. Use one keyword per commit. # # Examples: # git commit -m "fix: correct angle formula /test-cgal" -# git commit -m "release prep /ci-all" +# git commit -m "chore: update headers /quality-gates" # # test-fast always runs on every push — it is fast (< 5 s) and cheap. @@ -31,6 +33,7 @@ jobs: runs-on: eulernest container: image: git.eulernest.eu/conformallab/ci-cpp:latest + options: "--memory=800m --memory-swap=1200m" steps: - uses: actions/checkout@v4 @@ -76,9 +79,7 @@ jobs: # ───────────────────────────────────────────────────────────────────────────── test-cgal: needs: test-fast - if: | - contains(github.event.head_commit.message, '/test-cgal') || - contains(github.event.head_commit.message, '/ci-all') + if: contains(github.event.head_commit.message, '/test-cgal') runs-on: eulernest container: image: git.eulernest.eu/conformallab/ci-cpp:latest @@ -133,12 +134,11 @@ jobs: # Cheap (~30 s): license headers, CGAL conventions, codespell, shellcheck. # ───────────────────────────────────────────────────────────────────────────── quality-gates: - if: | - contains(github.event.head_commit.message, '/quality-gates') || - contains(github.event.head_commit.message, '/ci-all') + if: contains(github.event.head_commit.message, '/quality-gates') runs-on: eulernest container: image: git.eulernest.eu/conformallab/ci-cpp:latest + options: "--memory=600m --memory-swap=900m" steps: - uses: actions/checkout@v4 diff --git a/.gitea/workflows/doc-build.yaml b/.gitea/workflows/doc-build.yaml index b66cfaa..79ef786 100644 --- a/.gitea/workflows/doc-build.yaml +++ b/.gitea/workflows/doc-build.yaml @@ -27,11 +27,11 @@ jobs: doc-build: if: | github.event_name == 'workflow_dispatch' || - contains(github.event.head_commit.message, '/docs') || - contains(github.event.head_commit.message, '/ci-all') + contains(github.event.head_commit.message, '/docs') runs-on: eulernest container: image: git.eulernest.eu/conformallab/ci-cpp:latest + options: "--memory=800m --memory-swap=1200m" continue-on-error: true # never block the merge steps: - uses: actions/checkout@v4 diff --git a/.gitea/workflows/markdown-links.yml b/.gitea/workflows/markdown-links.yml index 8bce97f..1f058b5 100644 --- a/.gitea/workflows/markdown-links.yml +++ b/.gitea/workflows/markdown-links.yml @@ -27,11 +27,11 @@ jobs: if: | github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || - contains(github.event.head_commit.message, '/links') || - contains(github.event.head_commit.message, '/ci-all') + contains(github.event.head_commit.message, '/links') runs-on: eulernest container: image: git.eulernest.eu/conformallab/ci-cpp:latest + options: "--memory=400m --memory-swap=600m" steps: - uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 3f1df6e..ad9906b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -270,6 +270,11 @@ Three jobs in `.gitea/workflows/cpp-tests.yml`: | `doc-build` | *(none)* | Doxygen | `/docs` in commit message or `workflow_dispatch` | **active** | | `markdown-links` | *(none)* | python3 | `/links` in commit message, weekly cron, `workflow_dispatch` | **active** | +> **⚠ Pi runner limit:** use **one keyword per commit**. The Pi (3-4 GB RAM) cannot run +> multiple Docker containers simultaneously. `/ci-all` was removed for this reason. +> Typical workflow: one commit with `/test-cgal`, then if green a separate commit with +> `/quality-gates`. + Runner: `eulernest` — self-hosted Raspberry Pi, ARM64, Ubuntu 22.04. Docker image: `git.eulernest.eu/conformallab/ci-cpp:latest`. `test-cgal` and `quality-gates` both need `test-fast` to pass first (`needs: test-fast`). `quality-gates` runs four required structural gates: `license-headers.sh`, `cgal-conventions.py`, `codespell.sh`, `shellcheck.sh --strict`. Seven more gates (clang-format, cmake-format, cppcheck, sanitizers, clang-tidy, multi-compiler, reproducible-build) are local-only — see `scripts/quality/README.md`. diff --git a/README.md b/README.md index 80473e0..7161007 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,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. Full test suite passing, 0 skipped — see [`doc/api/tests.md`](doc/api/tests.md) for the per-suite breakdown. +**Status:** v0.10.0 — Phases 1–9b 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. 277 tests passing, 0 skipped — see [`doc/api/tests.md`](doc/api/tests.md) for the per-suite breakdown. --- @@ -75,6 +75,12 @@ cmake -S code -B build -DWITH_CGAL_TESTS=ON -DCONFORMALLAB_DEV_BUILD=ON # build at the cost of 5–15× slower test RUN. Neutral on macOS. cmake -S code -B build -DWITH_CGAL_TESTS=ON -DCONFORMALLAB_FAST_TEST_BUILD=ON +# Low-memory build: -O0, no PCH, unity batch 1, --no-keep-memory linker. +# Drops cc1plus peak from ~700 MB to ~150 MB per TU. Use on Raspberry Pi +# or any runner with ≤ 4 GB RAM. Always build with -j1. +cmake -S code -B build -DWITH_CGAL_TESTS=ON -DCONFORMALLAB_LOW_MEMORY_BUILD=ON +cmake --build build --target conformallab_cgal_tests -j1 + # Pristine measurement: disable both performance levers, e.g. for # scripts/quality/coverage.sh that needs every TU compiled fresh. cmake -S code -B build -DWITH_CGAL_TESTS=ON \ @@ -103,14 +109,12 @@ ConformalMesh mesh = load_mesh("input.off"); EuclideanMaps maps = setup_euclidean_maps(mesh); compute_euclidean_lambda0_from_mesh(mesh, maps); -// Assign DOFs — pin first vertex (gauge fix) -auto vit = mesh.vertices().begin(); -maps.v_idx[*vit++] = -1; -int idx = 0; -for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++; +// Assign DOFs — pin first vertex as gauge fix, index the rest 0..n-1 +auto gauge = *mesh.vertices().begin(); +int n = assign_euclidean_vertex_dof_indices(mesh, maps, gauge); // Natural equilibrium target: x* = 0 by construction -std::vector x0(idx, 0.0); +std::vector x0(static_cast(n), 0.0); auto G0 = euclidean_gradient(mesh, x0, maps); for (auto v : mesh.vertices()) if (maps.v_idx[v] >= 0) maps.theta_v[v] -= G0[maps.v_idx[v]]; diff --git a/code/examples/CMakeLists.txt b/code/examples/CMakeLists.txt index 8b9c724..22a5683 100644 --- a/code/examples/CMakeLists.txt +++ b/code/examples/CMakeLists.txt @@ -42,6 +42,22 @@ target_include_directories(example_layout SYSTEM PRIVATE ${EXAMPLE_INCLUDES}) target_include_directories(example_layout PRIVATE ${EXAMPLE_PRIVATE_INCLUDES}) target_compile_definitions(example_layout PRIVATE ${EXAMPLE_DEFS}) +# ── example_flatten (primary use case: real conformal flattening) ───────────── +# Demonstrates Θ_v = 2π → non-trivial conformal map; contrast with +# example_euclidean which uses "natural theta" (u_v ≈ 0, testing trick). +add_executable(example_flatten example_flatten.cpp) +target_include_directories(example_flatten SYSTEM PRIVATE ${EXAMPLE_INCLUDES}) +target_include_directories(example_flatten PRIVATE ${EXAMPLE_PRIVATE_INCLUDES}) +target_compile_definitions(example_flatten PRIVATE ${EXAMPLE_DEFS}) + +# ── example_cgal_api (CGAL public API: one-call interface) ──────────────────── +# Demonstrates CGAL::discrete_conformal_map_euclidean from +# ; contrast with example_flatten (internal API). +add_executable(example_cgal_api example_cgal_api.cpp) +target_include_directories(example_cgal_api SYSTEM PRIVATE ${EXAMPLE_INCLUDES}) +target_include_directories(example_cgal_api PRIVATE ${EXAMPLE_PRIVATE_INCLUDES}) +target_compile_definitions(example_cgal_api PRIVATE ${EXAMPLE_DEFS}) + # ── example_viewer (requires WITH_VIEWER) ───────────────────────────────────── if(WITH_VIEWER) add_executable(example_viewer example_viewer.cpp) diff --git a/code/examples/example_cgal_api.cpp b/code/examples/example_cgal_api.cpp new file mode 100644 index 0000000..00fea2a --- /dev/null +++ b/code/examples/example_cgal_api.cpp @@ -0,0 +1,171 @@ +// example_cgal_api.cpp +// +// conformallab++ — CGAL public API example +// +// This example demonstrates the HIGH-LEVEL CGAL API defined in +// . It is the recommended entry point for +// users who want a simple one-call interface without managing Maps bundles, +// DOF assignment, or Newton solver details. +// +// Contrast with example_euclidean.cpp / example_flatten.cpp which use the +// INTERNAL API (setup_euclidean_maps + newton_euclidean + euclidean_layout). +// +// ┌─────────────────────────────────────────────────────────────────────┐ +// │ When to use the CGAL API vs the internal API │ +// │ │ +// │ CGAL API (this file): │ +// │ • One call, sensible defaults │ +// │ • Named parameters for tuning (tolerance, cone angles, …) │ +// │ • Result type: Conformal_map_result (u_per_vertex indexed │ +// │ by raw vertex index) │ +// │ • Layout via CGAL::euclidean_layout (Conformal_layout.h) │ +// │ │ +// │ Internal API (example_flatten.cpp, example_layout.cpp): │ +// │ • Full control over every pipeline step │ +// │ • Access to holonomy, period matrix, cut graph │ +// │ • Result type: NewtonResult (x indexed by DOF index) │ +// │ • Required for closed surfaces (genus ≥ 1) │ +// └─────────────────────────────────────────────────────────────────────┘ +// +// Build (requires -DWITH_CGAL=ON): +// cmake -S code -B build -DWITH_CGAL=ON +// cmake --build build --target example_cgal_api +// +// Run: +// ./build/examples/example_cgal_api # built-in mesh +// ./build/examples/example_cgal_api code/data/obj/cathead.obj flat.off + +#include +#include +#include // CGAL public API +#include // CGAL layout wrapper + +// For loading meshes and saving results we still use the internal helpers. +#include "mesh_io.hpp" +#include "mesh_builder.hpp" +#include "layout.hpp" + +#include +#include +#include +#include + +int main(int argc, char* argv[]) +{ + using Kernel = CGAL::Simple_cartesian; + using Mesh = CGAL::Surface_mesh; + + // ── Step 1: obtain mesh ─────────────────────────────────────────────────── + Mesh mesh; + std::string input_path = (argc > 1) ? argv[1] : ""; + std::string output_path = (argc > 2) ? argv[2] : "/tmp/conformallab_cgal_api_out.off"; + + if (input_path.empty()) { + std::cout << "[example_cgal_api] No input given. Using make_quad_strip().\n" + << " For a non-trivial result, supply a 3-D mesh:\n" + << " ./example_cgal_api code/data/obj/cathead.obj\n\n"; + mesh = conformallab::make_quad_strip(); + } else { + std::cout << "[example_cgal_api] Loading mesh: " << input_path << "\n"; + try { mesh = conformallab::load_mesh(input_path); } + catch (const std::exception& e) { + std::cerr << "Error loading mesh: " << e.what() << "\n"; + return 1; + } + } + + std::cout << "[example_cgal_api] Mesh: " + << mesh.number_of_vertices() << " vertices, " + << mesh.number_of_faces() << " faces.\n"; + + // ── Step 2: CGAL API call ───────────────────────────────────────────────── + // + // Default invocation — one function call, no explicit target angles: + // • No vertex_curvature_map supplied → "natural theta" default: + // Θ_v is set to the ACTUAL angle sum at x = 0. This makes x* = 0 + // the equilibrium, so Newton converges in 0 iterations with u_v = 0. + // + // ⚠ Natural theta is a TESTING CONVENTION, not a conformal flattening. + // The output u_v = 0 means "no deformation" — the map is identity. + // For real conformal flattening use: + // • example_flatten.cpp (internal API, recommended for open meshes) + // • Supply vertex_curvature_map(theta_map) with Θ_v = 2π for a + // closed mesh (must satisfy Gauss-Bonnet; see below) + // + // The function sets up maps, computes λ°, assigns DOFs, runs Newton, + // and returns the converged result. + auto result = CGAL::discrete_conformal_map_euclidean(mesh); + + std::cout << "[example_cgal_api] CGAL API result (natural theta — identity map):\n" + << " converged = " << std::boolalpha << result.converged << "\n" + << " iterations = " << result.iterations + << " (0 = natural theta, trivially at equilibrium)\n" + << " |grad|_inf = " << std::scientific << std::setprecision(2) + << result.gradient_norm << "\n"; + + // ── Step 3: inspect the result ──────────────────────────────────────────── + // + // result.u_per_vertex is indexed by raw vertex index (v.idx()), NOT by + // DOF index. Length = num_vertices(mesh). Pinned vertices have u = 0. + // + // This differs from the internal API (NewtonResult.x) which is indexed + // by DOF index (v_idx[v]). The CGAL API handles the mapping internally. + double u_min = 0.0, u_max = 0.0; + for (auto v : mesh.vertices()) { + double u = result.u_per_vertex[static_cast(v.idx())]; + u_min = std::min(u_min, u); + u_max = std::max(u_max, u); + } + std::cout << " u_v range = [" << std::fixed << std::setprecision(4) + << u_min << ", " << u_max << "]\n\n"; + + // Print a few per-vertex values + std::cout << "[example_cgal_api] First 6 per-vertex scale factors u_v:\n"; + int shown = 0; + for (auto v : mesh.vertices()) { + if (shown++ >= 6) break; + double u = result.u_per_vertex[static_cast(v.idx())]; + std::cout << " v" << v.idx() << " u = " << std::fixed + << std::setprecision(6) << u << "\n"; + } + + // ── Step 4: tuning via named parameters ─────────────────────────────────── + // + // The CGAL API accepts named parameters for fine-grained control. + // Example: tighter tolerance and more iterations. + // + // auto result2 = CGAL::discrete_conformal_map_euclidean( + // mesh, + // CGAL::parameters::gradient_tolerance(1e-12) + // .max_iterations(500)); + // + // Example: supply explicit cone angles (requires Gauss-Bonnet to hold): + // + // auto angle_map = mesh.add_property_map( + // "my:angles", 2.0 * M_PI).first; + // // … set angle_map[v] for cone vertices … + // auto result3 = CGAL::discrete_conformal_map_euclidean( + // mesh, + // CGAL::parameters::vertex_curvature_map(angle_map)); + // + // See for all named parameters. + + // ── Step 5: compute layout via the CGAL layout wrapper ──────────────────── + // + // The CGAL API does not return a layout directly; call CGAL::euclidean_layout + // (Conformal_layout.h) with the converged DOF vector and the internal maps. + // Note: to access the DOF vector and maps from a CGAL-API call, use the + // result.x field (if exposed) or call the internal API directly. + // + // For Phase 8b-Lite, the cleanest way to get a layout after the CGAL call + // is to use the internal API (example_flatten.cpp) — the CGAL result carries + // u_per_vertex for inspection but the full pipeline (layout, holonomy, period + // matrix) still requires the internal Maps bundle. + + if (result.converged) + std::cout << "\n[example_cgal_api] ✓ Conformal map computed successfully.\n" + << " For UV layout output, see example_flatten.cpp (internal API)\n" + << " which provides direct access to euclidean_layout().\n"; + + return result.converged ? 0 : 1; +} diff --git a/code/examples/example_euclidean.cpp b/code/examples/example_euclidean.cpp index 5278b94..37c282d 100644 --- a/code/examples/example_euclidean.cpp +++ b/code/examples/example_euclidean.cpp @@ -59,20 +59,25 @@ int main(int argc, char* argv[]) compute_euclidean_lambda0_from_mesh(mesh, maps); // ── Step 3: pin the first vertex (gauge fix) ────────────────────────── - auto vit = mesh.vertices().begin(); - Vertex_index v_pinned = *vit++; - maps.v_idx[v_pinned] = -1; // pinned: u[v_pinned] = 0 (fixed) - int idx = 0; - for (; vit != mesh.vertices().end(); ++vit) - maps.v_idx[*vit] = idx++; - const int n = idx; + // The gauge-vertex overload pins the chosen vertex (v_idx = -1) and + // assigns sequential indices 0..n-1 to the rest in a single call. + Vertex_index v_pinned = *mesh.vertices().begin(); + const int n = assign_euclidean_vertex_dof_indices(mesh, maps, v_pinned); std::cout << "[example_euclidean] DOFs: " << n << " (1 vertex pinned).\n"; // ── Step 4: natural equilibrium — set theta_v = actual angle sum at x=0 ─ - // After this step x* = 0 is the equilibrium (no deformation). - // In a real application you would set theta_v = desired angle (e.g. 2π - // for flat disks, or the cone angles for a cone metric). + // + // ⚠ TESTING CONVENTION — NOT A REAL CONFORMAL MAP + // + // "Natural theta" sets Θ_v = actual angle sum at x = 0, so x* = 0 is the + // equilibrium by construction. The solver converges in 0–1 iterations and + // u_v ≈ 0 everywhere. This is useful for testing the solver pipeline but + // produces NO conformal deformation. + // + // For a REAL conformal flattening (the primary use case): + // → see example_flatten.cpp + // which sets Θ_v = 2π (flat interior target) and produces non-trivial u_v. { std::vector x0(static_cast(n), 0.0); auto G0 = euclidean_gradient(mesh, x0, maps); diff --git a/code/examples/example_flatten.cpp b/code/examples/example_flatten.cpp new file mode 100644 index 0000000..af663d0 --- /dev/null +++ b/code/examples/example_flatten.cpp @@ -0,0 +1,206 @@ +// example_flatten.cpp +// +// conformallab++ — Real conformal flattening example +// +// This example demonstrates the PRIMARY USE CASE of the library: +// conformally flatten a 3-D surface mesh to the plane with minimal +// angle distortion. Every interior vertex is assigned a target cone +// angle of 2π (a regular flat vertex); the solver finds the unique +// conformal factor u_v that realises this target. +// +// Contrast with the other examples (example_euclidean, example_layout) +// which use the "natural theta" testing trick that produces x* = 0 — +// a valid solver test but NOT a conformal flattening. +// +// ┌─────────────────────────────────────────────────────────────────────┐ +// │ Pipeline for conformal flattening of an OPEN mesh (disk topology) │ +// │ │ +// │ 1. Load mesh │ +// │ 2. Setup maps + compute λ° from geometry │ +// │ 3. Pin all boundary vertices (they define the boundary of the UV) │ +// │ Set Θ_v = 2π for all interior vertices (flat target) │ +// │ ← NO Gauss–Bonnet check needed for open meshes │ +// │ 4. Solve Newton │ +// │ 5. Compute planar layout — the conformal UV parameterisation │ +// │ 6. Save UV-mapped OFF + report distortion │ +// └─────────────────────────────────────────────────────────────────────┘ +// +// Build (requires -DWITH_CGAL=ON): +// cmake -S code -B build -DWITH_CGAL=ON +// cmake --build build --target example_flatten +// +// Run: +// # With a real 3-D surface mesh (open, disk topology): +// ./build/examples/example_flatten code/data/obj/cathead.obj flat.off +// +// # Without arguments: uses a built-in synthetic open mesh (6 vertices) +// ./build/examples/example_flatten + +#include "conformal_mesh.hpp" +#include "mesh_builder.hpp" +#include "mesh_io.hpp" +#include "euclidean_functional.hpp" +#include "gauss_bonnet.hpp" +#include "newton_solver.hpp" +#include "layout.hpp" +#include "constants.hpp" +#include +#include +#include +#include +#include +#include +#include + +using namespace conformallab; + +int main(int argc, char* argv[]) +{ + // ── Step 1: load or synthesise a mesh ──────────────────────────────────── + ConformalMesh mesh; + std::string input_path = (argc > 1) ? argv[1] : ""; + std::string output_path = (argc > 2) ? argv[2] : "/tmp/conformallab_flatten_out.off"; + + if (input_path.empty()) { + // Fallback: load cathead.obj from the standard data location if + // it exists alongside the executable; otherwise use a synthetic mesh. + // For a meaningful non-trivial flattening, supply a real 3-D mesh: + // ./example_flatten code/data/obj/cathead.obj + std::cout << "[example_flatten] No input given. Using make_quad_strip() " + "(flat synthetic mesh — u_v will be near-zero).\n" + << " For a non-trivial flattening, provide a 3-D mesh:\n" + << " ./example_flatten code/data/obj/cathead.obj\n\n"; + mesh = make_quad_strip(); + } else { + std::cout << "[example_flatten] Loading mesh: " << input_path << "\n"; + try { mesh = load_mesh(input_path); } + catch (const std::exception& e) { + std::cerr << "Error loading mesh: " << e.what() << "\n"; + return 1; + } + } + + const int V = static_cast(mesh.number_of_vertices()); + const int F = static_cast(mesh.number_of_faces()); + std::cout << "[example_flatten] Mesh: " << V << " vertices, " << F << " faces\n"; + + // ── Step 2: setup maps + compute initial edge lengths ───────────────────── + auto maps = setup_euclidean_maps(mesh); + compute_euclidean_lambda0_from_mesh(mesh, maps); + + // ── Step 3: DOF assignment — pin boundary, free interior ────────────────── + // + // For an OPEN mesh (disk topology): + // • Boundary vertices are pinned (v_idx = -1): they define the + // boundary of the UV domain and are not optimised. + // • Interior vertices get a DOF (v_idx ≥ 0) and target Θ_v = 2π. + // This means "make every interior point look like a flat plane vertex". + // + // For a CLOSED mesh (e.g. a sphere or torus), use the Euclidean pipeline + // on a cut-open mesh (see example_layout.cpp + cut_graph.hpp), or use the + // spherical / hyper-ideal functional instead. + // + // ⚠ This is the KEY difference from example_euclidean.cpp: + // There, "natural theta" sets Θ_v = actual angle sum → x* = 0 (trivial). + // Here, Θ_v = 2π → the solver finds the REAL conformal map. + int idx = 0; + int n_boundary = 0, n_interior = 0; + + for (auto v : mesh.vertices()) { + // CGAL: a vertex is on the boundary iff it has an incident border halfedge. + bool is_bnd = false; + for (auto h : CGAL::halfedges_around_target(v, mesh)) + if (mesh.is_border(h)) { is_bnd = true; break; } + + if (is_bnd) { + maps.v_idx[v] = -1; // pinned — boundary defines the UV border + ++n_boundary; + } else { + maps.theta_v[v] = TWO_PI; // flat interior target — the actual goal + maps.v_idx[v] = idx++; + ++n_interior; + } + } + + std::cout << "[example_flatten] Boundary vertices (pinned): " << n_boundary + << " Interior (free DOFs): " << n_interior << "\n"; + + if (n_interior == 0) { + std::cerr << "[example_flatten] No interior vertices — mesh has no free DOFs.\n" + << " Use a mesh with interior vertices (e.g. cathead.obj).\n"; + return 1; + } + + // For open meshes the Gauss–Bonnet identity holds in a different form and + // does NOT need to be checked before calling newton_euclidean. The solver + // converges as long as at least one interior vertex exists. + // (For CLOSED meshes: call enforce_gauss_bonnet(mesh, maps) here.) + + // ── Step 4: Newton ──────────────────────────────────────────────────────── + std::vector x0(static_cast(idx), 0.0); + + std::cout << "[example_flatten] Running Newton (tol = 1e-9)…\n"; + auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-9); + + if (res.converged) + std::cout << "[example_flatten] Converged in " << res.iterations + << " iterations ||G||_inf = " << std::scientific + << std::setprecision(2) << res.grad_inf_norm << "\n"; + else + std::cout << "[example_flatten] WARNING: did not converge after " + << res.iterations << " iterations ||G||_inf = " + << res.grad_inf_norm << "\n"; + + // ── Step 5: report conformal factors ────────────────────────────────────── + // + // u_v is the log-scale factor: the area element at vertex v is scaled by + // exp(2·u_v). For a non-trivial mesh the values are non-zero. + double u_min = 0.0, u_max = 0.0; + for (auto v : mesh.vertices()) { + int iv = maps.v_idx[v]; + if (iv >= 0) { + double u = res.x[static_cast(iv)]; + u_min = std::min(u_min, u); + u_max = std::max(u_max, u); + } + } + std::cout << "[example_flatten] Conformal factors u_v: " + << "min = " << std::fixed << std::setprecision(4) << u_min + << " max = " << u_max << "\n"; + + if (std::abs(u_max - u_min) < 1e-6) + std::cout << "[example_flatten] Note: u_v ≈ 0 everywhere — the mesh is " + "already flat in the\n" + " Euclidean sense (e.g. a planar mesh). Use a 3-D surface " + "for non-trivial output.\n"; + else + std::cout << "[example_flatten] Non-trivial u_v range = " + << (u_max - u_min) << " — real conformal deformation computed.\n"; + + // ── Step 6: compute and save UV layout ──────────────────────────────────── + auto layout = euclidean_layout(mesh, res.x, maps); + + if (layout.success) { + // Find the bounding box of the UV coords + double xmin = 1e30, xmax = -1e30, ymin = 1e30, ymax = -1e30; + for (auto v : mesh.vertices()) { + if (static_cast(v.idx()) < layout.uv.size()) { + const auto& p = layout.uv[static_cast(v.idx())]; + xmin = std::min(xmin, p.x()); xmax = std::max(xmax, p.x()); + ymin = std::min(ymin, p.y()); ymax = std::max(ymax, p.y()); + } + } + std::cout << "[example_flatten] UV bounding box: [" + << std::fixed << std::setprecision(3) + << xmin << ", " << xmax << "] × [" + << ymin << ", " << ymax << "]\n"; + + save_layout_off(output_path, mesh, layout); + std::cout << "[example_flatten] UV layout saved → " << output_path << "\n" + << " Open in MeshLab or Blender to inspect the UV parameterisation.\n"; + } else { + std::cerr << "[example_flatten] Layout failed.\n"; + } + + return (res.converged && layout.success) ? 0 : 1; +} diff --git a/code/examples/example_layout.cpp b/code/examples/example_layout.cpp index dc8b210..b7bbce4 100644 --- a/code/examples/example_layout.cpp +++ b/code/examples/example_layout.cpp @@ -37,6 +37,11 @@ using namespace conformallab; // ── Helper: natural target angles so x* = 0 is the equilibrium ─────────────── +// +// ⚠ TESTING CONVENTION — NOT A REAL CONFORMAL MAP +// Natural theta sets Θ_v = actual angle sum at x=0, making x* = 0 trivially +// the equilibrium (u_v ≈ 0, no deformation). For real conformal flattening, +// see example_flatten.cpp which uses Θ_v = 2π (flat interior target). static void set_natural_theta(ConformalMesh& mesh, EuclideanMaps& maps, int n) { std::vector x0(static_cast(n), 0.0); @@ -49,14 +54,13 @@ static void set_natural_theta(ConformalMesh& mesh, EuclideanMaps& maps, int n) } // ── Helper: pin vertex 0, assign DOF indices 0..n-1 to the rest ────────────── +// Uses the gauge-vertex overload introduced in external-audit Finding-D: +// assign_euclidean_vertex_dof_indices(mesh, maps, gauge) pins the chosen +// vertex and assigns sequential indices in a single pass. static int pin_first(ConformalMesh& mesh, EuclideanMaps& maps) { - auto vit = mesh.vertices().begin(); - maps.v_idx[*vit++] = -1; // pinned - int idx = 0; - for (; vit != mesh.vertices().end(); ++vit) - maps.v_idx[*vit] = idx++; - return idx; + return assign_euclidean_vertex_dof_indices( + mesh, maps, *mesh.vertices().begin()); } // ── Main ───────────────────────────────────────────────────────────────────── @@ -124,7 +128,9 @@ int main(int argc, char* argv[]) if (layout.success) { std::cout << "UV coordinates:\n"; for (auto v : mesh.vertices()) { - auto& p = layout.uv[v.idx()]; + // layout.uv is indexed by v.idx() (raw integer vertex index). + // Valid as long as no vertices were removed/compacted after loading. + auto& p = layout.uv[static_cast(v.idx())]; std::cout << " v" << v.idx() << ": (" << std::fixed << std::setprecision(6) << p.x() << ", " << p.y() << ")\n"; diff --git a/code/include/CGAL/Discrete_conformal_map.h b/code/include/CGAL/Discrete_conformal_map.h index 869748d..98ad104 100644 --- a/code/include/CGAL/Discrete_conformal_map.h +++ b/code/include/CGAL/Discrete_conformal_map.h @@ -7,13 +7,17 @@ \file CGAL/Discrete_conformal_map.h \ingroup PkgConformalMapRef -User-facing entry point for the Discrete_conformal_map package. +User-facing entry points for the Discrete_conformal_map package (Phase 8b-Lite). -This header provides a single function — `discrete_conformal_map_euclidean` -— that computes a Euclidean discrete-conformal flattening of an open or -closed triangle mesh. Spherical and hyperbolic variants are scheduled -for Phase 8b.2 once the Euclidean pattern is validated by Phase 9a -(Inversive-Distance functional). +This header provides three functions covering all three DCE geometries: + +- `CGAL::discrete_conformal_map_euclidean` — flat conformal map (ℝ²), open or closed mesh +- `CGAL::discrete_conformal_map_spherical` — spherical uniformisation (S²), genus-0 mesh +- `CGAL::discrete_conformal_map_hyper_ideal` — hyperbolic conformal map (H²), genus ≥ 1 + +For circle-packing models see the companion headers: +- `` — `discrete_circle_packing_euclidean` (CP-Euclidean) +- `` — `discrete_inversive_distance_map` (Luo 2004) \section Example Simplest usage diff --git a/code/include/layout.hpp b/code/include/layout.hpp index 6a52f08..11eb688 100644 --- a/code/include/layout.hpp +++ b/code/include/layout.hpp @@ -138,18 +138,38 @@ struct MobiusMap { /// per-vertex UV coordinates plus a per-half-edge UV atlas for seamed /// textures. struct Layout2D { - /// uv[v.idx()] — primary 2-D position (first / shallowest-BFS-depth visit). + /// Primary 2-D position per vertex (first / shallowest-BFS-depth visit). + /// + /// **Indexing:** `uv[v.idx()]` — indexed by the raw integer vertex index. + /// **Length:** `mesh.number_of_vertices()`. + /// + /// \pre No vertices have been removed from `mesh` after loading (i.e. + /// `mesh.is_valid()` and no compaction was performed). On a fresh + /// `Surface_mesh` loaded from file, `v.idx()` is always in + /// `[0, number_of_vertices())` and contiguous. If vertices were + /// deleted and `mesh.collect_garbage()` was called, re-run the + /// layout — indices will have shifted. + /// + /// Access pattern: + /// ```cpp + /// for (auto v : mesh.vertices()) + /// Eigen::Vector2d uv_v = layout.uv[v.idx()]; + /// ``` std::vector uv; - /// halfedge_uv[h.idx()] — UV of source(h) as seen from face(h). + /// UV of `source(h)` as seen from `face(h)`, indexed by `h.idx()`. /// - /// For interior (non-seam) halfedges: equals uv[source(h).idx()]. - /// For seam halfedges: carries the UV from the virtual unfolding across - /// the cut (i.e. the trilaterated position that was NOT used as the - /// primary uv). This gives each face its own copy of a seam vertex, - /// enabling a proper GPU texture atlas without vertex duplication. + /// **Indexing:** `halfedge_uv[h.idx()]` — raw integer halfedge index. + /// **Length:** `mesh.number_of_halfedges()`. Same no-compaction precondition + /// as `uv` (see above). /// - /// Size = mesh.number_of_halfedges(). Border halfedges = (0,0). + /// For interior (non-seam) halfedges: equals `uv[source(h).idx()]`. + /// For seam halfedges: carries the UV from the virtual unfolding across the + /// cut — the trilaterated position that was NOT used as the primary `uv`. + /// This gives each face its own copy of a seam vertex, enabling a proper + /// per-halfedge GPU texture atlas without vertex duplication. + /// + /// Border halfedges (outer face) hold `(0, 0)`. std::vector halfedge_uv; bool success = false; ///< `true` iff the BFS placed every vertex. diff --git a/doc/api/contracts.md b/doc/api/contracts.md index e49383f..9de916e 100644 --- a/doc/api/contracts.md +++ b/doc/api/contracts.md @@ -13,11 +13,11 @@ of all preceding units. | `load_mesh()` | Valid file path, supported format (OFF/OBJ/PLY) | Manifold, oriented, triangulated `ConformalMesh` | | `setup_*_maps()` | Triangulated mesh | Initialised property maps; `lambda0` zeroed | | `compute_*_lambda0_from_mesh()` | `setup_*_maps()` called | `lambda0[e]` set from 3-D edge lengths | -| `check_gauss_bonnet()` | `theta_v[v]` set for all vertices | Throws `std::runtime_error` if Σ(2π−Θᵥ) ≠ 2π·χ(M) | -| `enforce_gauss_bonnet()` | `theta_v[v]` set | Σ(2π−Θᵥ) = 2π·χ(M) guaranteed; `theta_v` modified | +| `check_gauss_bonnet()` | `theta_v[v]` set · **EuclideanMaps or SphericalMaps only** | Throws `std::runtime_error` if Σ(2π−Θᵥ) ≠ 2π·χ(M). Deleted overload for `HyperIdealMaps` — see note below. | +| `enforce_gauss_bonnet()` | `theta_v[v]` set · **EuclideanMaps or SphericalMaps only** | Σ(2π−Θᵥ) = 2π·χ(M) guaranteed; `theta_v` modified. Deleted overload for `HyperIdealMaps` — see note below. | | `newton_euclidean()` | GB satisfied · DOFs assigned · `lambda0` initialised | `NewtonResult.x` — converged scale factors; `.converged`, `.iterations`, `.grad_inf_norm` | | `newton_spherical()` | GB satisfied · DOFs assigned · gauge vertex pinned | Same as above | -| `newton_hyper_ideal()` | `assign_all_dof_indices()` called · `lambda0` initialised | Same as above | +| `newton_hyper_ideal()` | `assign_all_dof_indices()` called · **no `lambda0` needed** · **no GB pre-check** | Same as above. HyperIdeal computes lengths internally from b_v, a_e (via ζ₁₃/ζ₁₄/ζ₁₅); `lambda0` is irrelevant. Energy is strictly convex → Newton converges for any targets without a pre-check. | | `compute_cut_graph()` | Closed, orientable, triangulated mesh | `CutGraph.cut_edge_flags` — 2g seam edges; `.genus` | | `euclidean_layout()` | `newton_euclidean()` converged | `Layout2D.uv[v]` · `.halfedge_uv[h]` · `HolonomyData.translations` | | `spherical_layout()` | `newton_spherical()` converged | `Layout3D.xyz[v]` | @@ -53,17 +53,28 @@ The solver never writes to pinned DOFs. All indexing is 0-based and contiguous. --- -## Gauss–Bonnet — the most common source of failure +## Gauss–Bonnet — the most common source of failure for Euclidean and Spherical Prescribing angles that violate Gauss–Bonnet means no conformal factor can realise the target metric — the Newton solver will iterate indefinitely without converging. ```cpp -// Option A: verify before solving (throws on violation) -check_gauss_bonnet(mesh, maps); +// Option A: verify before solving — throws std::runtime_error on violation. +// Works with EuclideanMaps and SphericalMaps only. +check_gauss_bonnet(mesh, euclidean_maps); // or spherical_maps +check_gauss_bonnet(mesh, spherical_maps); -// Option B: auto-correct (redistributes defect uniformly across all vertices) -enforce_gauss_bonnet(mesh, maps); +// Option B: auto-correct — redistributes the defect uniformly across all vertices. +enforce_gauss_bonnet(mesh, euclidean_maps); +enforce_gauss_bonnet(mesh, spherical_maps); + +// ⚠ HyperIdealMaps: both functions have deleted overloads — calling them is a +// compile error. Reason: the correct hyperbolic Gauss–Bonnet identity includes +// a surface area term: Σ(2π−Θᵥ) − Area(M) = 2π·χ(M), not Σ(2π−Θᵥ) = 2π·χ(M). +// No pre-check is needed for HyperIdeal: the energy is strictly convex +// (Springborn 2020 Theorem 1.3), so newton_hyper_ideal converges for any targets. ``` The target violation is `|Σ(2π−Θᵥ) − 2π·χ(M)| > tol`. Default tolerance: `1e-10`. +Applicable to closed meshes only — for open meshes, pin the boundary directly +and skip the Gauss–Bonnet check (the identity does not hold for meshes with boundary). diff --git a/doc/getting-started.md b/doc/getting-started.md index c5100a0..933b592 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -75,6 +75,21 @@ cmake --build build -j$(nproc) > **Note:** `-DWITH_CGAL=ON` implies `-DWITH_VIEWER=ON`. Do not use this in headless > environments — it will fail with `Failed to find wayland-scanner`. +### Mode 4 — Low-memory build (RAM-constrained CI / Raspberry Pi ≤ 4 GB) + +For machines where the CGAL build OOMs (peak ~700 MB per compilation unit at `-O3`): + +```bash +cmake -S code -B build -DWITH_CGAL_TESTS=ON \ + -DCONFORMALLAB_LOW_MEMORY_BUILD=ON +cmake --build build --target conformallab_cgal_tests -j1 +``` + +`LOW_MEMORY_BUILD` applies four measures: `-O0` (no debug info), PCH off, +unity batch size 1, `--no-keep-memory` linker flag. Drops cc1plus peak from +~700 MB to ~150-200 MB per TU. Tests run ~15× slower at `-O0` but all pass. +**Always use `-j1`** — parallel compilation would defeat the memory savings. + --- ## Running a single test @@ -114,16 +129,53 @@ After a full build (`-DWITH_CGAL=ON`): ./bin/conformallab_core --help ``` +### CLI parameter reference + +| Flag | Default | Description | +|------|---------|-------------| +| `-i / --input` | *required* | Input mesh file (OFF / OBJ / PLY) | +| `-o / --output` | *(none)* | Save layout as OFF file | +| `-j / --json` | *(none)* | Serialise solver result + UV to JSON | +| `-x / --xml` | *(none)* | Serialise solver result + UV to XML | +| `-g / --geometry` | `euclidean` | Target geometry: `euclidean` · `spherical` · `hyper_ideal` | +| `-s / --show` | `false` | Open the input mesh in the interactive viewer | +| `-v / --verbose` | `false` | Print mesh topology, DOF counts, convergence details | + +> **Tip:** `./bin/conformallab_core --help` always shows the canonical up-to-date +> list generated by CLI11. The table above matches `conformallab_cli.cpp` +> as of v0.10.0. + ## Example programs ```bash +# PRIMARY USE CASE: conformally flatten a mesh to the plane +./build/examples/example_flatten code/data/obj/cathead.obj flat.off +# → non-trivial u_v (e.g. range ≈ 2.96), real conformal deformation + +# CGAL public API: one-call interface (natural theta by default) +./build/examples/example_cgal_api [input.off] + +# Full pipeline with JSON/XML serialisation and round-trip test ./build/examples/example_layout [input.off] [layout.off] [result.json] + +# Solver test (natural theta — u_v ≈ 0, used for pipeline validation) ./build/examples/example_euclidean [input.off] [output.off] + +# Hyper-ideal (hyperbolic) functional ./build/examples/example_hyper_ideal [input.off] [output.off] -./build/examples/example_viewer [input.off] # interactive, requires WITH_VIEWER + +# Interactive viewer (requires WITH_VIEWER) +./build/examples/example_viewer [input.off] ``` -`example_layout.cpp` is the best starting point — it shows the complete pipeline in ~120 lines. +**Start here:** `example_flatten.cpp` shows the primary use case — real conformal +flattening with `Θ_v = 2π`. `example_layout.cpp` adds JSON/XML serialisation. + +> **Note on "natural theta":** `example_euclidean` and `example_layout` use the +> "natural theta" testing trick (`Θ_v = actual angle sum at x=0`), which makes +> `x* = 0` trivially the equilibrium. The output `u_v ≈ 0` is expected and +> correct for a pipeline test, but means **no conformal deformation was applied**. +> For real UV parameterisation, use `example_flatten.cpp`. **Expected output of `example_euclidean` on the built-in quad-strip mesh:** ``` diff --git a/doc/reviewer/usability-audit-2026-05-31.md b/doc/reviewer/usability-audit-2026-05-31.md new file mode 100644 index 0000000..a2e9b3a --- /dev/null +++ b/doc/reviewer/usability-audit-2026-05-31.md @@ -0,0 +1,556 @@ +# Usability & Documentation Audit — ConformalLabpp v0.10.0 + +**Date:** 2026-05-31 +**Auditor:** External reviewer (Claude Sonnet 4.6) +**Branch:** `review/usability-audit-2026-05-31` +**Base:** `main` (post external-audit-2026-05-30 merge) +**Focus:** Documentation quality, API usability, new-user experience + +This document is self-contained. A new session can pick up any finding +and act on it without prior context. Each finding includes exact file paths, +the concrete problem, and a precise fix with acceptance criteria. + +Status legend: 🔴 Usability bug · 🟡 Doc error/stale · 🟠 Missing content + +--- + +## How to read this document in a new session + +```bash +git checkout review/usability-audit-2026-05-31 + +# No build needed for most fixes — documentation and example changes only. +# For changes that touch compilable code (examples), verify with: +cmake -S code -B build-cgal -DWITH_CGAL_TESTS=ON -DCONFORMALLAB_LOW_MEMORY_BUILD=ON +cmake --build build-cgal --target conformallab_cgal_tests -j1 +ctest --test-dir build-cgal -R '^cgal\.' --output-on-failure +``` + +--- + +## FINDING-U1 — 🔴 CRITICAL: All examples show the trivial identity map, not real conformal flattening + +### Location +- `README.md` lines 100–121 ("Minimal usage") +- `code/examples/example_euclidean.cpp` lines 72–83 (Step 4) +- `code/examples/example_layout.cpp` lines 40–49 (`set_natural_theta`) +- `code/examples/example_hyper_ideal.cpp` lines 89–98 (Step 3) + +### Problem + +Every example uses the "natural theta" trick: +```cpp +auto G0 = euclidean_gradient(mesh, x0, maps); +for (auto v : mesh.vertices()) + if (maps.v_idx[v] >= 0) + maps.theta_v[v] -= G0[maps.v_idx[v]]; +``` + +This sets Θ_v to the *actual angle sums at x=0*, making x*=0 the equilibrium. +The result: `u_v ≈ 0` everywhere — **no deformation**. The map is identity. + +Natural theta is a valid test pattern (it proves Newton converges in 0 or 1 +steps and guards against solver regressions). But as the *only* pattern shown to +a new user, it gives the false impression that the library produces trivial +output. The real use case — **conformally flatten a surface to the plane** — +is never demonstrated. + +### What is missing + +A "real-world" example that shows the actual use case: + +```cpp +// Conformally flatten a mesh to the plane. +// Set Θ_v = 2π (regular interior vertex, zero cone angle defect) for all +// free vertices; enforce_gauss_bonnet makes the assignment topologically +// consistent. The result is a flat conformal map with minimal angle distortion. +for (auto v : mesh.vertices()) + maps.theta_v[v] = conformallab::TWO_PI; +conformallab::enforce_gauss_bonnet(mesh, maps); +auto res = conformallab::newton_euclidean(mesh, x0, maps); +auto layout = conformallab::euclidean_layout(mesh, res.x, maps); +// layout.uv[v.idx()] now holds 2D coordinates of the flattening +``` + +### Fix + +1. **Add a new example** `code/examples/example_flatten.cpp`: + - Takes a real mesh from `code/data/` (e.g. `cathead.obj` or `torus_8x8.off`) + - Sets Θ_v = 2π, calls `enforce_gauss_bonnet`, solves Newton, computes layout + - Saves a UV-mapped OFF file + - Shows non-trivial u_v values at output + +2. **Update README "Minimal usage"** to show the flatten use case instead of + (or in addition to) natural theta. Add a comment explaining what natural + theta is and when to use it. + +3. **Add a comment** to all existing examples explaining that natural theta + is a *testing convenience*, not a typical use case. + +### Acceptance criteria +- [ ] `example_flatten.cpp` compiles and runs with a real mesh input +- [ ] Output shows non-trivial `u_v ≠ 0` and a visible UV parameterisation +- [ ] README "Minimal usage" demonstrates real flattening (not identity) +- [ ] Existing examples have a comment: "Note: natural theta → x* = 0 (testing + pattern). For real flattening, set theta_v = TWO_PI and call enforce_gauss_bonnet." + +--- + +## FINDING-U2 — 🔴 CRITICAL: No example for the CGAL public API + +### Location +- `code/include/CGAL/Discrete_conformal_map.h` (the "user-facing entry") +- `code/examples/` (no example uses CGAL::discrete_conformal_map_euclidean) + +### Problem + +`Discrete_conformal_map.h` is documented as "User-facing entry point for the +Discrete_conformal_map package." The Doxygen doc shows a minimal usage snippet: + +```cpp +auto result = CGAL::discrete_conformal_map_euclidean(mesh); +``` + +But this snippet only exists in a Doxygen comment — no compilable example +demonstrates it. All four `code/examples/*.cpp` use the *internal* API +(`setup_euclidean_maps + newton_euclidean`). A CGAL user who reaches this +library via the CGAL ecosystem has no runnable starting point. + +The CGAL API and the internal API also produce *different result types*: +- Internal: `NewtonResult.x` — DOF-index-indexed `std::vector` +- CGAL: `Conformal_map_result.u_per_vertex` — vertex-index-indexed `std::vector` + +This discrepancy is not explained anywhere for users. + +### Fix + +Add `code/examples/example_cgal_api.cpp`: +```cpp +// Demonstrates the CGAL public API (Discrete_conformal_map.h). +// Contrast with example_euclidean.cpp which uses the internal API directly. + +#include +#include +#include +// ... +auto result = CGAL::discrete_conformal_map_euclidean(mesh); +// result.u_per_vertex[v.idx()] — per-vertex scale factor +// result.converged, result.iterations, result.gradient_norm +``` + +Also add to README: a short paragraph explaining when to use CGAL API +vs internal API (CGAL API: simpler, less control; internal API: full pipeline, +holonomy, period matrix). + +### Acceptance criteria +- [ ] `example_cgal_api.cpp` compiles with `-DWITH_CGAL=ON` +- [ ] Example uses `CGAL::discrete_conformal_map_euclidean` (not internal API) +- [ ] README explains the two API levels and when to use each + +--- + +## FINDING-U3 — 🟡 DOC ERROR: `contracts.md` incorrect after Finding-B + +### Location +`doc/api/contracts.md` line 16 + +### Problem + +```markdown +| `check_gauss_bonnet()` | `theta_v[v]` set for all vertices | + Throws `std::runtime_error` if Σ(2π−Θᵥ) ≠ 2π·χ(M) | +``` + +After the external-audit-2026-05-30 Finding-B fix, calling +`check_gauss_bonnet(mesh, hyper_ideal_maps)` is a **compile error** +(the overload is `= delete`). The contract table implies it works for +all map types — which is now false. + +Also missing from the table: the HyperIdeal functional needs no +Gauss-Bonnet pre-check because its energy is strictly convex +(Springborn 2020 Theorem 1.3) — Newton converges for any target angles. + +### Fix + +```markdown +| `check_gauss_bonnet()` | `theta_v[v]` set · **Euclidean or Spherical maps only** | + Throws if Σ(2π−Θᵥ) ≠ 2π·χ(M). **Not applicable to HyperIdealMaps** — + deleted overload; HyperIdeal needs no pre-check (strictly convex energy). | +``` + +Also add a row for `enforce_gauss_bonnet`: +```markdown +| `enforce_gauss_bonnet()` | `theta_v[v]` set · **Euclidean or Spherical maps only** | + Shifts all Θᵥ by δ = (lhs−rhs)/V so that GB holds exactly. + **Not applicable to HyperIdealMaps** — deleted overload. | +``` + +### Acceptance criteria +- [ ] `check_gauss_bonnet` row notes "Euclidean/Spherical only, not HyperIdeal" +- [ ] `enforce_gauss_bonnet` row added with same note +- [ ] A brief explanation why HyperIdeal doesn't need GB: "strictly convex energy" + +--- + +## FINDING-U4 — 🟡 DOC ERROR: Stale version in README (v0.9.0) + +### Location +`README.md` line 17 + +### Problem + +```markdown +**Status:** v0.9.0 — Phases 1–9a complete, Phase 8b-Lite CGAL API surface. +``` + +The current release is **v0.10.0** (tagged on `main`; CLAUDE.md confirms this). +CLAUDE.md was updated to v0.10.0 but the README was not. + +### Fix + +```markdown +**Status:** v0.10.0 — Phases 1–9b complete. 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. +277 tests passing, 0 skipped — see [`doc/api/tests.md`](doc/api/tests.md). +``` + +### Acceptance criteria +- [ ] README status line shows v0.10.0 and the correct feature list +- [ ] Test count updated to 277 + +--- + +## FINDING-U5 — 🟡 DOC ERROR: CGAL header comment describes superseded state + +### Location +`code/include/CGAL/Discrete_conformal_map.h` lines 14–16 + +### Problem + +```cpp +/// This header provides a single function — `discrete_conformal_map_euclidean` +/// — that computes a Euclidean discrete-conformal flattening … Spherical and +/// hyperbolic variants are scheduled for Phase 8b.2 once the Euclidean +/// pattern is validated +``` + +But the file already contains `discrete_conformal_map_spherical()` and +`discrete_conformal_map_hyper_ideal()` (and the other models). The comment +describes Phase 8a state; the file is at Phase 8b-Lite. + +### Fix + +Update the `\file` Doxygen block at the top of `Discrete_conformal_map.h`: + +```cpp +/*! +\file CGAL/Discrete_conformal_map.h +\ingroup PkgConformalMapRef + +User-facing entry points for the Discrete_conformal_map package. + +This header provides five functions covering all three DCE geometries: + - `CGAL::discrete_conformal_map_euclidean` (flat / ℝ²) + - `CGAL::discrete_conformal_map_spherical` (S², genus 0) + - `CGAL::discrete_conformal_map_hyper_ideal` (H², genus ≥ 1) + - `CGAL::discrete_circle_packing_euclidean` → Discrete_circle_packing.h + - `CGAL::discrete_inversive_distance_map` → Discrete_inversive_distance.h +... +*/ +``` + +### Acceptance criteria +- [ ] `\file` doc block lists all five entry functions +- [ ] No forward-reference to "planned Phase 8b.2" — it is already implemented + +--- + +## FINDING-U6 — 🟡 STALE EXAMPLES: New gauge-vertex overload not reflected + +### Location +- `README.md` lines 107–110 +- `code/examples/example_euclidean.cpp` lines 62–68 +- `code/examples/example_layout.cpp` lines 52–59 (`pin_first` helper) + +### Problem + +Finding-D (external-audit-2026-05-30) added a clean gauge-vertex overload: +```cpp +assign_euclidean_vertex_dof_indices(mesh, maps, gauge_vertex); +``` + +But all examples still use the old verbose loop: +```cpp +auto vit = mesh.vertices().begin(); +maps.v_idx[*vit++] = -1; // pinned +int idx = 0; +for (; vit != mesh.vertices().end(); ++vit) + maps.v_idx[*vit] = idx++; +``` + +A new user copying from the examples will write the old error-prone pattern +instead of the new clean overload — defeating the purpose of the fix. + +### Fix + +Replace the old loop in all three locations with the new overload: +```cpp +// Pin the first vertex, assign sequential DOF indices to the rest. +int n = assign_euclidean_vertex_dof_indices(mesh, maps, + *mesh.vertices().begin()); +``` + +Same for `assign_vertex_dof_indices` (spherical) and +`assign_inversive_distance_vertex_dof_indices` wherever they appear in examples. + +### Acceptance criteria +- [ ] README "Minimal usage" uses the overload +- [ ] `example_euclidean.cpp` uses the overload +- [ ] `example_layout.cpp` `pin_first` helper replaced with the overload +- [ ] Old manual loop removed from all user-facing code + +--- + +## FINDING-U7 — 🟠 MISSING: `CONFORMALLAB_LOW_MEMORY_BUILD` absent from `getting-started.md` + +### Location +`doc/getting-started.md` — "Build modes" section + +### Problem + +`getting-started.md` documents five compile-time modes but does not list +`CONFORMALLAB_LOW_MEMORY_BUILD=ON`. A Raspberry Pi or low-RAM user who +reads `getting-started.md` (the natural first stop) cannot find the flag. +It only appears in CLAUDE.md and the README table. + +### Fix + +Add to `getting-started.md` after the `CONFORMALLAB_FAST_TEST_BUILD` entry: + +```markdown +### Low-memory build (RAM-constrained CI / Raspberry Pi) + +For machines with ≤ 4 GB RAM, the default CGAL build (PCH + -O3) OOMs +the compiler. `LOW_MEMORY_BUILD` uses `-O0`, disables PCH, and sets +unity batch size to 1, keeping `cc1plus` peak at ~150-200 MB per TU: + +```bash +cmake -S code -B build -DWITH_CGAL_TESTS=ON \ + -DCONFORMALLAB_LOW_MEMORY_BUILD=ON +cmake --build build --target conformallab_cgal_tests -j1 +``` + +Tests run ~15× slower at -O0 but all pass. Useful for CI runners with +limited memory. +``` + +### Acceptance criteria +- [ ] `getting-started.md` documents `CONFORMALLAB_LOW_MEMORY_BUILD` +- [ ] Example command shows `-j1` (required for memory safety) + +--- + +## FINDING-U8 — 🟠 MISSING: `LOW_MEMORY_BUILD` absent from README compile-time table + +### Location +`README.md` lines 53–82 ("Compile-time workflow modes") + +### Problem + +The README code block shows five compile-time patterns but omits +`CONFORMALLAB_LOW_MEMORY_BUILD`. This is the flag that re-enabled CI +and is directly relevant to anyone building on resource-constrained hardware. + +### Fix + +Add to the README compile-time examples: +```bash +# Low-memory build: -O0, no PCH, unity batch 1 (~150 MB peak per cc1plus). +# Use on Raspberry Pi / CI runners with ≤ 4 GB RAM. Tests run ~15× slower. +cmake -S code -B build -DWITH_CGAL_TESTS=ON -DCONFORMALLAB_LOW_MEMORY_BUILD=ON +cmake --build build --target conformallab_cgal_tests -j1 +``` + +### Acceptance criteria +- [ ] README shows `LOW_MEMORY_BUILD` usage with a one-line explanation + +--- + +## FINDING-U9 — 🟠 MISSING: `layout.uv` indexing semantics not documented + +### Location +`code/examples/example_layout.cpp` line 127 +`code/include/layout.hpp` — `Layout2D` struct definition + +### Problem + +```cpp +auto& p = layout.uv[v.idx()]; // example_layout.cpp:127 +``` + +`Layout2D.uv` is a `std::vector` indexed by the raw integer `.idx()` of +`Vertex_index`. This is undocumented: nowhere is it stated that the vector +length equals `num_vertices(mesh)` and that the index is contiguous. In CGAL, +`vertex.idx()` is contiguous on a fresh `Surface_mesh` but may have gaps after +vertex removals. + +The `halfedge_uv` field (also in `Layout2D`) has the same issue for halfedge +indices. + +### Fix + +In `layout.hpp`, add explicit documentation to the `Layout2D` struct: + +```cpp +struct Layout2D { + /// UV coordinate of vertex v in the layout plane. + /// Indexed by `v.idx()` — length equals `mesh.number_of_vertices()`. + /// \pre No vertices have been removed from `mesh` since construction + /// (i.e., `mesh.is_valid(false)` and no compaction was performed). + std::vector uv; + + /// UV of source(h) as seen from face(h), indexed by `h.idx()`. + /// At seam halfedges the two opposite halfedges carry different UV values, + /// enabling proper per-halfedge UV for GPU texture atlasing. + std::vector halfedge_uv; + ... +}; +``` + +Also update `example_layout.cpp` to add a comment: +```cpp +// layout.uv is indexed by v.idx() — valid as long as no vertices were +// removed from the mesh after loading. +auto& p = layout.uv[v.idx()]; +``` + +### Acceptance criteria +- [ ] `Layout2D.uv` and `Layout2D.halfedge_uv` have explicit Doxygen docs + stating the index semantics and the no-compaction precondition +- [ ] `example_layout.cpp` has an inline comment at the access site + +--- + +## FINDING-U10 — 🟠 MISSING: CLI parameter reference not in README or getting-started + +### Location +- `README.md` (has CLI examples but no parameter table) +- `doc/getting-started.md` (same) +- `code/src/apps/v0/conformallab_cli.cpp` lines 333–340 (source of truth) + +### Problem + +The CLI app (`conformallab_core`) is the primary non-programmatic entry point +for the library. The README shows a few usage examples but no parameter +reference. A user running `--help` gets CLI11's auto-generated output, but +that output is not reproduced anywhere in the documentation. + +Current parameters (from `conformallab_cli.cpp`): + +| Flag | Default | Description | +|------|---------|-------------| +| `-i / --input` | *required* | Input mesh (OFF / OBJ / PLY) | +| `-o / --output` | *(none)* | Output layout OFF file | +| `-j / --json` | *(none)* | Serialise result to JSON | +| `-x / --xml` | *(none)* | Serialise result to XML | +| `-g / --geometry` | `euclidean` | Target geometry: `euclidean` \| `spherical` \| `hyper_ideal` | +| `-s / --show` | `false` | Open input mesh in interactive viewer | +| `-v / --verbose` | `false` | Print topology / DOF stats | + +### Fix + +Add a concise parameter table to `doc/getting-started.md` in the +"First run — CLI app" section. The README already shows the most common +invocations; the table can live in getting-started to avoid README bloat. + +### Acceptance criteria +- [ ] `doc/getting-started.md` has a parameter table matching the source +- [ ] Each flag has a one-line description and notes the default +- [ ] Cross-reference to `--help` for the canonical up-to-date list + +--- + +## FINDING-U11 — 🟠 ROADMAP: CLI missing three useful parameters + two models + +### Location +`code/src/apps/v0/conformallab_cli.cpp` + +### Problem (not a bug — a gap for a future session to fill) + +**Missing solver-tuning parameters** (low effort, high value): + +The Newton tolerance and iteration limit are hardcoded at their defaults +(`tol=1e-8`, `max_iter=200`). Any user who wants to solve a stiff mesh +more carefully or stop early has to recompile. + +```bash +# Not yet possible: +./conformallab_core -i hard_mesh.off -g euclidean --tol 1e-12 --max-iter 1000 +./conformallab_core -i quick_check.off --tol 1e-4 --max-iter 20 +``` + +**Missing geometry modes** (medium effort): + +The two Phase-9a functionals are not reachable from the CLI at all: + +```bash +# Not yet possible — needs run_cp_euclidean() + run_inversive_distance() helpers: +./conformallab_core -i mesh.off -g cp_euclidean +./conformallab_core -i mesh.off -g inversive_distance +``` + +CP-Euclidean (`Discrete_circle_packing.h`) and Inversive-Distance +(`Discrete_inversive_distance.h`) are fully implemented in the library and +exposed via the CGAL public API, but a CLI user cannot reach them. + +### Suggested implementation (for a future session) + +1. **`--tol ` and `--max-iter `:** one `app.add_option` each, + thread through all three `run_*` functions. ~15 lines. + +2. **`-g cp_euclidean` and `-g inversive_distance`:** add two new + `run_cp_euclidean()` / `run_inversive_distance()` helpers following + the existing `run_euclidean()` pattern. ~60 lines each. Add to the + `-g` `CLI::IsMember` list. Register the two new geometry strings in + the dispatch block. + +3. **Update README + getting-started** to list all five geometry modes. + +### Effort estimate +- `--tol` + `--max-iter`: ~30 min +- Phase-9a CLI exposure: ~2 hours (mostly copy-paste + adaptation) +- Tests: add two CLI smoke tests (geometry converges on tetrahedron) + +### Acceptance criteria (when implemented) +- [ ] `--tol` and `--max-iter` are accepted and forwarded to all three/five solvers +- [ ] `./conformallab_core -i mesh.off -g cp_euclidean -o out.off` works +- [ ] `./conformallab_core -i mesh.off -g inversive_distance -o out.off` works +- [ ] `--help` output lists all five geometry modes + +--- + +## Summary table + +| ID | File | Type | Severity | Status | +|----|------|------|----------|--------| +| U1 | README + 4 examples | Usability | 🔴 Critical | ✅ Fixed 2026-05-31 | +| U2 | examples/ (missing) | Usability | 🔴 Critical | ✅ Fixed 2026-05-31 | +| U3 | `contracts.md:16` | Doc error | 🟡 Medium | ✅ Fixed 2026-05-31 | +| U4 | `README.md:17` | Stale | 🟡 Medium | ✅ Fixed 2026-05-31 | +| U5 | `Discrete_conformal_map.h:14` | Stale | 🟡 Medium | ✅ Fixed 2026-05-31 | +| U6 | README + 2 examples | Stale | 🟡 Medium | ✅ Fixed 2026-05-31 | +| U7 | `getting-started.md` | Missing | 🟠 Minor | ✅ Fixed 2026-05-31 | +| U8 | `README.md` | Missing | 🟠 Minor | ✅ Fixed 2026-05-31 | +| U9 | `layout.hpp` + example | Missing | 🟠 Minor | ✅ Fixed 2026-05-31 | +| U10 | `getting-started.md` | Missing | 🟠 Minor | ✅ Fixed 2026-05-31 | +| U11 | `conformallab_cli.cpp` | Roadmap | 🟠 Minor | ✅ Added to phases.md (9h) | + +**Priority order for fixing:** +1. ~~U1 + U2~~ ✅ done +2. U3 (correctness — contracts.md wrong after Finding-B) +3. U4 + U5 + U6 (stale content — quick fixes) +4. U7 + U8 + U9 + U10 (missing docs — minor) +5. U11 (roadmap — CLI extensions, not urgent) diff --git a/doc/roadmap/phases.md b/doc/roadmap/phases.md index 3a939f4..7a70672 100644 --- a/doc/roadmap/phases.md +++ b/doc/roadmap/phases.md @@ -287,6 +287,37 @@ mesh type. no new library surface). Effort: small (~2–3 days). ``` +9h — CLI usability extensions (infrastructure — no Java equivalent) +────────────────────────────────────────────────────────────────── + +Identified by usability-audit-2026-05-31 (Finding-U11). Two independent +sub-tasks; either can land independently. + +``` +9h.1 Newton solver tuning parameters (~30 min) + Currently hardcoded in all three run_*() helpers: + tol = 1e-8, max_iter = 200 + Add to conformallab_cli.cpp: + app.add_option("--tol", tol, "Newton gradient tolerance [1e-8]"); + app.add_option("--max-iter", max_iter, "Newton iteration limit [200]"); + Thread both through run_euclidean / run_spherical / run_hyper_ideal. + Update getting-started.md CLI parameter table. + Status: 🔲 planned. Effort: ~30 min. No dependencies. + +9h.2 Phase-9a models in CLI (~2–4 hours) + The CP-Euclidean and Inversive-Distance functionals are fully + implemented in the library and exposed via the CGAL public API + (Discrete_circle_packing.h, Discrete_inversive_distance.h), but + a CLI user cannot reach them. Add: + -g cp_euclidean → run_cp_euclidean() + -g inversive_distance → run_inversive_distance() + Following the existing run_euclidean() pattern (~60 lines each). + Add both geometry strings to the CLI::IsMember validator. + Update README + getting-started.md parameter table. + Status: 🔲 planned. Effort: ~2–4 h. Requires: 9a complete ✅. + Java reference: none (both functionals are research / literature ports). +``` + --- ## ◼ New research directions — Phases 10d–10g (2026 library scan)