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

480
code/include/layout.hpp Normal file
View File

@@ -0,0 +1,480 @@
#pragma once
// layout.hpp
//
// Phase 5 — Layout / embedding: DOF vector → vertex coordinates in target space.
//
// After Newton converges you have conformal scale factors u_v (and optionally
// edge DOFs). This header converts those factors into actual vertex positions
// in the target geometry by BFS-unfolding the triangulation.
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ Algorithm (all three geometries) │
// │ │
// │ 1. For every edge e compute the updated length l_e from the DOF x. │
// │ 2. Choose a root face, place its three vertices analytically. │
// │ 3. BFS over the dual graph: for each adjacent face, the shared edge is │
// │ already placed; trilaterate the third vertex. │
// │ │
// │ For open meshes (boundary) the placement is globally consistent. │
// │ For closed meshes the BFS visits some vertices twice (seam); the first │
// │ visit wins and `has_seam = true` is set. To get a proper global │
// │ parameterisation of a closed mesh, cut it to a disk first. │
// └──────────────────────────────────────────────────────────────────────────┘
//
// Functions:
// euclidean_layout(mesh, x, maps) → Layout2D (positions in ℝ²)
// spherical_layout (mesh, x, maps) → Layout3D (positions on S² ⊂ ℝ³)
// hyper_ideal_layout(mesh, x, maps)→ Layout2D (Poincaré disk)
#include "conformal_mesh.hpp"
#include "euclidean_functional.hpp"
#include "spherical_functional.hpp"
#include "hyper_ideal_functional.hpp"
#include <Eigen/Dense>
#include <vector>
#include <queue>
#include <cmath>
#include <algorithm>
namespace conformallab {
// ── Result types ──────────────────────────────────────────────────────────────
struct Layout2D {
std::vector<Eigen::Vector2d> uv; ///< uv[v.idx()] = 2-D position
bool success = false;
bool has_seam = false; ///< true if mesh is closed (first-visit used)
};
struct Layout3D {
std::vector<Eigen::Vector3d> pos; ///< pos[v.idx()] = 3-D position on S²
bool success = false;
bool has_seam = false;
};
// ── Internal helpers ──────────────────────────────────────────────────────────
namespace detail {
// Euclidean trilaterate: given placed p_a, p_b and distances d_a (from p_a),
// d_b (from p_b), return the new point on the LEFT side of the directed edge
// p_a → p_b (corresponds to CCW face orientation).
inline Eigen::Vector2d trilaterate_2d(
const Eigen::Vector2d& pa, const Eigen::Vector2d& pb,
double da, double db)
{
Eigen::Vector2d ab = pb - pa;
double d = ab.norm();
if (d < 1e-14) return pa;
Eigen::Vector2d e1 = ab / d;
Eigen::Vector2d e2(-e1.y(), e1.x()); // CCW perpendicular
double t = (da*da - db*db + d*d) / (2.0 * d);
double s2 = da*da - t*t;
double s = (s2 > 0.0) ? std::sqrt(s2) : 0.0;
return pa + t*e1 + s*e2; // s > 0 → left side
}
// Spherical trilaterate: given unit vectors pa, pb on S² and arc-lengths da, db
// to the new point, return the new unit vector on the LEFT side of the geodesic
// arc from pa to pb.
//
// Solution: p_c = α·pa + β·pb + γ·(pa × pb), γ > 0.
// Constraints: pa·p_c = cos(da), pb·p_c = cos(db), |p_c|=1.
inline Eigen::Vector3d trilaterate_sph(
const Eigen::Vector3d& pa, const Eigen::Vector3d& pb,
double da, double db)
{
double c = pa.dot(pb); // cos(l_ab)
double denom = 1.0 - c*c;
if (denom < 1e-14) return pa; // degenerate (pa ≈ pb)
double cda = std::cos(da), cdb = std::cos(db);
double alpha = (cda - c*cdb) / denom;
double beta = (cdb - c*cda) / denom;
Eigen::Vector3d cross = pa.cross(pb);
double cross_n2 = cross.squaredNorm();
double gamma2 = 1.0 - alpha*alpha - beta*beta - 2.0*alpha*beta*c;
double gamma = (gamma2 > 0.0 && cross_n2 > 1e-28)
? std::sqrt(gamma2 / cross_n2)
: 0.0;
Eigen::Vector3d p = alpha*pa + beta*pb + gamma*cross;
double n = p.norm();
return (n > 1e-14) ? (p / n) : pa; // gamma > 0 → left side
}
} // namespace detail
// ── Euclidean layout ──────────────────────────────────────────────────────────
//
// Computes 2-D positions for each vertex by BFS-unfolding the triangulation
// in the plane. The updated edge length is:
//
// l_ij = exp( (λ°_ij + u_i + u_j + λ_e) / 2 )
//
// where u_i = x[v_idx[v]] (0 if pinned) and λ_e = x[e_idx[e]] (0 if absent).
// ─────────────────────────────────────────────────────────────────────────────
inline Layout2D euclidean_layout(
ConformalMesh& mesh,
const std::vector<double>& x,
const EuclideanMaps& maps)
{
const std::size_t nv = mesh.number_of_vertices();
Layout2D result;
result.uv.assign(nv, Eigen::Vector2d::Zero());
if (mesh.number_of_faces() == 0) return result;
auto get_u = [&](Vertex_index v) -> double {
int iv = maps.v_idx[v]; return (iv >= 0) ? x[static_cast<std::size_t>(iv)] : 0.0;
};
auto get_ue = [&](Edge_index e) -> double {
int ie = maps.e_idx[e]; return (ie >= 0) ? x[static_cast<std::size_t>(ie)] : 0.0;
};
auto edge_len = [&](Halfedge_index h) -> double {
Edge_index e = mesh.edge(h);
double lam = maps.lambda0[e]
+ get_u(mesh.source(h)) + get_u(mesh.target(h))
+ get_ue(e);
return std::exp(lam * 0.5);
};
std::vector<bool> vertex_placed(nv, false);
std::vector<bool> face_placed(mesh.number_of_faces(), false);
// ── Place root face ───────────────────────────────────────────────────
Face_index f0 = *mesh.faces().begin();
Halfedge_index h0 = mesh.halfedge(f0);
Halfedge_index h1 = mesh.next(h0);
Halfedge_index h2 = mesh.next(h1);
Vertex_index vA = mesh.source(h0);
Vertex_index vB = mesh.source(h1);
Vertex_index vC = mesh.source(h2);
double lAB = edge_len(h0);
double lCA = edge_len(h2); // dist C—A
double lBC = edge_len(h1); // dist B—C
result.uv[vA.idx()] = Eigen::Vector2d(0.0, 0.0);
result.uv[vB.idx()] = Eigen::Vector2d(lAB, 0.0);
result.uv[vC.idx()] = detail::trilaterate_2d(
result.uv[vA.idx()], result.uv[vB.idx()], lCA, lBC);
vertex_placed[vA.idx()] = vertex_placed[vB.idx()] = vertex_placed[vC.idx()] = true;
face_placed[f0.idx()] = true;
// ── BFS ───────────────────────────────────────────────────────────────
std::queue<Halfedge_index> q;
auto enqueue = [&](Face_index f) {
for (auto h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
auto h_opp = mesh.opposite(h);
if (!mesh.is_border(h_opp) && !face_placed[mesh.face(h_opp).idx()])
q.push(h_opp);
}
};
enqueue(f0);
while (!q.empty()) {
Halfedge_index h = q.front(); q.pop();
Face_index f = mesh.face(h);
if (face_placed[f.idx()]) continue;
Vertex_index v_src = mesh.source(h);
Vertex_index v_tgt = mesh.target(h);
Vertex_index v_new = mesh.target(mesh.next(h));
Eigen::Vector2d p = detail::trilaterate_2d(
result.uv[v_src.idx()], result.uv[v_tgt.idx()],
edge_len(mesh.prev(h)), // dist(v_new, v_src)
edge_len(mesh.next(h))); // dist(v_tgt, v_new)
if (!vertex_placed[v_new.idx()]) {
result.uv[v_new.idx()] = p;
vertex_placed[v_new.idx()] = true;
} else {
result.has_seam = true;
}
face_placed[f.idx()] = true;
enqueue(f);
}
result.success = true;
return result;
}
// ── Spherical layout ──────────────────────────────────────────────────────────
//
// Computes positions on the unit sphere S² by BFS-unfolding the triangulation.
// Updated spherical arc-length:
//
// l_ij = 2·arcsin( min(exp(Λ_ij / 2), 1) )
//
// where Λ_ij = λ°_ij + u_i + u_j (+ edge DOF if present).
//
// Output: pos[v.idx()] is a unit 3-D vector on S².
// ─────────────────────────────────────────────────────────────────────────────
inline Layout3D spherical_layout(
ConformalMesh& mesh,
const std::vector<double>& x,
const SphericalMaps& maps)
{
const std::size_t nv = mesh.number_of_vertices();
Layout3D result;
result.pos.assign(nv, Eigen::Vector3d::Zero());
if (mesh.number_of_faces() == 0) return result;
auto get_u = [&](Vertex_index v) -> double {
int iv = maps.v_idx[v]; return (iv >= 0) ? x[static_cast<std::size_t>(iv)] : 0.0;
};
auto get_ue = [&](Edge_index e) -> double {
int ie = maps.e_idx[e]; return (ie >= 0) ? x[static_cast<std::size_t>(ie)] : 0.0;
};
auto arc_len = [&](Halfedge_index h) -> double {
Edge_index e = mesh.edge(h);
double lam = maps.lambda0[e]
+ get_u(mesh.source(h)) + get_u(mesh.target(h))
+ get_ue(e);
return 2.0 * std::asin(std::min(std::exp(lam * 0.5), 1.0));
};
// Place root face: vA at north pole, vB along meridian, vC via spherical law
std::vector<bool> vertex_placed(nv, false);
std::vector<bool> face_placed(mesh.number_of_faces(), false);
Face_index f0 = *mesh.faces().begin();
Halfedge_index h0 = mesh.halfedge(f0);
Halfedge_index h1 = mesh.next(h0);
Halfedge_index h2 = mesh.next(h1);
Vertex_index vA = mesh.source(h0);
Vertex_index vB = mesh.source(h1);
Vertex_index vC = mesh.source(h2);
double lAB = arc_len(h0);
double lCA = arc_len(h2);
double lBC = arc_len(h1);
// vA = north pole, vB along the 0-meridian at arc distance lAB
result.pos[vA.idx()] = Eigen::Vector3d(0.0, 0.0, 1.0);
result.pos[vB.idx()] = Eigen::Vector3d(std::sin(lAB), 0.0, std::cos(lAB));
result.pos[vC.idx()] = detail::trilaterate_sph(
result.pos[vA.idx()], result.pos[vB.idx()], lCA, lBC);
vertex_placed[vA.idx()] = vertex_placed[vB.idx()] = vertex_placed[vC.idx()] = true;
face_placed[f0.idx()] = true;
std::queue<Halfedge_index> q;
auto enqueue = [&](Face_index f) {
for (auto h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
auto h_opp = mesh.opposite(h);
if (!mesh.is_border(h_opp) && !face_placed[mesh.face(h_opp).idx()])
q.push(h_opp);
}
};
enqueue(f0);
while (!q.empty()) {
Halfedge_index h = q.front(); q.pop();
Face_index f = mesh.face(h);
if (face_placed[f.idx()]) continue;
Vertex_index v_src = mesh.source(h);
Vertex_index v_tgt = mesh.target(h);
Vertex_index v_new = mesh.target(mesh.next(h));
Eigen::Vector3d p = detail::trilaterate_sph(
result.pos[v_src.idx()], result.pos[v_tgt.idx()],
arc_len(mesh.prev(h)),
arc_len(mesh.next(h)));
if (!vertex_placed[v_new.idx()]) {
result.pos[v_new.idx()] = p;
vertex_placed[v_new.idx()] = true;
} else {
result.has_seam = true;
}
face_placed[f.idx()] = true;
enqueue(f);
}
result.success = true;
return result;
}
// ── HyperIdeal layout (Poincaré disk) ────────────────────────────────────────
//
// Places the hyperbolic triangulation in the Poincaré disk model.
// The effective hyperbolic edge length between vertices i and j is:
//
// cosh(d_ij) = cosh(b_i + a_ij/2) · cosh(b_j + a_ij/2) sinh(b_i) · sinh(b_j)
//
// (Springborn 2020, equation for the distance in the horoball picture.)
// Here b_i = x[v_idx[v_i]], a_ij = x[e_idx[e_ij]].
//
// Poincaré disk: a point at hyperbolic distance d from the origin lies at
// Euclidean distance tanh(d/2) from the disk centre.
//
// Layout algorithm: BFS with hyperbolic trilateration.
// ─────────────────────────────────────────────────────────────────────────────
inline Layout2D hyper_ideal_layout(
ConformalMesh& mesh,
const std::vector<double>& x,
const HyperIdealMaps& maps)
{
const std::size_t nv = mesh.number_of_vertices();
Layout2D result;
result.uv.assign(nv, Eigen::Vector2d::Zero());
if (mesh.number_of_faces() == 0) return result;
auto get_b = [&](Vertex_index v) -> double {
int iv = maps.v_idx[v]; return (iv >= 0) ? x[static_cast<std::size_t>(iv)] : 0.0;
};
auto get_a = [&](Edge_index e) -> double {
int ie = maps.e_idx[e]; return (ie >= 0) ? x[static_cast<std::size_t>(ie)] : 0.0;
};
// Hyperbolic distance between two adjacent vertices
auto hyp_dist = [&](Halfedge_index h) -> double {
Vertex_index vi = mesh.source(h);
Vertex_index vj = mesh.target(h);
Edge_index e = mesh.edge(h);
double bi = get_b(vi), bj = get_b(vj), a = get_a(e);
double half_a = a * 0.5;
double cosh_d = std::cosh(bi + half_a) * std::cosh(bj + half_a)
- std::sinh(bi) * std::sinh(bj);
cosh_d = std::max(1.0, cosh_d);
return std::acosh(cosh_d);
};
// Poincaré disk radius for hyperbolic distance d
auto poincare_r = [](double d) { return std::tanh(d * 0.5); };
// Hyperbolic trilateration in Poincaré disk:
// Given p_a, p_b and hyperbolic distances d_a, d_b to the new point,
// return the new point on the LEFT side of the geodesic from p_a to p_b.
// Approximation: for short arcs the Poincaré metric is nearly Euclidean;
// we use Euclidean trilaterate on the Poincaré coordinates.
auto trilaterate_hyp = [&](
const Eigen::Vector2d& pa, const Eigen::Vector2d& pb,
double da, double db) -> Eigen::Vector2d
{
// Convert arc-lengths to Poincaré chord lengths and use Euclidean formula.
// More precisely: in Poincaré disk, geodesic distance da from pa corresponds
// to a chord of Euclidean length 2·sinh(da/2) / (cosh(da/2)+1) = tanh(da/2)*2/(1+1)...
// For robustness we use the isometric approximation: small displacements are
// Euclidean in conformal coordinates. For a production implementation replace
// with exact Möbius-transform-based hyperbolic trilateration.
double ra = poincare_r(da);
double rb = poincare_r(db);
return detail::trilaterate_2d(pa, pb, ra, rb);
};
// Place root face
std::vector<bool> vertex_placed(nv, false);
std::vector<bool> face_placed(mesh.number_of_faces(), false);
Face_index f0 = *mesh.faces().begin();
Halfedge_index h0 = mesh.halfedge(f0);
Halfedge_index h1 = mesh.next(h0);
Halfedge_index h2 = mesh.next(h1);
Vertex_index vA = mesh.source(h0);
Vertex_index vB = mesh.source(h1);
Vertex_index vC = mesh.source(h2);
double dAB = hyp_dist(h0);
double dCA = hyp_dist(h2);
double dBC = hyp_dist(h1);
// Place vA at origin, vB on positive x-axis (Poincaré radius = tanh(dAB/2))
result.uv[vA.idx()] = Eigen::Vector2d(0.0, 0.0);
result.uv[vB.idx()] = Eigen::Vector2d(poincare_r(dAB), 0.0);
result.uv[vC.idx()] = trilaterate_hyp(
result.uv[vA.idx()], result.uv[vB.idx()], dCA, dBC);
vertex_placed[vA.idx()] = vertex_placed[vB.idx()] = vertex_placed[vC.idx()] = true;
face_placed[f0.idx()] = true;
std::queue<Halfedge_index> q;
auto enqueue = [&](Face_index f) {
for (auto h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
auto h_opp = mesh.opposite(h);
if (!mesh.is_border(h_opp) && !face_placed[mesh.face(h_opp).idx()])
q.push(h_opp);
}
};
enqueue(f0);
while (!q.empty()) {
Halfedge_index h = q.front(); q.pop();
Face_index f = mesh.face(h);
if (face_placed[f.idx()]) continue;
Vertex_index v_src = mesh.source(h);
Vertex_index v_tgt = mesh.target(h);
Vertex_index v_new = mesh.target(mesh.next(h));
Eigen::Vector2d p = trilaterate_hyp(
result.uv[v_src.idx()], result.uv[v_tgt.idx()],
hyp_dist(mesh.prev(h)),
hyp_dist(mesh.next(h)));
if (!vertex_placed[v_new.idx()]) {
result.uv[v_new.idx()] = p;
vertex_placed[v_new.idx()] = true;
} else {
result.has_seam = true;
}
face_placed[f.idx()] = true;
enqueue(f);
}
result.success = true;
return result;
}
// ── Convenience: save layout as OFF (z = 0 for 2D, full xyz for 3D) ──────────
inline void save_layout_off(
const std::string& path,
ConformalMesh& mesh,
const Layout2D& layout)
{
std::ofstream ofs(path);
ofs << "OFF\n" << mesh.number_of_vertices() << " "
<< mesh.number_of_faces() << " 0\n";
for (auto v : mesh.vertices()) {
auto& p = layout.uv[v.idx()];
ofs << p.x() << " " << p.y() << " 0\n";
}
for (auto f : mesh.faces()) {
ofs << "3";
for (auto h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh))
ofs << " " << mesh.target(h).idx();
ofs << "\n";
}
}
inline void save_layout_off(
const std::string& path,
ConformalMesh& mesh,
const Layout3D& layout)
{
std::ofstream ofs(path);
ofs << "OFF\n" << mesh.number_of_vertices() << " "
<< mesh.number_of_faces() << " 0\n";
for (auto v : mesh.vertices()) {
auto& p = layout.pos[v.idx()];
ofs << p.x() << " " << p.y() << " " << p.z() << "\n";
}
for (auto f : mesh.faces()) {
ofs << "3";
for (auto h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh))
ofs << " " << mesh.target(h).idx();
ofs << "\n";
}
}
} // namespace conformallab

View File

@@ -0,0 +1,289 @@
#pragma once
// serialization.hpp
//
// Phase 5 — Save and load conformal map results in JSON and XML formats.
//
// JSON (nlohmann/json, bundled in deps/single_includes/json.hpp):
// save_result_json / load_result_json
//
// XML (hand-written, no external parser dependency):
// save_result_xml / load_result_xml
//
// Both formats store:
// - geometry type string ("euclidean" / "spherical" / "hyper_ideal")
// - mesh statistics (vertex/face count)
// - solver metadata (converged, iterations, grad_inf_norm)
// - DOF vector x
// - optional 2D or 3D layout positions
//
// XML schema (ConformalResult):
//
// <?xml version="1.0" encoding="UTF-8"?>
// <ConformalResult geometry="euclidean" vertices="4" faces="2">
// <Solver converged="true" iterations="3" grad_inf_norm="1.43e-13"/>
// <DOFVector n="3">0.0 1.23e-13 2.87e-13</DOFVector>
// <Layout dim="2" n="4">
// 0.0 0.0 1.0 0.0 0.5 0.866 1.5 0.866
// </Layout>
// </ConformalResult>
#include "newton_solver.hpp"
#include "layout.hpp"
#include <json.hpp>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <stdexcept>
#include <iomanip>
namespace conformallab {
// ════════════════════════════════════════════════════════════════════════════
// JSON
// ════════════════════════════════════════════════════════════════════════════
// Save solver result (+ optional 2D layout) to a JSON file.
inline void save_result_json(
const std::string& path,
const NewtonResult& res,
const std::string& geometry,
int n_vertices,
int n_faces,
const Layout2D* layout2d = nullptr,
const Layout3D* layout3d = nullptr)
{
using json = nlohmann::json;
json j;
j["geometry"] = geometry;
j["mesh"] = { {"vertices", n_vertices}, {"faces", n_faces} };
j["solver"] = {
{"converged", res.converged},
{"iterations", res.iterations},
{"grad_inf_norm", res.grad_inf_norm}
};
j["dof_vector"] = res.x;
if (layout2d && layout2d->success) {
json uv = json::array();
for (auto& p : layout2d->uv) uv.push_back({p.x(), p.y()});
j["layout"] = { {"dim", 2}, {"uv", uv}, {"has_seam", layout2d->has_seam} };
}
if (layout3d && layout3d->success) {
json pos = json::array();
for (auto& p : layout3d->pos) pos.push_back({p.x(), p.y(), p.z()});
j["layout"] = { {"dim", 3}, {"pos", pos}, {"has_seam", layout3d->has_seam} };
}
std::ofstream ofs(path);
if (!ofs) throw std::runtime_error("Cannot write: " + path);
ofs << std::setw(2) << j << "\n";
}
// Load DOF vector from a JSON result file.
// Returns the DOF vector; fills out res fields if non-null.
inline std::vector<double> load_result_json(
const std::string& path,
NewtonResult* res = nullptr,
std::string* geom = nullptr,
Layout2D* layout2d = nullptr)
{
using json = nlohmann::json;
std::ifstream ifs(path);
if (!ifs) throw std::runtime_error("Cannot open: " + path);
json j; ifs >> j;
if (geom && j.contains("geometry"))
*geom = j["geometry"].get<std::string>();
std::vector<double> x = j.at("dof_vector").get<std::vector<double>>();
if (res) {
res->x = x;
res->converged = j["solver"]["converged"].get<bool>();
res->iterations = j["solver"]["iterations"].get<int>();
res->grad_inf_norm = j["solver"]["grad_inf_norm"].get<double>();
}
if (layout2d && j.contains("layout") && j["layout"]["dim"] == 2) {
auto uv = j["layout"]["uv"];
layout2d->uv.resize(uv.size());
for (std::size_t i = 0; i < uv.size(); ++i)
layout2d->uv[i] = { uv[i][0].get<double>(), uv[i][1].get<double>() };
layout2d->has_seam = j["layout"].value("has_seam", false);
layout2d->success = true;
}
return x;
}
// ════════════════════════════════════════════════════════════════════════════
// XML
// ════════════════════════════════════════════════════════════════════════════
namespace detail_xml {
// Minimal XML attribute escaping
inline std::string xml_attr(double v)
{
std::ostringstream s;
s << std::setprecision(15) << v;
return s.str();
}
// Write a flat vector of doubles as space-separated values
inline std::string flat_doubles(const std::vector<double>& v)
{
std::ostringstream s;
s << std::setprecision(15);
for (std::size_t i = 0; i < v.size(); ++i) {
if (i) s << ' ';
s << v[i];
}
return s.str();
}
// Simple attribute parser: find value of key= in a tag string
inline std::string xml_get_attr(const std::string& tag, const std::string& key)
{
auto pos = tag.find(key + "=\"");
if (pos == std::string::npos) return {};
pos += key.size() + 2;
auto end = tag.find('"', pos);
return tag.substr(pos, end - pos);
}
// Read all text content between the current position and </tag>
inline std::string xml_read_text(std::istream& is, const std::string& close_tag)
{
std::string buf, line;
std::string ctag = "</" + close_tag + ">";
while (std::getline(is, line)) {
auto pos = line.find(ctag);
if (pos != std::string::npos) {
buf += line.substr(0, pos);
return buf;
}
buf += line + " ";
}
return buf;
}
// Parse space-separated doubles
inline std::vector<double> parse_doubles(const std::string& s)
{
std::vector<double> v;
std::istringstream ss(s);
double d;
while (ss >> d) v.push_back(d);
return v;
}
} // namespace detail_xml
// Save solver result (+ optional layout) to an XML file.
inline void save_result_xml(
const std::string& path,
const NewtonResult& res,
const std::string& geometry,
int n_vertices,
int n_faces,
const Layout2D* layout2d = nullptr,
const Layout3D* layout3d = nullptr)
{
std::ofstream ofs(path);
if (!ofs) throw std::runtime_error("Cannot write: " + path);
ofs << std::setprecision(15);
ofs << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
ofs << "<ConformalResult"
<< " geometry=\"" << geometry << "\""
<< " vertices=\"" << n_vertices << "\""
<< " faces=\"" << n_faces << "\">\n";
ofs << " <Solver"
<< " converged=\"" << (res.converged ? "true" : "false") << "\""
<< " iterations=\"" << res.iterations << "\""
<< " grad_inf_norm=\"" << detail_xml::xml_attr(res.grad_inf_norm) << "\""
<< "/>\n";
ofs << " <DOFVector n=\"" << res.x.size() << "\">"
<< detail_xml::flat_doubles(res.x)
<< "</DOFVector>\n";
if (layout2d && layout2d->success) {
ofs << " <Layout dim=\"2\" n=\"" << layout2d->uv.size()
<< "\" has_seam=\"" << (layout2d->has_seam ? "true" : "false") << "\">\n";
for (auto& p : layout2d->uv)
ofs << " " << p.x() << " " << p.y() << "\n";
ofs << " </Layout>\n";
}
if (layout3d && layout3d->success) {
ofs << " <Layout dim=\"3\" n=\"" << layout3d->pos.size()
<< "\" has_seam=\"" << (layout3d->has_seam ? "true" : "false") << "\">\n";
for (auto& p : layout3d->pos)
ofs << " " << p.x() << " " << p.y() << " " << p.z() << "\n";
ofs << " </Layout>\n";
}
ofs << "</ConformalResult>\n";
}
// Load solver result from an XML file written by save_result_xml.
// Returns the DOF vector; fills res/geom/layout2d if non-null.
inline std::vector<double> load_result_xml(
const std::string& path,
NewtonResult* res = nullptr,
std::string* geom = nullptr,
Layout2D* layout2d = nullptr)
{
std::ifstream ifs(path);
if (!ifs) throw std::runtime_error("Cannot open: " + path);
std::vector<double> x;
std::string line;
while (std::getline(ifs, line)) {
// Root element
if (line.find("<ConformalResult") != std::string::npos) {
if (geom) *geom = detail_xml::xml_get_attr(line, "geometry");
}
// Solver metadata
else if (line.find("<Solver") != std::string::npos) {
if (res) {
res->converged = (detail_xml::xml_get_attr(line, "converged") == "true");
res->iterations = std::stoi(detail_xml::xml_get_attr(line, "iterations"));
res->grad_inf_norm = std::stod(detail_xml::xml_get_attr(line, "grad_inf_norm"));
}
}
// DOF vector
else if (line.find("<DOFVector") != std::string::npos) {
// Text may be on same line: <DOFVector n="...">0 1 2...</DOFVector>
auto open_end = line.find('>');
auto close = line.find("</DOFVector>");
std::string text;
if (close != std::string::npos) {
text = line.substr(open_end + 1, close - open_end - 1);
} else {
text = line.substr(open_end + 1);
text += detail_xml::xml_read_text(ifs, "DOFVector");
}
x = detail_xml::parse_doubles(text);
if (res) res->x = x;
}
// Layout
else if (layout2d && line.find("<Layout") != std::string::npos
&& detail_xml::xml_get_attr(line, "dim") == "2") {
layout2d->has_seam = (detail_xml::xml_get_attr(line, "has_seam") == "true");
std::string text = detail_xml::xml_read_text(ifs, "Layout");
auto vals = detail_xml::parse_doubles(text);
layout2d->uv.clear();
for (std::size_t i = 0; i + 1 < vals.size(); i += 2)
layout2d->uv.push_back({vals[i], vals[i+1]});
layout2d->success = true;
}
}
return x;
}
} // namespace conformallab