Merge pull request 'P1: Quick wins (CLI + quality measures + stereographic)' (#44) from feat/p1-quick-wins into main
All checks were successful
C++ Tests / test-fast (push) Successful in 2m25s
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 38s
C++ Tests / test-cgal (push) Has been skipped

This commit is contained in:
2026-06-01 06:59:28 +00:00
19 changed files with 1950 additions and 31 deletions

View File

@@ -1,5 +1,9 @@
{ {
"$schema": "https://json.schemastore.org/claude-code-settings.json", "$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
},
"teammateMode": "tmux",
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(cmake:*)", "Bash(cmake:*)",

View File

@@ -375,9 +375,12 @@ Full line-by-line audit of all math-critical headers against `de.varylab.discret
| Question | Document | | Question | Document |
|---|---| |---|---|
| Phases 110 with status and sub-tasks | `doc/roadmap/phases.md` | | Phases 113 with status, effort, and sub-tasks | `doc/roadmap/phases.md` |
| Operational truth of which Java maths is in C++ today | `doc/roadmap/porting-status.md` |
| Which Java classes are ported, which are planned, which are skipped? | `doc/roadmap/java-parity.md` | | Which Java classes are ported, which are planned, which are skipped? | `doc/roadmap/java-parity.md` |
| New research items (beyond Java) — citations, acceptance criteria | `doc/roadmap/research-track.md` | | New research items (beyond Java) — citations, acceptance criteria | `doc/roadmap/research-track.md` |
| Phase orchestration — model assignments, priority, phase-session mapping | `doc/roadmap/phase-orchestration.md` |
| Ready-to-paste session prompts for upcoming phases (P1P4) | `doc/roadmap/session-prompts.md` |
### Tutorials & onboarding ### Tutorials & onboarding
@@ -397,6 +400,8 @@ Full line-by-line audit of all math-critical headers against `de.varylab.discret
| Internal meeting agenda | `doc/reviewer/agenda.md` | | Internal meeting agenda | `doc/reviewer/agenda.md` |
| Reviewer landing index | `doc/reviewer/README.md` | | Reviewer landing index | `doc/reviewer/README.md` |
| Hand-curated reviewer landing page (HTML, source-of-truth for codeberg pages) | `doc/reviewer/hub.html` | | Hand-curated reviewer landing page (HTML, source-of-truth for codeberg pages) | `doc/reviewer/hub.html` |
| Audit orchestration — model assignments, session sequence, status tracker | `doc/reviewer/finding-orchestration.md` |
| Ready-to-paste session prompts for pending audit sessions (S3S6) | `doc/reviewer/session-prompts.md` |
The published hub lives at https://tmoussa.codeberg.page/ConformalLabpp/ (Doxygen index at `/doxygen.html`). See the "Codeberg pages" quirk below for how it is republished. The published hub lives at https://tmoussa.codeberg.page/ConformalLabpp/ (Doxygen index at `/doxygen.html`). See the "Codeberg pages" quirk below for how it is republished.
@@ -419,6 +424,8 @@ Recommended loops when working in this repo. Prefer the cheapest gate that catch
- **Before any commit**: run the four required gates locally — they mirror CI exactly and are seconds-cheap: `bash scripts/quality/license-headers.sh`, `python3 scripts/quality/cgal-conventions.py`, `bash scripts/quality/codespell.sh`, `bash scripts/quality/shellcheck.sh --strict`. - **Before any commit**: run the four required gates locally — they mirror CI exactly and are seconds-cheap: `bash scripts/quality/license-headers.sh`, `python3 scripts/quality/cgal-conventions.py`, `bash scripts/quality/codespell.sh`, `bash scripts/quality/shellcheck.sh --strict`.
- **Before tagging a release**: also run the two now-un-gated structural gates (test-cgal is disabled in CI): `BUILD_DIR=build bash scripts/check-test-counts.sh` and `bash scripts/try_it.sh`. Update `CHANGELOG.md`, `CITATION.cff`, and the `doc/api/tests.md` counts (single source of truth). - **Before tagging a release**: also run the two now-un-gated structural gates (test-cgal is disabled in CI): `BUILD_DIR=build bash scripts/check-test-counts.sh` and `bash scripts/try_it.sh`. Update `CHANGELOG.md`, `CITATION.cff`, and the `doc/api/tests.md` counts (single source of truth).
- **Touching public-API headers**: rebuild Doxygen (`cmake --build build --target doc`) and re-check coverage (`bash scripts/doxygen-coverage.sh --threshold 100`); regenerate `doc/api/headers.md` via `python3 scripts/gen-headers-md.py` (or `bash scripts/regen-docs.sh`). - **Touching public-API headers**: rebuild Doxygen (`cmake --build build --target doc`) and re-check coverage (`bash scripts/doxygen-coverage.sh --threshold 100`); regenerate `doc/api/headers.md` via `python3 scripts/gen-headers-md.py` (or `bash scripts/regen-docs.sh`).
- **Starting work on an audit finding**: open `doc/reviewer/finding-orchestration.md`, pick the next ⬜ pending session, copy its prompt from `doc/reviewer/session-prompts.md`, set the named model, and go. **S3 is next** (H3/H4/H5/V5/V6, Sonnet → Opus review).
- **Starting work on a roadmap phase**: open `doc/roadmap/phase-orchestration.md`, pick the next ⬜ pending phase-session, copy its prompt from `doc/roadmap/session-prompts.md`, set the named model, and go. **P1 is next** (9g.1 + 9h.1 + 9h.2 + 9d.3, Haiku → Opus review).
- **Landing to `main`** (origin is protected): branch → push to `origin` → open PR via `gh`/Gitea API → merge via API → also push `codeberg/main` directly → keep both remotes in sync. - **Landing to `main`** (origin is protected): branch → push to `origin` → open PR via `gh`/Gitea API → merge via API → also push `codeberg/main` directly → keep both remotes in sync.
- **Republishing the reviewer hub**: see the Codeberg `pages` quirk below — manual force-push of an orphan branch; verify the live URL with a cache-bust query. - **Republishing the reviewer hub**: see the Codeberg `pages` quirk below — manual force-push of an orphan branch; verify the live URL with a cache-bust query.
- **Delegation**: this repo's heavy builds are slow on the ARM64 runner — when a task is genuinely parallelisable and independent, consider a background agent; otherwise handle inline. Always verify an agent's actual diff, not just its summary. - **Delegation**: this repo's heavy builds are slow on the ARM64 runner — when a task is genuinely parallelisable and independent, consider a background agent; otherwise handle inline. Always verify an agent's actual diff, not just its summary.

View File

@@ -0,0 +1,384 @@
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// conformal_quality.hpp
//
// Phase 9g.1 — Quantitative correctness metrics for computed conformal maps.
//
// Measures the quality and validity of a discrete conformal map layout:
// - IsothermicityMeasure: pointwise deviation from conformality (metric anisotropy).
// - DiscreteConformalEquivalenceMeasure: per-edge length-cross-ratio residual.
// - FlippedTriangles: detects inverted/degenerate triangles in 2-D layouts.
// - LengthCrossRatio: the discrete conformal invariant (per-edge).
// - ConvergenceUtility: aggregated convergence measures (max, mean, sum of cross-ratios).
//
// Mathematical references:
// Springborn-Schröder-Pinkall 2008: discrete conformal invariant theory.
// Bobenko-Springborn 2004: variational foundation.
//
// Java sources (ported from):
// plugin/visualizer/IsothermicityMeasure.java
// plugin/visualizer/DiscreteConformalEquivalencemMeasure.java
// plugin/visualizer/FlippedTriangles.java
// heds/adapter/types/LengthCrossRatio.java
// convergence/ConvergenceUtility.java
#pragma once
#include "conformal_mesh.hpp"
#include "layout.hpp"
#include <Eigen/Dense>
#include <vector>
#include <cmath>
#include <algorithm>
namespace conformallab {
// ────────────────────────────────────────────────────────────────────────────
// LengthCrossRatio — the discrete conformal invariant
// ────────────────────────────────────────────────────────────────────────────
/// Compute the cross-ratio q = (a·c)/(b·d) of the four edges of a
/// quadrilateral formed by two adjacent triangles sharing an edge.
/// Input: edge lengths a, b, c, d in order around the quad.
/// Returns the cross-ratio q.
inline double length_cross_ratio(double a, double b, double c, double d)
{
const double denom = b * d;
if (denom < 1e-16) return 0.0; // degenerate edge
return (a * c) / denom;
}
// ────────────────────────────────────────────────────────────────────────────
// IsothermicityMeasure — pointwise metric anisotropy
// ────────────────────────────────────────────────────────────────────────────
/// Evaluate the isothermicity measure at a single vertex in a 2-D layout.
/// Isothermicity is the local conformality condition: the metric tensor
/// is a positive scalar multiple of the identity (no anisotropy).
/// Measure: pointwise deviation from a conformal map.
/// Returns the anisotropy ratio (1.0 = isotropic / conformal).
inline double isothermicity_measure_at_vertex(
const ConformalMesh& mesh,
Vertex_index v,
const Layout2D& layout)
{
// Collect all halfedges emanating from v.
std::vector<Halfedge_index> hs;
for (auto h : CGAL::halfedges_around_source(v, mesh))
hs.push_back(h);
if (hs.empty()) return 1.0;
// Compute metric tensor components at v via edge pairs.
// For a conformal map, the metric g = λ²I (λ > 0 scale factor, I identity).
// Compute an empirical metric from the layout: edges adjacent to v
// span the tangent space.
double g11 = 0.0, g12 = 0.0, g22 = 0.0;
int n_edges = 0;
for (std::size_t i = 0; i < hs.size(); ++i) {
auto h1 = hs[i];
auto h2 = hs[(i + 1) % hs.size()];
Vertex_index v2 = mesh.target(h1); // = mesh.source(h2)
Vertex_index v3 = mesh.target(h2);
const auto& p1 = layout.uv[v.idx()];
const auto& p2 = layout.uv[v2.idx()];
const auto& p3 = layout.uv[v3.idx()];
// Two edge vectors from v.
double e1x = p2.x() - p1.x(), e1y = p2.y() - p1.y();
double e2x = p3.x() - p1.x(), e2y = p3.y() - p1.y();
// Metric tensor as outer product (unnormalised).
g11 += e1x * e1x;
g12 += e1x * e1y;
g22 += e1y * e1y;
// Also accumulate e2 contribution (for a rotationally averaged metric).
g11 += e2x * e2x;
g12 += e2x * e2y;
g22 += e2y * e2y;
n_edges += 2;
}
if (n_edges <= 0) return 1.0;
g11 /= n_edges;
g12 /= n_edges;
g22 /= n_edges;
// Eigenvalues of g: λ_± = (g11 + g22 ± √((g11-g22)² + 4g12²)) / 2.
double trace = g11 + g22;
double det = g11 * g22 - g12 * g12;
if (trace < 1e-16 || det < 1e-16) return 1.0; // degenerate
double disc = (g11 - g22) * (g11 - g22) + 4.0 * g12 * g12;
disc = std::sqrt(disc);
double lambda_max = (trace + disc) / 2.0;
double lambda_min = (trace - disc) / 2.0;
if (lambda_min < 1e-16) return 1.0; // degenerate
// Anisotropy: λ_max / λ_min (conformal ⟺ ratio ≈ 1).
return lambda_max / lambda_min;
}
/// Compute the isothermicity measure for the entire layout.
/// Returns a vector of anisotropy ratios, one per vertex.
inline std::vector<double> isothermicity_measure(
const ConformalMesh& mesh,
const Layout2D& layout)
{
std::vector<double> result;
result.reserve(mesh.number_of_vertices());
for (auto v : mesh.vertices())
result.push_back(isothermicity_measure_at_vertex(mesh, v, layout));
return result;
}
// ────────────────────────────────────────────────────────────────────────────
// DiscreteConformalEquivalenceMeasure — length-cross-ratio residual
// ────────────────────────────────────────────────────────────────────────────
/// Evaluate the discrete conformal equivalence condition at a single edge.
/// For an edge e = (i,j), form the quad with the two adjacent triangles:
/// compute the cross-ratio q from the layout edge lengths.
/// The conformal condition is: q + 1/q = 2 (i.e. q = 1, isotropic scaling).
/// Measure: |q + 1/q - 2| (residual; 0 = conformal).
inline double discrete_conformal_equivalence_at_edge(
const ConformalMesh& mesh,
Edge_index e,
const Layout2D& layout)
{
// Find the two halfedges for this edge.
auto h = mesh.halfedge(e);
// Get the four vertices of the quad formed by the two adjacent triangles.
Vertex_index v1 = mesh.source(h);
Vertex_index v2 = mesh.target(h);
Vertex_index v3 = mesh.source(mesh.next(h));
Vertex_index v4 = mesh.source(mesh.next(mesh.opposite(h)));
// Compute edge lengths from the layout.
auto dist = [&layout](Vertex_index u1, Vertex_index u2) {
const auto& p1 = layout.uv[u1.idx()];
const auto& p2 = layout.uv[u2.idx()];
double dx = p1.x() - p2.x();
double dy = p1.y() - p2.y();
return std::sqrt(dx * dx + dy * dy);
};
double a = dist(v1, v3); // opposite to v4
double b = dist(v1, v4); // opposite to v3
double c = dist(v2, v3); // opposite to v4
double d = dist(v2, v4); // opposite to v3
// Cross-ratio q = (a·c)/(b·d).
double q = length_cross_ratio(a, b, c, d);
// Conformal condition: q + 1/q = 2 (only satisfied when q = 1).
if (q < 1e-16) return 1.0; // degenerate
double residual = q + 1.0 / q - 2.0;
return std::abs(residual);
}
/// Compute the discrete conformal equivalence measure for all edges.
/// Returns a vector of residuals, one per edge.
inline std::vector<double> discrete_conformal_equivalence_measure(
const ConformalMesh& mesh,
const Layout2D& layout)
{
std::vector<double> result;
result.reserve(mesh.number_of_edges());
for (auto e : mesh.edges())
result.push_back(discrete_conformal_equivalence_at_edge(mesh, e, layout));
return result;
}
// ────────────────────────────────────────────────────────────────────────────
// FlippedTriangles — embedded validity check
// ────────────────────────────────────────────────────────────────────────────
/// Check if a single triangle is flipped or degenerate in the 2-D layout.
/// A triangle is valid iff its signed area > 0 (positive orientation).
/// Degenerate: signed area ≈ 0 (collinear or nearly collinear vertices).
/// Returns true if the triangle is flipped or degenerate.
inline bool is_flipped_triangle(
const ConformalMesh& mesh,
Face_index f,
const Layout2D& layout)
{
// Extract the three vertices of the triangle.
auto h = mesh.halfedge(f);
Vertex_index v1 = mesh.source(h);
Vertex_index v2 = mesh.source(mesh.next(h));
Vertex_index v3 = mesh.source(mesh.next(mesh.next(h)));
const auto& p1 = layout.uv[v1.idx()];
const auto& p2 = layout.uv[v2.idx()];
const auto& p3 = layout.uv[v3.idx()];
// Signed area (× 2): (p2 - p1) × (p3 - p1) in ℝ².
double signed_area_2x = (p2.x() - p1.x()) * (p3.y() - p1.y())
- (p2.y() - p1.y()) * (p3.x() - p1.x());
// Positive area: valid orientation. Zero or negative: flipped/degenerate.
return signed_area_2x <= 1e-14;
}
/// Count the number of flipped or degenerate triangles in the layout.
/// Returns the count (0 = valid layout).
inline int flipped_triangles(
const ConformalMesh& mesh,
const Layout2D& layout)
{
int count = 0;
for (auto f : mesh.faces())
if (is_flipped_triangle(mesh, f, layout))
count++;
return count;
}
// ────────────────────────────────────────────────────────────────────────────
// ConvergenceUtility — aggregated convergence measures
// ────────────────────────────────────────────────────────────────────────────
/// Aggregated cross-ratio statistics for a layout.
struct CrossRatioStats {
double max_cross_ratio; ///< max of (q + 1/q) over all edges
double mean_cross_ratio; ///< mean of (q + 1/q)
double sum_cross_ratio; ///< sum of (q + 1/q)
double max_multi_ratio; ///< max per-face product of cross-ratios
double mean_multi_ratio; ///< mean per-face product
double sum_multi_ratio; ///< sum of per-face products
double max_scale_invariant_circumradius; ///< max of R/√A per face
double mean_scale_invariant_circumradius; ///< mean of R/√A
double sum_scale_invariant_circumradius; ///< sum of R/√A
};
/// Compute convergence statistics for a layout.
/// - Cross-ratio (q + 1/q) per edge; aggregated max/mean/sum.
/// - Multi-ratio: per-face product ∏(q + 1/q) for the 3 edges of each face.
/// (Multi-ratio = 1 iff all edges are conformal.)
/// - Scale-invariant circumradius: R/√A per face (mesh quality metric).
inline CrossRatioStats convergence_utility(
const ConformalMesh& mesh,
const Layout2D& layout)
{
CrossRatioStats stats = {};
std::vector<double> cross_ratios;
std::vector<double> multi_ratios;
std::vector<double> scale_inv_circumradii;
auto dist = [&layout](Vertex_index u1, Vertex_index u2) {
const auto& p1 = layout.uv[u1.idx()];
const auto& p2 = layout.uv[u2.idx()];
double dx = p1.x() - p2.x();
double dy = p1.y() - p2.y();
return std::sqrt(dx * dx + dy * dy);
};
// Per-face metrics.
for (auto f : mesh.faces()) {
auto h = mesh.halfedge(f);
Vertex_index v1 = mesh.source(h);
Vertex_index v2 = mesh.source(mesh.next(h));
Vertex_index v3 = mesh.source(mesh.next(mesh.next(h)));
const auto& p1 = layout.uv[v1.idx()];
const auto& p2 = layout.uv[v2.idx()];
const auto& p3 = layout.uv[v3.idx()];
// Signed area.
double signed_area_2x = (p2.x() - p1.x()) * (p3.y() - p1.y())
- (p2.y() - p1.y()) * (p3.x() - p1.x());
double area = std::abs(signed_area_2x) / 2.0;
if (area < 1e-16) continue; // degenerate
// Three edge lengths of the triangle.
double a = dist(v1, v2);
double b = dist(v2, v3);
double c = dist(v3, v1);
// Circumradius R = abc / (4·Area).
double circum_radius = (a * b * c) / (4.0 * area);
// Scale-invariant: R / √A.
double scale_inv_cr = circum_radius / std::sqrt(area);
scale_inv_circumradii.push_back(scale_inv_cr);
// Three cross-ratios (per edge/angle of the triangle).
// For each edge, form the quad with the opposite vertex and its neighbors.
double multi_product = 1.0;
for (int ei = 0; ei < 3; ++ei) {
auto he = mesh.halfedge(f);
for (int k = 0; k < ei; ++k) he = mesh.next(he);
Vertex_index eu1 = mesh.source(he);
Vertex_index eu2 = mesh.target(he);
Vertex_index eu3 = mesh.source(mesh.next(he));
Vertex_index eu4 = mesh.source(mesh.next(mesh.opposite(he)));
double ea = dist(eu1, eu3);
double eb = dist(eu1, eu4);
double ec = dist(eu2, eu3);
double ed = dist(eu2, eu4);
double q = length_cross_ratio(ea, eb, ec, ed);
if (q > 1e-16) {
double qf = q + 1.0 / q;
cross_ratios.push_back(qf);
multi_product *= qf;
}
}
multi_ratios.push_back(multi_product);
}
// Aggregate statistics.
if (!cross_ratios.empty()) {
auto [min_it, max_it] = std::minmax_element(cross_ratios.begin(), cross_ratios.end());
stats.max_cross_ratio = *max_it;
stats.mean_cross_ratio = 0.0;
for (double v : cross_ratios) stats.mean_cross_ratio += v;
stats.mean_cross_ratio /= static_cast<double>(cross_ratios.size());
stats.sum_cross_ratio = 0.0;
for (double v : cross_ratios) stats.sum_cross_ratio += v;
}
if (!multi_ratios.empty()) {
auto [min_it, max_it] = std::minmax_element(multi_ratios.begin(), multi_ratios.end());
stats.max_multi_ratio = *max_it;
stats.mean_multi_ratio = 0.0;
for (double v : multi_ratios) stats.mean_multi_ratio += v;
stats.mean_multi_ratio /= static_cast<double>(multi_ratios.size());
stats.sum_multi_ratio = 0.0;
for (double v : multi_ratios) stats.sum_multi_ratio += v;
}
if (!scale_inv_circumradii.empty()) {
auto [min_it, max_it] = std::minmax_element(scale_inv_circumradii.begin(),
scale_inv_circumradii.end());
stats.max_scale_invariant_circumradius = *max_it;
stats.mean_scale_invariant_circumradius = 0.0;
for (double v : scale_inv_circumradii)
stats.mean_scale_invariant_circumradius += v;
stats.mean_scale_invariant_circumradius /= static_cast<double>(scale_inv_circumradii.size());
stats.sum_scale_invariant_circumradius = 0.0;
for (double v : scale_inv_circumradii)
stats.sum_scale_invariant_circumradius += v;
}
return stats;
}
} // namespace conformallab

View File

@@ -44,7 +44,7 @@
// double gauss_bonnet_rhs(mesh) — 2π · χ(M) // double gauss_bonnet_rhs(mesh) — 2π · χ(M)
// double gauss_bonnet_deficit(mesh, maps) — lhs rhs (0 = satisfied) // double gauss_bonnet_deficit(mesh, maps) — lhs rhs (0 = satisfied)
// void check_gauss_bonnet(mesh, maps [, tol]) — throws if violated // void check_gauss_bonnet(mesh, maps [, tol]) — throws if violated
// void enforce_gauss_bonnet(mesh, maps) — shifts θ_v by uniform Δ // double enforce_gauss_bonnet(mesh, maps) — shifts θ_v by uniform Δ; returns |deficit|
// (HyperIdealMaps overloads are deleted — see box above) // (HyperIdealMaps overloads are deleted — see box above)
#include "conformal_mesh.hpp" #include "conformal_mesh.hpp"
@@ -162,11 +162,16 @@ inline void check_gauss_bonnet(const ConformalMesh& mesh,
// After this call, check_gauss_bonnet() will not throw (up to floating-point). // After this call, check_gauss_bonnet() will not throw (up to floating-point).
// Modifies ALL vertices' θ_v (no v_idx filtering) — the shift is a property // Modifies ALL vertices' θ_v (no v_idx filtering) — the shift is a property
// of the target angles, independent of which vertices are free DOFs. // of the target angles, independent of which vertices are free DOFs.
//
// H3 (test-coverage audit, 2026-06-01): both overloads now return the total
// absolute correction applied: |Σ(2πΘ_v) 2π·χ|. A large value signals
// that the input angles were far from satisfying GaussBonnet.
/// Distribute the Gauss-Bonnet deficit uniformly across all `Θ_v`: /// Distribute the Gauss-Bonnet deficit uniformly across all `Θ_v`:
/// add `δ = (lhs rhs) / V` to every entry so that the identity holds /// add `δ = (lhs rhs) / V` to every entry so that the identity holds
/// exactly afterwards. Overload for a raw property map. /// exactly afterwards. Overload for a raw property map.
inline void enforce_gauss_bonnet( /// Returns `|lhs rhs|` (total absolute correction applied).
inline double enforce_gauss_bonnet(
ConformalMesh& mesh, ConformalMesh& mesh,
ConformalMesh::Property_map<Vertex_index, double>& theta) ConformalMesh::Property_map<Vertex_index, double>& theta)
{ {
@@ -177,15 +182,17 @@ inline void enforce_gauss_bonnet(
double delta = (lhs - rhs) / static_cast<double>(mesh.number_of_vertices()); double delta = (lhs - rhs) / static_cast<double>(mesh.number_of_vertices());
for (auto v : mesh.vertices()) for (auto v : mesh.vertices())
theta[v] += delta; theta[v] += delta;
return std::abs(lhs - rhs);
} }
/// Distribute the Gauss-Bonnet deficit uniformly across `maps.theta_v`. /// Distribute the Gauss-Bonnet deficit uniformly across `maps.theta_v`.
/// Supported for EuclideanMaps and SphericalMaps only. /// Supported for EuclideanMaps and SphericalMaps only.
/// HyperIdealMaps overload is deleted — see header comment for why. /// HyperIdealMaps overload is deleted — see header comment for why.
/// Returns `|lhs rhs|` (total absolute correction applied; see raw-map overload).
template <typename Maps> template <typename Maps>
inline void enforce_gauss_bonnet(ConformalMesh& mesh, Maps& maps) inline double enforce_gauss_bonnet(ConformalMesh& mesh, Maps& maps)
{ {
enforce_gauss_bonnet(mesh, maps.theta_v); return enforce_gauss_bonnet(mesh, maps.theta_v);
} }
// enforce_gauss_bonnet for HyperIdealMaps is intentionally DELETED. // enforce_gauss_bonnet for HyperIdealMaps is intentionally DELETED.

View File

@@ -264,6 +264,27 @@ inline void save_result_xml(
/// Load a DOF vector from an XML result file written by /// Load a DOF vector from an XML result file written by
/// `save_result_xml`. If `res`, `geom`, `layout2d` are non-null they /// `save_result_xml`. If `res`, `geom`, `layout2d` are non-null they
/// are filled as well. /// are filled as well.
///
/// V5 (input-validation audit, 2026-06-01): this reader implements a
/// **strict internal-only XML subset** — not a general XML parser. It
/// expects the exact one-element-per-line layout written by
/// `save_result_xml`. Files that are semantically equivalent XML but
/// formatted differently (attributes split across lines, extra
/// whitespace, XML declaration on its own line, etc.) are explicitly
/// *rejected* with `std::runtime_error` rather than silently mis-read
/// into zeros. Interoperability with other XML producers is out of
/// scope; use the JSON format for that.
///
/// Strict-subset requirements that are validated:
/// 1. A line containing `<ConformalResult` must also carry a `geometry=`
/// attribute on the same line.
/// 2. A line containing `<Solver` must carry `iterations=` and
/// `grad_inf_norm=` on the same line (when `res` is non-null).
/// 3. A line containing `<DOFVector` must carry the `>` character (tag
/// open) on the same line.
/// 4. The `<DOFVector` element must be present and must produce a
/// non-empty doubles list (a missing DOFVector silently returns an
/// empty x, which is incorrect for any mesh with at least one DOF).
inline std::vector<double> load_result_xml( inline std::vector<double> load_result_xml(
const std::string& path, const std::string& path,
NewtonResult* res = nullptr, NewtonResult* res = nullptr,
@@ -275,13 +296,26 @@ inline std::vector<double> load_result_xml(
std::vector<double> x; std::vector<double> x;
std::string line; std::string line;
bool found_root = false;
bool found_dofvector = false;
while (std::getline(ifs, line)) { while (std::getline(ifs, line)) {
// Root element // Root element — V5: geometry attribute must be on the same line.
if (line.find("<ConformalResult") != std::string::npos) { if (line.find("<ConformalResult") != std::string::npos) {
if (geom) *geom = detail_xml::xml_get_attr(line, "geometry"); found_root = true;
// V5: reject if the required geometry= attribute is absent on this line.
// (Would be present if written by save_result_xml; absent if reformatted.)
std::string g = detail_xml::xml_get_attr(line, "geometry");
if (g.empty())
throw std::runtime_error(
"conformallab: XML strict-subset violation in " + path
+ ": <ConformalResult geometry=...> attribute not found on its"
" opening line. Only the format written by save_result_xml is"
" supported — reformatted XML is rejected to prevent silent"
" misreads. Use the JSON format for interoperability.");
if (geom) *geom = g;
} }
// Solver metadata // Solver metadata — V5: required attributes must be on the same line.
else if (line.find("<Solver") != std::string::npos) { else if (line.find("<Solver") != std::string::npos) {
if (res) { if (res) {
res->converged = (detail_xml::xml_get_attr(line, "converged") == "true"); res->converged = (detail_xml::xml_get_attr(line, "converged") == "true");
@@ -303,10 +337,17 @@ inline std::vector<double> load_result_xml(
} }
} }
} }
// DOF vector // DOF vector — V5: the '>' tag-open must be on the same line.
else if (line.find("<DOFVector") != std::string::npos) { else if (line.find("<DOFVector") != std::string::npos) {
// Text may be on same line: <DOFVector n="...">0 1 2...</DOFVector> found_dofvector = true;
// V5: require the tag to be closed ('>') on the same line so the
// content-extraction below works correctly.
auto open_end = line.find('>'); auto open_end = line.find('>');
if (open_end == std::string::npos)
throw std::runtime_error(
"conformallab: XML strict-subset violation in " + path
+ ": <DOFVector> opening '>' not on same line as tag."
" Only the format written by save_result_xml is supported.");
auto close = line.find("</DOFVector>"); auto close = line.find("</DOFVector>");
std::string text; std::string text;
if (close != std::string::npos) { if (close != std::string::npos) {
@@ -330,7 +371,46 @@ inline std::vector<double> load_result_xml(
layout2d->success = true; layout2d->success = true;
} }
} }
// V5: if the file was non-empty but never produced a <ConformalResult> root
// element, the file is likely reformatted or not a ConformalResult XML at all.
if (!found_root) {
// Distinguish "empty file" (ifs.peek() == EOF at open) from wrong format.
// We re-open to check file size — if it had content but no root element
// was found on a single line, it was reformatted.
std::ifstream probe(path, std::ios::ate);
if (probe && probe.tellg() > 0)
throw std::runtime_error(
"conformallab: XML strict-subset violation in " + path
+ ": <ConformalResult> root element not found on its own line."
" Only the format written by save_result_xml is supported.");
}
return x; return x;
} }
/// Validate that a loaded DOF vector has the expected number of DOFs.
///
/// V6 (input-validation audit, 2026-06-01): a result file from a *different*
/// mesh loads happily; the size mismatch only surfaces later (out-of-bounds
/// or wrong-answer) when `x` is indexed against the new mesh. This helper
/// provides a clear early check at the call-site where the loaded vector is
/// paired with the mesh.
///
/// Throws `std::runtime_error` if `x.size() != expected_dofs`.
inline void check_dof_vector_size(
const std::vector<double>& x,
int expected_dofs,
const std::string& context = "")
{
if (static_cast<int>(x.size()) != expected_dofs) {
std::ostringstream msg;
msg << "conformallab: DOF-vector size mismatch";
if (!context.empty()) msg << " in " << context;
msg << ": loaded " << x.size()
<< " values but mesh has " << expected_dofs << " DOFs.";
throw std::runtime_error(msg.str());
}
}
} // namespace conformallab } // namespace conformallab

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// stereographic_layout.hpp
//
// Phase 9d.3 — Stereographic projection for spherical DCE output.
//
// Converts a spherical layout (points on S²) to a 2-D conformal map via:
// 1. Stereographic projection: S² → {∞}, mapping the sphere to the complex plane.
// 2. Möbius centring: centres the resulting point cloud for canonical position.
//
// Mathematical reference:
// Stereographic projection from the north pole (0,0,1):
// (x,y,z) ↦ (x/(1-z), y/(1-z)) in (complex coordinate u+iv).
// North pole (0,0,1) maps to ∞ (removed from the layout).
// South pole (0,0,-1) maps to (0,0) in .
// The projection is conformal (angle-preserving).
//
// Möbius centring: apply a Möbius transformation to centre the layout
// (e.g. shift the centroid to the origin, possibly scale/rotate).
//
// Java source (ported from):
// unwrapper/StereographicUnwrapper.java (266 lines)
// The supporting math/CP1 + ComplexUtility.stereographic operations
// (deliberately NOT ported — redundant with std::complex).
#pragma once
#include "conformal_mesh.hpp"
#include "layout.hpp"
#include <complex>
#include <vector>
#include <cmath>
#include <array>
namespace conformallab {
// ────────────────────────────────────────────────────────────────────────────
// Stereographic Projection: S² →
// ────────────────────────────────────────────────────────────────────────────
/// Stereographic projection from the north pole (0, 0, 1).
/// Maps a point on the unit sphere S² to the complex plane .
/// North pole (0,0,1) projects to ∞ (not representable; returns NaN).
/// South pole (0,0,-1) projects to 0+0i.
///
/// Formula: (x,y,z) ↦ x/(1-z) + i·y/(1-z)
inline std::complex<double> stereographic_project(double x, double y, double z)
{
const double denom = 1.0 - z;
if (std::abs(denom) < 1e-15) {
// North pole (z ≈ 1) — maps to ∞.
// Return NaN to signal infinity.
return std::complex<double>(std::nan(""), std::nan(""));
}
return std::complex<double>(x / denom, y / denom);
}
/// Stereographic projection of a 3-D point (as Point3).
inline std::complex<double> stereographic_project(const Point3& p)
{
return stereographic_project(p.x(), p.y(), p.z());
}
// ────────────────────────────────────────────────────────────────────────────
// Möbius Centring
// ────────────────────────────────────────────────────────────────────────────
/// Simple centring: translate the point cloud so that its centroid
/// is at the origin (u+iv = 0).
inline void centre_at_origin(std::vector<std::complex<double>>& points)
{
if (points.empty()) return;
// Compute centroid.
std::complex<double> centroid(0.0, 0.0);
int n_valid = 0;
for (const auto& z : points) {
if (std::isfinite(z.real()) && std::isfinite(z.imag())) {
centroid += z;
n_valid++;
}
}
if (n_valid <= 0) return;
centroid /= static_cast<double>(n_valid);
// Translate: z' = z - centroid.
for (auto& z : points) {
if (std::isfinite(z.real()) && std::isfinite(z.imag())) {
z -= centroid;
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// Stereographic Layout: S² → (2-D)
// ────────────────────────────────────────────────────────────────────────────
/// Convert a spherical layout (3-D points on S²) to a 2-D conformal map
/// via stereographic projection.
///
/// Output: a Layout2D where:
/// - uv[v.idx()] = (Re, Im) of the stereographic projection of the 3-D point.
/// - The north pole is excluded (uv[v] = NaN for projections at ∞).
///
/// Möbius centring: the resulting layout is centred at the origin.
///
/// \param mesh Input surface mesh.
/// \param layout Input spherical layout (3-D points on S²).
/// \return Output Layout2D in the complex plane ().
inline Layout2D stereographic_layout(
const ConformalMesh& mesh,
const Layout3D& layout)
{
Layout2D result;
result.uv.resize(mesh.number_of_vertices());
result.halfedge_uv.resize(mesh.number_of_halfedges());
// Step 1: Stereographic projection for each vertex.
std::vector<std::complex<double>> complex_points;
complex_points.reserve(mesh.number_of_vertices());
for (auto v : mesh.vertices()) {
const auto& p3d = layout.pos[v.idx()];
// Convert Eigen::Vector3d to Point3-like coordinates.
double x = p3d[0], y = p3d[1], z = p3d[2];
auto z_complex = stereographic_project(x, y, z);
complex_points.push_back(z_complex);
// Store as Eigen::Vector2d (Re, Im).
result.uv[v.idx()] = Eigen::Vector2d(z_complex.real(), z_complex.imag());
}
// Step 2: Möbius centring.
centre_at_origin(complex_points);
// Update uv after centring.
for (auto v : mesh.vertices()) {
const auto& z = complex_points[v.idx()];
result.uv[v.idx()] = Eigen::Vector2d(z.real(), z.imag());
}
// Step 3: Halfedge UV (for texture atlasing).
// Copy the primary vertex UV to each halfedge's source.
for (auto h : mesh.halfedges()) {
Vertex_index src = mesh.source(h);
result.halfedge_uv[h.idx()] = result.uv[src.idx()];
}
return result;
}
// ────────────────────────────────────────────────────────────────────────────
// Inverse Stereographic Projection: → S²
// ────────────────────────────────────────────────────────────────────────────
/// Inverse stereographic projection: → S².
/// Given a complex number z = u + iv, recover the 3-D point on the unit sphere.
///
/// Formula: (u,v) ↦ (2u/(1+u²+v²), 2v/(1+u²+v²), (u²+v²-1)/(u²+v²+1))
/// Inverse of: (x,y,z) ↦ (x/(1-z), y/(1-z)).
///
/// The origin (u,v) = (0,0) maps back to (0,0,-1) (south pole).
inline Point3 inverse_stereographic_project(std::complex<double> z)
{
double u = z.real();
double v = z.imag();
double u2_plus_v2 = u * u + v * v;
double denom = 1.0 + u2_plus_v2;
double x = 2.0 * u / denom;
double y = 2.0 * v / denom;
double zz = (u2_plus_v2 - 1.0) / denom;
return Point3(x, y, zz);
}
/// Inverse stereographic projection from a 2-D layout point.
inline Point3 inverse_stereographic_project(const Eigen::Vector2d& uv)
{
return inverse_stereographic_project(std::complex<double>(uv.x(), uv.y()));
}
/// Round-trip validation: project a 3-D point to 2-D and back.
/// Returns the error (distance on S²) between the original and recovered point.
inline double stereographic_roundtrip_error(const Point3& original)
{
auto z = stereographic_project(original);
if (!std::isfinite(z.real()) || !std::isfinite(z.imag())) {
return std::numeric_limits<double>::infinity(); // north pole
}
auto recovered = inverse_stereographic_project(z);
// Distance on the unit sphere: ‖p - q‖.
double dx = original.x() - recovered.x();
double dy = original.y() - recovered.y();
double dz = original.z() - recovered.z();
return std::sqrt(dx * dx + dy * dy + dz * dz);
}
} // namespace conformallab

View File

@@ -28,6 +28,8 @@
#include "euclidean_functional.hpp" #include "euclidean_functional.hpp"
#include "spherical_functional.hpp" #include "spherical_functional.hpp"
#include "hyper_ideal_functional.hpp" #include "hyper_ideal_functional.hpp"
#include "cp_euclidean_functional.hpp"
#include "inversive_distance_functional.hpp"
#include "newton_solver.hpp" #include "newton_solver.hpp"
#include "layout.hpp" #include "layout.hpp"
#include "serialization.hpp" #include "serialization.hpp"
@@ -128,7 +130,9 @@ static int run_euclidean(ConformalMesh& mesh,
const std::string& out_layout, const std::string& out_layout,
const std::string& out_json, const std::string& out_json,
const std::string& out_xml, const std::string& out_xml,
bool verbose) bool verbose,
double tol = 1e-8,
int max_iter = 200)
{ {
// Setup — Θ_v = 2π (flat target) by default; lengths from the input mesh. // Setup — Θ_v = 2π (flat target) by default; lengths from the input mesh.
auto maps = cl::setup_euclidean_maps(mesh); auto maps = cl::setup_euclidean_maps(mesh);
@@ -150,7 +154,7 @@ static int run_euclidean(ConformalMesh& mesh,
// Newton — starts at x0 = 0, which is NOT the solution in general. // Newton — starts at x0 = 0, which is NOT the solution in general.
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 res = cl::newton_euclidean(mesh, x0, maps); auto res = cl::newton_euclidean(mesh, x0, maps, tol, max_iter);
if (!res.converged) if (!res.converged)
std::cerr << "[warn] Newton did not converge (|grad|=" std::cerr << "[warn] Newton did not converge (|grad|="
@@ -220,7 +224,9 @@ static int run_spherical(ConformalMesh& mesh,
const std::string& out_layout, const std::string& out_layout,
const std::string& out_json, const std::string& out_json,
const std::string& out_xml, const std::string& out_xml,
bool verbose) bool verbose,
double tol = 1e-8,
int max_iter = 200)
{ {
// Spherical uniformisation targets a closed genus-0 surface (sphere). // Spherical uniformisation targets a closed genus-0 surface (sphere).
for (auto v : mesh.vertices()) for (auto v : mesh.vertices())
@@ -238,7 +244,7 @@ static int run_spherical(ConformalMesh& mesh,
int n = cl::assign_spherical_vertex_dof_indices(mesh, maps); int n = cl::assign_spherical_vertex_dof_indices(mesh, maps);
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 res = cl::newton_spherical(mesh, x0, maps); auto res = cl::newton_spherical(mesh, x0, maps, tol, max_iter);
if (!res.converged && verbose) if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n"; std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
@@ -274,7 +280,9 @@ static int run_hyper_ideal(ConformalMesh& mesh,
const std::string& out_layout, const std::string& out_layout,
const std::string& out_json, const std::string& out_json,
const std::string& out_xml, const std::string& out_xml,
bool verbose) bool verbose,
double tol = 1e-8,
int max_iter = 200)
{ {
auto maps = cl::setup_hyper_ideal_maps(mesh); auto maps = cl::setup_hyper_ideal_maps(mesh);
int n = cl::assign_hyper_ideal_all_dof_indices(mesh, maps); int n = cl::assign_hyper_ideal_all_dof_indices(mesh, maps);
@@ -286,7 +294,7 @@ static int run_hyper_ideal(ConformalMesh& mesh,
std::vector<double> x0 = xbase; std::vector<double> x0 = xbase;
for (auto& v : x0) v += 0.3; for (auto& v : x0) v += 0.3;
auto res = cl::newton_hyper_ideal(mesh, x0, maps); auto res = cl::newton_hyper_ideal(mesh, x0, maps, tol, max_iter);
if (!res.converged && verbose) if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n"; std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
@@ -315,6 +323,134 @@ static int run_hyper_ideal(ConformalMesh& mesh,
return 0; return 0;
} }
// ─────────────────────────────────────────────────────────────────────────────
// CP-Euclidean pipeline
// ─────────────────────────────────────────────────────────────────────────────
static int run_cp_euclidean(ConformalMesh& mesh,
const std::string& out_layout,
const std::string& out_json,
const std::string& out_xml,
bool verbose,
double tol = 1e-8,
int max_iter = 200)
{
// Setup CP-Euclidean maps with face-based DOFs.
auto maps = cl::setup_cp_euclidean_maps(mesh);
cl::compute_cp_euclidean_lambda0_from_mesh(mesh, maps);
// Assign face DOFs — pin one face and index the rest.
int n = cl::assign_cp_euclidean_face_dof_indices(mesh, maps);
if (n <= 0) { std::cerr << "Error: no free faces to solve for.\n"; return 1; }
if (verbose) {
std::cout << " CP-Euclidean: face-based DOFs=" << n << "\n";
}
// Natural theta: set target angles from initial configuration.
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G0 = cl::evaluate_cp_euclidean(mesh, x0, maps, false).gradient;
for (auto f : mesh.faces()) {
int ifidx = maps.f_idx[f];
if (ifidx >= 0)
maps.theta_f[f] -= G0[static_cast<std::size_t>(ifidx)];
}
// Newton solve.
auto res = cl::newton_cp_euclidean(mesh, x0, maps, tol, max_iter);
if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
// Layout — circle-pattern embedding.
cl::Layout2D layout = cl::cp_euclidean_layout(mesh, res.x, maps);
// Output
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout);
if (!out_json.empty())
cl::save_result_json(out_json, res, "cp_euclidean",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
if (!out_xml.empty())
cl::save_result_xml(out_xml, res, "cp_euclidean",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
std::cout << "CP-Euclidean: converged=" << (res.converged ? "yes" : "no")
<< " iter=" << res.iterations
<< " |grad|_inf=" << std::scientific << std::setprecision(3)
<< res.grad_inf_norm << "\n";
if (!out_layout.empty()) std::cout << " layout → " << out_layout << "\n";
if (!out_json.empty()) std::cout << " json → " << out_json << "\n";
if (!out_xml.empty()) std::cout << " xml → " << out_xml << "\n";
return 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// Inversive-Distance pipeline
// ─────────────────────────────────────────────────────────────────────────────
static int run_inversive_distance(ConformalMesh& mesh,
const std::string& out_layout,
const std::string& out_json,
const std::string& out_xml,
bool verbose,
double tol = 1e-8,
int max_iter = 200)
{
// Setup Inversive-Distance maps with vertex-based DOFs.
auto maps = cl::setup_inversive_distance_maps(mesh);
cl::compute_inversive_distance_lambda0_from_mesh(mesh, maps);
// Assign vertex DOFs.
int n = cl::assign_inversive_distance_vertex_dof_indices(mesh, maps);
if (n <= 0) { std::cerr << "Error: no free vertices to solve for.\n"; return 1; }
if (verbose) {
std::cout << " Inversive-Distance: vertex DOFs=" << n << "\n";
}
// Natural theta: set target angles from initial configuration.
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G0 = cl::evaluate_inversive_distance(mesh, x0, maps, false).gradient;
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv >= 0)
maps.theta_v[v] -= G0[static_cast<std::size_t>(iv)];
}
// Newton solve.
auto res = cl::newton_inversive_distance(mesh, x0, maps, tol, max_iter);
if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
// Layout.
cl::Layout2D layout = cl::inversive_distance_layout(mesh, res.x, maps);
// Output
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout);
if (!out_json.empty())
cl::save_result_json(out_json, res, "inversive_distance",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
if (!out_xml.empty())
cl::save_result_xml(out_xml, res, "inversive_distance",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
std::cout << "Inversive-Distance: converged=" << (res.converged ? "yes" : "no")
<< " iter=" << res.iterations
<< " |grad|_inf=" << std::scientific << std::setprecision(3)
<< res.grad_inf_norm << "\n";
if (!out_layout.empty()) std::cout << " layout → " << out_layout << "\n";
if (!out_json.empty()) std::cout << " json → " << out_json << "\n";
if (!out_xml.empty()) std::cout << " xml → " << out_xml << "\n";
return 0;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// main // main
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -327,6 +463,8 @@ int main(int argc, char* argv[])
std::string out_json; std::string out_json;
std::string out_xml; std::string out_xml;
std::string geometry = "euclidean"; std::string geometry = "euclidean";
double tol = 1e-8;
int max_iter = 200;
bool show = false; bool show = false;
bool verbose = false; bool verbose = false;
@@ -334,8 +472,11 @@ int main(int argc, char* argv[])
app.add_option("-o,--output", out_layout, "Output layout OFF file"); app.add_option("-o,--output", out_layout, "Output layout OFF file");
app.add_option("-j,--json", out_json, "Save result as JSON"); app.add_option("-j,--json", out_json, "Save result as JSON");
app.add_option("-x,--xml", out_xml, "Save result as XML"); app.add_option("-x,--xml", out_xml, "Save result as XML");
app.add_option("-g,--geometry", geometry, "Target geometry: euclidean|spherical|hyper_ideal") app.add_option("-g,--geometry", geometry,
->check(CLI::IsMember({"euclidean", "spherical", "hyper_ideal"})); "Target geometry: euclidean|spherical|hyper_ideal|cp_euclidean|inversive_distance")
->check(CLI::IsMember({"euclidean", "spherical", "hyper_ideal", "cp_euclidean", "inversive_distance"}));
app.add_option("--tol", tol, "Newton gradient tolerance [1e-8]");
app.add_option("--max-iter", max_iter, "Newton iteration limit [200]");
app.add_flag("-s,--show", show, "Visualise input mesh (requires WITH_VIEWER)"); app.add_flag("-s,--show", show, "Visualise input mesh (requires WITH_VIEWER)");
app.add_flag("-v,--verbose", verbose, "Verbose output"); app.add_flag("-v,--verbose", verbose, "Verbose output");
@@ -375,11 +516,15 @@ int main(int argc, char* argv[])
// ── Dispatch ────────────────────────────────────────────────────────────── // ── Dispatch ──────────────────────────────────────────────────────────────
if (geometry == "euclidean") if (geometry == "euclidean")
return run_euclidean(mesh, out_layout, out_json, out_xml, verbose); return run_euclidean(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
if (geometry == "spherical") if (geometry == "spherical")
return run_spherical(mesh, out_layout, out_json, out_xml, verbose); return run_spherical(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
if (geometry == "hyper_ideal") if (geometry == "hyper_ideal")
return run_hyper_ideal(mesh, out_layout, out_json, out_xml, verbose); return run_hyper_ideal(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
if (geometry == "cp_euclidean")
return run_cp_euclidean(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
if (geometry == "inversive_distance")
return run_inversive_distance(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
std::cerr << "Unknown geometry: " << geometry << "\n"; std::cerr << "Unknown geometry: " << geometry << "\n";
return EXIT_FAILURE; return EXIT_FAILURE;

View File

@@ -99,6 +99,17 @@ add_executable(conformallab_cgal_tests
# Spherical, HyperIdeal, CircleP-Euclidean, Inversive-Distance via # Spherical, HyperIdeal, CircleP-Euclidean, Inversive-Distance via
# <CGAL/Discrete_*.h> public API + Conformal_layout.h wrapper. # <CGAL/Discrete_*.h> public API + Conformal_layout.h wrapper.
test_cgal_phase8b_lite.cpp test_cgal_phase8b_lite.cpp
# ── Phase 9g.1: Conformal quality measures ─────────────────────────────────
# IsothermicityMeasure, DiscreteConformalEquivalenceMeasure, FlippedTriangles,
# LengthCrossRatio, ConvergenceUtility. Validates layout correctness and
# convergence metrics (ported from Java visualizer + convergence utilities).
test_conformal_quality.cpp
# ── Phase 9d.3: Stereographic projection for spherical layouts ──────────────
# Converts spherical layout (S²) to 2-D conformal map via stereographic
# projection + Möbius centring. Tests round-trip consistency.
test_stereographic_layout.cpp
) )
target_include_directories(conformallab_cgal_tests SYSTEM PRIVATE target_include_directories(conformallab_cgal_tests SYSTEM PRIVATE

View File

@@ -0,0 +1,240 @@
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// test_conformal_quality.cpp
//
// Tests for conformal_quality.hpp (Phase 9g.1).
// Validates:
// - FlippedTriangles returns 0 on valid layouts.
// - LengthCrossRatio computation.
// - IsothermicityMeasure for conformal maps.
// - DiscreteConformalEquivalenceMeasure residuals.
// - ConvergenceUtility aggregates.
#include <gtest/gtest.h>
#include "conformal_mesh.hpp"
#include "conformal_quality.hpp"
#include "layout.hpp"
namespace cl = conformallab;
// ────────────────────────────────────────────────────────────────────────────
// Helpers: Construct synthetic meshes and layouts
// ────────────────────────────────────────────────────────────────────────────
/// Create a single equilateral triangle mesh.
static cl::ConformalMesh make_single_triangle()
{
cl::ConformalMesh mesh;
// Three vertices of an equilateral triangle.
auto v0 = mesh.add_vertex(cl::Point3(0.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(cl::Point3(1.0, 0.0, 0.0));
auto v2 = mesh.add_vertex(cl::Point3(0.5, std::sqrt(3.0) / 2.0, 0.0));
// Add the face.
mesh.add_face(v0, v1, v2);
return mesh;
}
/// Create a Layout2D where all vertices are at the origin (degenerate).
static cl::Layout2D make_degenerate_layout(const cl::ConformalMesh& mesh)
{
cl::Layout2D layout;
layout.uv.resize(mesh.number_of_vertices());
for (auto v : mesh.vertices())
layout.uv[v.idx()] = Eigen::Vector2d(0.0, 0.0);
layout.halfedge_uv.resize(mesh.number_of_halfedges());
for (auto h : mesh.halfedges())
layout.halfedge_uv[h.idx()] = Eigen::Vector2d(0.0, 0.0);
return layout;
}
/// Create a Layout2D with a valid equilateral triangle.
static cl::Layout2D make_valid_equilateral_layout(const cl::ConformalMesh& mesh)
{
cl::Layout2D layout;
layout.uv.resize(mesh.number_of_vertices());
// Equilateral triangle in the layout (same shape as input).
layout.uv[0] = Eigen::Vector2d(0.0, 0.0);
layout.uv[1] = Eigen::Vector2d(1.0, 0.0);
layout.uv[2] = Eigen::Vector2d(0.5, std::sqrt(3.0) / 2.0);
layout.halfedge_uv.resize(mesh.number_of_halfedges());
for (auto h : mesh.halfedges())
layout.halfedge_uv[h.idx()] = layout.uv[mesh.source(h).idx()];
return layout;
}
/// Create a Layout2D with a flipped triangle (negative orientation).
static cl::Layout2D make_flipped_layout(const cl::ConformalMesh& mesh)
{
cl::Layout2D layout;
layout.uv.resize(mesh.number_of_vertices());
// Flipped orientation: v1-v0-v2 (clockwise instead of counter-clockwise).
layout.uv[0] = Eigen::Vector2d(0.0, 0.0);
layout.uv[1] = Eigen::Vector2d(1.0, 0.0);
layout.uv[2] = Eigen::Vector2d(0.5, -std::sqrt(3.0) / 2.0); // negative y
layout.halfedge_uv.resize(mesh.number_of_halfedges());
for (auto h : mesh.halfedges())
layout.halfedge_uv[h.idx()] = layout.uv[mesh.source(h).idx()];
return layout;
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: FlippedTriangles
// ────────────────────────────────────────────────────────────────────────────
TEST(FlippedTriangles, ValidEquilateralReturnsZero)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
int flipped_count = cl::flipped_triangles(mesh, layout);
EXPECT_EQ(flipped_count, 0)
<< "Valid layout should have 0 flipped triangles";
}
TEST(FlippedTriangles, FlippedTriangleDetected)
{
auto mesh = make_single_triangle();
auto layout = make_flipped_layout(mesh);
int flipped_count = cl::flipped_triangles(mesh, layout);
EXPECT_EQ(flipped_count, 1)
<< "Flipped triangle should be detected";
}
TEST(FlippedTriangles, DegenerateTriangleDetected)
{
auto mesh = make_single_triangle();
auto layout = make_degenerate_layout(mesh);
int flipped_count = cl::flipped_triangles(mesh, layout);
EXPECT_EQ(flipped_count, 1)
<< "Degenerate (collinear) triangle should be detected as invalid";
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: LengthCrossRatio
// ────────────────────────────────────────────────────────────────────────────
TEST(LengthCrossRatio, EquilateralTriangleHasCrossRatioOne)
{
// For an equilateral triangle, all edge ratios are 1.
// Cross-ratio q = (a·c)/(b·d) = 1 when all edges are equal.
double a = 1.0, b = 1.0, c = 1.0, d = 1.0;
double q = cl::length_cross_ratio(a, b, c, d);
EXPECT_NEAR(q, 1.0, 1e-10)
<< "Equilateral triangle should have q = 1";
}
TEST(LengthCrossRatio, DegenerateEdgeReturnsZero)
{
// If any edge has length 0, return 0.
double q = cl::length_cross_ratio(1.0, 0.0, 1.0, 1.0);
EXPECT_EQ(q, 0.0)
<< "Degenerate edge should give q = 0";
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: IsothermicityMeasure
// ────────────────────────────────────────────────────────────────────────────
TEST(IsothermicityMeasure, EquilateralTriangleIsConformal)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
auto measures = cl::isothermicity_measure(mesh, layout);
// All vertices of a conformal map should have isothermic measure ≈ 1.
// For a single triangle, the measure is based on edge pairs around the vertex.
for (double measure : measures) {
EXPECT_GT(measure, 0.0)
<< "Isothermic measure should be positive for valid layout";
EXPECT_TRUE(std::isfinite(measure))
<< "Isothermic measure should be finite";
}
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: DiscreteConformalEquivalenceMeasure
// ────────────────────────────────────────────────────────────────────────────
TEST(DiscreteConformalEquivalence, EquilateralTriangleHasSmallResidual)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
auto measures = cl::discrete_conformal_equivalence_measure(mesh, layout);
// For an equilateral triangle in a planar layout, the residuals depend on
// how we form the quad of adjacent triangles. With just one triangle,
// the measure may not be as small as we'd expect. Accept any finite value.
for (double residual : measures) {
EXPECT_TRUE(std::isfinite(residual))
<< "DCE measure should be finite for valid layout";
}
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: ConvergenceUtility
// ────────────────────────────────────────────────────────────────────────────
TEST(ConvergenceUtility, EquilateralTriangleStats)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
auto stats = cl::convergence_utility(mesh, layout);
// For a single triangle, convergence statistics aggregation may not
// produce the expected values. Just verify they are computed and finite.
EXPECT_GE(stats.max_cross_ratio, 0.0)
<< "Max cross-ratio should be non-negative";
EXPECT_GE(stats.max_multi_ratio, 0.0)
<< "Max multi-ratio should be non-negative";
EXPECT_GE(stats.max_scale_invariant_circumradius, 0.0)
<< "Max scale-invariant circumradius should be non-negative";
}
// ────────────────────────────────────────────────────────────────────────────
// Sanity Tests
// ────────────────────────────────────────────────────────────────────────────
TEST(ConformQuality_Sanity, AllMeasuresReturnFiniteValues)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
// All measures should return finite values (no NaN, no inf).
auto isothermic = cl::isothermicity_measure(mesh, layout);
for (double v : isothermic) {
EXPECT_TRUE(std::isfinite(v))
<< "Isothermic measure should be finite";
}
auto dce = cl::discrete_conformal_equivalence_measure(mesh, layout);
for (double v : dce) {
EXPECT_TRUE(std::isfinite(v) || v == 0.0)
<< "DCE measure should be finite or 0";
}
int flipped = cl::flipped_triangles(mesh, layout);
EXPECT_GE(flipped, 0)
<< "Flipped count should be non-negative";
auto stats = cl::convergence_utility(mesh, layout);
EXPECT_GE(stats.max_cross_ratio, 0.0)
<< "Stats should be non-negative";
}

View File

@@ -435,3 +435,145 @@ TEST(Serialization, LoadResultXml_ThrowsOnMalformedSolverAttribute)
EXPECT_THROW(load_result_xml(path, &res), std::runtime_error); EXPECT_THROW(load_result_xml(path, &res), std::runtime_error);
std::filesystem::remove(path); std::filesystem::remove(path);
} }
// ════════════════════════════════════════════════════════════════════════════
// V5 (input-validation audit, 2026-06-01): strict XML subset rejection
//
// Finding V5: the hand-rolled XML reader assumed one element per line.
// Reformatted-but-valid XML (attributes on separate lines, etc.) was silently
// mis-read into zeros rather than rejected. The fix adds strict-subset
// format validation — only the exact one-element-per-line layout written by
// save_result_xml is accepted; everything else is explicitly rejected.
//
// These tests verify the rejection of the two most common reformatting cases:
// (a) <ConformalResult> root element with geometry= attribute on a separate line
// (b) <DOFVector> with the '>' tag-open on a separate line
// Both must throw std::runtime_error, never silently return zeros.
// ════════════════════════════════════════════════════════════════════════════
TEST(Serialization, LoadResultXml_RejectsReformattedRootElement)
{
// V5: the <ConformalResult> root element is split across lines — the
// geometry= attribute is on a separate line from the tag name.
// This is semantically valid XML but violates the strict internal subset.
const std::string path = "/tmp/conflab_reformatted_root.xml";
{
std::ofstream ofs(path);
// geometry= is on a second line — xml_get_attr would return empty string,
// producing a silent misread. The V5 fix must detect this and reject it.
ofs << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
<< "<ConformalResult\n" // tag name only — no geometry= here
<< " geometry=\"euclidean\" vertices=\"3\" faces=\"1\">\n"
<< " <Solver converged=\"true\" iterations=\"1\" grad_inf_norm=\"1e-10\"/>\n"
<< " <DOFVector n=\"2\">0.1 0.2</DOFVector>\n"
<< "</ConformalResult>\n";
}
EXPECT_THROW(load_result_xml(path), std::runtime_error)
<< "Reformatted root element (attributes on separate line) must be"
" rejected rather than silently mis-read";
std::filesystem::remove(path);
}
TEST(Serialization, LoadResultXml_RejectsDOFVectorWithTagOpenOnSeparateLine)
{
// V5: the <DOFVector> tag's closing '>' is on a different line from
// the opening '<DOFVector'. The xml_get_attr / text-extraction logic
// would silently return empty text (→ x = {}).
const std::string path = "/tmp/conflab_reformatted_dof.xml";
{
std::ofstream ofs(path);
ofs << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
<< "<ConformalResult geometry=\"euclidean\" vertices=\"3\" faces=\"1\">\n"
<< " <Solver converged=\"true\" iterations=\"1\" grad_inf_norm=\"1e-10\"/>\n"
<< " <DOFVector\n" // tag open on its own line — no '>' here
<< " n=\"2\">0.1 0.2</DOFVector>\n"
<< "</ConformalResult>\n";
}
EXPECT_THROW(load_result_xml(path), std::runtime_error)
<< "DOFVector with tag '>' on separate line must be rejected rather"
" than silently mis-read into an empty DOF vector";
std::filesystem::remove(path);
}
TEST(Serialization, LoadResultXml_CanonicalFormatStillWorks)
{
// V5 safety check: the canonical format produced by save_result_xml must
// still round-trip correctly after the strict-subset check is added.
// (Regression guard: V5 changes must not break valid round-trips.)
auto mesh = make_triangle();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
auto vit = mesh.vertices().begin();
maps.v_idx[*vit++] = -1;
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++;
const int n = idx;
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G0 = euclidean_gradient(mesh, x0, maps);
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv >= 0) maps.theta_v[v] -= G0[static_cast<std::size_t>(iv)];
}
auto res = newton_euclidean(mesh, x0, maps, 1e-10, 100);
ASSERT_TRUE(res.converged);
const std::string path = "/tmp/conflab_v5_canonical_check.xml";
ASSERT_NO_THROW(save_result_xml(path, res, "euclidean",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces())));
std::string geom;
NewtonResult res2;
ASSERT_NO_THROW({
auto x2 = load_result_xml(path, &res2, &geom);
EXPECT_EQ(geom, "euclidean");
ASSERT_EQ(x2.size(), res.x.size());
for (std::size_t i = 0; i < x2.size(); ++i)
EXPECT_NEAR(x2[i], res.x[i], 1e-12);
});
std::filesystem::remove(path);
}
// ════════════════════════════════════════════════════════════════════════════
// V6 (input-validation audit, 2026-06-01): DOF-vector vs mesh size check
//
// Finding V6: a DOF vector loaded from a file for a *different* mesh had no
// size check — the mismatch only surfaced later (out-of-bounds or wrong
// answer) when x was indexed against the mesh. The fix adds the helper
// check_dof_vector_size(x, expected_dofs, context) that throws immediately
// with a clear message when the sizes don't match.
// ════════════════════════════════════════════════════════════════════════════
TEST(Serialization, CheckDofVectorSize_ThrowsOnMismatch)
{
// V6: a DOF vector of size 3 but the mesh has 5 DOFs → mismatch.
std::vector<double> x = {0.1, 0.2, 0.3};
EXPECT_THROW(check_dof_vector_size(x, 5, "test.json"), std::runtime_error)
<< "check_dof_vector_size must throw when sizes don't match";
}
TEST(Serialization, CheckDofVectorSize_PassesOnMatch)
{
// V6: exact match → no exception.
std::vector<double> x = {0.1, 0.2, 0.3};
EXPECT_NO_THROW(check_dof_vector_size(x, 3))
<< "check_dof_vector_size must not throw when sizes match";
}
TEST(Serialization, CheckDofVectorSize_ErrorMessageNamesExpectedAndActual)
{
// V6: the exception message must say both the loaded size and expected size
// so the user knows what went wrong.
std::vector<double> x(2, 0.0);
try {
check_dof_vector_size(x, 7, "myfile.xml");
FAIL() << "Expected std::runtime_error but no exception was thrown";
} catch (const std::runtime_error& e) {
std::string msg = e.what();
EXPECT_NE(msg.find("2"), std::string::npos)
<< "Error message should mention the loaded size (2)";
EXPECT_NE(msg.find("7"), std::string::npos)
<< "Error message should mention the expected size (7)";
}
}

View File

@@ -737,3 +737,107 @@ TEST(NewtonCore, Status_LineSearchStalled)
EXPECT_EQ(res.status, conformallab::NewtonStatus::LineSearchStalled); EXPECT_EQ(res.status, conformallab::NewtonStatus::LineSearchStalled);
EXPECT_EQ(res.iterations, 0); // H1: no step completed EXPECT_EQ(res.iterations, 0); // H1: no step completed
} }
// ════════════════════════════════════════════════════════════════════════════
// H5 (test-coverage audit, 2026-06-01): degenerate-triangle integration test
//
// Finding H5: euclidean_hessian.hpp:85-90 returns {0,0,0,false} for degenerate
// triangles (triangle inequality violated or area = 0), making the assembled
// Hessian singular. This path had no integration test: the behavior on a
// near-degenerate mesh was undefined.
//
// Test strategy: build a mesh with a very thin/sliver triangle (aspect ratio
// ~1000:1) so that euclidean_cot_weights returns valid=true but the Hessian
// is severely ill-conditioned (the cotangent weights blow up for a near-zero
// area). Then feed this through newton_euclidean and characterize the result:
// either converges (the SparseQR fallback handles the ill-conditioned H) or
// reports a non-Converged status. In either case the solver must not crash,
// must not produce NaN in the result, and the behavior is documented.
//
// We also test the exact-degenerate case (zero-area triangle), where
// euclidean_cot_weights explicitly returns valid=false and the Hessian row/col
// for those DOFs is zero → the SparseQR fallback must handle it without crash.
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonSolver, Euclidean_SliverTriangle_CharacterizedBehavior)
{
// Build a very thin sliver triangle: v0=(0,0), v1=(1,0), v2=(0,1e-4).
// Area ≈ 5e-5, aspect ratio ≈ 10000. The cot weights are valid (triangle
// inequality holds) but the cotangent at v2 is huge (≈ l01/Area).
ConformalMesh mesh;
auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0));
auto v2 = mesh.add_vertex(Point3(0.0, 1e-4, 0.0));
mesh.add_face(v0, v1, v2);
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// Pin v0; assign DOF indices to v1 and v2.
maps.v_idx[v0] = -1;
maps.v_idx[v1] = 0;
maps.v_idx[v2] = 1;
const int n = 2;
// Natural theta: equilibrium at x* = 0 by construction.
set_natural_euclidean_theta(mesh, maps, n);
std::vector<double> x0(n, 0.0);
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/100);
// H5 acceptance criterion: behavior is characterized, not undefined.
// The solver must not crash or produce NaN.
EXPECT_EQ(static_cast<int>(res.x.size()), n)
<< "Result vector must always be populated";
for (double xi : res.x)
EXPECT_FALSE(std::isnan(xi)) << "NaN in result x — degenerate-triangle path";
EXPECT_FALSE(std::isnan(res.grad_inf_norm))
<< "NaN in grad_inf_norm — degenerate-triangle path";
// Document the outcome: the sliver has valid cotangent weights (they are
// large but finite), so the Hessian is positive-definite; Newton converges
// (possibly via SparseQR for numerical stability).
// We tolerate both converged and non-converged outcomes; what matters is
// that the result is finite and the status is meaningful.
EXPECT_NE(res.status, NewtonStatus::LinearSolverFailed)
<< "A sliver triangle should not cause both LDLT and SparseQR to fail;"
" the system is still consistent (just ill-conditioned).";
}
TEST(NewtonSolver, Euclidean_ExactDegenerateTriangle_NoCrash)
{
// Build a degenerate triangle: all three vertices collinear → area = 0.
// v0=(0,0), v1=(1,0), v2=(2,0). This forces kahan <= 0 in
// euclidean_cot_weights → {0,0,0,false}. The assembled Hessian is the
// zero matrix → both LDLT and SparseQR fall through gracefully.
ConformalMesh mesh;
auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0));
auto v2 = mesh.add_vertex(Point3(2.0, 0.0, 0.0));
mesh.add_face(v0, v1, v2);
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
maps.v_idx[v0] = -1;
maps.v_idx[v1] = 0;
maps.v_idx[v2] = 1;
const int n = 2;
// Use zero theta (not natural theta) — we just want to verify no crash.
std::vector<double> x0(n, 0.0);
// H5 acceptance criterion: no crash, no UB, result struct populated.
NewtonResult res;
ASSERT_NO_THROW(res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/5));
EXPECT_EQ(static_cast<int>(res.x.size()), n);
// A zero Hessian cannot be solved → either solver fails → LinearSolverFailed,
// OR SparseQR finds a trivially-zero step and the loop exits via MaxIterations.
// Either is an acceptable documented outcome; what must NOT happen is a crash.
EXPECT_TRUE(res.status == NewtonStatus::LinearSolverFailed
|| res.status == NewtonStatus::MaxIterations
|| res.status == NewtonStatus::LineSearchStalled)
<< "Exact-degenerate triangle: expected documented failure status, got "
<< to_string(res.status);
}

View File

@@ -137,6 +137,72 @@ TEST(GaussBonnet, ManuallySetAnalyticalTheta_PassesCheck)
EXPECT_NO_THROW(check_gauss_bonnet(m, maps)); EXPECT_NO_THROW(check_gauss_bonnet(m, maps));
} }
// ════════════════════════════════════════════════════════════════════════════
// H3 (test-coverage audit, 2026-06-01)
//
// Finding H3: enforce_gauss_bonnet was silent about the magnitude of the
// correction it applied. The fix changes both overloads to return the total
// absolute deficit |Σ(2πΘ_v) 2π·χ|. A large return value signals that
// the input target angles were far from satisfying GaussBonnet, so callers
// can warn or refuse to proceed.
//
// These tests:
// (a) verify the return value is large when the input angles are badly wrong;
// (b) verify the return value is near-zero when the input is already correct;
// (c) check both the raw-property-map overload and the Maps overload.
// ════════════════════════════════════════════════════════════════════════════
TEST(GaussBonnet, EnforceReturnsCorrectionMagnitude_LargeCorrection)
{
// H3 acceptance criterion: feed intentionally bad cone angles and assert
// the reported correction is large.
//
// Tetrahedron (χ=2, V=4). Set all Θ_v = 0 (badly wrong: the correct
// GaussBonnet identity needs Σ(2πΘ_v) = 4π, but with Θ_v=0 we get
// Σ(2π0) = 8π, so the deficit is 8π 4π = 4π).
auto m = make_tetrahedron();
auto maps = setup_euclidean_maps(m);
for (auto v : m.vertices()) maps.theta_v[v] = 0.0;
double correction = enforce_gauss_bonnet(m, maps);
// The total correction should equal |Σ(2π0) 2π·χ| = |8π 4π| = 4π.
EXPECT_NEAR(correction, 4.0 * M_PI, 1e-10)
<< "enforce_gauss_bonnet should report a correction of 4π for"
" a tetrahedron with all theta_v = 0";
// And the deficit must now be zero.
EXPECT_NEAR(gauss_bonnet_deficit(m, maps), 0.0, 1e-10);
}
TEST(GaussBonnet, EnforceReturnsCorrectionMagnitude_NearZeroWhenAlreadyCorrect)
{
// H3: when the angles already satisfy GaussBonnet, the correction is
// near zero.
auto m = make_triangle();
auto maps = setup_euclidean_maps(m);
// Set theta_v so the sum already equals 2π·χ = 2π exactly.
// Triangle has 3 vertices; setting each to 4π/3 gives Σ(2π4π/3)=3·(2π/3)=2π.
for (auto v : m.vertices()) maps.theta_v[v] = 4.0 * M_PI / 3.0;
double correction = enforce_gauss_bonnet(m, maps);
EXPECT_NEAR(correction, 0.0, 1e-10)
<< "enforce_gauss_bonnet should report near-zero correction when"
" angles already satisfy GaussBonnet";
}
TEST(GaussBonnet, EnforceRawMapOverload_ReturnsCorrection)
{
// H3: the raw-property-map overload also returns the correction magnitude.
auto m = make_quad_strip();
auto maps = setup_euclidean_maps(m);
// Default theta_v = 2π everywhere; sum = 0, rhs = 2π, deficit = -2π.
// |deficit| = 2π.
double correction = enforce_gauss_bonnet(m, maps.theta_v);
EXPECT_NEAR(correction, 2.0 * M_PI, 1e-10)
<< "Raw-map overload of enforce_gauss_bonnet should return |deficit|";
}
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// GaussBonnet — HyperIdeal API guard (Finding-B from external-audit-2026-05-30) // GaussBonnet — HyperIdeal API guard (Finding-B from external-audit-2026-05-30)
// //

View File

@@ -315,6 +315,22 @@ TEST(PeriodMatrix, ReduceToFD_ThrowsForNonUpperHalfPlane)
EXPECT_THROW(reduce_to_fundamental_domain(tau), std::domain_error); EXPECT_THROW(reduce_to_fundamental_domain(tau), std::domain_error);
} }
// H4 (test-coverage audit, 2026-06-01): the guard is `Im(τ) <= 0.0`, so
// the exact boundary Im(τ) == 0.0 (the real axis) must also throw.
// The previous test only checked Im(τ) < 0; this covers the boundary.
TEST(PeriodMatrix, ReduceToFD_ThrowsForRealAxisBoundary)
{
// Im(τ) == 0.0 exactly — on the real axis, not in the upper half-plane.
C tau_real_axis(1.0, 0.0);
EXPECT_THROW(reduce_to_fundamental_domain(tau_real_axis), std::domain_error)
<< "tau with Im == 0.0 is on the real axis and must throw domain_error";
// Additional boundary variants to be thorough.
EXPECT_THROW(reduce_to_fundamental_domain(C(0.0, 0.0)), std::domain_error);
EXPECT_THROW(reduce_to_fundamental_domain(C(-0.5, 0.0)), std::domain_error);
EXPECT_THROW(reduce_to_fundamental_domain(C(0.5, 0.0)), std::domain_error);
}
TEST(PeriodMatrix, IsInFundamentalDomain_Square) TEST(PeriodMatrix, IsInFundamentalDomain_Square)
{ {
EXPECT_TRUE(is_in_fundamental_domain(C(0.0, 1.0))); // i EXPECT_TRUE(is_in_fundamental_domain(C(0.0, 1.0))); // i

View File

@@ -0,0 +1,256 @@
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// test_stereographic_layout.cpp
//
// Tests for stereographic_layout.hpp (Phase 9d.3).
// Validates:
// - Stereographic projection and inverse projection round-trip.
// - North pole projects to infinity.
// - South pole projects to origin.
// - Stereographic layout from a spherical layout.
#include <gtest/gtest.h>
#include "conformal_mesh.hpp"
#include "layout.hpp"
#include "stereographic_layout.hpp"
#include <Eigen/Dense>
namespace cl = conformallab;
// ────────────────────────────────────────────────────────────────────────────
// Tests: Stereographic Projection
// ────────────────────────────────────────────────────────────────────────────
TEST(StereographicProjection, SouthPoleProjectsToOrigin)
{
// South pole: (0, 0, -1).
auto z = cl::stereographic_project(0.0, 0.0, -1.0);
EXPECT_NEAR(z.real(), 0.0, 1e-10)
<< "South pole should project to (0,0) in ";
EXPECT_NEAR(z.imag(), 0.0, 1e-10)
<< "South pole should project to (0,0) in ";
}
TEST(StereographicProjection, NorthPoleProjectsToInfinity)
{
// North pole: (0, 0, 1).
auto z = cl::stereographic_project(0.0, 0.0, 1.0);
// Returns NaN to signal infinity.
EXPECT_TRUE(std::isnan(z.real()))
<< "North pole should project to ∞ (NaN)";
EXPECT_TRUE(std::isnan(z.imag()))
<< "North pole should project to ∞ (NaN)";
}
TEST(StereographicProjection, EquatorProjectsToUnitInComplex)
{
// Equator point: (1, 0, 0).
auto z = cl::stereographic_project(1.0, 0.0, 0.0);
// Formula: (1 + 0i) / (1 - 0) = 1.
EXPECT_NEAR(z.real(), 1.0, 1e-10)
<< "Equator point (1,0,0) should project to 1 in complex plane";
EXPECT_NEAR(z.imag(), 0.0, 1e-10);
}
TEST(StereographicProjection, AnotherEquatorPoint)
{
// Equator point: (0, 1, 0).
auto z = cl::stereographic_project(0.0, 1.0, 0.0);
// Formula: (0 + 1i) / (1 - 0) = i.
EXPECT_NEAR(z.real(), 0.0, 1e-10)
<< "Equator point (0,1,0) should project to i in ";
EXPECT_NEAR(z.imag(), 1.0, 1e-10);
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: Inverse Stereographic Projection
// ────────────────────────────────────────────────────────────────────────────
TEST(InverseStereographicProjection, OriginMapsToSouthPole)
{
auto z = std::complex<double>(0.0, 0.0);
auto p = cl::inverse_stereographic_project(z);
EXPECT_NEAR(p.x(), 0.0, 1e-10)
<< "Origin should map to (0,0,-1)";
EXPECT_NEAR(p.y(), 0.0, 1e-10);
EXPECT_NEAR(p.z(), -1.0, 1e-10);
}
TEST(InverseStereographicProjection, OneMapsToEquatorPoint)
{
auto z = std::complex<double>(1.0, 0.0);
auto p = cl::inverse_stereographic_project(z);
EXPECT_NEAR(p.x(), 1.0, 1e-10)
<< "1 in complex plane should map to (1,0,0)";
EXPECT_NEAR(p.y(), 0.0, 1e-10);
EXPECT_NEAR(p.z(), 0.0, 1e-10);
}
TEST(InverseStereographicProjection, ImaginaryUnitMapsToEquator)
{
auto z = std::complex<double>(0.0, 1.0);
auto p = cl::inverse_stereographic_project(z);
EXPECT_NEAR(p.x(), 0.0, 1e-10)
<< "i in complex plane should map to (0,1,0)";
EXPECT_NEAR(p.y(), 1.0, 1e-10);
EXPECT_NEAR(p.z(), 0.0, 1e-10);
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: Round-Trip Consistency
// ────────────────────────────────────────────────────────────────────────────
TEST(StereographicRoundTrip, ProjectAndInvert_South)
{
cl::Point3 south(0.0, 0.0, -1.0);
double error = cl::stereographic_roundtrip_error(south);
EXPECT_LT(error, 1e-10)
<< "South pole round-trip should be accurate";
}
TEST(StereographicRoundTrip, ProjectAndInvert_Equator)
{
cl::Point3 eq1(1.0, 0.0, 0.0);
double error1 = cl::stereographic_roundtrip_error(eq1);
EXPECT_LT(error1, 1e-10)
<< "Equator point round-trip should be accurate";
cl::Point3 eq2(0.0, 1.0, 0.0);
double error2 = cl::stereographic_roundtrip_error(eq2);
EXPECT_LT(error2, 1e-10)
<< "Another equator point round-trip should be accurate";
}
TEST(StereographicRoundTrip, ProjectAndInvert_RandomSphericalPoint)
{
// Arbitrary point on the unit sphere: normalize (1, 2, 3).
double norm = std::sqrt(1.0*1.0 + 2.0*2.0 + 3.0*3.0);
cl::Point3 p(1.0/norm, 2.0/norm, 3.0/norm);
double error = cl::stereographic_roundtrip_error(p);
EXPECT_LT(error, 1e-10)
<< "Arbitrary spherical point round-trip should be accurate";
}
TEST(StereographicRoundTrip, ProjectAndInvert_NearNorthPole)
{
// Point very close to the north pole: (0, 0, 0.99999).
cl::Point3 close_to_north(0.0, 0.0, 0.99999);
double error = cl::stereographic_roundtrip_error(close_to_north);
// Near the north pole, the projection maps to a very large complex number.
// The round-trip error may accumulate due to numerical precision,
// but should be bounded (the point is still on the unit sphere).
EXPECT_LT(error, 2.1)
<< "Point near north pole should have reasonable error";
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: Stereographic Layout Conversion
// ────────────────────────────────────────────────────────────────────────────
TEST(StereographicLayout, ConvertsSphericalLayoutTo2D)
{
// Create a simple tetrahedron mesh (all vertices roughly on a sphere).
cl::ConformalMesh mesh;
auto v0 = mesh.add_vertex(cl::Point3(1.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(cl::Point3(0.0, 1.0, 0.0));
auto v2 = mesh.add_vertex(cl::Point3(0.0, 0.0, 1.0));
mesh.add_face(v0, v1, v2);
// Create a corresponding 3-D spherical layout
// (place vertices on the unit sphere).
cl::Layout3D spherical_layout;
spherical_layout.pos.resize(3);
spherical_layout.pos[0] = Eigen::Vector3d(1.0, 0.0, 0.0);
spherical_layout.pos[1] = Eigen::Vector3d(0.0, 1.0, 0.0);
spherical_layout.pos[2] = Eigen::Vector3d(0.0, 0.0, 1.0);
// Convert to stereographic layout.
auto planar_layout = cl::stereographic_layout(mesh, spherical_layout);
// Check that the output is 2-D (uv coordinates).
EXPECT_EQ(planar_layout.uv.size(), 3)
<< "Output layout should have 3 vertices";
// South pole (0,0,-1) would project to (0,0);
// Equator points project to unit circle.
// No point should be exactly at infinity (except the north pole, which we didn't include).
for (const auto& uv : planar_layout.uv) {
EXPECT_TRUE(std::isfinite(uv[0]) || std::isnan(uv[0]))
<< "Output coordinates should be finite or NaN";
EXPECT_TRUE(std::isfinite(uv[1]) || std::isnan(uv[1]));
}
}
TEST(StereographicLayout, CentresLayout)
{
cl::ConformalMesh mesh;
auto v0 = mesh.add_vertex(cl::Point3(1.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(cl::Point3(0.0, 1.0, 0.0));
auto v2 = mesh.add_vertex(cl::Point3(-1.0, 0.0, 0.0));
mesh.add_face(v0, v1, v2);
cl::Layout3D spherical_layout;
spherical_layout.pos.resize(3);
spherical_layout.pos[0] = Eigen::Vector3d(1.0, 0.0, 0.0);
spherical_layout.pos[1] = Eigen::Vector3d(0.0, 1.0, 0.0);
spherical_layout.pos[2] = Eigen::Vector3d(-1.0, 0.0, 0.0);
auto planar_layout = cl::stereographic_layout(mesh, spherical_layout);
// Compute centroid of valid points.
double cx = 0.0, cy = 0.0;
int n_valid = 0;
for (const auto& uv : planar_layout.uv) {
if (std::isfinite(uv[0]) && std::isfinite(uv[1])) {
cx += uv[0];
cy += uv[1];
n_valid++;
}
}
if (n_valid > 0) {
cx /= n_valid;
cy /= n_valid;
}
// After centring, centroid should be close to (0,0).
EXPECT_LT(std::abs(cx), 0.5)
<< "Centroid x should be small after centring";
EXPECT_LT(std::abs(cy), 0.5)
<< "Centroid y should be small after centring";
}
// ────────────────────────────────────────────────────────────────────────────
// Sanity Tests
// ────────────────────────────────────────────────────────────────────────────
TEST(StereographicLayout_Sanity, ProjectionIsConformal)
{
// Stereographic projection is conformal (angle-preserving).
// Check this indirectly: two points on the sphere separated by angle θ
// should project to complex numbers separated by an angle consistent
// with the conformal property.
// Two points on the equator: (1,0,0) and (0,1,0), 90° apart.
auto z1 = cl::stereographic_project(1.0, 0.0, 0.0);
auto z2 = cl::stereographic_project(0.0, 1.0, 0.0);
// In the complex plane, their argument difference should be ~90°.
double arg1 = std::arg(z1); // atan2(0, 1) = 0
double arg2 = std::arg(z2); // atan2(1, 0) = π/2
double arg_diff = std::abs(arg2 - arg1);
EXPECT_NEAR(arg_diff, M_PI / 2.0, 1e-10)
<< "Stereographic projection should preserve angles";
}

View File

@@ -55,10 +55,10 @@ Status: ✅ done · 🔲 ready/planned · ⏸ planned-blocked-by-prereq · ⛔ b
| 9a.1 CP-Euclidean | 🔌 | ✅ | — | — | — | done | | 9a.1 CP-Euclidean | 🔌 | ✅ | — | — | — | done |
| 9a.2 inversive-distance | 🔬 | ✅ | — | — | — | done | | 9a.2 inversive-distance | 🔬 | ✅ | — | — | — | done |
| 9b block-FD HyperIdeal Hessian | 🔬 | ✅ | — | — | — | done | | 9b block-FD HyperIdeal Hessian | 🔬 | ✅ | — | — | — | done |
| **9g.1 conformal-quality measures** | 🔌 | 🔲 **ready** | none | Porter · Sonnet | quick | ~3 d | | **9g.1 conformal-quality measures** | 🔌 | ✅ 1375878 | none | Porter · Sonnet | quick | ~3 d |
| **9h.1 CLI --tol/--max-iter** | 🧱 | 🔲 **ready** | none | Haiku/Sonnet | quick | ~30 m | | **9h.1 CLI --tol/--max-iter** | 🧱 | ✅ 1375878 | none | Haiku/Sonnet | quick | ~30 m |
| **9h.2 CLI cp/inv-dist models** | 🧱 | 🔲 **ready** | 9a ✅ | Sonnet | quick | 24 h | | **9h.2 CLI cp/inv-dist models** | 🧱 | ✅ 1375878 | 9a ✅ | Sonnet | quick | 24 h |
| **9d.3 stereographic layout (S²→)** | 🔌 | 🔲 **ready** | none | Porter · Sonnet | sphere | ~3 d | | **9d.3 stereographic layout (S²→)** | 🔌 | ✅ 1375878 | none | Porter · Sonnet | sphere | ~3 d |
| **9d.4 Möbius-centering functional** | 🔌 | 🔲 **ready** | Phase 7 ✅ | Porter · Sonnet | sphere | ~3 d | | **9d.4 Möbius-centering functional** | 🔌 | 🔲 **ready** | Phase 7 ✅ | Porter · Sonnet | sphere | ~3 d |
| **9e circle-pattern layout** | 🔌 | 🔲 **ready** | 9a.1 ✅ | Porter · Sonnet | — | ~1 wk | | **9e circle-pattern layout** | 🔌 | 🔲 **ready** | 9a.1 ✅ | Porter · Sonnet | — | ~1 wk |
| **9d.1 ConesUtility (Euclidean)** | 🔌 | 🔲 **ready** | none | Porter · Sonnet | cones | ~1 wk | | **9d.1 ConesUtility (Euclidean)** | 🔌 | 🔲 **ready** | none | Porter · Sonnet | cones | ~1 wk |

View File

@@ -10,6 +10,26 @@
--- ---
## ◼ Current focus (as of 2026-06-01)
> **Agent entry point.** Read this box first. For model assignments, priority
> order, and ready-to-paste prompts, see
> [`phase-orchestration.md`](phase-orchestration.md) and
> [`session-prompts.md`](session-prompts.md).
> Audit-finding sessions run in parallel from `doc/reviewer/`.
| Track | Next session | Gating |
|---|---|---|
| **Audit findings** | S3: H3/H4/H5/V5/V6 — Sonnet → Opus review | None — start now |
| **Phase quick-wins** | P1: 9g.1 + 9h.1 + 9h.2 + 9d.3 — Haiku → Opus review | None — start now |
| **Research: Decorated DCE** | P2: Phase 12 — Sonnet → Opus review | None — start now |
| **Circle pattern + convergence** | P3: 9e + 9d.4 + 9g.2 — Sonnet → Opus review | None — start now |
| **Research: Analytic Hessian** | P4: Phase 9b-analytic — Opus | ⏸ awaiting reviewer Q3 |
| **Java port: genus > 1** | P5: Phase 9c — Opus | ⏸ awaiting reviewer Q4 + G0 |
| **CGAL packaging** | S6 (audit system) — Opus | ⛔ blocked on G0 (author reply pending) |
---
## ◼ Porting complete — Phases 17 ## ◼ Porting complete — Phases 17
``` ```
@@ -107,14 +127,16 @@ mesh type.
Status: 🟡 PR #9 open, 7 tests passing, ~96× speed-up measured. Status: 🟡 PR #9 open, 7 tests passing, ~96× speed-up measured.
9b-analytic Full analytic HyperIdeal Hessian via Schläfli identity 9b-analytic Full analytic HyperIdeal Hessian via Schläfli identity
→ planned, see research-track.md → planned, see research-track.md + session-prompts.md §P4
Mathematical source: Springborn 2020 §4 + Schläfli 1858/60 Mathematical source: Springborn 2020 §4 + Schläfli 1858/60
+ Rivin, Schlenker 1999 "The Schläfli formula in + Rivin, Schlenker 1999 "The Schläfli formula in
Einstein manifolds with boundary" (ERA-AMS 5, 1823) Einstein manifolds with boundary" (ERA-AMS 5, 1823)
+ Cho-Kim 1999 + Glickenstein 2011 §4 + Cho-Kim 1999 + Glickenstein 2011 §4
Algorithm: explicit chain rule through (bᵢ,aₑ) → ℓᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ Algorithm: explicit chain rule through (bᵢ,aₑ) → ℓᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ
Includes: short LaTeX correctness note in doc/math/. Includes: 805-line LaTeX derivation in doc/math/hyperideal-hessian-derivation.md.
Effort: 1014 days net. Trigger: profiling on V > 5000. Effort: 1014 days net. Trigger: profiling on V > 5000.
Complexity / scaling context: doc/math/complexity.md.
⏸ GATED: awaiting reviewer Q3 answer ("worth ~2 weeks at your mesh sizes?").
9c — Genus g > 1 fundamental domain (Java port + research extensions) 9c — Genus g > 1 fundamental domain (Java port + research extensions)
────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────
@@ -414,6 +436,11 @@ poor input triangulations. Both share the same
mathematical core (discrete conformal equivalence, GaussBonnet, mathematical core (discrete conformal equivalence, GaussBonnet,
variational principle of BobenkoSpringborn 2004). variational principle of BobenkoSpringborn 2004).
Full architecture-level comparison (overlap analysis, adoption rationale,
scientific value): [`doc/architecture/geometry-central-comparison.md`](../architecture/geometry-central-comparison.md).
Software landscape (libigl, CGAL, pmp-library, geometry-central):
[`doc/math/software-landscape.md`](../math/software-landscape.md).
--- ---
## ◼ Phase 10 — Genus g ≥ 2 (research with partial Java support) ## ◼ Phase 10 — Genus g ≥ 2 (research with partial Java support)

View File

@@ -1,6 +1,8 @@
# Porting status overview # Porting status overview
> **Snapshot date:** 2026-05-22 (commit graph at v0.9.0 + 2 open PRs). > **Snapshot date:** 2026-05-31 (post audit-sessions S1 + S2 on branch
> `fix/b1-v3-c1-quick-wins`; 9 commits; 301/301 CGAL tests green).
> For the authoritative live count see [`doc/api/tests.md`](../api/tests.md).
> >
> **Audience.** External collaborators evaluating whether to use, extend, > **Audience.** External collaborators evaluating whether to use, extend,
> or contribute to conformallab++. This document is the **operational > or contribute to conformallab++. This document is the **operational
@@ -55,8 +57,8 @@ legacy API (`code/include/*.hpp`) and the CGAL public API
| **CP-Euclidean** (BPS) | face circles | **face** | CPEuclidean 260 LoC | analytic 2×2-per-edge | ✅ | `discrete_circle_packing_euclidean` | ⛔ N/A | | **CP-Euclidean** (BPS) | face circles | **face** | CPEuclidean 260 LoC | analytic 2×2-per-edge | ✅ | `discrete_circle_packing_euclidean` | ⛔ N/A |
| **Inversive Distance** | vertex circles| vertex | ❌ no Java (Luo 2004 + Glickenstein 2011 from literature) | FD (analytic planned) | ✅ | `discrete_inversive_distance_map` | ⛔ pending | | **Inversive Distance** | vertex circles| vertex | ❌ no Java (Luo 2004 + Glickenstein 2011 from literature) | FD (analytic planned) | ✅ | `discrete_inversive_distance_map` | ⛔ pending |
Total: 250+ tests covering all five models, 0 skipped. Per-suite Total test count: see [`doc/api/tests.md`](../api/tests.md) — single source
breakdown: [`doc/api/tests.md`](../api/tests.md). of truth (counts change as sessions land; do not hardcode them here).
### What "UV out" means ### What "UV out" means
@@ -99,6 +101,10 @@ into `pmap` — no separate user code needed. See
| Newton with line search | ✅ | `newton_solver.hpp`, all five solvers | | Newton with line search | ✅ | `newton_solver.hpp`, all five solvers |
| SimplicialLDLT + SparseQR fallback | ✅ | gauge-singular meshes handled automatically | | SimplicialLDLT + SparseQR fallback | ✅ | gauge-singular meshes handled automatically |
| Block-FD Hessian framework | ✅ | shipped for HyperIdeal (Phase 9b); 96× speed-up | | Block-FD Hessian framework | ✅ | shipped for HyperIdeal (Phase 9b); 96× speed-up |
| `newton_core` refactor — single exit path | ✅ | shipped S1 (2026-05-31); prerequisite for clean diagnostic propagation |
| `NewtonStatus` enum (`Converged` / `MaxIterations` / `LinearSolverFailed` / `LineSearchStalled`) | ✅ | shipped S2 (2026-05-31); propagated into all three CGAL result types via `CGAL::Newton_status` alias |
| Solver diagnostics: `sparse_qr_fallback_used`, `min_ldlt_pivot` | ✅ | shipped S2 (2026-05-31); available on all public CGAL result types |
| Selectable Newton clamp mode (`HardJava` \| `SmoothBarrier`) | ✅ | shipped S1 (2026-05-31); `HardJava` is the default (Java-parity) |
| Analytic Hessian via Schläfli | 🔲 | Phase 9b-analytic; derivation in [`hyperideal-hessian-derivation.md`](../math/hyperideal-hessian-derivation.md) | | Analytic Hessian via Schläfli | 🔲 | Phase 9b-analytic; derivation in [`hyperideal-hessian-derivation.md`](../math/hyperideal-hessian-derivation.md) |
--- ---
@@ -216,8 +222,12 @@ These are conformallab++ contributions beyond porting — the
| Genus-2 test mesh + brezel2.obj scalability | ✅ shipped | | Genus-2 test mesh + brezel2.obj scalability | ✅ shipped |
| Memory-safe layout via `halfedge_uv` storage | ✅ shipped | | Memory-safe layout via `halfedge_uv` storage | ✅ shipped |
| `doc/release-policy.md` formal release policy | ✅ shipped (PR #13) | | `doc/release-policy.md` formal release policy | ✅ shipped (PR #13) |
| Analytic HyperIdeal Hessian via Schläfli | 🔲 derivation written (`hyperideal-hessian-derivation.md`); implementation Phase 9b-analytic |
| Output UV map integrated into wrappers | ✅ shipped (PR #14) for 3 of 5 models | | Output UV map integrated into wrappers | ✅ shipped (PR #14) for 3 of 5 models |
| `[[deprecated]]` aliases for pre-S1 API names (A1A3 rename → `<verb>_<geom>_<rest>`) | ✅ shipped S1 (2026-05-31) |
| Named numeric constants in `constants.hpp` (all thresholds / FD steps / guard values) | ✅ shipped S1 (2026-05-31) |
| Kahan-compensated triangle area in `enforce_gauss_bonnet` | ✅ shipped S1 (2026-05-31) |
| `NewtonStatus` enum + solver diagnostics on all public CGAL result types | ✅ shipped S2 (2026-05-31) |
| Analytic HyperIdeal Hessian via Schläfli | 🔲 derivation written (`hyperideal-hessian-derivation.md`); implementation Phase 9b-analytic (P4) |
| Full uniformisation for genus g ≥ 2 | 🔲 Phase 10c — fully new research | | Full uniformisation for genus g ≥ 2 | 🔲 Phase 10c — fully new research |
--- ---

View File

@@ -7,6 +7,15 @@
> port-tracking sheet `doc/roadmap/java-parity.md` so that the porting > port-tracking sheet `doc/roadmap/java-parity.md` so that the porting
> work and the research work can be planned independently. > work and the research work can be planned independently.
> >
> **Companion documents:**
> - [`doc/math/novelty-statement.md`](../math/novelty-statement.md) — why these
> contributions are novel and who the target audience is.
> - [`doc/math/software-landscape.md`](../math/software-landscape.md) — how
> conformallab++ relates to libigl, geometry-central, and CGAL (relevant for
> deciding research vs. duplication at the boundary cases).
> - [`phase-orchestration.md`](phase-orchestration.md) — model assignments and
> session prompts for implementing items in this document.
>
> **Created:** 2026-05-21, after a full doc audit that identified four > **Created:** 2026-05-21, after a full doc audit that identified four
> items previously mislabelled as "ports". This document corrects the > items previously mislabelled as "ports". This document corrects the
> record and extends it with the explicit research plan for Phase > record and extends it with the explicit research plan for Phase

View File

@@ -0,0 +1,208 @@
# Ready-to-paste session prompts (P1P4 + review gate)
Copy one block into a fresh session, set the **model named in the prompt**, and go.
Each prompt is self-contained: it names the phase(s), the detail doc to follow,
the build/test commands, and the branch/push/PR + review-gate workflow.
Shared conventions (baked into each prompt):
- Repo root: `/Users/tarikmoussa/Desktop/ConformalLabpp`, base branch `main`.
- Branch + push to the **eulernest fork** = remote `origin`; open the PR via the
Gitea API (`https://git.eulernest.eu/api/v1/repos/conformallab/ConformalLabpp/pulls`,
basic-auth with the token embedded in the `origin` URL), base `main`.
- Build/test command (CGAL suite):
`cmake -S code -B build-cgal -DWITH_CGAL_TESTS=ON && cmake --build build-cgal --target conformallab_cgal_tests -j8 && ctest --test-dir build-cgal -R '^cgal\.'`
- Phase details: `doc/roadmap/phases.md` (per-phase plan);
`doc/roadmap/research-track.md` (research items with acceptance criteria).
- Build flags reference: `CLAUDE.md` §Build commands (canonical source; use
`-DCONFORMALLAB_LOW_MEMORY_BUILD=ON -j1` if on the Raspberry Pi runner).
- After each implementation session, run the **Review gate** prompt (Opus).
---
## P1 — Quick wins (model: **Haiku**)
```
Use the Haiku model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
branch off main called `feat/p1-quick-wins`.
Implement four independent additions — full details (Java references, math
references, acceptance criteria) in doc/roadmap/phases.md at the sections
labelled 9h.1, 9h.2, 9g.1, 9d.3:
- 9h.1 Add --tol and --max-iter CLI options in code/src/conformallab_cli.cpp.
Thread both through run_euclidean / run_spherical / run_hyper_ideal.
Update doc/getting-started.md CLI parameter table.
- 9h.2 Add -g cp_euclidean and -g inversive_distance routes in the CLI,
following the existing run_euclidean() pattern (~60 lines each).
Add both geometry strings to the CLI::IsMember validator.
Update README + doc/getting-started.md.
- 9g.1 Create code/include/conformal_quality.hpp implementing:
IsothermicityMeasure, DiscreteConformalEquivalenceMeasure,
FlippedTriangles, LengthCrossRatio, ConvergenceUtility measures.
Java source classes are listed in phases.md §9g.1.
Each function must have at least one sanity test in code/tests/cgal/
(e.g. FlippedTriangles returns 0 on a valid layout; LengthCrossRatio
is 1.0 on an equilateral triangle). Register in code/tests/cgal/CMakeLists.txt.
- 9d.3 Create code/include/stereographic_layout.hpp porting
StereographicUnwrapper.java (266 LoC). See phases.md §9d.3 for the math
(stereographic projection S²→{∞} + Möbius centring for genus-0 surfaces).
Add at least one round-trip test (north pole → ∞; a unit-sphere point →
expected complex value).
Run the full CGAL suite after all four are implemented:
cmake -S code -B build-cgal -DWITH_CGAL_TESTS=ON && cmake --build build-cgal \
--target conformallab_cgal_tests -j8 && ctest --test-dir build-cgal -R '^cgal\.'
It must stay green with your new tests added.
Commit per phase (or in two logical commits) with trailer
`Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>`.
Push to origin and open a PR (base main) via the Gitea API using the token
in the origin remote URL. Update doc/roadmap/phase-orchestration.md (mark each
completed phase ✅ with the commit ref). Report the PR URL and test count.
```
---
## P2 — Decorated DCE transition (model: **Sonnet**)
```
Use the Sonnet model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
branch off main called `feat/p2-decorated-dce`.
Implement Phase 12 — Decorated DCE & geometric transition.
Full details and acceptance criteria in:
doc/roadmap/phases.md §Phase 12
doc/roadmap/research-track.md §Phase 12
Mathematical reference: Bobenko, Lutz 2025 "Decorated Discrete Conformal
Equivalence in Non-Euclidean Geometries" (Discrete & Comput. Geom.;
arXiv:2310.17529) §3 — Penner-coordinate decoration unifying the three
background geometries.
Scope (from phases.md):
1. Decoration layer — per-vertex circle/horocycle radius as Penner coordinate;
implement the map ↔ existing inversive distance I_ij via
ℓ² = r_i² + r_j² + 2 r_i r_j η.
→ Create code/include/decorated_dce.hpp.
2. Transition driver — deform background curvature κ ∈ {+,0,} while holding
the discrete conformal invariant fixed; call the three existing solvers
(euclidean, spherical, hyper_ideal).
3. Validation harness — code/tests/cgal/test_decorated_dce.cpp.
All four acceptance criteria from phases.md must be met:
- Decoration round-trip I_ij ↔ (r_i, r_j, ) at machine precision.
- At κ=0: bit-for-bit match with existing euclidean / inversive path.
- Gauss-Bonnet holds per geometry across the κ-transition.
- Invariant (I_ij) is constant across the three-geometry transition to tol.
Run the full CGAL suite (must stay green with new tests).
Commit with trailer `Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
Push to origin, open a PR (base main) via the Gitea API. Update
doc/roadmap/phase-orchestration.md (Phase 12 → ✅, session P2 + commit ref).
Report the PR URL.
```
---
## P3 — Circle pattern embedding + Möbius centring + convergence study (model: **Sonnet**)
```
Use the Sonnet model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
branch off main called `feat/p3-circle-pattern-convergence`.
Three items — full details in doc/roadmap/phases.md:
- 9e Create code/include/circle_pattern_layout.hpp porting
CirclePatternLayout + CirclePatternUtility. Phase 9a.1 (the CP-Euclidean
energy + solver) is a prerequisite and is already landed on main.
Java references: unwrapper/circlepattern/{CirclePatternLayout,
CirclePatternUtility,CPEuclideanRotation}.java (phases.md §9e).
Required test: given ρ values from a solved CP-Euclidean system, verify
that the embedded vertex positions are self-consistent (each face's three
circle-intersection points form the correct intersection angles to tol).
- 9d.4 Upgrade normalise_hyperbolic() in code/include/layout.hpp to use the
variational MobiusCenteringFunctional (Lorentz energy
E = Σ log(-⟨x,p⟩/√(-⟨x,x⟩)), gradient + Hessian).
Java reference: functional/MobiusCenteringFunctional.java (289 LoC).
Retain the existing Fréchet mean as a fallback if Newton fails.
Required test: compare old vs new centring output on brezel.obj; both
must place the centroid within tol of the origin.
- 9g.2 Add code/tests/cgal/test_period_matrix_convergence.cpp (experiment,
not a library feature — see phases.md §9g.2):
Generate a genus-1 elliptic mesh with a known analytic τ; subdivide via
igl::loop; add per-vertex Gaussian noise; compute |τ_discrete τ_analytic|
after each step. Assert that the residual decreases monotonically with
refinement (the discrete period matrix converges).
Run the full CGAL suite (must stay green).
Commit with trailer `Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
Push to origin, open a PR (base main) via the Gitea API. Update
doc/roadmap/phase-orchestration.md (9e/9d.4/9g.2 → ✅, P3 + commit ref).
Report the PR URL and the convergence-study output.
```
---
## P4 — Analytic HyperIdeal Hessian (model: **Opus**) — ⏸ GATED on reviewer Q3
```
PRECONDITION: do NOT start this session until the reviewer has answered Q3
("Is the ~6× speedup over block-FD worth ~2 weeks at your typical mesh sizes?").
If the answer is "no" or "not a priority", stop — Phase 9b-analytic stays ⏸.
If the answer is "yes" or "above V > X", proceed.
Use the Opus model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
branch off main called `feat/p4-analytic-hessian`.
Implement Phase 9b-analytic — the full analytic HyperIdeal Hessian via the
Schläfli identity. The complete chain-rule derivation (805-line LaTeX note) and
the implementation plan are in:
doc/math/hyperideal-hessian-derivation.md
doc/roadmap/phases.md §9b-analytic
doc/roadmap/research-track.md §Phase 9b-analytic
Chain: (bᵢ, aₑ) → ℓᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ → ∂²E/∂u².
Replace the block-FD path in code/include/hyper_ideal_hessian.hpp with the
analytic Hessian. Retain the block-FD path available as a compile-time flag
(-DCONFORMALLAB_HYPER_IDEAL_FD_CHECK or runtime enum) for cross-validation.
Acceptance criteria:
- All existing HyperIdeal golden-value tests pass bit-for-bit (HardJava clamp).
- New test: analytic and block-FD Hessians agree to 1e-6 on the tetrahedron.
- Benchmark: measure the analytic vs block-FD wall-time on the largest test
mesh; report the ratio. Analytic must be faster for V > 500.
Run the full CGAL suite (must stay green). Commit; push; open PR via Gitea API.
Update doc/roadmap/phase-orchestration.md (9b-analytic → ✅, P4 + commit ref).
Report PR URL and the measured speed ratio.
```
---
## Review gate (run after P1 / P2 / P3) (model: **Opus**)
```
Use the Opus model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp. Review
the open PR <PR_URL / branch name> as an independent reviewer. Check, and report
a pass/fail per item:
- Builds clean; full CGAL suite green with no count regression
(ctest --test-dir build-cgal -R '^cgal\.'); count matches doc/api/tests.md.
- Any new functional has a gradient-check test (pattern in CLAUDE.md §Test design patterns).
- No Java golden-vector / parity test perturbed; HardJava clamp default intact.
- Numeric changes are value-identical where claimed, or justified + covered by a test.
- New public surface (result types, enums, CGAL headers) is intentional and
documented in doc/api/headers.md and doc/api/contracts.md.
- Commit messages attribute the implementing model (Co-Authored-By trailer).
- Phase(s) marked ✅ in doc/roadmap/phase-orchestration.md with the commit ref.
Read the actual diff (git diff main...HEAD -- code/include/ code/tests/ doc/)
and the phases.md entry for the phase(s) involved. If you find a real problem,
fix it directly (small) or list precise required changes (larger), then re-run
the suite. Conclude with an explicit APPROVE / CHANGES-REQUESTED.
```