fix+test: Euclidean holonomy/τ end-to-end + spherical edge-DOF oracle (2026-05-29 audit)
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 1m57s
API Docs / doc-build (pull_request) Successful in 1m3s
Markdown link check / check (pull_request) Successful in 44s
C++ Tests / test-cgal (pull_request) Has been skipped
C++ Tests / quality-gates (pull_request) Successful in 2m11s

Bundles the 2026-05-29 Java↔C++ math-correctness audit (doc/reviewer/
java-port-audit.md, 11 findings) with two follow-up fixes.

Audit code changes:
- Finding 3 (spherical_functional): edge-DOF replacement parameterization via
  spher_eff_lambda; edge gradient α_opp⁺+α_opp⁻−θ_e (drops additive −(S⁺+S⁻)/2)
- Finding 4 (spherical_hessian): always-compiled edge-DOF throw guard
- Finding 6 (period_matrix): faithful normalizeModulus (0≤Re≤½, Im≥0, |τ|≥1)
- Finding 9 (inversive_distance): degenerate-face limiting angles, no skip
- Findings 1/2 (euclidean): degenerate gradient limiting angles + Hessian guard

Euclidean holonomy/τ fix: develop the cut surface across the dual spanning tree
only (cut_graph now exposes is_dual_tree), so genus-1 cut edges yield
non-degenerate lattice generators. Previously τ came out 0 / NaN / 1e13 on the
bundled tori; now matches the analytic revolution modulus i·√(R²−r²)/r. Re-enabled
τ reporting in the Euclidean CLI; rewrote validation.md §3/§4 accordingly.

Tests (240 CGAL, 0 skipped):
- HolonomyEndToEnd ×3 — tori of revolution (4×4, hex 6×6, 8×8) vs analytic modulus
- SphericalFunctional.EdgeGradient_RegularTetClosedForm — independent closed-form
  π/3 oracle locking the Finding-3 edge formula (the path-integral FD check cannot
  detect a wrong-but-conservative gradient)

Also documents the latent spherical/hyperbolic holonomy-extraction bug (same
single-development pattern, dead code today) in research-track.md (Phase 9c/10),
and adds favour/normalisations to the codespell ignore list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-29 12:50:16 +02:00
parent ca936b7652
commit a3ee9576d4
23 changed files with 1065 additions and 165 deletions

View File

@@ -49,6 +49,13 @@ struct CutGraph {
/// Indices of the 2g cut edges in order (size = 2g).
std::vector<std::size_t> cut_edge_indices;
/// dual_tree_edge_flags[e.idx()] = true ↔ edge `e` is a dual-spanning-tree
/// edge (its dual is in T*). Size = mesh.number_of_edges().
/// Crossing only these edges develops the surface onto a topological disk
/// (the fundamental polygon), so that the `2g` cut edges become genuine
/// boundary identifications carrying the holonomy generators.
std::vector<bool> dual_tree_edge_flags;
/// Genus of the surface (0 for topological spheres and open patches).
int genus = 0;
@@ -58,6 +65,14 @@ struct CutGraph {
return static_cast<std::size_t>(e.idx()) < cut_edge_flags.size()
&& cut_edge_flags[static_cast<std::size_t>(e.idx())];
}
/// `true` iff edge `e` is a dual-spanning-tree edge (crossable when
/// developing the surface onto a disk).
bool is_dual_tree(Edge_index e) const
{
return static_cast<std::size_t>(e.idx()) < dual_tree_edge_flags.size()
&& dual_tree_edge_flags[static_cast<std::size_t>(e.idx())];
}
};
/// Compute the cut graph of `mesh` via the standard tree-cotree
@@ -145,6 +160,11 @@ inline CutGraph compute_cut_graph(const ConformalMesh& mesh)
cg.cut_edge_indices.push_back(idx);
}
// Expose the dual spanning tree T*: developing across only these edges
// unfolds the surface onto a disk, making the cut edges the boundary
// identifications that carry the holonomy generators.
cg.dual_tree_edge_flags = std::move(dual_tree_edge);
return cg;
}

View File

@@ -202,7 +202,11 @@ inline std::vector<double> euclidean_gradient(
double lam31 = m.lambda0[e31] + u3 + u1 + eucl_dof_val(m.e_idx[e31], x);
auto fa = euclidean_angles(lam12, lam23, lam31);
if (!fa.valid) continue; // degenerate triangle: contributes 0
// NOTE: do NOT skip degenerate faces. euclidean_angles() returns the
// limiting angles (one corner = π, others = 0) when the triangle
// inequality is violated; using them is exactly what the Java reference
// does and is required for the BPS energy to be the convex C¹ extension
// onto the infeasible region (otherwise Newton can stall at a flip).
// h_alpha[h] = corner angle OPPOSITE to h's edge:
// h0 (edge v1v2) → opposite corner at v3 → α3

View File

@@ -28,6 +28,7 @@
// rescales all three sides by the same factor, leaving angles unchanged but
// keeping the arguments of exp in a safe numerical range.
#include "constants.hpp"
#include <cmath>
namespace conformallab {
@@ -50,8 +51,16 @@ inline EuclideanFaceAngles euclidean_angles_from_lengths(
const double t23 = +l12 - l23 + l31; // 2*(s l23)
const double t31 = +l12 + l23 - l31; // 2*(s l31)
if (t12 <= 0.0 || t23 <= 0.0 || t31 <= 0.0)
return {0.0, 0.0, 0.0, false};
// Degenerate (triangle inequality violated): return the *limiting* angles
// of the flat-out triangle — the corner opposite the over-long edge is π,
// the other two are 0. This is the convex C¹ extension of the BPS energy
// onto the infeasible region and is what the Java reference
// (EuclideanCyclicFunctional.triangleEnergyAndAlphas) does. `valid` stays
// false so the cotangent Hessian still skips this face. At most one t can
// be ≤ 0 (t12+t23 = 2·l31 > 0, etc.), so the order of these checks is moot.
if (t23 <= 0.0) return {PI, 0.0, 0.0, false}; // l23 too long → α₁ = π
if (t31 <= 0.0) return {0.0, PI, 0.0, false}; // l31 too long → α₂ = π
if (t12 <= 0.0) return {0.0, 0.0, PI, false}; // l12 too long → α₃ = π
const double l123 = l12 + l23 + l31;
const double denom2 = t12 * t23 * t31 * l123; // = (4·Area)²

View File

@@ -44,6 +44,7 @@
#include <Eigen/Sparse>
#include <vector>
#include <cmath>
#include <stdexcept>
namespace conformallab {
@@ -104,6 +105,18 @@ inline Eigen::SparseMatrix<double> euclidean_hessian(
{
const int n = euclidean_dimension(mesh, m);
// Only the vertex block of the cyclic Hessian is implemented here. If any
// edge DOF is variable (assign_euclidean_all_dof_indices), the edge-edge and
// vertex-edge blocks present in the Java reference (conformalHessian) are
// missing, which would leave singular zero rows/cols. Fail loudly instead
// of silently returning a rank-deficient matrix (matches the doc contract).
for (auto e : mesh.edges()) {
if (m.e_idx[e] >= 0)
throw std::logic_error(
"euclidean_hessian: edge DOFs are not supported "
"(only the vertex-block cotangent Laplacian is implemented)");
}
// Collect triplets (row, col, value) — setFromTriplets sums duplicates.
std::vector<Eigen::Triplet<double>> trips;
trips.reserve(static_cast<std::size_t>(n) * 7); // rough estimate

View File

@@ -16,6 +16,13 @@
// If this fails, no conformal factor can realise the target angles and
// Newton will silently fail to converge.
//
// PRECONDITION — closed meshes only. Every function here sums (2π Θ_v)
// over ALL vertices. On a mesh with boundary the boundary vertices carry a
// (π Θ_v) term instead, so the identity Σ(2πΘ_v) = 2π·χ does NOT hold and
// `check_gauss_bonnet` will (correctly) throw. For open meshes pin the
// boundary directly and skip the GaussBonnet check (see the CLI's
// flattening path).
//
// API:
// int euler_characteristic(mesh)
// int genus(mesh)
@@ -128,10 +135,10 @@ inline void check_gauss_bonnet(const ConformalMesh& mesh,
// ── enforce_gauss_bonnet — adjust θ_v by uniform Δ ───────────────────────────
//
// Adds δ = (rhs lhs) / V to every θ_v so that GaussBonnet holds exactly.
// Adds δ = (lhs rhs) / V to every θ_v so that GaussBonnet holds exactly.
// After this call, check_gauss_bonnet() will not throw (up to floating-point).
// Only modifies free vertices (v_idx[v] >= 0 for EuclideanMaps / SphericalMaps;
// always all vertices for the raw property-map overload).
// Modifies ALL vertices' θ_v (no v_idx filtering) — the shift is a property
// of the target angles, independent of which vertices are free DOFs.
/// Distribute the Gauss-Bonnet deficit uniformly across all `Θ_v`:
/// add `δ = (lhs rhs) / V` to every entry so that the identity holds

View File

@@ -269,11 +269,18 @@ inline std::vector<double> inversive_distance_gradient(
double l23sq = id_detail::edge_length_squared(u2, u3, m.I_e[e23]);
double l31sq = id_detail::edge_length_squared(u3, u1, m.I_e[e31]);
// A non-real circle configuration (ℓ² ≤ 0) has no limiting angle — skip it.
if (l12sq <= 0 || l23sq <= 0 || l31sq <= 0) continue;
// euclidean_angles expects 2·log() per edge — feed log(ℓ²).
// For a triangle-inequality-violating face euclidean_angles returns the
// *limiting* angles (π opposite the over-long edge, 0/0 otherwise) with
// valid=false. We deliberately do NOT skip on !fa.valid: using those
// limiting angles is the convex C¹ extension onto the infeasible region,
// so Newton can pass through a flip instead of stalling (Finding 9 —
// mirrors the Euclidean/Spherical fix in Finding 1). The angles come
// from genuine Euclidean side lengths, so the extension is geometric.
auto fa = euclidean_angles(std::log(l12sq), std::log(l23sq), std::log(l31sq));
if (!fa.valid) continue;
h_alpha[id_detail::hidx(h0)] = fa.alpha3;
h_alpha[id_detail::hidx(h1)] = fa.alpha1;

View File

@@ -474,6 +474,125 @@ inline void set_root_huv_2d(
huv[static_cast<std::size_t>(hf.idx())] = uv[static_cast<std::size_t>(mesh.source(hf).idx())];
}
// ─────────────────────────────────────────────────────────────────────────────
// Euclidean holonomy via the developing map (genus-g closed surfaces).
//
// The per-vertex layout produced by euclidean_layout() places every face in a
// SINGLE consistent global frame (each vertex is placed once). In that frame
// the holonomy is identically trivial — it is exactly the obstruction to such a
// single frame existing on the uncut surface. To recover it we develop every
// face INDEPENDENTLY along a spanning tree of the dual graph that does not cross
// any cut edge, storing each face's own copy of its three corner positions
// (`hpos[h]` = global position of source(h) as developed inside face(h)).
//
// Two faces adjacent across a cut edge are NOT tree-adjacent, so they are
// developed via different tree branches and place the shared edge at two
// different locations. The rigid motion identifying those two copies is the
// deck transformation of the generator loop (cut edge + tree path) — i.e. the
// holonomy. For a flat cone metric (Θ ≡ 2π) the linear part is trivial, so the
// holonomy is the pure translation given by the displacement of the shared
// edge's midpoint between the two developments. That translation is exactly
// the lattice generator ω consumed by compute_period_matrix().
template <typename EdgeLenFn>
inline std::vector<Eigen::Vector2d> euclidean_holonomy(
const ConformalMesh& mesh,
const CutGraph& cut,
EdgeLenFn&& edge_len)
{
using C = std::complex<double>;
const std::size_t nh = mesh.number_of_halfedges();
const std::size_t nf = mesh.number_of_faces();
std::vector<C> hpos(nh, C(0.0, 0.0)); // pos of source(h) inside face(h)
std::vector<bool> face_done(nf, false);
auto place_root = [&](Face_index f) {
Halfedge_index h0 = mesh.halfedge(f);
Halfedge_index h1 = mesh.next(h0), h2 = mesh.next(h1);
double lAB = edge_len(h0), lBC = edge_len(h1), lCA = edge_len(h2);
Eigen::Vector2d A(0.0, 0.0), B(lAB, 0.0);
Eigen::Vector2d Cc = trilaterate_2d(A, B, lCA, lBC); // apex = source(h2)
hpos[static_cast<std::size_t>(h0.idx())] = C(A.x(), A.y());
hpos[static_cast<std::size_t>(h1.idx())] = C(B.x(), B.y());
hpos[static_cast<std::size_t>(h2.idx())] = C(Cc.x(), Cc.y());
face_done[static_cast<std::size_t>(f.idx())] = true;
};
auto develop = [&](Face_index root) {
place_root(root);
std::queue<Halfedge_index> q; // halfedges pointing INTO unplaced faces
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;
// Develop across the dual spanning tree T* ONLY. Crossing any
// other edge (a cut/generator edge OR a primal-tree edge) would
// over-connect the development: the surface minus the 2g cut
// edges is still non-simply-connected, so the immersion would
// wrap around and place the two copies of a generator edge on
// top of each other (zero/garbage holonomy). Crossing only T*
// unfolds the surface onto a disk (the fundamental polygon).
if (!cut.is_dual_tree(mesh.edge(hf))) continue;
Face_index fa = mesh.face(ho);
if (!face_done[static_cast<std::size_t>(fa.idx())]) q.push(ho);
}
};
enqueue(root);
while (!q.empty()) {
Halfedge_index h = q.front(); q.pop();
Face_index f = mesh.face(h);
if (face_done[static_cast<std::size_t>(f.idx())]) continue;
Halfedge_index ho = mesh.opposite(h);
// Shared edge endpoints, taken from the PARENT face's development:
// source(h) = target(ho) → parent pos hpos[next(ho)]
// target(h) = source(ho) → parent pos hpos[ho]
C ps = hpos[static_cast<std::size_t>(mesh.next(ho).idx())];
C pt = hpos[static_cast<std::size_t>(ho.idx())];
Eigen::Vector2d A(ps.real(), ps.imag()), B(pt.real(), pt.imag());
Eigen::Vector2d apex = trilaterate_2d(
A, B, edge_len(mesh.prev(h)), edge_len(mesh.next(h)));
hpos[static_cast<std::size_t>(h.idx())] = ps;
hpos[static_cast<std::size_t>(mesh.next(h).idx())] = pt;
hpos[static_cast<std::size_t>(mesh.prev(h).idx())] = C(apex.x(), apex.y());
face_done[static_cast<std::size_t>(f.idx())] = true;
enqueue(f);
}
};
develop(best_root_face(mesh));
for (auto f : mesh.faces())
if (!face_done[static_cast<std::size_t>(f.idx())]) develop(f);
// ── Holonomy translation per cut edge ─────────────────────────────────────
std::vector<Eigen::Vector2d> omega;
omega.reserve(cut.cut_edge_indices.size());
for (std::size_t ce : cut.cut_edge_indices) {
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce));
Halfedge_index h = mesh.halfedge(e);
Halfedge_index ho = mesh.opposite(h);
if (mesh.is_border(h) || mesh.is_border(ho)
|| !face_done[static_cast<std::size_t>(mesh.face(h).idx())]
|| !face_done[static_cast<std::size_t>(mesh.face(ho).idx())]) {
omega.push_back(Eigen::Vector2d::Zero());
continue;
}
// Face A = face(h) places edge e as halfedge h: S=source(h), T=target(h)
C As = hpos[static_cast<std::size_t>(h.idx())];
C At = hpos[static_cast<std::size_t>(mesh.next(h).idx())];
// Face B = face(ho) places the same edge as halfedge ho: source(ho)=T,
// target(ho)=S → S=pos of source(next(ho)), T=pos of source(ho)
C Bt = hpos[static_cast<std::size_t>(ho.idx())];
C Bs = hpos[static_cast<std::size_t>(mesh.next(ho).idx())];
C midA = 0.5 * (As + At);
C midB = 0.5 * (Bs + Bt);
C w = midA - midB; // pure translation for a flat cone metric
omega.push_back(Eigen::Vector2d(w.real(), w.imag()));
}
return omega;
}
} // namespace detail
// ── Euclidean layout ──────────────────────────────────────────────────────────
@@ -592,25 +711,30 @@ inline Layout2D euclidean_layout(
result.success = true;
// ── Holonomy ──────────────────────────────────────────────────────────────
// The single-frame BFS layout above puts every face in one consistent frame,
// so the holonomy read off it is identically trivial. Instead develop each
// face independently along a dual spanning tree that never crosses a cut edge
// (detail::euclidean_holonomy): the displacement of each cut edge between the
// two faces that share it is the lattice generator ω_i.
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();
holonomy->translations = detail::euclidean_holonomy(mesh, *cut, edge_len);
// Preserve the per-cut-edge seam UV in halfedge_uv (texture atlas), as
// before, so HalfedgeUV-based tests still see the seam-crossing layout.
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; }
if (mesh.is_border(hx)) 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; }
if (!vertex_placed[vn.idx()]) 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()]);
}
}
@@ -707,6 +831,15 @@ inline Layout3D spherical_layout(
for (auto f : mesh.faces()) if (!face_placed[f.idx()]) place_component(f);
result.success = true;
// KNOWN LIMITATION (latent — every current caller passes holonomy == nullptr).
// This block extracts holonomy from a single full-surface development (the BFS
// above crosses every non-cut edge), then reads off an apex trilateration on one
// side of each seam. That is the same flawed pattern that produced garbage τ for
// the Euclidean path; the correct approach is detail::euclidean_holonomy, which
// develops across only the dual spanning tree (is_dual_tree) and measures the
// shared-edge displacement between two independent developments. Until a
// detail::spherical_holonomy mirror exists (Phase 9c/10, see research-track.md),
// these spherical translations are not trustworthy for genus g ≥ 1.
if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.clear();
@@ -838,6 +971,16 @@ inline Layout2D hyper_ideal_layout(
result.success = true;
// ── Möbius-map holonomy ───────────────────────────────────────────────────
// KNOWN LIMITATION (latent — every current caller passes holonomy == nullptr).
// Like the spherical block above, this reads the Möbius deck transformation from
// a single full-surface development (BFS crosses all non-cut edges) and one-sided
// apex trilateration — the same flawed pattern fixed for the Euclidean path by
// detail::euclidean_holonomy (develop across the dual tree only, measure seam
// displacement between two independent developments). A faithful
// detail::hyperbolic_holonomy is Phase 9c/10 work and additionally requires
// cpp_dec_float_50: products of these generators grow exponentially, so verifying
// the group relation ∏gᵢ = Id overflows double (see CLAUDE.md, research-track.md).
// Until then these mobius_maps are NOT correct for genus g ≥ 2 uniformization.
if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.clear();

View File

@@ -26,6 +26,7 @@
#include "conformal_mesh.hpp"
#include <CGAL/IO/polygon_mesh_io.h>
#include <CGAL/boost/graph/helpers.h>
#include <string>
#include <stdexcept>
@@ -47,11 +48,20 @@ inline bool write_mesh(const std::string& filename, const ConformalMesh& mesh)
/// Throwing wrapper around `read_mesh`: returns the mesh by value
/// or throws `std::runtime_error` on read failure.
///
/// Also enforces that the mesh is triangulated, because every functional
/// in this library assumes triangle faces — a quad/polygon mesh would be
/// read in silently and then mis-handled by the angle/length formulas.
/// Fail loudly here at the I/O boundary instead.
inline ConformalMesh load_mesh(const std::string& filename)
{
ConformalMesh mesh;
if (!read_mesh(filename, mesh))
throw std::runtime_error("conformallab: failed to read mesh from " + filename);
if (!CGAL::is_triangle_mesh(mesh))
throw std::runtime_error(
"conformallab: mesh from " + filename +
" is not triangulated (functionals require triangle faces)");
return mesh;
}

View File

@@ -115,35 +115,89 @@ inline Eigen::VectorXd solve_linear_system(
namespace detail { // re-open for the remaining helpers
// Backtracking line search: find the largest α in {1, 0.5, 0.25, …} such that
// ||G(x + α·Δx)||₂ < ||G(x)||₂. Returns the accepted step (α may stay 1).
// Globalised line search for the Newton system G(x) = 0.
//
// Merit function f(x) = ½‖G(x)‖²₂. Driving f to its minimum drives the
// residual G to zero; because the merit only depends on ‖G‖ it is sign-agnostic
// and works identically for the convex Euclidean / HyperIdeal / CP / InvDist
// energies and the concave Spherical energy.
//
// Phase 1 — Newton direction `dx` (satisfies H·dx = G, so the merit slope
// ∇f·dx = GᵀH·dx = ‖G‖² < 0 — always a descent direction).
// Backtrack α ∈ {1, ½, ¼, …} until the Armijo sufficient-decrease
// condition holds:
// ‖G(x + α·dx)‖² ≤ (1 2·c1·α)·‖G(x)‖²
//
// Phase 2 — if Phase 1 exhausts its halvings, fall back to the steepest-
// descent direction of the merit, `d_sd = H·G` (slope
// ∇f·d_sd = ‖H·G‖² ≤ 0 regardless of the definiteness of H), with
// the analogous Armijo test:
// ‖G(x + α·d_sd)‖² ≤ ‖G(x)‖² 2·c1·α·‖d_sd‖²
//
// If neither phase satisfies Armijo, return the best (smallest-residual) point
// visited. If nothing beat ‖G(x)‖, return x unchanged and set *improved=false,
// so the caller can stop cleanly instead of taking the old divergent full step.
template <typename GradFn>
inline std::vector<double> line_search(
const std::vector<double>& x,
const Eigen::VectorXd& dx,
const Eigen::VectorXd& d_sd,
double norm0,
GradFn&& grad_fn,
int max_halvings = 20)
bool* improved = nullptr,
int max_halvings = 20,
double c1 = 1e-4)
{
const int n = static_cast<int>(x.size());
double alpha = 1.0;
std::vector<double> xnew(static_cast<std::size_t>(n));
const int n = static_cast<int>(x.size());
const double norm0_sq = norm0 * norm0;
for (int ls = 0; ls < max_halvings; ++ls) {
std::vector<double> xnew(static_cast<std::size_t>(n));
std::vector<double> best_x = x;
double best_norm = norm0;
// Evaluate ‖G(x + α·dir)‖₂, leaving the trial point in `xnew`.
auto eval = [&](const Eigen::VectorXd& dir, double alpha) -> double {
for (int i = 0; i < n; ++i)
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)]
+ alpha * dx[i];
xnew[static_cast<std::size_t>(i)] =
x[static_cast<std::size_t>(i)] + alpha * dir[i];
auto Gnew = grad_fn(xnew);
double norm_new = 0.0;
for (double v : Gnew) norm_new += v * v;
norm_new = std::sqrt(norm_new);
if (norm_new < norm0) return xnew;
double s = 0.0;
for (double v : Gnew) s += v * v;
return std::sqrt(s);
};
// ── Phase 1: Newton direction, Armijo backtracking ────────────────────────
double alpha = 1.0;
for (int ls = 0; ls < max_halvings; ++ls) {
double norm_new = eval(dx, alpha);
if (norm_new < best_norm) { best_norm = norm_new; best_x = xnew; }
if (norm_new * norm_new <= (1.0 - 2.0 * c1 * alpha) * norm0_sq) {
if (improved) *improved = true;
return xnew;
}
alpha *= 0.5;
}
// No improvement found — return best attempt (full step)
for (int i = 0; i < n; ++i)
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)] + dx[i];
return xnew;
// ── Phase 2: steepest-descent fallback (H·G), Armijo backtracking ────────
const double dsd_sq = d_sd.squaredNorm();
if (dsd_sq > 0.0) {
alpha = 1.0;
for (int ls = 0; ls < max_halvings; ++ls) {
double thresh_sq = norm0_sq - 2.0 * c1 * alpha * dsd_sq;
double norm_new = eval(d_sd, alpha);
if (norm_new < best_norm) { best_norm = norm_new; best_x = xnew; }
if (thresh_sq >= 0.0 && norm_new * norm_new <= thresh_sq) {
if (improved) *improved = true;
return xnew;
}
alpha *= 0.5;
}
}
// ── Both phases failed Armijo — never take the divergent full step. ───────
// Return the best point seen; if none improved, stay put and signal stall.
if (improved) *improved = (best_norm < norm0);
return best_x;
}
} // namespace detail
@@ -209,12 +263,15 @@ inline NewtonResult newton_euclidean(
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
if (!ok) break;
// ── Backtracking line search ──────────────────────────────────────────
// ── Globalised line search (Armijo + steepest-descent fallback) ───────
double norm0 = G.norm();
x = detail::line_search(x, dx, norm0,
Eigen::VectorXd d_sd = -(H * G); // merit-function steepest descent
bool improved = true;
x = detail::line_search(x, dx, d_sd, norm0,
[&](const std::vector<double>& xnew) {
return euclidean_gradient(mesh, xnew, m);
});
}, &improved);
if (!improved) break; // line search stalled — stop cleanly
res.iterations = iter + 1;
}
@@ -287,12 +344,16 @@ inline NewtonResult newton_spherical(
Eigen::VectorXd dx = detail::solve_with_fallback(negH, G, ok);
if (!ok) break;
// ── Backtracking line search ──────────────────────────────────────────
// ── Globalised line search (Armijo + steepest-descent fallback) ───────
// d_sd uses the actual (un-negated) Hessian: ∇f = H·G for f = ½‖G‖².
double norm0 = G.norm();
x = detail::line_search(x, dx, norm0,
Eigen::VectorXd d_sd = -(H * G);
bool improved = true;
x = detail::line_search(x, dx, d_sd, norm0,
[&](const std::vector<double>& xnew) {
return spherical_gradient(mesh, xnew, m);
});
}, &improved);
if (!improved) break;
res.iterations = iter + 1;
}
@@ -367,12 +428,15 @@ inline NewtonResult newton_hyper_ideal(
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
if (!ok) break;
// ── Backtracking line search ──────────────────────────────────────────
// ── Globalised line search (Armijo + steepest-descent fallback) ───────
double norm0 = G.norm();
x = detail::line_search(x, dx, norm0,
Eigen::VectorXd d_sd = -(H * G);
bool improved = true;
x = detail::line_search(x, dx, d_sd, norm0,
[&](const std::vector<double>& xnew) {
return evaluate_hyper_ideal(mesh, xnew, m, false).gradient;
});
}, &improved);
if (!improved) break;
res.iterations = iter + 1;
}
@@ -443,10 +507,13 @@ inline NewtonResult newton_cp_euclidean(
if (!ok) break;
double norm0 = G.norm();
x = detail::line_search(x, dx, norm0,
Eigen::VectorXd d_sd = -(H * G);
bool improved = true;
x = detail::line_search(x, dx, d_sd, norm0,
[&](const std::vector<double>& xnew) {
return cp_euclidean_gradient(mesh, xnew, m);
});
}, &improved);
if (!improved) break;
res.iterations = iter + 1;
}
@@ -552,10 +619,13 @@ inline NewtonResult newton_inversive_distance(
if (!ok) break;
double norm0 = G.norm();
x = detail::line_search(x, dx, norm0,
Eigen::VectorXd d_sd = -(H * G);
bool improved = true;
x = detail::line_search(x, dx, d_sd, norm0,
[&](const std::vector<double>& xnew) {
return inversive_distance_gradient(mesh, xnew, m);
});
}, &improved);
if (!improved) break;
res.iterations = iter + 1;
}

View File

@@ -41,6 +41,7 @@
// std::complex<double> reduce_to_fundamental_domain(τ) — apply SL(2,)
#include "layout.hpp"
#include "discrete_elliptic_utility.hpp" // normalizeModulus (Java-faithful)
#include <complex>
#include <cmath>
#include <vector>
@@ -129,8 +130,14 @@ inline bool is_in_fundamental_domain(std::complex<double> tau, double tol = 1e-9
// For genus-1 surfaces, also reduces τ to the fundamental domain.
// ─────────────────────────────────────────────────────────────────────────────
/// Compute the period data from the Euclidean holonomy translations.
/// For genus 1, also reduces `τ` to the SL(2,) fundamental domain
/// when `reduce` is `true` (default).
/// For genus 1, also normalises `τ` when `reduce` is `true` (default)
/// using `normalizeModulus` — the Java-faithful reduction
/// (`DiscreteEllipticUtility.normalizeModulus`), which folds τ into
/// `0 ≤ Re(τ) ≤ ½`, `Im(τ) ≥ 0`, `|τ| ≥ 1` (the extra `Re ≥ 0` fold
/// uses the mirror symmetry `τ ≅ −τ̄`). This matches the upstream Java
/// output exactly (Finding 6). For the canonical SL(2,) domain
/// (`−½ ≤ Re τ < ½`, no mirror fold) call `reduce_to_fundamental_domain`
/// on `pd.tau` instead.
inline PeriodData compute_period_matrix(const HolonomyData& hol, bool reduce = true)
{
PeriodData pd;
@@ -156,7 +163,9 @@ inline PeriodData compute_period_matrix(const HolonomyData& hol, bool reduce = t
if (tau.imag() < 0.0) return pd; // degenerate
if (reduce) {
tau = reduce_to_fundamental_domain(tau);
// Java-faithful normalisation (Finding 6): folds τ into
// 0 ≤ Re ≤ ½, Im ≥ 0, |τ| ≥ 1 via DiscreteEllipticUtility.normalizeModulus.
tau = normalizeModulus(tau);
pd.in_fundamental_domain = true;
}
pd.tau = tau;

View File

@@ -15,7 +15,9 @@
// │ x[e_idx[e]] = λ_e edge log-length variable (optional) │
// │ -1 means "pinned" (u_v = 0 / λ_e = λ°_e fixed) │
// │ │
// │ Effective log-length: Λ_ij = λ°_ij + u_i + u_j
// │ Effective log-length: Λ_ij = λ_e if edge e carries a DOF,
// │ = λ°_ij + u_i + u_j otherwise │
// │ (Java "replacement" convention, Finding 3) │
// │ Spherical arc length: l_ij = 2·asin(min(exp(Λ_ij/2), 1)) │
// │ │
// │ Gradient: │
@@ -155,6 +157,24 @@ static inline double spher_dof_val(int idx, const std::vector<double>& x)
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
}
/// Effective spherical log-length using the Java "replacement" convention
/// (SphericalFunctional.java:393400, Finding 3): when edge `e` carries a
/// variable (edge DOF), its value *replaces* `λ⁰ + u_i + u_j` entirely;
/// otherwise the effective length is `λ⁰_e + u_i + u_j`.
///
/// In the common vertex-only mode (no edge DOFs) this is identical to the
/// additive form `λ⁰_e + u_i + u_j`, so that path is unchanged.
static inline double spher_eff_lambda(const SphericalMaps& m,
const std::vector<double>& x,
Edge_index e,
double u_i,
double u_j)
{
int ie = m.e_idx[e];
return (ie >= 0) ? x[static_cast<std::size_t>(ie)]
: (m.lambda0[e] + u_i + u_j);
}
/// Convert a CGAL half-edge index to a plain `std::size_t` for vector indexing.
static inline std::size_t spher_hidx(Halfedge_index h)
{
@@ -197,22 +217,25 @@ inline std::vector<double> spherical_gradient(
Edge_index e23 = mesh.edge(h1);
Edge_index e31 = mesh.edge(h2);
// Effective log-length Λ_ij = λ°_ij + u_i + u_j
// Effective log-length (Java "replacement" convention, Finding 3):
// * edge DOF present → Λ_ij = λ_e (the edge variable)
// * vertex-only → Λ_ij = λ°_ij + u_i + u_j
double u1 = spher_dof_val(m.v_idx[v1], x);
double u2 = spher_dof_val(m.v_idx[v2], x);
double u3 = spher_dof_val(m.v_idx[v3], x);
double lam12 = m.lambda0[e12] + u1 + u2 + spher_dof_val(m.e_idx[e12], x);
double lam23 = m.lambda0[e23] + u2 + u3 + spher_dof_val(m.e_idx[e23], x);
double lam31 = m.lambda0[e31] + u3 + u1 + spher_dof_val(m.e_idx[e31], x);
double lam12 = spher_eff_lambda(m, x, e12, u1, u2);
double lam23 = spher_eff_lambda(m, x, e23, u2, u3);
double lam31 = spher_eff_lambda(m, x, e31, u3, u1);
double l12 = spherical_l(lam12);
double l23 = spherical_l(lam23);
double l31 = spherical_l(lam31);
SphericalFaceAngles fa = spherical_angles(l12, l23, l31);
if (!fa.valid) continue; // degenerate face: contributes 0
// Do NOT skip degenerate faces: spherical_angles() returns the limiting
// angles (matching the Java reference), required for the convex C¹
// extension of the energy onto the infeasible region.
// Store convention: h_alpha[h] = corner angle at source(prev(h))
// h0 (e12): opposite vertex is v3 → source(prev(h0)) = source(h2) = v3 → α3
@@ -237,38 +260,23 @@ inline std::vector<double> spherical_gradient(
G[static_cast<std::size_t>(iv)] = m.theta_v[v] - sum_alpha;
}
// Edge: G_e for λ_e additive (Λ_ij = λ°_ij + u_i + u_j + λ_e).
// Edge: G_e = α_opp(face⁺) + α_opp(face⁻) θ_e (Java-faithful, Finding 3).
//
// From the Schläfli identity applied to the spherical face,
// the contribution of edge DOF λ_e from face f is:
// a_f = (2·α_opp S_f) / 2 where S_f = Σ angles in face f.
//
// Summing over both adjacent faces:
// G_e = a_f+ + a_f
// = α_opp⁺ + α_opp⁻ (S_f⁺ + S_f⁻) / 2 θ_e
//
// For flat (Euclidean) triangles S_f = π, recovering the familiar
// α_opp⁺ + α_opp⁻ π formula. For spherical triangles S_f > π.
// With the replacement parameterization (Λ_ij = λ_e directly), the
// Schläfli derivative of the spherical edge energy w.r.t. λ_e reduces to
// the sum of the two opposite corner angles minus the target θ_e
// (default π). This matches SphericalFunctional.java:283292
// `G.add(i, αk + αl PI)`. Note this drops the (S_f⁺+S_f⁻)/2 term that
// would arise under the additive convention; the two conventions agree on
// the vertex-only path (no edge DOFs), which is exercised by every test.
for (auto e : mesh.edges()) {
int ie = m.e_idx[e];
if (ie < 0) continue;
auto h = mesh.halfedge(e);
auto ho = mesh.opposite(h);
double sum = 0.0;
if (!mesh.is_border(h)) {
double alpha_opp = h_alpha[spher_hidx(h)];
double S_f = alpha_opp
+ h_alpha[spher_hidx(mesh.next(h))]
+ h_alpha[spher_hidx(mesh.prev(h))];
sum += (2.0 * alpha_opp - S_f) * 0.5;
}
if (!mesh.is_border(ho)) {
double alpha_opp = h_alpha[spher_hidx(ho)];
double S_f = alpha_opp
+ h_alpha[spher_hidx(mesh.next(ho))]
+ h_alpha[spher_hidx(mesh.prev(ho))];
sum += (2.0 * alpha_opp - S_f) * 0.5;
}
if (!mesh.is_border(h)) sum += h_alpha[spher_hidx(h)];
if (!mesh.is_border(ho)) sum += h_alpha[spher_hidx(ho)];
G[static_cast<std::size_t>(ie)] = sum - m.theta_e[e];
}

View File

@@ -57,9 +57,19 @@ inline SphericalFaceAngles spherical_angles(double l12, double l23, double l31)
double s23 = s - l23;
double s31 = s - l31;
// Spherical triangle inequalities: all s-deficiencies > 0 and s < π.
if (s12 <= 0.0 || s23 <= 0.0 || s31 <= 0.0 || s >= PI_SPHER)
return {0.0, 0.0, 0.0, false};
// Degenerate spherical triangle: return the *limiting* angles, matching the
// Java reference (SphericalFunctional.triangleEnergyAndAlphas). a1 is the
// angle opposite l23, a2 opposite l31, a3 opposite l12. `valid` stays false
// so the Hessian still skips the face, but the gradient uses these angles
// (convex C¹ extension onto the infeasible region).
// s12<=0 (Δij<=0) → corner opposite l12 = π → a3 = π
// s23<=0 (Δjk<=0) → corner opposite l23 = π → a1 = π
// s31<=0 (Δki<=0) → corner opposite l31 = π → a2 = π
// s>=π (Δijk>=2π) → all three corners = π
if (s12 <= 0.0) return {0.0, 0.0, PI_SPHER, false};
if (s23 <= 0.0) return {PI_SPHER, 0.0, 0.0, false};
if (s31 <= 0.0) return {0.0, PI_SPHER, 0.0, false};
if (s >= PI_SPHER) return {PI_SPHER, PI_SPHER, PI_SPHER, false};
const double ss = std::sin(s);
const double ss12 = std::sin(s12);

View File

@@ -35,6 +35,7 @@
#include <Eigen/Sparse>
#include <vector>
#include <cmath>
#include <stdexcept>
namespace conformallab {
@@ -103,6 +104,18 @@ inline Eigen::SparseMatrix<double> spherical_hessian(
{
const int n = spherical_dimension(mesh, m);
// Only the vertex block of the spherical Hessian is implemented here. If any
// edge DOF is variable, the edge-edge and vertex-edge blocks present in the
// Java reference (conformalHessian) are missing, which would leave singular
// zero rows/cols. Fail loudly instead of silently returning a rank-deficient
// matrix (mirrors the euclidean_hessian guard — Finding 4).
for (auto e : mesh.edges()) {
if (m.e_idx[e] >= 0)
throw std::logic_error(
"spherical_hessian: edge DOFs are not supported "
"(only the vertex-block cotangent Laplacian is implemented)");
}
std::vector<Eigen::Triplet<double>> trips;
trips.reserve(static_cast<std::size_t>(n) * 9);

View File

@@ -12,9 +12,13 @@
// The tool:
// 1. Loads an OFF/OBJ/PLY mesh.
// 2. Sets up DOF maps + computes λ° from the input geometry.
// 3. Pins one vertex (Euclidean/Spherical) or uses all-free DOFs (HyperIdeal).
// 3. Euclidean: solves a genuine conformal-flattening problem with target
// cone angle Θ_v = 2π (zero curvature). Open meshes pin the boundary and
// flatten the interior; closed meshes pin one vertex and enforce
// Gauss-Bonnet. x = 0 is NOT the solution, so Newton does real work.
// 4. Runs Newton until convergence.
// 5. Computes a 2-D (Euclidean / HyperIdeal) or 3-D (Spherical) layout.
// 5. Computes a 2-D (Euclidean / HyperIdeal) or 3-D (Spherical) layout;
// closed surfaces are cut along the tree-cotree cut graph first.
// 6. Saves the layout as an OFF file and optionally serialises the result
// to JSON and/or XML.
// 7. Optionally shows the input mesh in a viewer (-s flag, requires WITH_VIEWER).
@@ -27,6 +31,9 @@
#include "newton_solver.hpp"
#include "layout.hpp"
#include "serialization.hpp"
#include "gauss_bonnet.hpp"
#include "cut_graph.hpp"
#include "period_matrix.hpp"
#include <CLI11.hpp>
#include <iostream>
@@ -49,28 +56,41 @@ using cl::Edge_index;
// Shared helpers (mirroring test_pipeline.cpp patterns)
// ─────────────────────────────────────────────────────────────────────────────
// Pin vertex 0, assign 0..n-1 to the rest
static int pin_first_vertex(ConformalMesh& mesh, cl::EuclideanMaps& maps)
// Assign Euclidean vertex DOFs for a genuine conformal-flattening problem.
//
// The target cone angle Θ_v = 2π (set by `setup_euclidean_maps`) asks for a
// *flat* metric — zero discrete Gaussian curvature at every free vertex. We do
// NOT overwrite it with the input angle sums, so x = 0 is generally NOT the
// solution and Newton has to do real work.
//
// • Open mesh (disk/cylinder…): pin the boundary (u = 0, original boundary
// lengths) and free the interior → fixed-boundary conformal flattening.
// • Closed mesh: pin one vertex to fix the scale gauge, free the rest, and
// call `enforce_gauss_bonnet` so the flat target is topology-consistent
// (no shift for a torus, uniform cone angles for genus 0).
//
// Returns the number of free DOFs and reports whether the mesh has a boundary.
static int assign_euclidean_flattening_dofs(ConformalMesh& mesh,
cl::EuclideanMaps& maps,
bool& has_boundary)
{
auto vit = mesh.vertices().begin();
Vertex_index v0 = *vit++;
maps.v_idx[v0] = -1;
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit)
maps.v_idx[*vit] = idx++;
return idx;
}
has_boundary = false;
for (auto v : mesh.vertices())
if (mesh.is_border(v)) { has_boundary = true; break; }
// Natural theta for Euclidean: make x=0 the equilibrium
static void set_natural_euclidean_theta(ConformalMesh& mesh, cl::EuclideanMaps& maps, int n)
{
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G = cl::euclidean_gradient(mesh, x0, maps);
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv < 0) continue;
maps.theta_v[v] -= G[static_cast<std::size_t>(iv)];
int idx = 0;
if (has_boundary) {
for (auto v : mesh.vertices())
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
} else {
bool pinned = false;
for (auto v : mesh.vertices()) {
if (!pinned) { maps.v_idx[v] = -1; pinned = true; }
else maps.v_idx[v] = idx++;
}
cl::enforce_gauss_bonnet(mesh, maps);
}
return idx;
}
// Natural theta for HyperIdeal at base point (b=1, a=0.5) to avoid x=0 singularity
@@ -110,26 +130,57 @@ static int run_euclidean(ConformalMesh& mesh,
const std::string& out_xml,
bool verbose)
{
// Setup
// Setup — Θ_v = 2π (flat target) by default; lengths from the input mesh.
auto maps = cl::setup_euclidean_maps(mesh);
cl::compute_euclidean_lambda0_from_mesh(mesh, maps);
// DOF assignment: pin vertex 0
int n = pin_first_vertex(mesh, maps);
if (n <= 0) { std::cerr << "Error: mesh has only one vertex.\n"; return 1; }
// DOF assignment for a genuine flattening problem (see helper).
bool has_boundary = false;
int n = assign_euclidean_flattening_dofs(mesh, maps, has_boundary);
if (n <= 0) { std::cerr << "Error: no free vertices to solve for.\n"; return 1; }
// Natural target angles
set_natural_euclidean_theta(mesh, maps, n);
const int g = has_boundary ? -1 : cl::genus(mesh);
if (verbose) {
std::cout << " topology: " << (has_boundary ? "open (boundary pinned)"
: "closed")
<< ", free DOFs=" << n;
if (!has_boundary) std::cout << ", genus=" << g;
std::cout << "\n";
}
// Newton
// Newton — starts at x0 = 0, which is NOT the solution in general.
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto res = cl::newton_euclidean(mesh, x0, maps);
if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
if (!res.converged)
std::cerr << "[warn] Newton did not converge (|grad|="
<< res.grad_inf_norm << ", iter=" << res.iterations << ")\n";
// Layout
cl::Layout2D layout = cl::euclidean_layout(mesh, res.x, maps);
// Layout. For a closed surface we cut along the tree-cotree cut graph so
// the result is a single planar fundamental domain rather than overlapping
// face copies. For genus 1 we also recover the holonomy lattice generators
// and report the period ratio τ.
cl::Layout2D layout;
cl::HolonomyData hol;
bool have_tau = false;
cl::PeriodData pd;
if (!has_boundary && g >= 1) {
cl::CutGraph cg = cl::compute_cut_graph(mesh);
layout = cl::euclidean_layout(mesh, res.x, maps, &cg, &hol, /*normalise=*/true);
if (g == 1 && hol.translations.size() >= 2) {
try {
pd = cl::compute_period_matrix(hol, /*reduce=*/true);
have_tau = std::isfinite(pd.tau.real())
&& std::isfinite(pd.tau.imag())
&& pd.tau.imag() > 0.0;
} catch (const std::exception& e) {
std::cerr << "[warn] period-matrix τ extraction failed: "
<< e.what() << "\n";
}
}
} else {
layout = cl::euclidean_layout(mesh, res.x, maps);
}
// Output
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout);
@@ -149,6 +200,13 @@ static int run_euclidean(ConformalMesh& mesh,
<< " iter=" << res.iterations
<< " |grad|_inf=" << std::scientific << std::setprecision(3)
<< res.grad_inf_norm << "\n";
if (have_tau) {
std::cout << std::fixed << std::setprecision(6)
<< " period ratio τ = " << pd.tau.real()
<< (pd.tau.imag() >= 0.0 ? " + " : " - ")
<< std::abs(pd.tau.imag()) << "i"
<< " (genus 1, reduced to fundamental domain)\n";
}
if (!out_layout.empty()) std::cout << " layout → " << out_layout << "\n";
if (!out_json.empty()) std::cout << " json → " << out_json << "\n";
if (!out_xml.empty()) std::cout << " xml → " << out_xml << "\n";
@@ -164,6 +222,17 @@ static int run_spherical(ConformalMesh& mesh,
const std::string& out_xml,
bool verbose)
{
// Spherical uniformisation targets a closed genus-0 surface (sphere).
for (auto v : mesh.vertices())
if (mesh.is_border(v)) {
std::cerr << "Error: spherical mode needs a closed mesh; this mesh "
"has a boundary. Use '-g euclidean' for open meshes.\n";
return 1;
}
if (int g = cl::genus(mesh); g != 0)
std::cerr << "[warn] spherical uniformisation assumes genus 0; this mesh "
"has genus " << g << " — convergence is not guaranteed.\n";
auto maps = cl::setup_spherical_maps(mesh);
cl::compute_lambda0_from_mesh(mesh, maps);
int n = cl::assign_vertex_dof_indices(mesh, maps);

View File

@@ -20,6 +20,9 @@
#include "layout.hpp"
#include "period_matrix.hpp"
#include "fundamental_domain.hpp"
#include "mesh_io.hpp"
#include "cut_graph.hpp"
#include "gauss_bonnet.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <complex>
@@ -343,6 +346,122 @@ TEST(PeriodMatrix, ComputePeriodMatrix_ReducedTau_InFD)
EXPECT_TRUE(is_in_fundamental_domain(pd.tau, 1e-9));
}
// ════════════════════════════════════════════════════════════════════════════
// End-to-end holonomy → τ on real genus-1 torus meshes
//
// Regression test for the holonomy-extraction bug: euclidean_holonomy() developed
// the cut surface along a BFS dual tree that crossed the primal-tree edges freely.
// Relative to that tree the cut graph's 2g generator edges were NOT generators —
// some were null-homotopic — so the two developed copies of a "cut" edge landed
// on top of each other and compute_period_matrix() got ω ≈ 0 (→ τ = 0 / NaN /
// huge). The fix develops across the cut graph's OWN dual spanning tree T* only
// (CutGraph::is_dual_tree), unfolding the surface onto a true disk so the cut
// edges become the boundary identifications that carry the lattice generators.
//
// Analytic target. The bundled meshes are tori of REVOLUTION (major radius R,
// minor radius r, R > r), not abstract square/hexagonal flat tori. Their
// conformal modulus is purely imaginary,
//
// τ = i · √(R² r²) / r (reduced so |τ| ≥ 1)
//
// derived from the flat-conformal change of variable dψ = r/(R + r cos φ) dφ on
// the induced metric ds² = (R + r cos φ)² dθ² + r² dφ²; the ψ-period is
// 2πr/√(R²r²), giving the rectangular lattice ratio above. Re(τ) = 0 follows
// from the meridian ⟂ longitude reflection symmetry. The coarse polygonal cross
// sections (square/hex/octagon) approximate the circular value from above; the
// gap shrinks as the cross section gains sides.
// ════════════════════════════════════════════════════════════════════════════
namespace {
// Run the full pipeline solve → cut → layout → period matrix on a torus mesh and
// return the reduced τ together with the two raw holonomy generators.
struct TorusTau {
std::complex<double> tau;
std::vector<Eigen::Vector2d> omega;
bool converged = false;
};
TorusTau run_torus_pipeline(const std::string& file)
{
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/off/" + file;
ConformalMesh mesh = load_mesh(path);
EuclideanMaps maps = setup_euclidean_maps(mesh); // Θ_v = 2π (flat target)
compute_euclidean_lambda0_from_mesh(mesh, maps);
int idx = 0;
bool pinned = false;
for (auto v : mesh.vertices()) {
if (!pinned) { maps.v_idx[v] = -1; pinned = true; }
else maps.v_idx[v] = idx++;
}
enforce_gauss_bonnet(mesh, maps);
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
auto res = newton_euclidean(mesh, x0, maps);
CutGraph cg = compute_cut_graph(mesh);
HolonomyData hol;
euclidean_layout(mesh, res.x, maps, &cg, &hol, /*normalise=*/false);
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
return TorusTau{pd.tau, hol.translations, res.converged};
}
// Reduced conformal modulus of a torus of revolution (major R, minor r).
double revolution_tau_imag(double R, double r)
{
return std::sqrt(R * R - r * r) / r; // ≥ 1 form (|τ| ≥ 1)
}
void check_torus(const std::string& file, double R, double r, double rel_tol)
{
TorusTau t = run_torus_pipeline(file);
ASSERT_TRUE(t.converged) << file << ": Newton did not converge";
// Generators must be non-degenerate (the bug collapsed them to ~0).
ASSERT_EQ(t.omega.size(), 2u);
EXPECT_GT(t.omega[0].norm(), 1e-3) << file << ": ω₁ degenerate";
EXPECT_GT(t.omega[1].norm(), 1e-3) << file << ": ω₂ degenerate";
EXPECT_TRUE(std::isfinite(t.tau.real()) && std::isfinite(t.tau.imag()))
<< file << ": τ is not finite (" << t.tau.real() << "+" << t.tau.imag() << "i)";
EXPECT_GT(t.tau.imag(), 0.0) << file << ": τ must lie in the upper half-plane";
EXPECT_TRUE(is_in_fundamental_domain(t.tau, 1e-6))
<< file << ": τ = " << t.tau.real() << "+" << t.tau.imag() << "i not in F";
// Re(τ) = 0 by the meridian ⟂ longitude reflection symmetry.
EXPECT_NEAR(t.tau.real(), 0.0, 0.05)
<< file << ": Re(τ) should vanish for a torus of revolution";
const double expected = revolution_tau_imag(R, r);
EXPECT_NEAR(t.tau.imag(), expected, rel_tol * expected)
<< file << ": Im(τ) = " << t.tau.imag()
<< " vs analytic i·√(R²r²)/r = " << expected;
}
} // namespace
// 4×4 torus of revolution: R = 2, r = 1 → τ = i√3 ≈ 1.732i.
// Square (4-gon) cross section → coarsest circle approximation, looser tolerance.
TEST(HolonomyEndToEnd, Torus4x4_TauMatchesRevolutionModulus)
{
check_torus("torus_4x4.off", /*R=*/2.0, /*r=*/1.0, /*rel_tol=*/0.10);
}
// Hexagonal 6×6 torus of revolution: R = 3, r = 1 → τ = i√8 ≈ 2.828i.
TEST(HolonomyEndToEnd, TorusHex6x6_TauMatchesRevolutionModulus)
{
check_torus("torus_hex_6x6.off", /*R=*/3.0, /*r=*/1.0, /*rel_tol=*/0.05);
}
// Octagonal 8×8 torus of revolution: R = 3, r = 1 → τ = i√8 ≈ 2.828i.
TEST(HolonomyEndToEnd, Torus8x8_TauMatchesRevolutionModulus)
{
check_torus("torus_8x8.off", /*R=*/3.0, /*r=*/1.0, /*rel_tol=*/0.05);
}
// ════════════════════════════════════════════════════════════════════════════
// FundamentalDomain — genus-1 parallelogram
// ════════════════════════════════════════════════════════════════════════════
@@ -486,3 +605,4 @@ TEST(TilingNeighbourhood, EmptyHolonomy_ReturnsSingleTile)
auto tiles = tiling_neighbourhood(lay, hol);
EXPECT_EQ(tiles.size(), 1u);
}

View File

@@ -164,8 +164,12 @@ TEST(SphericalFunctional, GradientCheck_SpherTetAllDofs)
compute_lambda0_from_mesh(mesh, maps);
int n = assign_all_spherical_dof_indices(mesh, maps);
// Small but non-zero values; vertex DOFs negative, edge DOFs zero.
// Edge DOF adjusts the effective log-length Λ_ij = λ°_ij + u_i + u_j + λ_e.
// Replacement parameterization (Finding 3): when an edge carries a DOF its
// value *replaces* λ°_ij + u_i + u_j entirely, so Λ_ij = λ_e. Here the edge
// DOFs stay at 0 and only the vertex DOFs are perturbed; this checks that the
// gradient is curl-free (energy = Schläfli path integral), not Java-faithfulness
// of the edge formula — that is locked separately by
// EdgeGradient_RegularTetClosedForm below.
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
// Set vertex DOFs (indices 0..3) to -0.2 to keep triangle well-formed.
for (int i = 0; i < 4; ++i) x[static_cast<std::size_t>(i)] = -0.2;
@@ -174,6 +178,59 @@ TEST(SphericalFunctional, GradientCheck_SpherTetAllDofs)
<< "Gradient check failed on spherical tetrahedron (all DOFs)";
}
// ════════════════════════════════════════════════════════════════════════════
// Closed-form oracle for the edge-DOF gradient (Finding 3, missing-test item 4)
//
// The FD gradient check above can only confirm that G is conservative — the
// spherical energy is *defined* as the path integral of G, so the energy↔gradient
// FD agreement is automatic and CANNOT detect a wrong-but-conservative edge
// formula. This test instead pins the edge gradient against an independent,
// closed-form geometric value, so it would fail if the Finding-3 formula
// (G_e = α_opp⁺ + α_opp⁻ θ_e, dropping the additive (S⁺+S⁻)/2 term) ever
// regressed.
//
// Geometry: the regular spherical tetrahedron has all edges a = arccos(1/3),
// so by the spherical law of cosines every interior corner angle is
// cos α = (cos a cos²a)/sin²a = cos a/(1+cos a) = (1/3)/(2/3) = 1/2
// ⇒ α = 2π/3.
// Each edge is shared by two faces, so both opposite angles equal 2π/3 and
// G_e = 2π/3 + 2π/3 θ_e with θ_e = π (default) = π/3.
//
// Setup: all edges carry DOFs, set to their λ⁰ (the replacement convention then
// reproduces the original tetrahedron metric exactly), vertex DOFs left at 0.
// ════════════════════════════════════════════════════════════════════════════
TEST(SphericalFunctional, EdgeGradient_RegularTetClosedForm)
{
const double PI_ = std::acos(-1.0);
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_lambda0_from_mesh(mesh, maps);
int n = assign_all_spherical_dof_indices(mesh, maps);
// Edge DOF = λ⁰ → Λ_ij = λ⁰ → reproduces the arccos(1/3) tetrahedron.
// Vertex DOFs stay at 0 (ignored by the replacement convention for DOF edges).
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
int n_edge_dofs = 0;
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e];
if (ie >= 0) { x[static_cast<std::size_t>(ie)] = maps.lambda0[e]; ++n_edge_dofs; }
}
ASSERT_EQ(n_edge_dofs, 6) << "regular tetrahedron must have 6 edge DOFs";
auto G = spherical_gradient(mesh, x, maps);
const double expected = PI_ / 3.0; // 2·(2π/3) π
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e];
if (ie < 0) continue;
EXPECT_NEAR(G[static_cast<std::size_t>(ie)], expected, 1e-9)
<< "edge gradient at DOF " << ie
<< " must equal the closed-form value π/3 (Finding 3)";
}
}
// ════════════════════════════════════════════════════════════════════════════
// Angles are finite at a known interior point
//