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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user