feat(p1): CLI extensions + quality measures + stereographic layout
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user