Merge pull request 'review: usability audit v0.10.0 — all 11 findings resolved' (#34) from review/usability-audit-2026-05-31 into main
Some checks failed
C++ Tests / test-fast (push) Failing after 2m11s
C++ Tests / quality-gates (push) Has been skipped
API Docs / doc-build (push) Has been skipped
Markdown link check / check (push) Has been skipped
Mirror to Codeberg / mirror (push) Successful in 32s
C++ Tests / test-cgal (push) Has been skipped

This commit is contained in:
2026-05-31 08:32:21 +00:00
16 changed files with 1147 additions and 60 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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`.

View File

@@ -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 19a 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, GaussBonnet, 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 19b 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, GaussBonnet, 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 515× 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<double> x0(idx, 0.0);
std::vector<double> x0(static_cast<std::size_t>(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]];

View File

@@ -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
# <CGAL/Discrete_conformal_map.h>; 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)

View File

@@ -0,0 +1,171 @@
// example_cgal_api.cpp
//
// conformallab++ — CGAL public API example
//
// This example demonstrates the HIGH-LEVEL CGAL API defined in
// <CGAL/Discrete_conformal_map.h>. 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<FT> (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 <CGAL/Simple_cartesian.h>
#include <CGAL/Surface_mesh.h>
#include <CGAL/Discrete_conformal_map.h> // CGAL public API
#include <CGAL/Conformal_layout.h> // 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 <iostream>
#include <iomanip>
#include <string>
#include <algorithm>
int main(int argc, char* argv[])
{
using Kernel = CGAL::Simple_cartesian<double>;
using Mesh = CGAL::Surface_mesh<Kernel::Point_3>;
// ── 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<std::size_t>(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<std::size_t>(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<Mesh::Vertex_index, double>(
// "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 <CGAL/Discrete_conformal_map.h> 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;
}

View File

@@ -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 01 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<double> x0(static_cast<std::size_t>(n), 0.0);
auto G0 = euclidean_gradient(mesh, x0, maps);

View File

@@ -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 GaussBonnet 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 <CGAL/boost/graph/iterator.h>
#include <iostream>
#include <iomanip>
#include <string>
#include <vector>
#include <cmath>
#include <algorithm>
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<int>(mesh.number_of_vertices());
const int F = static_cast<int>(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 GaussBonnet 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<double> x0(static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(v.idx()) < layout.uv.size()) {
const auto& p = layout.uv[static_cast<std::size_t>(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;
}

View File

@@ -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<double> x0(static_cast<std::size_t>(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<std::size_t>(v.idx())];
std::cout << " v" << v.idx()
<< ": (" << std::fixed << std::setprecision(6)
<< p.x() << ", " << p.y() << ")\n";

View File

@@ -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:
- `<CGAL/Discrete_circle_packing.h>` — `discrete_circle_packing_euclidean` (CP-Euclidean)
- `<CGAL/Discrete_inversive_distance.h>` — `discrete_inversive_distance_map` (Luo 2004)
\section Example Simplest usage

View File

@@ -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<Eigen::Vector2d> 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<Eigen::Vector2d> halfedge_uv;
bool success = false; ///< `true` iff the BFS placed every vertex.

View File

@@ -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.
---
## GaussBonnet — the most common source of failure
## GaussBonnet — the most common source of failure for Euclidean and Spherical
Prescribing angles that violate GaussBonnet 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 GaussBonnet 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 GaussBonnet check (the identity does not hold for meshes with boundary).

View File

@@ -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:**
```

View File

@@ -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 100121 ("Minimal usage")
- `code/examples/example_euclidean.cpp` lines 7283 (Step 4)
- `code/examples/example_layout.cpp` lines 4049 (`set_natural_theta`)
- `code/examples/example_hyper_ideal.cpp` lines 8998 (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<double>`
- CGAL: `Conformal_map_result.u_per_vertex` — vertex-index-indexed `std::vector<FT>`
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 <CGAL/Simple_cartesian.h>
#include <CGAL/Surface_mesh.h>
#include <CGAL/Discrete_conformal_map.h>
// ...
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 δ = (lhsrhs)/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 19a 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 19b complete. Newton solvers for five DCE
models (Euclidean / Spherical / HyperIdeal / CP-Euclidean / Inversive-Distance),
priority-BFS layout in ℝ²/S²/Poincaré disk, GaussBonnet, 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 1416
### 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 107110
- `code/examples/example_euclidean.cpp` lines 6268
- `code/examples/example_layout.cpp` lines 5259 (`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 5382 ("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<Eigen::Vector2d> 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<Eigen::Vector2d> 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 333340 (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 <double>` and `--max-iter <int>`:** 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)

View File

@@ -287,6 +287,37 @@ mesh type.
no new library surface). Effort: small (~23 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 (~24 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: ~24 h. Requires: 9a complete ✅.
Java reference: none (both functionals are research / literature ports).
```
---
## ◼ New research directions — Phases 10d10g (2026 library scan)