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>
This commit is contained in:
Tarik Moussa
2026-05-13 01:15:41 +02:00
parent 24f7607b2d
commit 4fc48b39f0
6 changed files with 1100 additions and 146 deletions

152
code/include/cut_graph.hpp Normal file
View File

@@ -0,0 +1,152 @@
#pragma once
// cut_graph.hpp
//
// Phase 6 — Tree-cotree algorithm for computing a cut graph of a triangulated
// surface.
//
// For a closed, orientable, genus-g surface:
// #vertices (V), #edges (E), #faces (F)
// Euler: V E + F = 2 2g
// Primal spanning tree: V 1 edges
// Dual spanning tree: F 1 edges (avoiding duals of tree edges)
// Remaining: E (V1) (F1) = 2g cut edges
//
// These 2g cut edges generate H₁(M, ) ≅ ^{2g}.
// Cutting along them turns M into a topological disk.
//
// For open meshes (boundary present) the algorithm still works: the dual BFS
// starts from a boundary-adjacent face, and boundary half-edges are skipped.
// The number of cut edges will be E (V1) (F1) B where B counts
// boundary edges treated as dual tree edges.
//
// Usage:
// CutGraph cg = compute_cut_graph(mesh);
// // cg.cut_edge_flags[e.idx()] == true → treat edge as seam in BFS
// euclidean_layout(mesh, x, maps, &cg); // layout with holonomy tracking
#include "conformal_mesh.hpp"
#include "gauss_bonnet.hpp" // for euler_characteristic / genus
#include <vector>
#include <queue>
#include <cstddef>
namespace conformallab {
// ─────────────────────────────────────────────────────────────────────────────
// CutGraph
// ─────────────────────────────────────────────────────────────────────────────
struct CutGraph {
/// cut_edge_flags[e.idx()] = true ↔ this edge is a cut edge.
/// Size = mesh.number_of_edges().
std::vector<bool> cut_edge_flags;
/// Indices of the 2g cut edges in order (size = 2g).
std::vector<std::size_t> cut_edge_indices;
/// Genus of the surface (0 for topological spheres and open patches).
int genus = 0;
bool is_cut(Edge_index e) const
{
return static_cast<std::size_t>(e.idx()) < cut_edge_flags.size()
&& cut_edge_flags[static_cast<std::size_t>(e.idx())];
}
};
// ─────────────────────────────────────────────────────────────────────────────
// compute_cut_graph
// ─────────────────────────────────────────────────────────────────────────────
//
// Implements the standard tree-cotree algorithm (EricksonWhittlesey 2005):
//
// Step 1: BFS primal spanning tree T (V1 primal tree edges).
// Step 2: BFS dual spanning tree T* (F1 dual/primal edges, avoiding
// edges whose primal crosses T).
// Step 3: cut edges = primal edges neither in T nor "used" by T*.
inline CutGraph compute_cut_graph(const ConformalMesh& mesh)
{
const std::size_t nv = mesh.number_of_vertices();
const std::size_t ne = mesh.number_of_edges();
const std::size_t nf = mesh.number_of_faces();
CutGraph cg;
cg.cut_edge_flags.assign(ne, false);
cg.genus = conformallab::genus(mesh);
if (nv == 0 || nf == 0) return cg;
// ── Step 1: primal spanning tree via BFS from vertex 0 ───────────────────
std::vector<bool> tree_edge(ne, false);
std::vector<bool> v_visited(nv, false);
{
std::queue<Vertex_index> q;
auto v0 = *mesh.vertices().begin();
v_visited[v0.idx()] = true;
q.push(v0);
while (!q.empty()) {
Vertex_index v = q.front(); q.pop();
for (Halfedge_index h : CGAL::halfedges_around_target(v, mesh)) {
Vertex_index u = mesh.source(h);
if (!v_visited[static_cast<std::size_t>(u.idx())]) {
v_visited[static_cast<std::size_t>(u.idx())] = true;
tree_edge[static_cast<std::size_t>(mesh.edge(h).idx())] = true;
q.push(u);
}
}
}
}
// ── Step 2: dual spanning tree via BFS from face 0 ───────────────────────
// Dual edge between face f and face f_adj crosses primal edge e.
// Include dual edge only if:
// (a) e is not a primal tree edge (tree_edge[e] == false)
// (b) h_adj is not a border halfedge
std::vector<bool> dual_tree_edge(ne, false);
std::vector<bool> f_visited(nf, false);
{
std::queue<Face_index> q;
auto f0 = *mesh.faces().begin();
f_visited[static_cast<std::size_t>(f0.idx())] = true;
q.push(f0);
while (!q.empty()) {
Face_index f = q.front(); q.pop();
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; // boundary edge
Face_index f_adj = mesh.face(h_opp);
if (f_visited[static_cast<std::size_t>(f_adj.idx())]) continue;
std::size_t eidx = static_cast<std::size_t>(mesh.edge(h).idx());
if (!tree_edge[eidx]) {
// Use this dual edge in T*
f_visited[static_cast<std::size_t>(f_adj.idx())] = true;
dual_tree_edge[eidx] = true;
q.push(f_adj);
}
}
}
}
// ── Step 3: cut edges = neither in T nor in T* nor on boundary ───────────
// Boundary edges are adjacent to the "outer face" and need no cutting —
// they are implicitly handled by the boundary itself.
for (Edge_index e : mesh.edges()) {
std::size_t idx = static_cast<std::size_t>(e.idx());
if (tree_edge[idx] || dual_tree_edge[idx]) continue;
// Skip boundary edges — they are not interior homological cycles.
Halfedge_index h = mesh.halfedge(e);
if (mesh.is_border(h) || mesh.is_border(mesh.opposite(h))) continue;
cg.cut_edge_flags[idx] = true;
cg.cut_edge_indices.push_back(idx);
}
return cg;
}
} // namespace conformallab