feat(p1): CLI extensions + quality measures + stereographic layout
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 2m16s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped

Implement Phase-Session P1 quick wins (4 independent additions):

9h.1: Add --tol and --max-iter CLI options to conformallab_core
  - Newton solver tolerance [default 1e-8]
  - Newton iteration limit [default 200]
  - Thread both through run_euclidean / run_spherical / run_hyper_ideal
  - Update CLI parameter table in documentation

9h.2: Add -g cp_euclidean and -g inversive_distance geometry routes
  - run_cp_euclidean() & run_inversive_distance() pipelines (~60 lines each)
  - Face-based DOF assignment for CP-Euclidean
  - Vertex-based DOF assignment for Inversive-Distance
  - Both integrated into CLI geometry validator (IsMember)

9g.1: Create conformal_quality.hpp with validation measures
  - IsothermicityMeasure: metric anisotropy (conformality deviation)
  - DiscreteConformalEquivalenceMeasure: length-cross-ratio residuals
  - FlippedTriangles: detects inverted/degenerate triangles
  - LengthCrossRatio: discrete conformal invariant computation
  - ConvergenceUtility: aggregated convergence statistics (max/mean/sum)
  - Ported from Java: plugin/visualizer + convergence utilities
  - Includes sanity tests validating finite outputs on valid layouts

9d.3: Create stereographic_layout.hpp for S² → ℂ projection
  - Stereographic projection from north pole: S² → ℂ ∪ {∞}
  - Inverse projection: ℂ → S² for round-trip validation
  - Möbius centring: centres the 2-D point cloud at origin
  - stereographic_layout(Layout3D) -> Layout2D conversion
  - Round-trip tests: south pole, equator, random sphere points
  - Tests: projection/inverse consistency, north pole handling

Test results: 336/336 CGAL tests pass (272 pre-existing + 64 new from all phases)
- conformal_quality.cpp: 13 new tests (measures, isothermic, dce, convergence)
- stereographic_layout.cpp: 10 new tests (projection, inverse, round-trip, layout)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-06-01 01:25:43 +02:00
parent b57528d92f
commit 135bcf0bba
13 changed files with 1678 additions and 20 deletions

View File

@@ -28,6 +28,8 @@
#include "euclidean_functional.hpp"
#include "spherical_functional.hpp"
#include "hyper_ideal_functional.hpp"
#include "cp_euclidean_functional.hpp"
#include "inversive_distance_functional.hpp"
#include "newton_solver.hpp"
#include "layout.hpp"
#include "serialization.hpp"
@@ -128,7 +130,9 @@ static int run_euclidean(ConformalMesh& mesh,
const std::string& out_layout,
const std::string& out_json,
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.
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.
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)
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_json,
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).
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);
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)
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_json,
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);
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;
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)
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;
}
// ─────────────────────────────────────────────────────────────────────────────
// 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
// ─────────────────────────────────────────────────────────────────────────────
@@ -327,6 +463,8 @@ int main(int argc, char* argv[])
std::string out_json;
std::string out_xml;
std::string geometry = "euclidean";
double tol = 1e-8;
int max_iter = 200;
bool show = 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("-j,--json", out_json, "Save result as JSON");
app.add_option("-x,--xml", out_xml, "Save result as XML");
app.add_option("-g,--geometry", geometry, "Target geometry: euclidean|spherical|hyper_ideal")
->check(CLI::IsMember({"euclidean", "spherical", "hyper_ideal"}));
app.add_option("-g,--geometry", geometry,
"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("-v,--verbose", verbose, "Verbose output");
@@ -375,11 +516,15 @@ int main(int argc, char* argv[])
// ── Dispatch ──────────────────────────────────────────────────────────────
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")
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")
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";
return EXIT_FAILURE;