feat(phase5): Layout, CLI, JSON/XML serialisation — 95 tests

Phase 5 complete:

layout.hpp
  - euclidean_layout(): BFS unfolding in ℝ² using trilaterate_2d
  - spherical_layout(): BFS on S² using trilaterate_sph (spherical law of cosines)
  - hyper_ideal_layout(): BFS in Poincaré disk (tanh(d/2) Euclidean approx)
  - save_layout_off(): convenience OFF writer for 2-D and 3-D layouts

serialization.hpp
  - save/load_result_json(): nlohmann/json; stores DOF vector + uv/pos layout
  - save/load_result_xml(): hand-written writer/parser; same schema

conformallab_cli.cpp (rewritten)
  - CLI11 interface: -i/-o/-g/-j/-x/-s/-v
  - Dispatches to euclidean / spherical / hyper_ideal pipeline
  - Runs Newton, computes layout, saves OFF + JSON + XML

examples/example_layout.cpp
  - Full round-trip demo: solve → layout → JSON/XML → reload → verify

tests/cgal/test_layout.cpp (8 tests)
  - Euclidean_PreservesEdgeLengths, CorrectVertexCount, TriangleIsNonDegenerate
  - Spherical_PreservesArcLengths, PositionsOnUnitSphere
  - HyperIdeal_SuccessAndFinitePositions
  - Serialization.JSON_RoundTrip, XML_RoundTrip

All 95 CGAL tests pass (2 skipped — Hessian stubs unchanged).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-13 00:44:33 +02:00
parent 3f124eb071
commit b7593e3f6d
8 changed files with 1707 additions and 116 deletions

View File

@@ -1,107 +1,314 @@
#include <CGAL/Simple_cartesian.h>
#include <CGAL/Surface_mesh.h>
#include <CGAL/IO/polygon_mesh_io.h>
#include "viewer_utils.h"
#include "mesh_utils.hpp"
// conformallab_cli.cpp
//
// ConformalLab++ command-line interface.
//
// Usage:
// conformallab_core -i input.off [-o layout.off] [-g euclidean|spherical|hyper_ideal]
// [-j result.json] [-x result.xml] [-s] [-v]
//
// The tool:
// 1. Loads an OFF/OBJ/PLY mesh.
// 2. Sets up DOF maps + computes λ° from the input geometry.
// 3. Pins one vertex (Euclidean/Spherical) or uses all-free DOFs (HyperIdeal).
// 4. Runs Newton until convergence.
// 5. Computes a 2-D (Euclidean / HyperIdeal) or 3-D (Spherical) layout.
// 6. Saves the layout as an OFF file and optionally serialises the result
// to JSON and/or XML.
// 7. Optionally shows the input mesh in a viewer (-s flag, requires WITH_VIEWER).
#include "conformal_mesh.hpp"
#include "mesh_io.hpp"
#include "euclidean_functional.hpp"
#include "spherical_functional.hpp"
#include "hyper_ideal_functional.hpp"
#include "newton_solver.hpp"
#include "layout.hpp"
#include "serialization.hpp"
#include <CLI11.hpp>
#include <iostream>
#include <iomanip>
#include <string>
#include <vector>
#include <cmath>
#include <algorithm>
// Viewer is always available when WITH_CGAL=ON (implies WITH_VIEWER=ON)
#include "viewer_utils.h"
#include <Eigen/Core>
namespace cl = conformallab;
using cl::ConformalMesh;
using cl::Vertex_index;
using cl::Edge_index;
using Kernel = CGAL::Simple_cartesian<double>;
using Point = Kernel::Point_3;
using Mesh = CGAL::Surface_mesh<Point>;
// ─────────────────────────────────────────────────────────────────────────────
// Shared helpers (mirroring test_pipeline.cpp patterns)
// ─────────────────────────────────────────────────────────────────────────────
bool parse_arguments(int argc, char* argv[], std::string& input_file, std::string& output_file, bool& show, bool& verbose) {
CLI::App app{"demo of conformallab"};
app.add_option("-i,--input", input_file, "Input OFF file")->required();
app.add_option("-o,--output", output_file, "Output OFF file (optional)");
app.add_flag("-s,--show", show, "Show the input mesh in a viewer ");
app.add_flag("-v,--verbose",verbose, "Enable verbose output");
app.set_help_flag("-h,--help", "Show this help message and exit");
try {
CLI11_PARSE(app, argc, argv);
} catch (const CLI::ParseError &e) {
return false;
}
if (input_file.empty()) {
std::cerr << "Input file is required.\n";
return EXIT_FAILURE;
}
if (input_file.substr(input_file.find_last_of('.') + 1) != "off") {
std::cerr << "Unsupported file format. Please provide an OFF file.\n";
return EXIT_FAILURE;
}
std::cout << "Input file: " << input_file << "\n";
std::cout << "Output file: " << output_file << "\n";
std::cout << "Show mesh: " << (show ? "Yes" : "No") << "\n";
std::cout << "Verbose: " << (verbose ? "Yes" : "No") << "\n";
return true;
// Pin vertex 0, assign 0..n-1 to the rest
static int pin_first_vertex(ConformalMesh& mesh, cl::EuclideanMaps& maps)
{
auto vit = mesh.vertices().begin();
Vertex_index v0 = *vit++;
maps.v_idx[v0] = -1;
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit)
maps.v_idx[*vit] = idx++;
return idx;
}
// Natural theta for Euclidean: make x=0 the equilibrium
static void set_natural_euclidean_theta(ConformalMesh& mesh, cl::EuclideanMaps& maps, int n)
{
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G = cl::euclidean_gradient(mesh, x0, maps);
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv < 0) continue;
maps.theta_v[v] -= G[static_cast<std::size_t>(iv)];
}
}
// Natural theta for HyperIdeal at base point (b=1, a=0.5) to avoid x=0 singularity
static std::vector<double> set_natural_hyper_ideal_targets(
ConformalMesh& mesh, cl::HyperIdealMaps& maps, int n)
{
const auto sz = static_cast<std::size_t>(n);
std::vector<double> xbase(sz, 0.0);
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv >= 0) xbase[static_cast<std::size_t>(iv)] = 1.0;
}
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e];
if (ie >= 0) xbase[static_cast<std::size_t>(ie)] = 0.5;
}
auto G = cl::evaluate_hyper_ideal(mesh, xbase, maps, false).gradient;
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv < 0) continue;
maps.theta_v[v] += G[static_cast<std::size_t>(iv)];
}
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e];
if (ie < 0) continue;
maps.theta_e[e] += G[static_cast<std::size_t>(ie)];
}
return xbase;
}
// ─────────────────────────────────────────────────────────────────────────────
// Euclidean pipeline
// ─────────────────────────────────────────────────────────────────────────────
static int run_euclidean(ConformalMesh& mesh,
const std::string& out_layout,
const std::string& out_json,
const std::string& out_xml,
bool verbose)
{
// Setup
auto maps = cl::setup_euclidean_maps(mesh);
cl::compute_euclidean_lambda0_from_mesh(mesh, maps);
// DOF assignment: pin vertex 0
int n = pin_first_vertex(mesh, maps);
if (n <= 0) { std::cerr << "Error: mesh has only one vertex.\n"; return 1; }
// Natural target angles
set_natural_euclidean_theta(mesh, maps, n);
// Newton
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto res = cl::newton_euclidean(mesh, x0, maps);
if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
// Layout
cl::Layout2D layout = cl::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, "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, "euclidean",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
std::cout << std::fixed << std::setprecision(6);
std::cout << "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;
}
// ─────────────────────────────────────────────────────────────────────────────
// Spherical pipeline
// ─────────────────────────────────────────────────────────────────────────────
static int run_spherical(ConformalMesh& mesh,
const std::string& out_layout,
const std::string& out_json,
const std::string& out_xml,
bool verbose)
{
auto maps = cl::setup_spherical_maps(mesh);
cl::compute_lambda0_from_mesh(mesh, maps);
int n = cl::assign_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);
if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
cl::Layout3D layout = cl::spherical_layout(mesh, res.x, maps);
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout);
if (!out_json.empty())
cl::save_result_json(out_json, res, "spherical",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
nullptr, &layout);
if (!out_xml.empty())
cl::save_result_xml(out_xml, res, "spherical",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
nullptr, &layout);
std::cout << "Spherical: 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;
}
// ─────────────────────────────────────────────────────────────────────────────
// HyperIdeal pipeline
// ─────────────────────────────────────────────────────────────────────────────
static int run_hyper_ideal(ConformalMesh& mesh,
const std::string& out_layout,
const std::string& out_json,
const std::string& out_xml,
bool verbose)
{
auto maps = cl::setup_hyper_ideal_maps(mesh);
int n = cl::assign_all_dof_indices(mesh, maps);
// Natural targets at base point (b=1, a=0.5)
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
// Perturb slightly from the base and solve back
std::vector<double> x0 = xbase;
for (auto& v : x0) v += 0.3;
auto res = cl::newton_hyper_ideal(mesh, x0, maps);
if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
cl::Layout2D layout = cl::hyper_ideal_layout(mesh, res.x, maps);
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout);
if (!out_json.empty())
cl::save_result_json(out_json, res, "hyper_ideal",
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, "hyper_ideal",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
std::cout << "HyperIdeal: 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
// ─────────────────────────────────────────────────────────────────────────────
int main(int argc, char* argv[])
{
CLI::App app{"ConformalLab++ — discrete conformal mapping"};
std::string input_file;
std::string output_file;
bool show = false;
std::string out_layout;
std::string out_json;
std::string out_xml;
std::string geometry = "euclidean";
bool show = false;
bool verbose = false;
if(!parse_arguments(argc, argv, input_file, output_file, show, verbose)) {
std::cerr << "Failed to parse arguments.\n";
app.add_option("-i,--input", input_file, "Input mesh (OFF/OBJ/PLY)")->required();
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_flag("-s,--show", show, "Visualise input mesh (requires WITH_VIEWER)");
app.add_flag("-v,--verbose", verbose, "Verbose output");
CLI11_PARSE(app, argc, argv);
// ── Load mesh ─────────────────────────────────────────────────────────────
ConformalMesh mesh;
if (!cl::read_mesh(input_file, mesh) || mesh.is_empty()) {
std::cerr << "Error: cannot load mesh from '" << input_file << "'\n";
return EXIT_FAILURE;
}
std::cout << "Loaded: " << input_file
<< " (" << mesh.number_of_vertices() << "v, "
<< mesh.number_of_faces() << "f)\n";
Mesh surface_mesh;
if (!CGAL::IO::read_polygon_mesh(input_file, surface_mesh) || surface_mesh.is_empty()) {
std::cerr << "Invalid input file: " << input_file << "\n";
return EXIT_FAILURE;
}
// ── Optional viewer ───────────────────────────────────────────────────────
if (show) {
// Convert ConformalMesh to Eigen matrices for the libigl viewer
const std::size_t nv = mesh.number_of_vertices();
const std::size_t nf = mesh.number_of_faces();
Eigen::MatrixXd V(static_cast<Eigen::Index>(nv), 3);
Eigen::MatrixXi F(static_cast<Eigen::Index>(nf), 3);
// #TODO: later: here we would call the unwrapping code, e.g.:
// UnwrapSettings settings;
// settings.target_geometry = TargetGeometry::Euclidean;
// UnwrapJob job(surface_mesh, settings);
// auto result = job.run();
// Mesh unwrapped = result.surface_unwrapped;
// CGAL -> libigl
if(show){
std::cout << "Visualizing input mesh...\n";
Eigen::MatrixXd V;
Eigen::MatrixXi F;
mesh_utils::cgal_to_eigen<Kernel>(surface_mesh, V, F);
viewer_utils::simple_visualize(V, F);
}
// for now, we just write the input mesh to the output file as a placeholder
if (!output_file.empty() && !CGAL::IO::write_polygon_mesh(output_file, surface_mesh)) {
std::cerr << "Failed to write output file: " << output_file << "\n";
return EXIT_FAILURE;
for (auto v : mesh.vertices()) {
auto p = mesh.point(v);
V.row(v.idx()) << p.x(), p.y(), p.z();
}
Eigen::Index fi = 0;
for (auto f : mesh.faces()) {
int ci = 0;
for (auto h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh))
F(fi, ci++) = static_cast<int>(mesh.target(h).idx());
++fi;
}
viewer_utils::simple_visualize(V, F);
}
return EXIT_SUCCESS;
// ── Dispatch ──────────────────────────────────────────────────────────────
if (geometry == "euclidean")
return run_euclidean(mesh, out_layout, out_json, out_xml, verbose);
if (geometry == "spherical")
return run_spherical(mesh, out_layout, out_json, out_xml, verbose);
if (geometry == "hyper_ideal")
return run_hyper_ideal(mesh, out_layout, out_json, out_xml, verbose);
std::cerr << "Unknown geometry: " << geometry << "\n";
return EXIT_FAILURE;
}