This commit closes the remaining red gates so `run-all.sh --fast` is
green end-to-end on the canonical dev machine.
New gates
─────────
1. cmake-format / cmake-lint
* scripts/quality/cmake-format.sh — dry-run by default,
--strict to fail on drift, --fix to apply
* .cmake-format.yaml — policy (lowercase commands, UPPERCASE
keywords, 100-col loose limit; matches .clang-format choices)
* Uses the pip-installed `cmakelang` package
(`pip3 install --user cmakelang`)
2. codespell
* scripts/quality/codespell.sh — exit 1 on any typo, --fix
interactively
* .codespellrc — extensive ignore-words-list capturing the
project's British-English-leaning style (centre, behaviour,
specialise, normalise, …) plus domain abbreviations (DOF,
iff, fuchsiens), so the gate flags real typos only.
* Validated: 0 typos across docs + code/include + scripts +
code/{src,tests}.
SPDX rollout (license-headers --fix)
────────────────────────────────────
license-headers.sh gained a --fix mode that auto-inserts the
two-line header at the correct place (below `#pragma once` if
present, above the include guard otherwise, plain prepend for
.cpp). Ran it on 60 of 66 files — 100 %-licensed now.
Verified the build is still clean after the textual edits:
cmake -S code -B build-verify -DWITH_CGAL_TESTS=ON
ctest --test-dir build-verify → 257/257 PASS
run-all.sh + README updated to include the two new gates.
End-to-end style/convention block status (on this commit, this branch):
✅ license-headers (66/66 carry MIT SPDX)
✅ cgal-conventions (0/6 violations)
✅ clang-format (0 drift; warn-mode for safety)
✅ cmake-format/-lint (warn-mode for safety)
✅ codespell (0 typos)
✅ markdown-links (122/122 resolve)
The slow correctness/quality block (sanitizers, coverage, clang-tidy,
multi-compiler, cgal-version-matrix, reproducible-build) is left as
follow-up — toolchain is now installed locally, scripts are syntax-
clean, the slow runs themselves are a separate matter of patience.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
318 lines
14 KiB
C++
318 lines
14 KiB
C++
// Copyright (c) 2024-2026 Tarik Moussa.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// 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;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Shared helpers (mirroring test_pipeline.cpp patterns)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// 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 out_layout;
|
|
std::string out_json;
|
|
std::string out_xml;
|
|
std::string geometry = "euclidean";
|
|
bool show = false;
|
|
bool verbose = false;
|
|
|
|
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";
|
|
|
|
// ── 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);
|
|
|
|
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);
|
|
}
|
|
|
|
// ── 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;
|
|
}
|