Files
ConformalLabpp/code/include/layout.hpp
Tarik Moussa 4fc48b39f0 feat(phase6): exact hyperbolic layout, Gauss–Bonnet, cut graph, normalisation — 121 tests
New files:
- gauss_bonnet.hpp: euler_characteristic, genus, Σ(2π-Θ_v) sum/rhs/deficit,
  check_gauss_bonnet (throws), enforce_gauss_bonnet (correct sign: Δ=(lhs-rhs)/V)
- cut_graph.hpp: CutGraph struct + compute_cut_graph (tree-cotree, Erickson–Whittlesey
  2005); boundary edges correctly excluded from cut set
- test_phase6.cpp: 26 new tests (GaussBonnet ×8, CutGraph ×6, HyperbolicTrilateration
  ×4, Normalisation ×4 — all pass)

layout.hpp (Phase 6 rewrite):
- detail::trilaterate_hyp: exact Möbius + hyperbolic law of cosines replacing old tanh(d/2)
- detail::center_poincare_disk: Möbius centering for hyperbolic normalisation
- normalise_euclidean: centroid → origin + PCA major-axis rotation
- normalise_hyperbolic: Möbius centering in the Poincaré disk
- normalise_spherical: Rodrigues rotation → north pole
- euclidean_layout / hyper_ideal_layout: optional CutGraph* + HolonomyData* + normalise

Bug fixes caught by new tests:
- gauss_bonnet.hpp: enforce_gauss_bonnet had wrong sign for delta
- cut_graph.hpp: boundary edges were incorrectly marked as cut edges

121 tests pass, 2 skipped (Hessian stubs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:15:41 +02:00

703 lines
30 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
// layout.hpp
//
// Phase 5/6 — 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 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. │
// │ 4. (Phase 6) If a CutGraph is supplied, cut edges are treated as │
// │ boundary; crossing them records a HolonomyData entry instead. │
// │ │
// │ For open meshes the placement is globally consistent. │
// │ For closed meshes without a cut: first visit wins, has_seam = true. │
// │ For closed meshes with a CutGraph: each vertex is placed exactly once; │
// │ the holonomy (translation / Möbius) of each cut is recorded. │
// └──────────────────────────────────────────────────────────────────────────┘
//
// Euclidean trilateration — exact (analytic formula in ℝ²)
// Spherical trilateration — exact (spherical law of cosines on S²)
// Hyperbolic trilateration — exact (hyperbolic law of cosines + Möbius maps
// in the Poincaré disk model)
//
// Normalisation
// normalise_euclidean(layout) — centroid → origin, major axis → x-axis
// normalise_hyperbolic(layout) — Möbius map centering to origin
// normalise_spherical(layout) — rotate centroid to north pole
//
// Holonomy (Euclidean, genus-g surfaces with CutGraph)
// HolonomyData.translations[i] — translation vector ω_i for cut edge i
// (for a flat torus: ω_1, ω_2 are the lattice generators)
#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 <fstream>
#include <string>
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 and no CutGraph given
};
struct Layout3D {
std::vector<Eigen::Vector3d> pos; ///< pos[v.idx()] = 3-D position on S²
bool success = false;
bool has_seam = false;
};
/// Per-cut-edge holonomy for the Euclidean case.
/// translations[i] is the translation ω associated with cut_edge_indices[i]
/// of the CutGraph. For a flat torus, ω_1 and ω_2 are the lattice generators.
struct HolonomyData {
std::vector<Eigen::Vector2d> translations; // one per cut edge
std::vector<std::size_t> cut_edge_indices;
};
// ── Internal helpers ──────────────────────────────────────────────────────────
namespace detail {
// ─────────────────────────────────────────────────────────────────────────────
// Euclidean trilateration
// Given placed p_a, p_b and distances d_a (from p_a), d_b (from p_b),
// return the point on the LEFT (CCW) side of the directed edge 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()); // 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 trilateration
// Given unit vectors pa, pb on S² and arc-lengths da, db to the new point,
// return the unit vector on the LEFT side of the geodesic arc pa → 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);
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 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;
}
// ─────────────────────────────────────────────────────────────────────────────
// Hyperbolic trilateration — exact via Möbius map + hyperbolic law of cosines.
//
// pa, pb: Poincaré disk coordinates of two already-placed vertices
// D: hyperbolic distance pa → pb (from the DOF vector)
// da: hyperbolic distance pa → pc (new vertex)
// db: hyperbolic distance pb → pc (new vertex)
//
// Returns the Poincaré disk coordinate of pc on the LEFT (CCW) side of pa→pb.
//
// Algorithm:
// 1. Map pa → 0 via Möbius T: z ↦ (zpa)/(1conj(pa)·z)
// 2. θ_b = arg(T(pb)) — direction from origin towards T(pb)
// 3. Angle α at pa from the hyperbolic law of cosines:
// cosh(db) = cosh(da)·cosh(D) sinh(da)·sinh(D)·cos(α)
// → cos(α) = (cosh(da)·cosh(D) cosh(db)) / (sinh(da)·sinh(D))
// 4. pc in origin frame: tanh(da/2)·exp(i·(θ_b + α)) [left = +α]
// 5. Map back: T⁻¹(w) = (w + pa)/(1 + conj(pa)·w)
// ─────────────────────────────────────────────────────────────────────────────
inline Eigen::Vector2d trilaterate_hyp(
const Eigen::Vector2d& pa, const Eigen::Vector2d& pb,
double D, // hyperbolic distance pa → pb
double da, // hyperbolic distance pa → pc
double db) // hyperbolic distance pb → pc
{
using C = std::complex<double>;
// Degenerate guard: very short edges or coincident points
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());
C b(pb.x(), pb.y());
// Möbius map: T_a(z) = (z a) / (1 conj(a)·z)
auto mobius_fwd = [](C z, C center) -> C {
return (z - center) / (C(1.0) - std::conj(center) * z);
};
// Inverse: T_a⁻¹(w) = (w + a) / (1 + conj(a)·w)
auto mobius_inv = [](C w, C center) -> C {
return (w + center) / (C(1.0) + std::conj(center) * w);
};
C b_mapped = mobius_fwd(b, a);
double theta_b = std::arg(b_mapped);
// Hyperbolic law of cosines for angle α at pa:
// cosh(db) = cosh(da)·cosh(D) sinh(da)·sinh(D)·cos(α)
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));
double alpha = std::acos(cos_alpha); // ∈ [0, π], sin > 0 for left side
// pc in the frame where pa = 0 and pb is on the positive real axis,
// then rotate back by theta_b:
C pc_origin = std::tanh(da * 0.5) * std::exp(C(0.0, theta_b + alpha));
// Map back to original disk
C pc_c = mobius_inv(pc_origin, a);
return Eigen::Vector2d(pc_c.real(), pc_c.imag());
}
// ─────────────────────────────────────────────────────────────────────────────
// Möbius centering of a Poincaré-disk layout
// Applies T_c(z) = (z c)/(1 conj(c)·z) where c is the Euclidean centroid
// of all vertex positions. Maps c → 0, i.e. centers the cloud in the disk.
// ─────────────────────────────────────────────────────────────────────────────
inline void center_poincare_disk(std::vector<Eigen::Vector2d>& uv)
{
if (uv.empty()) return;
using C = std::complex<double>;
// Euclidean centroid (good enough for moderate displacements from origin)
C centroid(0.0, 0.0);
for (auto& p : uv) centroid += C(p.x(), p.y());
centroid /= static_cast<double>(uv.size());
// Clamp to strictly inside the disk
double r = std::abs(centroid);
if (r > 0.999) centroid *= (0.999 / r);
if (r < 1e-12) return; // already centered
for (auto& p : uv) {
C z(p.x(), p.y());
C w = (z - centroid) / (C(1.0) - std::conj(centroid) * z);
p = Eigen::Vector2d(w.real(), w.imag());
}
}
} // namespace detail
// ── Layout normalisation ──────────────────────────────────────────────────────
/// Euclidean: translate centroid to origin, rotate major axis to x-axis (PCA).
inline void normalise_euclidean(Layout2D& layout)
{
if (!layout.success || layout.uv.empty()) return;
const std::size_t n = layout.uv.size();
// Centroid
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;
// PCA: 2×2 covariance
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);
// Eigenvectors in ascending order — we want the largest (index 1)
Eigen::Vector2d major = eig.eigenvectors().col(1);
// Rotation that aligns major axis with x-axis
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;
}
/// Hyperbolic: Möbius map centering the Euclidean centroid to the disk origin.
inline void normalise_hyperbolic(Layout2D& layout)
{
if (!layout.success || layout.uv.empty()) return;
detail::center_poincare_disk(layout.uv);
}
/// Spherical: rotate so the Euclidean centroid of vertex positions points
/// towards the north pole (0, 0, 1).
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; // unit vector of mean direction
Eigen::Vector3d north(0.0, 0.0, 1.0);
Eigen::Vector3d axis = mean.cross(north);
double sin_a = axis.norm();
double cos_a = mean.dot(north);
if (sin_a < 1e-12) return; // already at north (or south) pole
axis /= sin_a;
// Rodrigues rotation matrix
Eigen::Matrix3d K;
K << 0.0, -axis.z(), axis.y(),
axis.z(), 0.0, -axis.x(),
-axis.y(), axis.x(), 0.0;
Eigen::Matrix3d R = Eigen::Matrix3d::Identity()
+ sin_a * K + (1.0 - cos_a) * K * K;
for (auto& p : layout.pos) p = R * p;
}
// ── Euclidean layout ──────────────────────────────────────────────────────────
//
// l_ij = exp( (λ°_ij + u_i + u_j + λ_e) / 2 )
//
// Optional CutGraph: if non-null, cut edges are treated as boundary;
// holonomy translations are computed for each cut edge and returned via
// *holonomy (if non-null).
// ─────────────────────────────────────────────────────────────────────────────
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();
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);
double lBC = edge_len(h1);
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 (Halfedge_index h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
Halfedge_index h_opp = mesh.opposite(h);
if (mesh.is_border(h_opp)) continue;
// Treat cut edges as boundary (don't cross them in BFS)
if (cut && cut->is_cut(mesh.edge(h))) continue;
Face_index f_adj = mesh.face(h_opp);
if (!face_placed[f_adj.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)),
edge_len(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;
// ── Holonomy: compute translation for each cut edge ───────────────────────
// For each cut edge e = (u,v), look at the face on the far side (h_opp).
// Trilaterate the third vertex w of that face from uv[u] and uv[v],
// and compare to the actual position uv[w].
// Translation ω = trilaterated_position(w) uv[w].
if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.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 h = mesh.halfedge(e);
Halfedge_index h_opp = mesh.opposite(h);
// Choose the halfedge whose face was NOT placed during BFS
// (the one across the cut from the main BFS sweep).
// If both sides were placed, use h_opp; if neither, skip.
Halfedge_index h_cross = h_opp;
if (mesh.is_border(h_cross)) h_cross = h;
if (mesh.is_border(h_cross)) {
holonomy->translations.push_back(Eigen::Vector2d::Zero());
continue;
}
Vertex_index v_src = mesh.source(h_cross);
Vertex_index v_tgt = mesh.target(h_cross);
Vertex_index v_new = mesh.target(mesh.next(h_cross));
if (!vertex_placed[v_new.idx()]) {
holonomy->translations.push_back(Eigen::Vector2d::Zero());
continue;
}
Eigen::Vector2d p_tri = detail::trilaterate_2d(
result.uv[v_src.idx()], result.uv[v_tgt.idx()],
edge_len(mesh.prev(h_cross)),
edge_len(mesh.next(h_cross)));
holonomy->translations.push_back(p_tri - result.uv[v_new.idx()]);
}
}
if (normalise) normalise_euclidean(result);
return result;
}
// ── Spherical layout ──────────────────────────────────────────────────────────
//
// l_ij = 2·arcsin( min(exp(Λ_ij / 2), 1) )
//
// ─────────────────────────────────────────────────────────────────────────────
inline Layout3D spherical_layout(
ConformalMesh& mesh,
const std::vector<double>& x,
const SphericalMaps& maps,
bool normalise = false)
{
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));
};
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);
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 (Halfedge_index h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
Halfedge_index 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;
if (normalise) normalise_spherical(result);
return result;
}
// ── HyperIdeal layout (Poincaré disk) — exact trilateration ──────────────────
//
// Hyperbolic edge distance:
// cosh(d_ij) = cosh(b_i + a_ij/2)·cosh(b_j + a_ij/2) sinh(b_i)·sinh(b_j)
//
// Trilateration via Möbius map + hyperbolic law of cosines (exact, Phase 6).
//
// Optional CutGraph and HolonomyData for closed meshes.
// ─────────────────────────────────────────────────────────────────────────────
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();
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), 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 ch = std::cosh(bi + half_a) * std::cosh(bj + half_a)
- std::sinh(bi) * std::sinh(bj);
return std::acosh(std::max(1.0, ch));
};
std::vector<bool> vertex_placed(nv, false);
std::vector<bool> face_placed(mesh.number_of_faces(), false);
// ── Place root face: vA at origin, vB on positive real axis ──────────────
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);
result.uv[vA.idx()] = Eigen::Vector2d(0.0, 0.0);
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;
face_placed[f0.idx()] = true;
// ── BFS ───────────────────────────────────────────────────────────────────
std::queue<Halfedge_index> q;
auto enqueue = [&](Face_index f) {
for (Halfedge_index h : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
Halfedge_index h_opp = mesh.opposite(h);
if (mesh.is_border(h_opp)) continue;
if (cut && cut->is_cut(mesh.edge(h))) continue;
Face_index f_adj = mesh.face(h_opp);
if (!face_placed[f_adj.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));
double D = hyp_dist(h); // distance v_src → v_tgt
double da = hyp_dist(mesh.prev(h)); // distance v_new → v_src
double db = hyp_dist(mesh.next(h)); // distance v_tgt → v_new
Eigen::Vector2d p = detail::trilaterate_hyp(
result.uv[v_src.idx()], result.uv[v_tgt.idx()], D, da, db);
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;
// ── Holonomy ──────────────────────────────────────────────────────────────
if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.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 h_opp = mesh.opposite(mesh.halfedge(e));
Halfedge_index h_cross = mesh.is_border(h_opp)
? mesh.halfedge(e) : h_opp;
if (mesh.is_border(h_cross)) {
holonomy->translations.push_back(Eigen::Vector2d::Zero());
continue;
}
Vertex_index v_src = mesh.source(h_cross);
Vertex_index v_tgt = mesh.target(h_cross);
Vertex_index v_new = mesh.target(mesh.next(h_cross));
if (!vertex_placed[v_new.idx()]) {
holonomy->translations.push_back(Eigen::Vector2d::Zero());
continue;
}
double D = hyp_dist(h_cross);
double da = hyp_dist(mesh.prev(h_cross));
double db = hyp_dist(mesh.next(h_cross));
Eigen::Vector2d p_tri = detail::trilaterate_hyp(
result.uv[v_src.idx()], result.uv[v_tgt.idx()], D, da, db);
holonomy->translations.push_back(p_tri - result.uv[v_new.idx()]);
}
}
if (normalise) normalise_hyperbolic(result);
return result;
}
// ── Convenience: save layout as OFF ──────────────────────────────────────────
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