Files
ConformalLabpp/code/include/layout.hpp
user2595 704f42bbfd
Some checks failed
C++ Tests / test-fast (push) Has started running
C++ Tests / test-cgal (push) Has been cancelled
API Docs / doc-build (push) Has been cancelled
Doxygen → Codeberg Pages / publish (push) Has been cancelled
Markdown link check / check (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
Merge pull request 'ci+quality: structural gates (CI: 3 new; local: 7 new + .clang-tidy)' (#18) from ci/structural-tests into main
2026-05-26 09:14:45 +00:00

910 lines
46 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#pragma once
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// layout.hpp
//
// Phase 5/6/7 — Layout / embedding: DOF vector → vertex coordinates in the
// target geometry via BFS-trilateration.
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ Algorithm (all three geometries) │
// │ │
// │ 1. For every edge e compute the updated length l_e from the DOF x. │
// │ 2. Choose root face = largest 3-D area face (quality heuristic). │
// │ 3. Priority BFS over the dual graph: faces are processed in order of │
// │ shortest distance (BFS depth) from the root face, minimising error │
// │ accumulation. Equal depth: arbitrary tie-break. │
// │ 4. For each face: trilaterate the one unplaced vertex from the two │
// │ already-placed vertices on the shared edge. │
// │ 5. If a CutGraph is supplied, cut edges are treated as boundary; │
// │ holonomy is recorded for each cut edge. │
// │ 6. After the first connected component, unvisited faces start new │
// │ BFS sweeps (multi-component support). │
// │ │
// │ For open meshes: globally consistent placement. │
// │ For closed meshes without CutGraph: first (shallowest) visit wins, │
// │ has_seam = true. │
// │ For closed meshes with CutGraph: each vertex placed exactly once; │
// │ holonomy = translation (Euclidean) or Möbius map (hyperbolic). │
// └──────────────────────────────────────────────────────────────────────────┘
//
// Trilateration
// Euclidean — exact (analytic formula in ℝ²)
// Spherical — exact (spherical law of cosines on S²)
// Hyperbolic — exact (hyperbolic law of cosines + Möbius maps,
// Poincaré disk model)
//
// Normalisation
// normalise_euclidean(layout) — centroid → origin, major axis → x-axis (PCA)
// normalise_hyperbolic(layout) — face-area-weighted iterative Möbius centering
// normalise_spherical(layout) — rotate centroid to north pole (Rodrigues)
//
// Holonomy (genus-g surfaces with CutGraph)
// HolonomyData.translations[i] — translation ω_i (Euclidean / spherical)
// HolonomyData.mobius_maps[i] — Möbius map T_i (hyperbolic)
// For a flat torus: ω_1, ω_2 are the lattice generators; τ = ω_2/ω_1 ∈ .
//
// Texture atlas
// Layout2D.halfedge_uv[h.idx()] — UV of source(h) as seen from face(h).
// At seam edges the two opposite halfedges carry different UV values,
// enabling proper per-halfedge UV for GPU texture atlasing.
//
// Möbius map
// MobiusMap::from_three(z1→w1, z2→w2, z3→w3) — fit a Möbius transformation
// to three point correspondences (via 3×3 complex linear system).
#include "conformal_mesh.hpp"
#include "euclidean_functional.hpp"
#include "spherical_functional.hpp"
#include "hyper_ideal_functional.hpp"
#include "cut_graph.hpp"
#include <Eigen/Dense>
#include <vector>
#include <queue>
#include <complex>
#include <cmath>
#include <algorithm>
#include <limits>
#include <fstream>
#include <string>
namespace conformallab {
// ── Möbius map ────────────────────────────────────────────────────────────────
//
// T(z) = (a·z + b) / (c·z + d)
//
// For hyperbolic holonomy the map is an orientation-preserving isometry of
// the Poincaré disk (SU(1,1) element).
// ─────────────────────────────────────────────────────────────────────────────
/// Möbius transformation `T(z) = (a·z + b) / (c·z + d)` of the Riemann
/// sphere; restricted to SU(1,1) for hyperbolic holonomy on the
/// Poincaré disk.
struct MobiusMap {
/// Complex scalar used for all entries.
using C = std::complex<double>;
C a{1.0, 0.0}; ///< Top-left coefficient.
C b{0.0, 0.0}; ///< Top-right coefficient.
C c{0.0, 0.0}; ///< Bottom-left coefficient.
C d{1.0, 0.0}; ///< Bottom-right coefficient.
/// Apply the transformation to a complex point.
C apply(C z) const { return (a * z + b) / (c * z + d); }
/// Apply the transformation to a 2-D real point (interpreted as `x + iy`).
Eigen::Vector2d apply(const Eigen::Vector2d& p) const {
C w = apply(C(p.x(), p.y()));
return Eigen::Vector2d(w.real(), w.imag());
}
/// Identity map.
static MobiusMap identity() { return {C(1), C(0), C(0), C(1)}; }
/// Inverse map.
MobiusMap inverse() const { return {d, -b, -c, a}; }
/// Composition `(*this) ∘ T`, i.e. apply `T` first then `*this`.
MobiusMap compose(const MobiusMap& T) const {
return { a*T.a + b*T.c, a*T.b + b*T.d,
c*T.a + d*T.c, c*T.b + d*T.d };
}
/// `true` iff the map is the identity up to tolerance `tol`.
bool is_identity(double tol = 1e-9) const {
if (std::abs(d) < 1e-14) return false;
C a_ = a/d, b_ = b/d, c_ = c/d;
return std::abs(a_ - C(1)) < tol && std::abs(b_) < tol && std::abs(c_) < tol;
}
/// Fit T to three point correspondences T(z_i) = w_i.
/// Sets d=1 and solves the 3×3 complex linear system.
/// Returns identity when the system is (near-)singular.
static MobiusMap from_three(C z1, C w1, C z2, C w2, C z3, C w3) {
Eigen::Matrix3cd M;
M << z1, C(1), -w1*z1,
z2, C(1), -w2*z2,
z3, C(1), -w3*z3;
Eigen::Vector3cd rhs; rhs << w1, w2, w3;
Eigen::ColPivHouseholderQR<Eigen::Matrix3cd> qr(M);
if (qr.rank() < 3) return identity();
Eigen::Vector3cd sol = qr.solve(rhs);
return { sol[0], sol[1], sol[2], C(1.0) };
}
};
// ── Result types ──────────────────────────────────────────────────────────────
/// Result of a 2-D layout (`euclidean_layout`, `hyper_ideal_layout`):
/// per-vertex UV coordinates plus a per-half-edge UV atlas for seamed
/// textures.
struct Layout2D {
/// uv[v.idx()] — primary 2-D position (first / shallowest-BFS-depth visit).
std::vector<Eigen::Vector2d> uv;
/// halfedge_uv[h.idx()] — UV of source(h) as seen from face(h).
///
/// For interior (non-seam) halfedges: equals uv[source(h).idx()].
/// For seam halfedges: carries the UV from the virtual unfolding across
/// the cut (i.e. the trilaterated position that was NOT used as the
/// primary uv). This gives each face its own copy of a seam vertex,
/// enabling a proper GPU texture atlas without vertex duplication.
///
/// Size = mesh.number_of_halfedges(). Border halfedges = (0,0).
std::vector<Eigen::Vector2d> halfedge_uv;
bool success = false; ///< `true` iff the BFS placed every vertex.
bool has_seam = false; ///< `true` when a vertex was reached via two paths.
};
/// Result of a 3-D layout (`spherical_layout`): per-vertex positions on S².
struct Layout3D {
std::vector<Eigen::Vector3d> pos; ///< Per-vertex spherical positions.
bool success = false; ///< `true` iff the BFS placed every vertex.
bool has_seam = false; ///< `true` when a vertex was reached via two paths.
};
/// Per-cut-edge holonomy.
///
/// Euclidean: translations[i] = ω_i (translation vector for cut_edge_indices[i]).
/// For a flat torus ω_1, ω_2 are the lattice generators; τ = ω_2/ω_1 ∈ .
///
/// Hyperbolic: mobius_maps[i] = T_i (Möbius isometry of the Poincaré disk).
/// T_i(p_actual) = p_virtual — maps the placed vertex position to the
/// trilaterated virtual position obtained by continuing the unfolding across
/// the cut.
struct HolonomyData {
std::vector<Eigen::Vector2d> translations; ///< Euclidean / spherical translation per cut edge.
std::vector<MobiusMap> mobius_maps; ///< Hyperbolic Möbius isometry per cut edge (Phase 7).
std::vector<std::size_t> cut_edge_indices; ///< Index (in the cut-graph edge list) of each holonomy entry.
};
// ── Internal helpers ──────────────────────────────────────────────────────────
namespace detail {
// ─────────────────────────────────────────────────────────────────────────────
// Priority-BFS queue entry
// Faces closer to the root (lower depth) are processed first, reducing the
// accumulation of trilateration errors for later-placed vertices.
// ─────────────────────────────────────────────────────────────────────────────
struct BFSEntry {
int depth; ///< BFS depth = max(depth[v_src], depth[v_tgt]) + 1
Halfedge_index h; ///< halfedge pointing INTO the face to be placed
/// min-heap: smaller depth = higher priority
bool operator>(const BFSEntry& o) const { return depth > o.depth; }
};
using BFSQueue = std::priority_queue<BFSEntry,
std::vector<BFSEntry>,
std::greater<BFSEntry>>;
// ─────────────────────────────────────────────────────────────────────────────
// Euclidean trilateration — LEFT (CCW) side of p_a → p_b
// ─────────────────────────────────────────────────────────────────────────────
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());
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;
}
// ─────────────────────────────────────────────────────────────────────────────
// Spherical trilateration (S²) — LEFT side of geodesic arc pa → pb
// ─────────────────────────────────────────────────────────────────────────────
inline Eigen::Vector3d trilaterate_sph(
const Eigen::Vector3d& pa, const Eigen::Vector3d& pb,
double da, double db)
{
double c = pa.dot(pb);
double denom = 1.0 - c*c;
if (denom < 1e-14) return pa;
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 cn2 = cross.squaredNorm();
double gamma2 = 1.0 - alpha*alpha - beta*beta - 2.0*alpha*beta*c;
double gamma = (gamma2 > 0.0 && cn2 > 1e-28) ? std::sqrt(gamma2 / cn2) : 0.0;
Eigen::Vector3d p = alpha*pa + beta*pb + gamma*cross;
double n = p.norm();
return (n > 1e-14) ? (p / n) : pa;
}
// ─────────────────────────────────────────────────────────────────────────────
// Hyperbolic trilateration — exact via Möbius + hyperbolic law of cosines.
// LEFT (CCW) side of pa → pb in the Poincaré disk.
// ─────────────────────────────────────────────────────────────────────────────
inline Eigen::Vector2d trilaterate_hyp(
const Eigen::Vector2d& pa, const Eigen::Vector2d& pb,
double D, double da, double db)
{
using C = std::complex<double>;
if (D < 1e-12 || da < 1e-12) return pa;
if (std::sinh(da) * std::sinh(D) < 1e-14) return pa;
C a(pa.x(), pa.y()), b(pb.x(), pb.y());
auto fwd = [](C z, C c_) { return (z-c_)/(C(1)-std::conj(c_)*z); };
auto inv = [](C w, C c_) { return (w+c_)/(C(1)+std::conj(c_)*w); };
double theta_b = std::arg(fwd(b, a));
double cos_alpha = (std::cosh(da)*std::cosh(D) - std::cosh(db))
/ (std::sinh(da)*std::sinh(D));
cos_alpha = std::max(-1.0, std::min(1.0, cos_alpha));
C pc_origin = std::tanh(da*0.5) * std::exp(C(0.0, theta_b + std::acos(cos_alpha)));
C pc_c = inv(pc_origin, a);
return Eigen::Vector2d(pc_c.real(), pc_c.imag());
}
// ─────────────────────────────────────────────────────────────────────────────
// 3-D face area (cross product / 2)
// ─────────────────────────────────────────────────────────────────────────────
inline double face_3d_area(const ConformalMesh& mesh, Face_index f)
{
Halfedge_index h = mesh.halfedge(f);
auto pA = mesh.point(mesh.source(h));
auto pB = mesh.point(mesh.target(h));
auto pC = mesh.point(mesh.target(mesh.next(h)));
Eigen::Vector3d ab(pB.x()-pA.x(), pB.y()-pA.y(), pB.z()-pA.z());
Eigen::Vector3d ac(pC.x()-pA.x(), pC.y()-pA.y(), pC.z()-pA.z());
return 0.5 * ab.cross(ac).norm();
}
// ─────────────────────────────────────────────────────────────────────────────
// Best root face: largest 3-D area, with 1.5× bonus for interior faces.
// ─────────────────────────────────────────────────────────────────────────────
inline Face_index best_root_face(const ConformalMesh& mesh)
{
Face_index best;
double best_score = -1.0;
for (auto f : mesh.faces()) {
double area = face_3d_area(mesh, f);
bool interior = true;
for (auto h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh))
if (mesh.is_border(mesh.opposite(h))) { interior = false; break; }
double score = area * (interior ? 1.5 : 1.0);
if (score > best_score) { best_score = score; best = f; }
}
return best;
}
// ─────────────────────────────────────────────────────────────────────────────
// Euclidean bounding box of placed vertices (for multi-component offset)
// ─────────────────────────────────────────────────────────────────────────────
inline void bbox_2d(const std::vector<Eigen::Vector2d>& uv,
const std::vector<bool>& placed,
double& xmax)
{
xmax = 0.0;
for (std::size_t i = 0; i < uv.size(); ++i)
if (placed[i]) xmax = std::max(xmax, uv[i].x());
}
// ─────────────────────────────────────────────────────────────────────────────
// Möbius centering — unweighted (fallback / Phase 6 compat.)
// ─────────────────────────────────────────────────────────────────────────────
inline void center_poincare_disk(std::vector<Eigen::Vector2d>& uv)
{
if (uv.empty()) return;
using C = std::complex<double>;
C c(0);
for (auto& p : uv) c += C(p.x(), p.y());
c /= static_cast<double>(uv.size());
double r = std::abs(c);
if (r > 0.999) c *= 0.999/r;
if (r < 1e-12) return;
for (auto& p : uv) {
C z(p.x(), p.y());
C w = (z-c)/(C(1)-std::conj(c)*z);
p = {w.real(), w.imag()};
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Face-area-weighted iterative Möbius centering (Fréchet mean, Phase 7).
// weights[i] = Voronoi area of vertex i.
// ─────────────────────────────────────────────────────────────────────────────
inline void center_poincare_disk_weighted(
std::vector<Eigen::Vector2d>& uv, const std::vector<double>& weights)
{
if (uv.empty()) return;
using C = std::complex<double>;
double total_w = 0.0; for (double w : weights) total_w += w;
if (total_w < 1e-14) { center_poincare_disk(uv); return; }
C c(0);
for (int iter = 0; iter < 30; ++iter) {
C mt(0);
for (std::size_t i = 0; i < uv.size(); ++i) {
C z(uv[i].x(), uv[i].y());
mt += weights[i] * (z-c)/(C(1)-std::conj(c)*z);
}
mt /= total_w;
C c_new = (mt+c)/(C(1)+std::conj(c)*mt);
if (std::abs(c_new-c) < 1e-12) { c = c_new; break; }
c = c_new;
}
double r = std::abs(c);
if (r > 0.999) c *= 0.999/r;
if (r < 1e-12) return;
for (auto& p : uv) {
C z(p.x(), p.y());
C w = (z-c)/(C(1)-std::conj(c)*z);
p = {w.real(), w.imag()};
}
}
} // namespace detail
/// Compute per-vertex area weights (sum of 1/3 of each adjacent triangle area).
/// Used by area-weighted layout normalisation routines.
inline std::vector<double> compute_vertex_area_weights(const ConformalMesh& mesh)
{
std::vector<double> w(mesh.number_of_vertices(), 0.0);
for (auto f : mesh.faces()) {
double area = detail::face_3d_area(mesh, f);
for (auto h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh))
w[static_cast<std::size_t>(mesh.target(h).idx())] += area / 3.0;
}
return w;
}
// ── Layout normalisation ──────────────────────────────────────────────────────
/// Euclidean canonical normalisation: translate centroid to the origin
/// and rotate the principal axis of the UV cloud onto the x-axis.
inline void normalise_euclidean(Layout2D& layout)
{
if (!layout.success || layout.uv.empty()) return;
const std::size_t n = layout.uv.size();
Eigen::Vector2d mean = Eigen::Vector2d::Zero();
for (auto& p : layout.uv) mean += p;
mean /= static_cast<double>(n);
for (auto& p : layout.uv) p -= mean;
for (auto& p : layout.halfedge_uv) p -= mean;
Eigen::Matrix2d cov = Eigen::Matrix2d::Zero();
for (auto& p : layout.uv) cov += p * p.transpose();
cov /= static_cast<double>(n);
Eigen::SelfAdjointEigenSolver<Eigen::Matrix2d> eig(cov);
Eigen::Vector2d major = eig.eigenvectors().col(1);
double angle = -std::atan2(major.y(), major.x());
Eigen::Matrix2d R;
R << std::cos(angle), -std::sin(angle),
std::sin(angle), std::cos(angle);
for (auto& p : layout.uv) p = R * p;
for (auto& p : layout.halfedge_uv) p = R * p;
}
/// Hyperbolic — face-area-weighted iterative Möbius centering.
inline void normalise_hyperbolic(Layout2D& layout, const ConformalMesh& mesh)
{
if (!layout.success || layout.uv.empty()) return;
auto weights = compute_vertex_area_weights(mesh);
detail::center_poincare_disk_weighted(layout.uv, weights);
// halfedge_uv follows the same Möbius map
detail::center_poincare_disk(layout.halfedge_uv);
}
/// Hyperbolic canonical normalisation, mesh-free fallback: uniform
/// (unweighted) iterative Möbius centring of the Poincaré disk.
inline void normalise_hyperbolic(Layout2D& layout) // fallback without mesh
{
if (!layout.success || layout.uv.empty()) return;
detail::center_poincare_disk(layout.uv);
detail::center_poincare_disk(layout.halfedge_uv);
}
/// Spherical canonical normalisation: rotate the layout so that the
/// per-vertex centroid (projected back to S²) coincides with the north
/// pole (Rodrigues rotation).
inline void normalise_spherical(Layout3D& layout)
{
if (!layout.success || layout.pos.empty()) return;
Eigen::Vector3d mean = Eigen::Vector3d::Zero();
for (auto& p : layout.pos) mean += p;
double len = mean.norm(); if (len < 1e-12) return;
mean /= len;
Eigen::Vector3d north(0, 0, 1);
Eigen::Vector3d axis = mean.cross(north);
double sin_a = axis.norm(), cos_a = mean.dot(north);
if (sin_a < 1e-12) return;
axis /= sin_a;
Eigen::Matrix3d K;
K << 0, -axis.z(), axis.y(),
axis.z(), 0, -axis.x(),
-axis.y(), axis.x(), 0;
Eigen::Matrix3d Rot = Eigen::Matrix3d::Identity() + sin_a*K + (1-cos_a)*K*K;
for (auto& p : layout.pos) p = Rot * p;
}
// ─────────────────────────────────────────────────────────────────────────────
// BFS placement helpers shared across all three layout functions.
//
// set_face_huv: populate halfedge_uv for the three halfedges of face f,
// given that the face was entered via halfedge h_enter.
// h_enter: source=v_src, target=v_tgt → huv = uv[v_src]
// next(h_enter): source=v_tgt, target=v_new → huv = uv[v_tgt]
// prev(h_enter): source=v_new, target=v_src → huv = p_new (trilaterated)
// ─────────────────────────────────────────────────────────────────────────────
namespace detail {
inline void set_face_huv_2d(
std::vector<Eigen::Vector2d>& huv,
const ConformalMesh& mesh,
Halfedge_index h,
const std::vector<Eigen::Vector2d>& uv,
const Eigen::Vector2d& p_new)
{
auto s = [](std::size_t x){ return x; };
huv[s(static_cast<std::size_t>(h.idx()))] = uv[static_cast<std::size_t>(mesh.source(h).idx())];
huv[s(static_cast<std::size_t>(mesh.next(h).idx()))] = uv[static_cast<std::size_t>(mesh.target(h).idx())];
huv[s(static_cast<std::size_t>(mesh.prev(h).idx()))] = p_new;
}
inline void set_root_huv_2d(
std::vector<Eigen::Vector2d>& huv,
const ConformalMesh& mesh,
Face_index f,
const std::vector<Eigen::Vector2d>& uv)
{
for (auto hf : CGAL::halfedges_around_face(mesh.halfedge(f), mesh))
huv[static_cast<std::size_t>(hf.idx())] = uv[static_cast<std::size_t>(mesh.source(hf).idx())];
}
} // namespace detail
// ── Euclidean layout ──────────────────────────────────────────────────────────
/// Embed the mesh in ℝ² using the Euclidean metric encoded in x.
///
/// Runs priority-BFS trilateration: places vertices in order of BFS depth
/// from the root face (largest 3D area), so errors accumulate last.
/// For closed genus-g surfaces a CutGraph must be supplied — otherwise
/// the layout will have a seam discontinuity (Layout2D::has_seam = true).
///
/// \param mesh Input surface mesh. Must have lambda0 and v_idx set in maps.
/// \param x DOF vector returned by newton_euclidean().
/// \param maps EuclideanMaps (lambda0, v_idx, e_idx).
/// \param cut Optional cut graph (compute_cut_graph()). Pass nullptr for
/// open meshes or if seams are acceptable.
/// \param holonomy If non-null and cut != nullptr, receives the lattice
/// translations ω_i ∈ per cut edge.
/// Pass to compute_period_matrix() for the conformal modulus τ.
/// \param normalise If true, calls normalise_euclidean() on the result:
/// centroid → origin, major axis → x-axis (PCA).
/// \return Layout2D with .uv[v] (per-vertex UV) and
/// .halfedge_uv[h] (per-halfedge UV for texture atlasing).
///
/// \note halfedge_uv differs from uv at seam edges: the two sides of a cut
/// carry different UV coordinates for proper GPU texture atlasing.
inline Layout2D euclidean_layout(
ConformalMesh& mesh,
const std::vector<double>& x,
const EuclideanMaps& maps,
const CutGraph* cut = nullptr,
HolonomyData* holonomy = nullptr,
bool normalise = false)
{
const std::size_t nv = mesh.number_of_vertices();
const std::size_t nf = mesh.number_of_faces();
const std::size_t nh = mesh.number_of_halfedges();
Layout2D result;
result.uv.assign(nv, Eigen::Vector2d::Zero());
result.halfedge_uv.assign(nh, Eigen::Vector2d::Zero());
if (nf == 0) return result;
auto get_u = [&](Vertex_index v) {
int iv = maps.v_idx[v]; return (iv>=0) ? x[static_cast<std::size_t>(iv)] : 0.0; };
auto get_ue = [&](Edge_index e) {
int ie = maps.e_idx[e]; return (ie>=0) ? x[static_cast<std::size_t>(ie)] : 0.0; };
auto edge_len = [&](Halfedge_index h) {
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(nf, false);
std::vector<int> vertex_depth(nv, std::numeric_limits<int>::max());
auto place_component = [&](Face_index f_root, Eigen::Vector2d offset) {
Halfedge_index h0 = mesh.halfedge(f_root);
Halfedge_index h1 = mesh.next(h0), h2 = mesh.next(h1);
Vertex_index vA = mesh.source(h0), vB = mesh.source(h1), vC = mesh.source(h2);
double lAB = edge_len(h0), lBC = edge_len(h1), lCA = edge_len(h2);
result.uv[vA.idx()] = offset;
result.uv[vB.idx()] = offset + 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;
vertex_depth[vA.idx()] = vertex_depth[vB.idx()] = vertex_depth[vC.idx()] = 0;
face_placed[f_root.idx()] = true;
detail::set_root_huv_2d(result.halfedge_uv, mesh, f_root, result.uv);
detail::BFSQueue pq;
auto enqueue = [&](Face_index f) {
for (auto hf : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
Halfedge_index ho = mesh.opposite(hf);
if (mesh.is_border(ho)) continue;
if (cut && cut->is_cut(mesh.edge(hf))) continue;
Face_index fadj = mesh.face(ho);
if (!face_placed[fadj.idx()]) {
int d = std::max(vertex_depth[mesh.source(ho).idx()],
vertex_depth[mesh.target(ho).idx()]) + 1;
pq.push({d, ho});
}
}
};
enqueue(f_root);
while (!pq.empty()) {
auto [depth, h] = pq.top(); pq.pop();
Face_index f = mesh.face(h);
if (face_placed[f.idx()]) continue;
Vertex_index v_src = mesh.source(h), 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)), edge_len(mesh.next(h)));
if (!vertex_placed[v_new.idx()]) {
result.uv[v_new.idx()] = p;
vertex_placed[v_new.idx()] = true;
vertex_depth[v_new.idx()] = depth;
} else {
result.has_seam = true;
}
// halfedge_uv: p is the UV of v_new in THIS face (may differ from uv[v_new] at seam)
detail::set_face_huv_2d(result.halfedge_uv, mesh, h, result.uv, p);
face_placed[f.idx()] = true;
enqueue(f);
}
};
place_component(detail::best_root_face(mesh), Eigen::Vector2d::Zero());
for (auto f : mesh.faces()) {
if (face_placed[f.idx()]) continue;
double xmax; detail::bbox_2d(result.uv, vertex_placed, xmax);
place_component(f, Eigen::Vector2d(xmax * 1.1 + 1.0, 0.0));
}
result.success = true;
// ── Holonomy ──────────────────────────────────────────────────────────────
if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.clear();
holonomy->translations.reserve(cut->cut_edge_indices.size());
holonomy->mobius_maps.clear();
for (std::size_t ce_idx : cut->cut_edge_indices) {
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce_idx));
Halfedge_index h = mesh.halfedge(e);
Halfedge_index ho = mesh.opposite(h);
Halfedge_index hx = mesh.is_border(ho) ? h : ho;
if (mesh.is_border(hx)) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
Vertex_index vs = mesh.source(hx), vt = mesh.target(hx), vn = mesh.target(mesh.next(hx));
if (!vertex_placed[vn.idx()]) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
Eigen::Vector2d p_tri = detail::trilaterate_2d(
result.uv[vs.idx()], result.uv[vt.idx()],
edge_len(mesh.prev(hx)), edge_len(mesh.next(hx)));
// Store seam UV for the cut-crossing halfedges
detail::set_face_huv_2d(result.halfedge_uv, mesh, hx, result.uv, p_tri);
holonomy->translations.push_back(p_tri - result.uv[vn.idx()]);
}
}
if (normalise) normalise_euclidean(result);
return result;
}
// ── Spherical layout ──────────────────────────────────────────────────────────
/// Embed the mesh on the unit sphere S² using the spherical metric encoded in x.
///
/// Runs priority-BFS trilateration using the spherical law of cosines.
/// Typical use: genus-0 (sphere-like) surfaces after newton_spherical().
///
/// \param mesh Input genus-0 surface mesh.
/// \param x DOF vector returned by newton_spherical().
/// \param maps SphericalMaps.
/// \param cut Optional cut graph (rarely needed for genus-0).
/// \param holonomy If non-null, receives rotational holonomies (spherical).
/// \param normalise If true, calls normalise_spherical(): rotates the centroid
/// to the north pole (Rodrigues rotation formula).
/// \return Layout3D with .xyz[v] ∈ S² ⊂ ℝ³ for each vertex.
inline Layout3D spherical_layout(
ConformalMesh& mesh,
const std::vector<double>& x,
const SphericalMaps& maps,
const CutGraph* cut = nullptr,
HolonomyData* holonomy = nullptr,
bool normalise = false)
{
const std::size_t nv = mesh.number_of_vertices();
const std::size_t nf = mesh.number_of_faces();
Layout3D result;
result.pos.assign(nv, Eigen::Vector3d::Zero());
if (nf == 0) return result;
auto get_u = [&](Vertex_index v) {
int iv = maps.v_idx[v]; return (iv>=0) ? x[static_cast<std::size_t>(iv)] : 0.0; };
auto get_ue = [&](Edge_index e) {
int ie = maps.e_idx[e]; return (ie>=0) ? x[static_cast<std::size_t>(ie)] : 0.0; };
auto arc_len = [&](Halfedge_index h) {
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)); };
std::vector<bool> vertex_placed(nv, false);
std::vector<bool> face_placed(nf, false);
std::vector<int> vertex_depth(nv, std::numeric_limits<int>::max());
auto place_component = [&](Face_index f_root) {
Halfedge_index h0 = mesh.halfedge(f_root);
Halfedge_index h1 = mesh.next(h0), h2 = mesh.next(h1);
Vertex_index vA = mesh.source(h0), vB = mesh.source(h1), vC = mesh.source(h2);
double lAB = arc_len(h0), lBC = arc_len(h1), lCA = arc_len(h2);
result.pos[vA.idx()] = Eigen::Vector3d(0, 0, 1);
result.pos[vB.idx()] = Eigen::Vector3d(std::sin(lAB), 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;
vertex_depth[vA.idx()] = vertex_depth[vB.idx()] = vertex_depth[vC.idx()] = 0;
face_placed[f_root.idx()] = true;
detail::BFSQueue pq;
auto enqueue = [&](Face_index f) {
for (auto hf : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
Halfedge_index ho = mesh.opposite(hf);
if (mesh.is_border(ho)) continue;
if (cut && cut->is_cut(mesh.edge(hf))) continue;
Face_index fadj = mesh.face(ho);
if (!face_placed[fadj.idx()]) {
int d = std::max(vertex_depth[mesh.source(ho).idx()],
vertex_depth[mesh.target(ho).idx()]) + 1;
pq.push({d, ho});
}
}
};
enqueue(f_root);
while (!pq.empty()) {
auto [depth, h] = pq.top(); pq.pop();
Face_index f = mesh.face(h);
if (face_placed[f.idx()]) continue;
Vertex_index vs = mesh.source(h), vt = mesh.target(h), vn = mesh.target(mesh.next(h));
Eigen::Vector3d p = detail::trilaterate_sph(
result.pos[vs.idx()], result.pos[vt.idx()],
arc_len(mesh.prev(h)), arc_len(mesh.next(h)));
if (!vertex_placed[vn.idx()]) {
result.pos[vn.idx()] = p; vertex_placed[vn.idx()] = true; vertex_depth[vn.idx()] = depth;
} else result.has_seam = true;
face_placed[f.idx()] = true; enqueue(f);
}
};
place_component(detail::best_root_face(mesh));
for (auto f : mesh.faces()) if (!face_placed[f.idx()]) place_component(f);
result.success = true;
if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.clear();
holonomy->translations.reserve(cut->cut_edge_indices.size());
holonomy->mobius_maps.clear();
for (std::size_t ce_idx : cut->cut_edge_indices) {
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce_idx));
Halfedge_index hx = mesh.is_border(mesh.opposite(mesh.halfedge(e)))
? mesh.halfedge(e) : mesh.opposite(mesh.halfedge(e));
if (mesh.is_border(hx)) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
Vertex_index vs = mesh.source(hx), vt = mesh.target(hx), vn = mesh.target(mesh.next(hx));
if (!vertex_placed[vn.idx()]) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
Eigen::Vector3d p_tri = detail::trilaterate_sph(
result.pos[vs.idx()], result.pos[vt.idx()],
arc_len(mesh.prev(hx)), arc_len(mesh.next(hx)));
Eigen::Vector3d diff = p_tri - result.pos[vn.idx()];
// Note: spherical holonomy is geometrically a 3-D rotation, not a 2-D
// translation. The Vector2d here stores only the (x,y) component of the
// S²-position difference across the cut, which is an approximation.
// For accurate spherical holonomy (rotation axis + angle) use the full
// 3-D positions in result.pos[] directly. Phase 10+ will replace this
// with a proper SO(3) representation.
holonomy->translations.push_back(Eigen::Vector2d(diff.x(), diff.y()));
}
}
if (normalise) normalise_spherical(result);
return result;
}
// ── HyperIdeal layout (Poincaré disk) — exact trilateration ──────────────────
/// Embed the mesh in the Poincaré disk (H²) using the hyperbolic metric encoded in x.
///
/// Runs priority-BFS trilateration using exact Möbius-isometric placement:
/// each new vertex is located by solving the hyperbolic law of cosines and
/// applying a Möbius map to position it in the disk.
/// For closed genus-g surfaces (g ≥ 1) a CutGraph is required.
///
/// \param mesh Input genus-g surface mesh (g ≥ 1).
/// \param x DOF vector returned by newton_hyper_ideal()
/// (vertex b_v and edge a_e variables).
/// \param maps HyperIdealMaps (lambda0, v_idx, e_idx).
/// \param cut CutGraph from compute_cut_graph(). Required for closed surfaces.
/// \param holonomy If non-null, receives the Möbius maps T_i ∈ SU(1,1) per cut edge.
/// Pass to compute_period_matrix() for holonomy analysis.
/// \param normalise If true, calls normalise_hyperbolic(): iterative face-area-weighted
/// Möbius centring (Fréchet mean, 30 iterations) → disk origin.
/// \return Layout2D with .uv[v] ∈ Poincaré disk (|uv| < 1) for each vertex,
/// and .halfedge_uv[h] for seam-aware texture atlasing.
///
/// \note All vertex positions satisfy |uv[v]| < 1 (inside the Poincaré disk)
/// if the metric is hyperbolic. Points on or outside the boundary indicate
/// a non-hyperbolic metric (GaussBonnet violation).
inline Layout2D hyper_ideal_layout(
ConformalMesh& mesh,
const std::vector<double>& x,
const HyperIdealMaps& maps,
const CutGraph* cut = nullptr,
HolonomyData* holonomy = nullptr,
bool normalise = false)
{
const std::size_t nv = mesh.number_of_vertices();
const std::size_t nf = mesh.number_of_faces();
const std::size_t nh = mesh.number_of_halfedges();
Layout2D result;
result.uv.assign(nv, Eigen::Vector2d::Zero());
result.halfedge_uv.assign(nh, Eigen::Vector2d::Zero());
if (nf == 0) return result;
auto get_b = [&](Vertex_index v) {
int iv = maps.v_idx[v]; return (iv>=0) ? x[static_cast<std::size_t>(iv)] : 0.0; };
auto get_a = [&](Edge_index e) {
int ie = maps.e_idx[e]; return (ie>=0) ? x[static_cast<std::size_t>(ie)] : 0.0; };
auto hyp_dist = [&](Halfedge_index h) {
double bi = get_b(mesh.source(h)), bj = get_b(mesh.target(h)), a = get_a(mesh.edge(h));
return std::acosh(std::max(1.0, std::cosh(bi+a*0.5)*std::cosh(bj+a*0.5)-std::sinh(bi)*std::sinh(bj))); };
std::vector<bool> vertex_placed(nv, false);
std::vector<bool> face_placed(nf, false);
std::vector<int> vertex_depth(nv, std::numeric_limits<int>::max());
auto place_component = [&](Face_index f_root) {
Halfedge_index h0 = mesh.halfedge(f_root);
Halfedge_index h1 = mesh.next(h0), h2 = mesh.next(h1);
Vertex_index vA = mesh.source(h0), vB = mesh.source(h1), vC = mesh.source(h2);
double dAB = hyp_dist(h0), dBC = hyp_dist(h1), dCA = hyp_dist(h2);
result.uv[vA.idx()] = Eigen::Vector2d::Zero();
result.uv[vB.idx()] = Eigen::Vector2d(std::tanh(dAB*0.5), 0.0);
result.uv[vC.idx()] = detail::trilaterate_hyp(result.uv[vA.idx()], result.uv[vB.idx()], dAB, dCA, dBC);
vertex_placed[vA.idx()] = vertex_placed[vB.idx()] = vertex_placed[vC.idx()] = true;
vertex_depth[vA.idx()] = vertex_depth[vB.idx()] = vertex_depth[vC.idx()] = 0;
face_placed[f_root.idx()] = true;
detail::set_root_huv_2d(result.halfedge_uv, mesh, f_root, result.uv);
detail::BFSQueue pq;
auto enqueue = [&](Face_index f) {
for (auto hf : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
Halfedge_index ho = mesh.opposite(hf);
if (mesh.is_border(ho)) continue;
if (cut && cut->is_cut(mesh.edge(hf))) continue;
Face_index fadj = mesh.face(ho);
if (!face_placed[fadj.idx()]) {
int d = std::max(vertex_depth[mesh.source(ho).idx()],
vertex_depth[mesh.target(ho).idx()]) + 1;
pq.push({d, ho});
}
}
};
enqueue(f_root);
while (!pq.empty()) {
auto [depth, h] = pq.top(); pq.pop();
Face_index f = mesh.face(h);
if (face_placed[f.idx()]) continue;
Vertex_index vs = mesh.source(h), vt = mesh.target(h), vn = mesh.target(mesh.next(h));
double D = hyp_dist(h), da = hyp_dist(mesh.prev(h)), db = hyp_dist(mesh.next(h));
Eigen::Vector2d p = detail::trilaterate_hyp(result.uv[vs.idx()], result.uv[vt.idx()], D, da, db);
if (!vertex_placed[vn.idx()]) {
result.uv[vn.idx()] = p; vertex_placed[vn.idx()] = true; vertex_depth[vn.idx()] = depth;
} else result.has_seam = true;
detail::set_face_huv_2d(result.halfedge_uv, mesh, h, result.uv, p);
face_placed[f.idx()] = true; enqueue(f);
}
};
place_component(detail::best_root_face(mesh));
for (auto f : mesh.faces()) if (!face_placed[f.idx()]) place_component(f);
result.success = true;
// ── Möbius-map holonomy ───────────────────────────────────────────────────
if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.clear();
holonomy->mobius_maps.clear();
holonomy->mobius_maps.reserve(cut->cut_edge_indices.size());
for (std::size_t ce_idx : cut->cut_edge_indices) {
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce_idx));
Halfedge_index hcut = mesh.halfedge(e), ho = mesh.opposite(hcut);
Halfedge_index hx = mesh.is_border(ho) ? hcut : ho;
if (mesh.is_border(hx)) { holonomy->mobius_maps.push_back(MobiusMap::identity()); continue; }
Vertex_index vs = mesh.source(hx), vt = mesh.target(hx), vn = mesh.target(mesh.next(hx));
if (!vertex_placed[vn.idx()]) { holonomy->mobius_maps.push_back(MobiusMap::identity()); continue; }
double D = hyp_dist(hx), da = hyp_dist(mesh.prev(hx)), db = hyp_dist(mesh.next(hx));
Eigen::Vector2d p_tri = detail::trilaterate_hyp(result.uv[vs.idx()], result.uv[vt.idx()], D, da, db);
detail::set_face_huv_2d(result.halfedge_uv, mesh, hx, result.uv, p_tri);
using C = std::complex<double>;
// Möbius deck transformation T across cut edge (vs,vt):
// T is the unique Möbius isometry of the Poincaré disk that:
// - fixes vs and vt (z1=w1, z2=w2: the cut-edge endpoints are
// identified across the seam, so T maps each to itself)
// - maps vn (placed side) → p_tri (virtual side)
// This uniquely determines the hyperbolic translation/rotation
// along the geodesic through vs and vt.
holonomy->mobius_maps.push_back(MobiusMap::from_three(
C(result.uv[vs.idx()].x(), result.uv[vs.idx()].y()),
C(result.uv[vs.idx()].x(), result.uv[vs.idx()].y()),
C(result.uv[vt.idx()].x(), result.uv[vt.idx()].y()),
C(result.uv[vt.idx()].x(), result.uv[vt.idx()].y()),
C(result.uv[vn.idx()].x(), result.uv[vn.idx()].y()),
C(p_tri.x(), p_tri.y())));
}
}
if (normalise) normalise_hyperbolic(result, mesh);
return result;
}
// ── Convenience: save layout as OFF ──────────────────────────────────────────
/// Write a 2-D layout to disk in OFF format with z = 0. Convenience
/// helper for quickly inspecting the UV result in any OFF viewer.
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";
}
}
/// Write a 3-D (spherical) layout to disk in OFF format.
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