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): # Trigger keywords in commit message (checked via head_commit.message):
# /test-cgal — full CGAL test suite (277 tests, ~5 min build) # /test-cgal — full CGAL test suite (277 tests, ~5 min build)
# /quality-gates — license, codespell, shellcheck, CGAL conventions # /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: # Examples:
# git commit -m "fix: correct angle formula /test-cgal" # 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. # test-fast always runs on every push — it is fast (< 5 s) and cheap.
@@ -31,6 +33,7 @@ jobs:
runs-on: eulernest runs-on: eulernest
container: container:
image: git.eulernest.eu/conformallab/ci-cpp:latest image: git.eulernest.eu/conformallab/ci-cpp:latest
options: "--memory=800m --memory-swap=1200m"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -76,9 +79,7 @@ jobs:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
test-cgal: test-cgal:
needs: test-fast needs: test-fast
if: | if: contains(github.event.head_commit.message, '/test-cgal')
contains(github.event.head_commit.message, '/test-cgal') ||
contains(github.event.head_commit.message, '/ci-all')
runs-on: eulernest runs-on: eulernest
container: container:
image: git.eulernest.eu/conformallab/ci-cpp:latest image: git.eulernest.eu/conformallab/ci-cpp:latest
@@ -133,12 +134,11 @@ jobs:
# Cheap (~30 s): license headers, CGAL conventions, codespell, shellcheck. # Cheap (~30 s): license headers, CGAL conventions, codespell, shellcheck.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
quality-gates: quality-gates:
if: | if: contains(github.event.head_commit.message, '/quality-gates')
contains(github.event.head_commit.message, '/quality-gates') ||
contains(github.event.head_commit.message, '/ci-all')
runs-on: eulernest runs-on: eulernest
container: container:
image: git.eulernest.eu/conformallab/ci-cpp:latest image: git.eulernest.eu/conformallab/ci-cpp:latest
options: "--memory=600m --memory-swap=900m"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -27,11 +27,11 @@ jobs:
doc-build: doc-build:
if: | if: |
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, '/docs') || contains(github.event.head_commit.message, '/docs')
contains(github.event.head_commit.message, '/ci-all')
runs-on: eulernest runs-on: eulernest
container: container:
image: git.eulernest.eu/conformallab/ci-cpp:latest image: git.eulernest.eu/conformallab/ci-cpp:latest
options: "--memory=800m --memory-swap=1200m"
continue-on-error: true # never block the merge continue-on-error: true # never block the merge
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -27,11 +27,11 @@ jobs:
if: | if: |
github.event_name == 'schedule' || github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
contains(github.event.head_commit.message, '/links') || contains(github.event.head_commit.message, '/links')
contains(github.event.head_commit.message, '/ci-all')
runs-on: eulernest runs-on: eulernest
container: container:
image: git.eulernest.eu/conformallab/ci-cpp:latest image: git.eulernest.eu/conformallab/ci-cpp:latest
options: "--memory=400m --memory-swap=600m"
steps: steps:
- uses: actions/checkout@v4 - 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** | | `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** | | `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`). 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`. `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 · > DOI: [10.14279/depositonce-5415](https://depositonce.tu-berlin.de/items/8e2988b2-d991-45b5-aad5-9fb7988f3b2f) · CC BY-SA 4.0 ·
> [Java original](https://github.com/varylab/conformallab) · [sechel.de](https://sechel.de/) > [Java original](https://github.com/varylab/conformallab) · [sechel.de](https://sechel.de/)
**Status:** v0.9.0 — Phases 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. # 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 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 # Pristine measurement: disable both performance levers, e.g. for
# scripts/quality/coverage.sh that needs every TU compiled fresh. # scripts/quality/coverage.sh that needs every TU compiled fresh.
cmake -S code -B build -DWITH_CGAL_TESTS=ON \ 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); EuclideanMaps maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps); compute_euclidean_lambda0_from_mesh(mesh, maps);
// Assign DOFs — pin first vertex (gauge fix) // Assign DOFs — pin first vertex as gauge fix, index the rest 0..n-1
auto vit = mesh.vertices().begin(); auto gauge = *mesh.vertices().begin();
maps.v_idx[*vit++] = -1; int n = assign_euclidean_vertex_dof_indices(mesh, maps, gauge);
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++;
// Natural equilibrium target: x* = 0 by construction // 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); auto G0 = euclidean_gradient(mesh, x0, maps);
for (auto v : mesh.vertices()) for (auto v : mesh.vertices())
if (maps.v_idx[v] >= 0) maps.theta_v[v] -= G0[maps.v_idx[v]]; 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_include_directories(example_layout PRIVATE ${EXAMPLE_PRIVATE_INCLUDES})
target_compile_definitions(example_layout PRIVATE ${EXAMPLE_DEFS}) 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) ───────────────────────────────────── # ── example_viewer (requires WITH_VIEWER) ─────────────────────────────────────
if(WITH_VIEWER) if(WITH_VIEWER)
add_executable(example_viewer example_viewer.cpp) 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); compute_euclidean_lambda0_from_mesh(mesh, maps);
// ── Step 3: pin the first vertex (gauge fix) ────────────────────────── // ── Step 3: pin the first vertex (gauge fix) ──────────────────────────
auto vit = mesh.vertices().begin(); // The gauge-vertex overload pins the chosen vertex (v_idx = -1) and
Vertex_index v_pinned = *vit++; // assigns sequential indices 0..n-1 to the rest in a single call.
maps.v_idx[v_pinned] = -1; // pinned: u[v_pinned] = 0 (fixed) Vertex_index v_pinned = *mesh.vertices().begin();
int idx = 0; const int n = assign_euclidean_vertex_dof_indices(mesh, maps, v_pinned);
for (; vit != mesh.vertices().end(); ++vit)
maps.v_idx[*vit] = idx++;
const int n = idx;
std::cout << "[example_euclidean] DOFs: " << n << " (1 vertex pinned).\n"; std::cout << "[example_euclidean] DOFs: " << n << " (1 vertex pinned).\n";
// ── Step 4: natural equilibrium — set theta_v = actual angle sum at x=0 ─ // ── 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π // TESTING CONVENTION — NOT A REAL CONFORMAL MAP
// for flat disks, or the cone angles for a cone metric). //
// "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); std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G0 = euclidean_gradient(mesh, x0, maps); 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; using namespace conformallab;
// ── Helper: natural target angles so x* = 0 is the equilibrium ─────────────── // ── 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) static void set_natural_theta(ConformalMesh& mesh, EuclideanMaps& maps, int n)
{ {
std::vector<double> x0(static_cast<std::size_t>(n), 0.0); 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 ────────────── // ── 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) static int pin_first(ConformalMesh& mesh, EuclideanMaps& maps)
{ {
auto vit = mesh.vertices().begin(); return assign_euclidean_vertex_dof_indices(
maps.v_idx[*vit++] = -1; // pinned mesh, maps, *mesh.vertices().begin());
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit)
maps.v_idx[*vit] = idx++;
return idx;
} }
// ── Main ───────────────────────────────────────────────────────────────────── // ── Main ─────────────────────────────────────────────────────────────────────
@@ -124,7 +128,9 @@ int main(int argc, char* argv[])
if (layout.success) { if (layout.success) {
std::cout << "UV coordinates:\n"; std::cout << "UV coordinates:\n";
for (auto v : mesh.vertices()) { 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::cout << " v" << v.idx()
<< ": (" << std::fixed << std::setprecision(6) << ": (" << std::fixed << std::setprecision(6)
<< p.x() << ", " << p.y() << ")\n"; << p.x() << ", " << p.y() << ")\n";

View File

@@ -7,13 +7,17 @@
\file CGAL/Discrete_conformal_map.h \file CGAL/Discrete_conformal_map.h
\ingroup PkgConformalMapRef \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` This header provides three functions covering all three DCE geometries:
— that computes a Euclidean discrete-conformal flattening of an open or
closed triangle mesh. Spherical and hyperbolic variants are scheduled - `CGAL::discrete_conformal_map_euclidean` — flat conformal map (ℝ²), open or closed mesh
for Phase 8b.2 once the Euclidean pattern is validated by Phase 9a - `CGAL::discrete_conformal_map_spherical` — spherical uniformisation (S²), genus-0 mesh
(Inversive-Distance functional). - `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 \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 /// per-vertex UV coordinates plus a per-half-edge UV atlas for seamed
/// textures. /// textures.
struct Layout2D { 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; 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()]. /// **Indexing:** `halfedge_uv[h.idx()]` — raw integer halfedge index.
/// For seam halfedges: carries the UV from the virtual unfolding across /// **Length:** `mesh.number_of_halfedges()`. Same no-compaction precondition
/// the cut (i.e. the trilaterated position that was NOT used as the /// as `uv` (see above).
/// primary uv). This gives each face its own copy of a seam vertex,
/// enabling a proper GPU texture atlas without vertex duplication.
/// ///
/// 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; std::vector<Eigen::Vector2d> halfedge_uv;
bool success = false; ///< `true` iff the BFS placed every vertex. 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` | | `load_mesh()` | Valid file path, supported format (OFF/OBJ/PLY) | Manifold, oriented, triangulated `ConformalMesh` |
| `setup_*_maps()` | Triangulated mesh | Initialised property maps; `lambda0` zeroed | | `setup_*_maps()` | Triangulated mesh | Initialised property maps; `lambda0` zeroed |
| `compute_*_lambda0_from_mesh()` | `setup_*_maps()` called | `lambda0[e]` set from 3-D edge lengths | | `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) | | `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 | Σ(2πΘᵥ) = 2π·χ(M) guaranteed; `theta_v` modified | | `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_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_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` | | `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` | | `euclidean_layout()` | `newton_euclidean()` converged | `Layout2D.uv[v]` · `.halfedge_uv[h]` · `HolonomyData.translations` |
| `spherical_layout()` | `newton_spherical()` converged | `Layout3D.xyz[v]` | | `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 Prescribing angles that violate GaussBonnet means no conformal factor can realise the
target metric — the Newton solver will iterate indefinitely without converging. target metric — the Newton solver will iterate indefinitely without converging.
```cpp ```cpp
// Option A: verify before solving (throws on violation) // Option A: verify before solving throws std::runtime_error on violation.
check_gauss_bonnet(mesh, maps); // 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) // Option B: auto-correct redistributes the defect uniformly across all vertices.
enforce_gauss_bonnet(mesh, maps); 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`. 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 > **Note:** `-DWITH_CGAL=ON` implies `-DWITH_VIEWER=ON`. Do not use this in headless
> environments — it will fail with `Failed to find wayland-scanner`. > 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 ## Running a single test
@@ -114,16 +129,53 @@ After a full build (`-DWITH_CGAL=ON`):
./bin/conformallab_core --help ./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 ## Example programs
```bash ```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] ./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] ./build/examples/example_euclidean [input.off] [output.off]
# Hyper-ideal (hyperbolic) functional
./build/examples/example_hyper_ideal [input.off] [output.off] ./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:** **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). 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) ## ◼ New research directions — Phases 10d10g (2026 library scan)