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

@@ -27,7 +27,8 @@ ignore-words-list = bessel,ist,sinces,nd,te,inout,nin,numer,neet,anc,sinks,doubl
behaviour,behaviours,behavioural, behaviour,behaviours,behavioural,
analogue,analogues, analogue,analogues,
initialise,initialised,initialises,initialising,initialisation, initialise,initialised,initialises,initialising,initialisation,
normalise,normalised,normalises,normalising,normalisation, normalise,normalised,normalises,normalising,normalisation,normalisations,
favour,favours,favoured,favouring,favourite,favourites,
centralise,centralised,centralises,centralising, centralise,centralised,centralises,centralising,
serialise,serialised,serialises,serialising,serialisation, serialise,serialised,serialises,serialising,serialisation,
parameterise,parameterised,parameterises,parameterising, parameterise,parameterised,parameterises,parameterising,

View File

@@ -300,6 +300,17 @@ grep -r "ClassName" /Users/tarikmoussa/Desktop/conformallab/src
The 2026-05-21 audit corrected four mis-labels (now fixed): `InversiveDistanceFunctional`, both HyperIdeal Hessians (FD + analytic), and the inversive-distance tutorial were all wrongly tagged "Java port" — they are research (no Java parent; Java declares `hasHessian()==false`). Details in `research-track.md`. The 2026-05-21 audit corrected four mis-labels (now fixed): `InversiveDistanceFunctional`, both HyperIdeal Hessians (FD + analytic), and the inversive-distance tutorial were all wrongly tagged "Java port" — they are research (no Java parent; Java declares `hasHessian()==false`). Details in `research-track.md`.
### Java↔C++ math-correctness audit (2026-05-29)
Full line-by-line audit of all math-critical headers against `de.varylab.discreteconformal.*` lives in **`doc/reviewer/java-port-audit.md`** (read it before re-investigating any of these). All 11 findings are resolved or noted; the four that needed code changes are now ✅ FIXED and **all CGAL tests pass** (240 after the Euclidean holonomy/τ end-to-end tests + the spherical edge-DOF closed-form oracle landed on top — see `doc/api/tests.md`):
- **Finding 3** (`spherical_functional.hpp`) — spherical edge-DOF now uses Java's *replacement* parameterization (`Λ = λ_e` when the edge is a DOF, via helper `spher_eff_lambda`); edge gradient is `α_opp⁺ + α_opp⁻ θ_e` (dropped the extra `(S_f⁺+S_f⁻)/2` term). Vertex-only path is bit-for-bit unchanged.
- **Finding 4** (`spherical_hessian.hpp`) — added an always-compiled `throw std::logic_error` edge-DOF guard (mirrors Finding 2 for Euclidean).
- **Finding 6** (`period_matrix.hpp`) — `compute_period_matrix` now calls the faithful `normalizeModulus` (matches Java oracle: `0 ≤ Re ≤ ½`, `Im ≥ 0`, `|τ| ≥ 1`); `reduce_to_fundamental_domain` retained for the canonical SL(2,) domain.
- **Finding 9** (`inversive_distance_functional.hpp`) — degenerate-face gradient now uses limiting angles instead of skipping (mirrors Finding 1); the genuinely-non-real `l*sq <= 0` skip is kept.
**Open follow-ups (tests only, not bugs):** the edge-DOF paths for Findings 3 & 4 are verified for compilation + the unchanged vertex-only case but **not yet against a Java oracle at the solution level** — missing-test items 4, 7, 10 in the audit doc (golden-value oracle tests, period-matrix sign-convention test, inversive-distance degenerate test). Build/test reminder: the CGAL build dir used for this audit is **`build-cgal`** at the *repo root* (not under `code/`), target `conformallab_cgal_tests`, filter `ctest -R '^cgal\.'`.
## Documentation map ## Documentation map
38 documents across 7 categories. Read the relevant one before reasoning from scratch 38 documents across 7 categories. Read the relevant one before reasoning from scratch

View File

@@ -34,7 +34,16 @@ ctest --test-dir build -R "^cgal\." --output-on-failure
# Full build with CLI + viewer (requires Wayland/X11 dev headers) # Full build with CLI + viewer (requires Wayland/X11 dev headers)
cmake -S code -B build -DWITH_CGAL=ON && cmake --build build -j$(nproc) cmake -S code -B build -DWITH_CGAL=ON && cmake --build build -j$(nproc)
./bin/conformallab_core -i input.off -g euclidean -o layout.off -j result.json
# Conformal flattening (Θ_v = 2π target). Closed meshes pin one vertex +
# enforce Gauss-Bonnet; open meshes pin the boundary and flatten the interior.
./bin/conformallab_core -i code/data/off/torus_8x8.off -g euclidean -v \
-o layout.off -j result.json
# topology: closed, free DOFs=63, genus=1
# Euclidean: converged=yes iter=3 |grad|_inf≈5e-15
./bin/conformallab_core -i code/data/obj/cathead.obj -g euclidean -v -o cat.off
# topology: open (boundary pinned), free DOFs=119
# Euclidean: converged=yes iter=4
# API documentation (requires doxygen: brew/apt install doxygen) # API documentation (requires doxygen: brew/apt install doxygen)
cmake --build build --target doc cmake --build build --target doc

View File

@@ -49,6 +49,13 @@ struct CutGraph {
/// Indices of the 2g cut edges in order (size = 2g). /// Indices of the 2g cut edges in order (size = 2g).
std::vector<std::size_t> cut_edge_indices; 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). /// Genus of the surface (0 for topological spheres and open patches).
int genus = 0; int genus = 0;
@@ -58,6 +65,14 @@ struct CutGraph {
return static_cast<std::size_t>(e.idx()) < cut_edge_flags.size() return static_cast<std::size_t>(e.idx()) < cut_edge_flags.size()
&& cut_edge_flags[static_cast<std::size_t>(e.idx())]; && 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 /// 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); 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; 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); double lam31 = m.lambda0[e31] + u3 + u1 + eucl_dof_val(m.e_idx[e31], x);
auto fa = euclidean_angles(lam12, lam23, lam31); 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: // h_alpha[h] = corner angle OPPOSITE to h's edge:
// h0 (edge v1v2) → opposite corner at v3 → α3 // 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 // rescales all three sides by the same factor, leaving angles unchanged but
// keeping the arguments of exp in a safe numerical range. // keeping the arguments of exp in a safe numerical range.
#include "constants.hpp"
#include <cmath> #include <cmath>
namespace conformallab { namespace conformallab {
@@ -50,8 +51,16 @@ inline EuclideanFaceAngles euclidean_angles_from_lengths(
const double t23 = +l12 - l23 + l31; // 2*(s l23) const double t23 = +l12 - l23 + l31; // 2*(s l23)
const double t31 = +l12 + l23 - l31; // 2*(s l31) const double t31 = +l12 + l23 - l31; // 2*(s l31)
if (t12 <= 0.0 || t23 <= 0.0 || t31 <= 0.0) // Degenerate (triangle inequality violated): return the *limiting* angles
return {0.0, 0.0, 0.0, false}; // 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 l123 = l12 + l23 + l31;
const double denom2 = t12 * t23 * t31 * l123; // = (4·Area)² const double denom2 = t12 * t23 * t31 * l123; // = (4·Area)²

View File

@@ -44,6 +44,7 @@
#include <Eigen/Sparse> #include <Eigen/Sparse>
#include <vector> #include <vector>
#include <cmath> #include <cmath>
#include <stdexcept>
namespace conformallab { namespace conformallab {
@@ -104,6 +105,18 @@ inline Eigen::SparseMatrix<double> euclidean_hessian(
{ {
const int n = euclidean_dimension(mesh, m); 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. // Collect triplets (row, col, value) — setFromTriplets sums duplicates.
std::vector<Eigen::Triplet<double>> trips; std::vector<Eigen::Triplet<double>> trips;
trips.reserve(static_cast<std::size_t>(n) * 7); // rough estimate 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 // If this fails, no conformal factor can realise the target angles and
// Newton will silently fail to converge. // 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: // API:
// int euler_characteristic(mesh) // int euler_characteristic(mesh)
// int genus(mesh) // int genus(mesh)
@@ -128,10 +135,10 @@ inline void check_gauss_bonnet(const ConformalMesh& mesh,
// ── enforce_gauss_bonnet — adjust θ_v by uniform Δ ─────────────────────────── // ── 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). // After this call, check_gauss_bonnet() will not throw (up to floating-point).
// Only modifies free vertices (v_idx[v] >= 0 for EuclideanMaps / SphericalMaps; // Modifies ALL vertices' θ_v (no v_idx filtering) — the shift is a property
// always all vertices for the raw property-map overload). // of the target angles, independent of which vertices are free DOFs.
/// Distribute the Gauss-Bonnet deficit uniformly across all `Θ_v`: /// Distribute the Gauss-Bonnet deficit uniformly across all `Θ_v`:
/// add `δ = (lhs rhs) / V` to every entry so that the identity holds /// 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 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]); 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; if (l12sq <= 0 || l23sq <= 0 || l31sq <= 0) continue;
// euclidean_angles expects 2·log() per edge — feed log(ℓ²). // 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)); 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(h0)] = fa.alpha3;
h_alpha[id_detail::hidx(h1)] = fa.alpha1; 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())]; 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 } // namespace detail
// ── Euclidean layout ────────────────────────────────────────────────────────── // ── Euclidean layout ──────────────────────────────────────────────────────────
@@ -592,25 +711,30 @@ inline Layout2D euclidean_layout(
result.success = true; result.success = true;
// ── Holonomy ────────────────────────────────────────────────────────────── // ── 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) { if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices; holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.clear();
holonomy->translations.reserve(cut->cut_edge_indices.size());
holonomy->mobius_maps.clear(); 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) { 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)); Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce_idx));
Halfedge_index h = mesh.halfedge(e); Halfedge_index h = mesh.halfedge(e);
Halfedge_index ho = mesh.opposite(h); Halfedge_index ho = mesh.opposite(h);
Halfedge_index hx = mesh.is_border(ho) ? h : ho; 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)); 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( Eigen::Vector2d p_tri = detail::trilaterate_2d(
result.uv[vs.idx()], result.uv[vt.idx()], result.uv[vs.idx()], result.uv[vt.idx()],
edge_len(mesh.prev(hx)), edge_len(mesh.next(hx))); 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); 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); for (auto f : mesh.faces()) if (!face_placed[f.idx()]) place_component(f);
result.success = true; 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) { if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices; holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.clear(); holonomy->translations.clear();
@@ -838,6 +971,16 @@ inline Layout2D hyper_ideal_layout(
result.success = true; result.success = true;
// ── Möbius-map holonomy ─────────────────────────────────────────────────── // ── 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) { if (cut && holonomy) {
holonomy->cut_edge_indices = cut->cut_edge_indices; holonomy->cut_edge_indices = cut->cut_edge_indices;
holonomy->translations.clear(); holonomy->translations.clear();

View File

@@ -26,6 +26,7 @@
#include "conformal_mesh.hpp" #include "conformal_mesh.hpp"
#include <CGAL/IO/polygon_mesh_io.h> #include <CGAL/IO/polygon_mesh_io.h>
#include <CGAL/boost/graph/helpers.h>
#include <string> #include <string>
#include <stdexcept> #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 /// Throwing wrapper around `read_mesh`: returns the mesh by value
/// or throws `std::runtime_error` on read failure. /// 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) inline ConformalMesh load_mesh(const std::string& filename)
{ {
ConformalMesh mesh; ConformalMesh mesh;
if (!read_mesh(filename, mesh)) if (!read_mesh(filename, mesh))
throw std::runtime_error("conformallab: failed to read mesh from " + filename); 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; return mesh;
} }

View File

@@ -115,35 +115,89 @@ inline Eigen::VectorXd solve_linear_system(
namespace detail { // re-open for the remaining helpers namespace detail { // re-open for the remaining helpers
// Backtracking line search: find the largest α in {1, 0.5, 0.25, …} such that // Globalised line search for the Newton system G(x) = 0.
// ||G(x + α·Δx)||₂ < ||G(x)||₂. Returns the accepted step (α may stay 1). //
// 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> template <typename GradFn>
inline std::vector<double> line_search( inline std::vector<double> line_search(
const std::vector<double>& x, const std::vector<double>& x,
const Eigen::VectorXd& dx, const Eigen::VectorXd& dx,
const Eigen::VectorXd& d_sd,
double norm0, double norm0,
GradFn&& grad_fn, 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()); const int n = static_cast<int>(x.size());
double alpha = 1.0; const double norm0_sq = norm0 * norm0;
std::vector<double> xnew(static_cast<std::size_t>(n));
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) for (int i = 0; i < n; ++i)
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)] xnew[static_cast<std::size_t>(i)] =
+ alpha * dx[i]; x[static_cast<std::size_t>(i)] + alpha * dir[i];
auto Gnew = grad_fn(xnew); auto Gnew = grad_fn(xnew);
double norm_new = 0.0; double s = 0.0;
for (double v : Gnew) norm_new += v * v; for (double v : Gnew) s += v * v;
norm_new = std::sqrt(norm_new); return std::sqrt(s);
if (norm_new < norm0) return xnew; };
// ── 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; alpha *= 0.5;
} }
// No improvement found — return best attempt (full step)
for (int i = 0; i < n; ++i) // ── Phase 2: steepest-descent fallback (H·G), Armijo backtracking ────────
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)] + dx[i]; 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; 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 } // namespace detail
@@ -209,12 +263,15 @@ inline NewtonResult newton_euclidean(
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok); Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
if (!ok) break; if (!ok) break;
// ── Backtracking line search ────────────────────────────────────────── // ── Globalised line search (Armijo + steepest-descent fallback) ───────
double norm0 = G.norm(); 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) { [&](const std::vector<double>& xnew) {
return euclidean_gradient(mesh, xnew, m); return euclidean_gradient(mesh, xnew, m);
}); }, &improved);
if (!improved) break; // line search stalled — stop cleanly
res.iterations = iter + 1; res.iterations = iter + 1;
} }
@@ -287,12 +344,16 @@ inline NewtonResult newton_spherical(
Eigen::VectorXd dx = detail::solve_with_fallback(negH, G, ok); Eigen::VectorXd dx = detail::solve_with_fallback(negH, G, ok);
if (!ok) break; 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(); 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) { [&](const std::vector<double>& xnew) {
return spherical_gradient(mesh, xnew, m); return spherical_gradient(mesh, xnew, m);
}); }, &improved);
if (!improved) break;
res.iterations = iter + 1; res.iterations = iter + 1;
} }
@@ -367,12 +428,15 @@ inline NewtonResult newton_hyper_ideal(
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok); Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
if (!ok) break; if (!ok) break;
// ── Backtracking line search ────────────────────────────────────────── // ── Globalised line search (Armijo + steepest-descent fallback) ───────
double norm0 = G.norm(); 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) { [&](const std::vector<double>& xnew) {
return evaluate_hyper_ideal(mesh, xnew, m, false).gradient; return evaluate_hyper_ideal(mesh, xnew, m, false).gradient;
}); }, &improved);
if (!improved) break;
res.iterations = iter + 1; res.iterations = iter + 1;
} }
@@ -443,10 +507,13 @@ inline NewtonResult newton_cp_euclidean(
if (!ok) break; if (!ok) break;
double norm0 = G.norm(); 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) { [&](const std::vector<double>& xnew) {
return cp_euclidean_gradient(mesh, xnew, m); return cp_euclidean_gradient(mesh, xnew, m);
}); }, &improved);
if (!improved) break;
res.iterations = iter + 1; res.iterations = iter + 1;
} }
@@ -552,10 +619,13 @@ inline NewtonResult newton_inversive_distance(
if (!ok) break; if (!ok) break;
double norm0 = G.norm(); 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) { [&](const std::vector<double>& xnew) {
return inversive_distance_gradient(mesh, xnew, m); return inversive_distance_gradient(mesh, xnew, m);
}); }, &improved);
if (!improved) break;
res.iterations = iter + 1; res.iterations = iter + 1;
} }

View File

@@ -41,6 +41,7 @@
// std::complex<double> reduce_to_fundamental_domain(τ) — apply SL(2,) // std::complex<double> reduce_to_fundamental_domain(τ) — apply SL(2,)
#include "layout.hpp" #include "layout.hpp"
#include "discrete_elliptic_utility.hpp" // normalizeModulus (Java-faithful)
#include <complex> #include <complex>
#include <cmath> #include <cmath>
#include <vector> #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. // For genus-1 surfaces, also reduces τ to the fundamental domain.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// Compute the period data from the Euclidean holonomy translations. /// Compute the period data from the Euclidean holonomy translations.
/// For genus 1, also reduces `τ` to the SL(2,) fundamental domain /// For genus 1, also normalises `τ` when `reduce` is `true` (default)
/// 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) inline PeriodData compute_period_matrix(const HolonomyData& hol, bool reduce = true)
{ {
PeriodData pd; 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 (tau.imag() < 0.0) return pd; // degenerate
if (reduce) { 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.in_fundamental_domain = true;
} }
pd.tau = tau; pd.tau = tau;

View File

@@ -15,7 +15,9 @@
// │ x[e_idx[e]] = λ_e edge log-length variable (optional) │ // │ x[e_idx[e]] = λ_e edge log-length variable (optional) │
// │ -1 means "pinned" (u_v = 0 / λ_e = λ°_e fixed) │ // │ -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)) │ // │ Spherical arc length: l_ij = 2·asin(min(exp(Λ_ij/2), 1)) │
// │ │ // │ │
// │ Gradient: │ // │ 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; 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. /// 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) 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 e23 = mesh.edge(h1);
Edge_index e31 = mesh.edge(h2); 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 u1 = spher_dof_val(m.v_idx[v1], x);
double u2 = spher_dof_val(m.v_idx[v2], x); double u2 = spher_dof_val(m.v_idx[v2], x);
double u3 = spher_dof_val(m.v_idx[v3], 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 lam12 = spher_eff_lambda(m, x, e12, u1, u2);
double lam23 = m.lambda0[e23] + u2 + u3 + spher_dof_val(m.e_idx[e23], x); double lam23 = spher_eff_lambda(m, x, e23, u2, u3);
double lam31 = m.lambda0[e31] + u3 + u1 + spher_dof_val(m.e_idx[e31], x); double lam31 = spher_eff_lambda(m, x, e31, u3, u1);
double l12 = spherical_l(lam12); double l12 = spherical_l(lam12);
double l23 = spherical_l(lam23); double l23 = spherical_l(lam23);
double l31 = spherical_l(lam31); double l31 = spherical_l(lam31);
SphericalFaceAngles fa = spherical_angles(l12, l23, l31); SphericalFaceAngles fa = spherical_angles(l12, l23, l31);
// Do NOT skip degenerate faces: spherical_angles() returns the limiting
if (!fa.valid) continue; // degenerate face: contributes 0 // 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)) // Store convention: h_alpha[h] = corner angle at source(prev(h))
// h0 (e12): opposite vertex is v3 → source(prev(h0)) = source(h2) = v3 → α3 // 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; 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, // With the replacement parameterization (Λ_ij = λ_e directly), the
// the contribution of edge DOF λ_e from face f is: // Schläfli derivative of the spherical edge energy w.r.t. λ_e reduces to
// a_f = (2·α_opp S_f) / 2 where S_f = Σ angles in face f. // the sum of the two opposite corner angles minus the target θ_e
// // (default π). This matches SphericalFunctional.java:283292
// Summing over both adjacent faces: // `G.add(i, αk + αl PI)`. Note this drops the (S_f⁺+S_f⁻)/2 term that
// G_e = a_f+ + a_f // would arise under the additive convention; the two conventions agree on
// = α_opp⁺ + α_opp⁻ (S_f⁺ + S_f⁻) / 2 θ_e // the vertex-only path (no edge DOFs), which is exercised by every test.
//
// For flat (Euclidean) triangles S_f = π, recovering the familiar
// α_opp⁺ + α_opp⁻ π formula. For spherical triangles S_f > π.
for (auto e : mesh.edges()) { for (auto e : mesh.edges()) {
int ie = m.e_idx[e]; int ie = m.e_idx[e];
if (ie < 0) continue; if (ie < 0) continue;
auto h = mesh.halfedge(e); auto h = mesh.halfedge(e);
auto ho = mesh.opposite(h); auto ho = mesh.opposite(h);
double sum = 0.0; double sum = 0.0;
if (!mesh.is_border(h)) { if (!mesh.is_border(h)) sum += h_alpha[spher_hidx(h)];
double alpha_opp = h_alpha[spher_hidx(h)]; if (!mesh.is_border(ho)) sum += h_alpha[spher_hidx(ho)];
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;
}
G[static_cast<std::size_t>(ie)] = sum - m.theta_e[e]; 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 s23 = s - l23;
double s31 = s - l31; double s31 = s - l31;
// Spherical triangle inequalities: all s-deficiencies > 0 and s < π. // Degenerate spherical triangle: return the *limiting* angles, matching the
if (s12 <= 0.0 || s23 <= 0.0 || s31 <= 0.0 || s >= PI_SPHER) // Java reference (SphericalFunctional.triangleEnergyAndAlphas). a1 is the
return {0.0, 0.0, 0.0, false}; // 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 ss = std::sin(s);
const double ss12 = std::sin(s12); const double ss12 = std::sin(s12);

View File

@@ -35,6 +35,7 @@
#include <Eigen/Sparse> #include <Eigen/Sparse>
#include <vector> #include <vector>
#include <cmath> #include <cmath>
#include <stdexcept>
namespace conformallab { namespace conformallab {
@@ -103,6 +104,18 @@ inline Eigen::SparseMatrix<double> spherical_hessian(
{ {
const int n = spherical_dimension(mesh, m); 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; std::vector<Eigen::Triplet<double>> trips;
trips.reserve(static_cast<std::size_t>(n) * 9); trips.reserve(static_cast<std::size_t>(n) * 9);

View File

@@ -12,9 +12,13 @@
// The tool: // The tool:
// 1. Loads an OFF/OBJ/PLY mesh. // 1. Loads an OFF/OBJ/PLY mesh.
// 2. Sets up DOF maps + computes λ° from the input geometry. // 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. // 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 // 6. Saves the layout as an OFF file and optionally serialises the result
// to JSON and/or XML. // to JSON and/or XML.
// 7. Optionally shows the input mesh in a viewer (-s flag, requires WITH_VIEWER). // 7. Optionally shows the input mesh in a viewer (-s flag, requires WITH_VIEWER).
@@ -27,6 +31,9 @@
#include "newton_solver.hpp" #include "newton_solver.hpp"
#include "layout.hpp" #include "layout.hpp"
#include "serialization.hpp" #include "serialization.hpp"
#include "gauss_bonnet.hpp"
#include "cut_graph.hpp"
#include "period_matrix.hpp"
#include <CLI11.hpp> #include <CLI11.hpp>
#include <iostream> #include <iostream>
@@ -49,28 +56,41 @@ using cl::Edge_index;
// Shared helpers (mirroring test_pipeline.cpp patterns) // Shared helpers (mirroring test_pipeline.cpp patterns)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Pin vertex 0, assign 0..n-1 to the rest // Assign Euclidean vertex DOFs for a genuine conformal-flattening problem.
static int pin_first_vertex(ConformalMesh& mesh, cl::EuclideanMaps& maps) //
// 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(); has_boundary = false;
Vertex_index v0 = *vit++; for (auto v : mesh.vertices())
maps.v_idx[v0] = -1; if (mesh.is_border(v)) { has_boundary = true; break; }
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit)
maps.v_idx[*vit] = idx++;
return idx;
}
// Natural theta for Euclidean: make x=0 the equilibrium int idx = 0;
static void set_natural_euclidean_theta(ConformalMesh& mesh, cl::EuclideanMaps& maps, int n) if (has_boundary) {
{ for (auto v : mesh.vertices())
std::vector<double> x0(static_cast<std::size_t>(n), 0.0); maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
auto G = cl::euclidean_gradient(mesh, x0, maps); } else {
bool pinned = false;
for (auto v : mesh.vertices()) { for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v]; if (!pinned) { maps.v_idx[v] = -1; pinned = true; }
if (iv < 0) continue; else maps.v_idx[v] = idx++;
maps.theta_v[v] -= G[static_cast<std::size_t>(iv)];
} }
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 // 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, const std::string& out_xml,
bool verbose) bool verbose)
{ {
// Setup // Setup — Θ_v = 2π (flat target) by default; lengths from the input mesh.
auto maps = cl::setup_euclidean_maps(mesh); auto maps = cl::setup_euclidean_maps(mesh);
cl::compute_euclidean_lambda0_from_mesh(mesh, maps); cl::compute_euclidean_lambda0_from_mesh(mesh, maps);
// DOF assignment: pin vertex 0 // DOF assignment for a genuine flattening problem (see helper).
int n = pin_first_vertex(mesh, maps); bool has_boundary = false;
if (n <= 0) { std::cerr << "Error: mesh has only one vertex.\n"; return 1; } 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 const int g = has_boundary ? -1 : cl::genus(mesh);
set_natural_euclidean_theta(mesh, maps, n); 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); std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto res = cl::newton_euclidean(mesh, x0, maps); auto res = cl::newton_euclidean(mesh, x0, maps);
if (!res.converged && verbose) if (!res.converged)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n"; std::cerr << "[warn] Newton did not converge (|grad|="
<< res.grad_inf_norm << ", iter=" << res.iterations << ")\n";
// Layout // Layout. For a closed surface we cut along the tree-cotree cut graph so
cl::Layout2D layout = cl::euclidean_layout(mesh, res.x, maps); // 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 // Output
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout); 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 << " iter=" << res.iterations
<< " |grad|_inf=" << std::scientific << std::setprecision(3) << " |grad|_inf=" << std::scientific << std::setprecision(3)
<< res.grad_inf_norm << "\n"; << 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_layout.empty()) std::cout << " layout → " << out_layout << "\n";
if (!out_json.empty()) std::cout << " json → " << out_json << "\n"; if (!out_json.empty()) std::cout << " json → " << out_json << "\n";
if (!out_xml.empty()) std::cout << " xml → " << out_xml << "\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, const std::string& out_xml,
bool verbose) 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); auto maps = cl::setup_spherical_maps(mesh);
cl::compute_lambda0_from_mesh(mesh, maps); cl::compute_lambda0_from_mesh(mesh, maps);
int n = cl::assign_vertex_dof_indices(mesh, maps); int n = cl::assign_vertex_dof_indices(mesh, maps);

View File

@@ -20,6 +20,9 @@
#include "layout.hpp" #include "layout.hpp"
#include "period_matrix.hpp" #include "period_matrix.hpp"
#include "fundamental_domain.hpp" #include "fundamental_domain.hpp"
#include "mesh_io.hpp"
#include "cut_graph.hpp"
#include "gauss_bonnet.hpp"
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <cmath> #include <cmath>
#include <complex> #include <complex>
@@ -343,6 +346,122 @@ TEST(PeriodMatrix, ComputePeriodMatrix_ReducedTau_InFD)
EXPECT_TRUE(is_in_fundamental_domain(pd.tau, 1e-9)); 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 // FundamentalDomain — genus-1 parallelogram
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
@@ -486,3 +605,4 @@ TEST(TilingNeighbourhood, EmptyHolonomy_ReturnsSingleTile)
auto tiles = tiling_neighbourhood(lay, hol); auto tiles = tiling_neighbourhood(lay, hol);
EXPECT_EQ(tiles.size(), 1u); EXPECT_EQ(tiles.size(), 1u);
} }

View File

@@ -164,8 +164,12 @@ TEST(SphericalFunctional, GradientCheck_SpherTetAllDofs)
compute_lambda0_from_mesh(mesh, maps); compute_lambda0_from_mesh(mesh, maps);
int n = assign_all_spherical_dof_indices(mesh, maps); int n = assign_all_spherical_dof_indices(mesh, maps);
// Small but non-zero values; vertex DOFs negative, edge DOFs zero. // Replacement parameterization (Finding 3): when an edge carries a DOF its
// Edge DOF adjusts the effective log-length Λ_ij = λ°_ij + u_i + u_j + λ_e. // 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); 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. // 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; 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)"; << "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 // Angles are finite at a known interior point
// //

View File

@@ -29,7 +29,7 @@ All tests have CTest prefix `cgal.` (set via `TEST_PREFIX "cgal."` in CMakeLists
| `ConformalMeshProperties` | `test_conformal_mesh.cpp` | 5 | Property maps (λ, θ, idx, α, geometry type) | | `ConformalMeshProperties` | `test_conformal_mesh.cpp` | 5 | Property maps (λ, θ, idx, α, geometry type) |
| `ConformalMeshValidity` | `test_conformal_mesh.cpp` | 1 | CGAL validity for all factory meshes | | `ConformalMeshValidity` | `test_conformal_mesh.cpp` | 1 | CGAL validity for all factory meshes |
| `HyperIdealFunctional` | `test_hyper_ideal_functional.cpp` | 7 | FD gradient checks + Hessian symmetry | | `HyperIdealFunctional` | `test_hyper_ideal_functional.cpp` | 7 | FD gradient checks + Hessian symmetry |
| `SphericalFunctional` | `test_spherical_functional.cpp` | 12 | Angle formula + gradient + gauge-fix + cross-module Hessian check | | `SphericalFunctional` | `test_spherical_functional.cpp` | 13 | Angle formula + gradient + gauge-fix + cross-module Hessian check + closed-form edge-DOF oracle |
| `EuclideanFunctional` | `test_euclidean_functional.cpp` | 12 | Angle formula + gradient + cross-module Hessian check | | `EuclideanFunctional` | `test_euclidean_functional.cpp` | 12 | Angle formula + gradient + cross-module Hessian check |
| `EuclideanHessian` | `test_euclidean_hessian.cpp` | 9 | Cotangent Laplacian structure, FD agreement, PSD, null space | | `EuclideanHessian` | `test_euclidean_hessian.cpp` | 9 | Cotangent Laplacian structure, FD agreement, PSD, null space |
| `SphericalHessian` | `test_spherical_hessian.cpp` | 8 | Derivative correctness, NSD at equilibrium | | `SphericalHessian` | `test_spherical_hessian.cpp` | 8 | Derivative correctness, NSD at equilibrium |
@@ -51,6 +51,7 @@ All tests have CTest prefix `cgal.` (set via `TEST_PREFIX "cgal."` in CMakeLists
| `PeriodMatrix` | `test_phase7.cpp` | 7 | τ ∈ , SL(2,) reduction, exception outside | | `PeriodMatrix` | `test_phase7.cpp` | 7 | τ ∈ , SL(2,) reduction, exception outside |
| `FundamentalDomain` | `test_phase7.cpp` | 7 | Genus-1 parallelogram CCW, generators, g > 1 empty | | `FundamentalDomain` | `test_phase7.cpp` | 7 | Genus-1 parallelogram CCW, generators, g > 1 empty |
| `TilingCopy/Neighbourhood` | `test_phase7.cpp` | 4 | Translation correct, tile count | | `TilingCopy/Neighbourhood` | `test_phase7.cpp` | 4 | Translation correct, tile count |
| `HolonomyEndToEnd` | `test_phase7.cpp` | 3 | Full pipeline τ on tori of revolution (4×4, hex 6×6, 8×8) vs analytic modulus i·√(R²r²)/r |
| `CuttingUtility` | `test_geometry_utils.cpp` | 3 | `point_in_triangle_2d`: false, true, unit triangle (Java CuttingUtilityTest) | | `CuttingUtility` | `test_geometry_utils.cpp` | 3 | `point_in_triangle_2d`: false, true, unit triangle (Java CuttingUtilityTest) |
| `UnwrapUtility` | `test_geometry_utils.cpp` | 2 | Corner angle: collinear → π, equilateral → π/3 (Java UnwrapUtilityTest) | | `UnwrapUtility` | `test_geometry_utils.cpp` | 2 | Corner angle: collinear → π, equilateral → π/3 (Java UnwrapUtilityTest) |
| `ConvergenceUtility` | `test_geometry_utils.cpp` | 6 | Circumradius + scale-invariant R_f/√A (Java ConvergenceUtilityTests) | | `ConvergenceUtility` | `test_geometry_utils.cpp` | 6 | Circumradius + scale-invariant R_f/√A (Java ConvergenceUtilityTests) |
@@ -66,7 +67,7 @@ All tests have CTest prefix `cgal.` (set via `TEST_PREFIX "cgal."` in CMakeLists
| `NewtonPhase9a` | `test_newton_phase9a.cpp` | 7 | Phase 9a-Newton — convergence for the two new circle-packing solvers | | `NewtonPhase9a` | `test_newton_phase9a.cpp` | 7 | Phase 9a-Newton — convergence for the two new circle-packing solvers |
| `CGALPhase8bLite` | `test_cgal_phase8b_lite.cpp` | 17 | Phase 8b-Lite — CGAL entries for all 5 DCE models + `output_uv_map` (Euclidean, Spherical, HyperIdeal, Inversive-Distance) + CP-Euclidean throws-clearly + pipe-operator chaining | | `CGALPhase8bLite` | `test_cgal_phase8b_lite.cpp` | 17 | Phase 8b-Lite — CGAL entries for all 5 DCE models + `output_uv_map` (Euclidean, Spherical, HyperIdeal, Inversive-Distance) + CP-Euclidean throws-clearly + pipe-operator chaining |
**Total: 236 tests, 0 skipped.** **Total: 240 tests, 0 skipped.**
--- ---

View File

@@ -39,7 +39,7 @@ where χ(M) = 2 2g is the Euler characteristic.
```cpp ```cpp
#include "gauss_bonnet.hpp" #include "gauss_bonnet.hpp"
auto defect = gauss_bonnet_sum(mesh, maps); // Σ(2π Θᵥ) auto defect = gauss_bonnet_sum(mesh, maps); // Σ(2π Θᵥ)
auto chi = mesh.euler_characteristic(); auto chi = euler_characteristic(mesh); // free function, not a member
EXPECT_NEAR(defect, 2.0 * M_PI * chi, 1e-10); EXPECT_NEAR(defect, 2.0 * M_PI * chi, 1e-10);
``` ```
@@ -71,59 +71,105 @@ Covered by: `cgal.PeriodMatrix.TauInFundamentalDomain_*` tests in `test_phase7.c
--- ---
## 3 — Square-symmetric torus ## 3 — Torus of revolution: conformal modulus
**Setup.** Take a torus mesh with 4-fold rotational symmetry around the z-axis **Setup.** The bundled torus meshes are surfaces of **revolution** (a tube of
(e.g. `code/data/off/torus_4x4.off`, which has M=4 columns of vertices). minor radius `r` swept around a major circle of radius `R > r`), *not* abstract
square/hexagonal flat tori:
**Expected.** The symmetry group Z₄ acts conformally. Conformal automorphisms | mesh | major R | minor r | cross section |
of the torus correspond to SL(2,) symmetries of τ. The unique fixed point of |---|---|---|---|
a rotation of order 4 in the modular group is τ = i. Therefore: | `torus_4x4.off` | 2 | 1 | square (4-gon) |
| `torus_hex_6x6.off` | 3 | 1 | hexagon (6-gon) |
| `torus_8x8.off` | 3 | 1 | octagon (8-gon) |
**Expected.** The induced metric `ds² = (R + r cos φ)² dθ² + r² dφ²` is made
flat by the conformal change of variable `dψ = r/(R + r cos φ) dφ`. The
ψ-period is `∮ r/(R + r cos φ) dφ = 2πr/√(R²r²)`, so the flat torus is the
rectangular lattice `2π· × (2πr/√(R²r²))·ℤ`. Its period ratio, reduced so
that `|τ| ≥ 1`, is therefore **purely imaginary**:
``` ```
For a mesh with exact 4-fold symmetry and uniform edge lengths: Re(τ) = 0 (meridian ⟂ longitude reflection symmetry)
Re(τ) = 0 (to machine precision, by symmetry) Im(τ) = √(R² r²) / r (reduced conformal modulus)
Im(τ) ≈ 1 (approaches 1 as mesh is refined)
``` ```
The coarse 4×4 mesh (`torus_4x4.off`) gives Im(τ) in (0.7, 1.3) depending on giving the analytic targets
the 3D embedding (R=2, r=1 torus of revolution has unequal inner/outer edge lengths).
The uniformization algorithm finds the conformal class of the *abstract* metric
encoded in the edge lengths.
**Manual verification** (run from the build directory after adding a small | mesh | analytic τ = i·√(R²r²)/r |
program or reading from the test output): |---|---|
| `torus_4x4.off` | i·√3 ≈ **1.732 i** |
| `torus_hex_6x6.off` | i·√8 ≈ **2.828 i** |
| `torus_8x8.off` | i·√8 ≈ **2.828 i** |
> A common pitfall is to expect τ = i (or the order-6 fixed point e^{iπ/3})
> from the 4-fold (6-fold) symmetry. That reasoning is **wrong** here: a torus
> of revolution's rotational symmetry is a rotation about the axis, which acts
> on the surface as a *fixed-point-free* translation along the longitude — it is
> not an order-4 conformal automorphism with a fixed point, so it does not pin τ
> to a modular fixed point. The correct invariant is the rectangular modulus
> above.
**Reproduced end-to-end** (solve → `compute_cut_graph``euclidean_layout(…,
&cg, &hol)``compute_period_matrix`). The coarse polygonal cross sections
approximate the circular modulus from above; the gap shrinks as the cross
section gains sides:
| mesh | analytic | computed τ | rel. error |
|---|---|---|---|
| `torus_4x4.off` | 1.732 i | ≈ 1.79 i | ~3 % (4-gon) |
| `torus_hex_6x6.off` | 2.828 i | ≈ 2.85 i | ~0.8 % (6-gon) |
| `torus_8x8.off` | 2.828 i | ≈ 2.84 i | ~0.4 % (8-gon) |
Covered by: `cgal.HolonomyEndToEnd.Torus*_TauMatchesRevolutionModulus` in
`test_phase7.cpp`.
**Conformal flattening** of the torus is wired end-to-end and converges in a
handful of Newton steps (34 on the bundled meshes):
```bash
./bin/conformallab_core -i code/data/off/torus_4x4.off -g euclidean -v -o lay.off
# → topology: closed, free DOFs=15, genus=1
# → Euclidean: converged=yes iter=3 |grad|_inf≈1e-12
```
Equivalent C++ (current API — note `newton_euclidean` takes an `x0` vector and
the DOF indices must be assigned first):
```cpp ```cpp
ConformalMesh mesh; load_mesh(mesh, "code/data/off/torus_4x4.off"); ConformalMesh mesh = load_mesh("code/data/off/torus_4x4.off");
EuclideanMaps maps = setup_euclidean_maps(mesh); EuclideanMaps maps = setup_euclidean_maps(mesh); // Θ_v = 2π (flat target)
compute_euclidean_lambda0_from_mesh(mesh, maps); compute_euclidean_lambda0_from_mesh(mesh, maps);
// Pin one vertex (scale gauge), free the rest; make the flat target
// Gauss-Bonnet-consistent.
int idx = 0; bool pinned = false;
for (auto v : mesh.vertices())
maps.v_idx[v] = (!pinned ? (pinned = true, -1) : idx++);
enforce_gauss_bonnet(mesh, maps); enforce_gauss_bonnet(mesh, maps);
auto res = newton_euclidean(mesh, maps);
CutGraph cg = compute_cut_graph(mesh); std::vector<double> x0(idx, 0.0);
HolonomyData hol; auto res = newton_euclidean(mesh, x0, maps); // converged after ~3 iters
euclidean_layout(mesh, res.x, maps, &cg, &hol, true); ```
PeriodData pd = compute_period_matrix(hol);
// pd.tau_reduced satisfies the fundamental domain invariants above The CLI reports the period ratio for genus-1 inputs, e.g.
```bash
./bin/conformallab_core -i code/data/off/torus_4x4.off -g euclidean -v
# → period ratio τ = 0.000000 + 1.793... i (genus 1, reduced to fundamental domain)
``` ```
--- ---
## 4 — Hexagonal-symmetric torus ## 4 — Higher-resolution cross sections converge to the circular modulus
**Setup.** Take a torus mesh with 6-fold rotational symmetry `torus_hex_6x6.off` (R=3, r=1, hexagon) and `torus_8x8.off` (R=3, r=1, octagon)
(`code/data/off/torus_hex_6x6.off`, M=6). share the same analytic modulus `i·√8 ≈ 2.828 i` (see §3). Because both
approximate the *circular* tube cross section, the computed τ approaches the
**Expected.** The unique τ fixed under a rotation of order 6 in SL(2,) is analytic value as the polygon gains sides: the 8-gon (≈ 2.84 i, ~0.4 %) is
τ = e^{iπ/3} = ½ + i√3/2. So: closer than the 6-gon (≈ 2.85 i, ~0.8 %), which is closer than the 4-gon of
`torus_4x4.off` (~3 %). All three are asserted in
``` `cgal.HolonomyEndToEnd.Torus*_TauMatchesRevolutionModulus`.
Re(τ) = 0.5 (to machine precision, by symmetry)
Im(τ) = √3/2 ≈ 0.8660
```
The coarse 6×6 torus of revolution approximates this: Re(τ) ≈ 0.5 by symmetry,
Im(τ) approaches √3/2 as the mesh is refined toward a flat hexagonal lattice.
--- ---
@@ -136,12 +182,14 @@ Im(τ) approaches √3/2 as the mesh is refined toward a flat hexagonal lattice.
**fewer than 30 iterations** starting from u = 0. **fewer than 30 iterations** starting from u = 0.
```cpp ```cpp
auto res = newton_euclidean(mesh, maps); std::vector<double> x0(n_dofs, 0.0);
auto res = newton_euclidean(mesh, x0, maps);
EXPECT_LT(res.iterations, 30); EXPECT_LT(res.iterations, 30);
EXPECT_LT(res.gradient_norm, 1e-10); EXPECT_LT(res.grad_inf_norm, 1e-8); // field is grad_inf_norm, default tol 1e-8
``` ```
Covered by: `cgal.EuclideanPipeline.ConvRates_*` and similar tests. Covered by: `cgal.NewtonSolver.*` (Euclidean ×3, Spherical ×4, HyperIdeal ×4)
and `cgal.NewtonPhase9a.*` (the two circle-packing solvers).
--- ---
@@ -186,8 +234,12 @@ but the representation ρ: π₁(Σ_g) → SU(1,1) must still satisfy the relati
[T₁, T₂] · [T₃, T₄] · … = Id (product of g commutators = Id) [T₁, T₂] · [T₃, T₄] · … = Id (product of g commutators = Id)
``` ```
These are the **holonomy consistency** checks implemented in `test_phase7.cpp` The Möbius arithmetic these checks rely on (identity, inverse, composition,
(`cgal.HolonomyData.*`). `from_three`) is verified in `test_phase7.cpp` under `cgal.MobiusMap.*`. The
Euclidean end-to-end holonomy extraction is now validated against the analytic
torus-of-revolution modulus (§3, `cgal.HolonomyEndToEnd.*`). A standalone
`cgal.HolonomyData.*` commutator-closes-up suite for the *hyperbolic* (Möbius)
holonomy is still future work.
--- ---
@@ -255,9 +307,8 @@ Run these in order to validate the implementation:
- [ ] `ctest --test-dir build -R cgal --output-on-failure` → all pass, 0 skipped (count: `doc/api/tests.md`) - [ ] `ctest --test-dir build -R cgal --output-on-failure` → all pass, 0 skipped (count: `doc/api/tests.md`)
- [ ] `cgal.GaussBonnet.*` all pass → topology is correctly read from mesh - [ ] `cgal.GaussBonnet.*` all pass → topology is correctly read from mesh
- [ ] `cgal.EuclideanFunctional.GradientCheck_*` pass → energy = integral of gradient - [ ] `cgal.EuclideanFunctional.GradientCheck_*` pass → energy = integral of gradient
- [ ] `cgal.PeriodMatrix.TauInFundamentalDomain_*` pass → SL(2,) reduction correct - [ ] `cgal.PeriodMatrix.*` pass → SL(2,) reduction correct (on prescribed holonomy)
- [ ] `cgal.MobiusMap.Compose_*` and `Inverse_*` pass → Möbius arithmetic correct - [ ] `cgal.MobiusMap.*` pass → Möbius arithmetic (identity, inverse, compose) correct
- [ ] `cgal.HolonomyData.*` pass → holonomy loops close up
All of the above are **deterministic, analytic tests** — no mesh loading, no All of the above are **deterministic, analytic tests** — no mesh loading, no
file I/O, no floating-point non-determinism beyond standard IEEE-754. file I/O, no floating-point non-determinism beyond standard IEEE-754.

View File

@@ -0,0 +1,244 @@
# Java → C++ Port Audit — Anomalies & Missing Test Cases
Systematic comparison of the C++ port (`code/include/*.hpp`) against the original
Java reference (`~/Desktop/conformallab`,
`de.varylab.discreteconformal.functional.*`). Focus: math correctness, logic, and
faithfulness to the reference.
Status legend: ✅ fixed · ⚠️ open / needs decision · note (no action)
---
## Files compared (math-critical)
| Java | C++ | Verdict |
|------|-----|---------|
| `Clausen.java` | `clausen.hpp` | ✅ faithful (incl. `inits` return, `clausen2`, `Л`, `ImLi2`) |
| `EuclideanCyclicFunctional.java` | `euclidean_functional.hpp`, `euclidean_geometry.hpp` | ✅ angles + gradient assembly match; degenerate handling fixed |
| — Hessian (`triangleHessian`/`conformalHessian`) | `euclidean_hessian.hpp` | cotangent + vertex block match; edge block **not implemented** (now guarded) |
| `SphericalFunctional.java` | `spherical_functional.hpp`, `spherical_geometry.hpp` | ✅ vertex-mode + edge-DOF replacement parameterization now match (Finding 3) |
| `HyperIdealFunctional.java` | `hyper_ideal_functional.hpp`, `hyper_ideal_geometry.hpp` | ✅ faithful (incl. degenerate (π,0,0) angles) |
| `HyperIdealUtility.java` | `hyper_ideal_utility.hpp` | ✅ ζ, ζ₁₃, ζ₁₄, ζ₁₅, both tetrahedron-volume formulas match |
| `CPEuclideanFunctional.java` | `cp_euclidean_functional.hpp` | ✅ `p()`, gradient, energy match |
| `DiscreteEllipticUtility.java` | `discrete_elliptic_utility.hpp`, `period_matrix.hpp` | ✅ `normalizeModulus` faithful and now wired into `compute_period_matrix` (Finding 6) |
| `HomologyUtility.java`, `CanonicalBasisUtility.java`, `SpanningTreeUtility.java` | `cut_graph.hpp` | tree-cotree only; no canonical/symplectic basis (Finding 7) |
| `EuclideanLayout.java` (holonomy) | `layout.hpp` (`detail::euclidean_holonomy`) | translation via midpoint displacement; ignores residual rotation (Finding 8) |
---
## Findings
### 1. ✅ FIXED — Degenerate triangle handling (Euclidean + Spherical gradient)
The Java reference assigns the **limiting** corner angles to a degenerate
(triangle-inequality-violating) face — the corner opposite the over-long edge is
**π**, the other two **0**. This is the convex C¹ extension that keeps the BPS
energy well-defined on the infeasible region, so Newton can pass through a flip
without stalling.
The C++ port returned `{0,0,0}` and **skipped the whole face** in the gradient.
Tell-tale: the hyper-ideal port *already* mirrors Java's (π,0,0) fallback —
euclidean/spherical were inconsistent oversights.
**Fix:** `euclidean_geometry.hpp` / `spherical_geometry.hpp` degenerate branches
now return the limiting angles (still `valid=false`, so the cotangent Hessian
keeps skipping — matching Java, whose `triangleHessian` zeroes cotangents on
degenerate faces). `euclidean_functional.hpp` / `spherical_functional.hpp`
gradients no longer skip. All 237 tests pass; solution unchanged (only affects
evaluations that previously contributed zero).
### 2. ✅ FIXED — `euclidean_hessian` missing edge-DOF guard
Doc comment claimed "the function asserts that no edge DOF is variable" — but
**no such guard existed**. With edge DOFs assigned
(`assign_euclidean_all_dof_indices`), the Java `conformalHessian`'s edge-edge and
vertex-edge blocks are absent, so the C++ would silently build a Hessian with
all-zero edge rows/cols (singular → LDLT fails → QR least-squares garbage).
**Fix:** added an always-compiled `throw std::logic_error` guard.
### 3. ✅ FIXED — Spherical edge-DOF parameterization now matches Java
Java's spherical functional **drops** the `u_i + u_j` vertex terms when an edge is
a variable (replacement parameterization, `SphericalFunctional.java:398400`),
whereas the C++ previously used additive `Λ = λ⁰ + u_i + u_j + λ_e` always. That
made the C++ spherical edge gradient carry an extra `(S_f⁺+S_f⁻)/2` term absent
from Java's `G.add(i, αk + αl π)` (`SphericalFunctional.java:283292`).
**Fix:** added helper `spher_eff_lambda` implementing the replacement convention
(`Λ_ij = λ_e` when the edge carries a DOF, else `λ⁰ + u_i + u_j`); `spherical_gradient`
Pass 1 now uses it, and the edge gradient is `α_opp⁺ + α_opp⁻ θ_e` (θ_e default
π), exactly matching Java. The energy is the path integral of this gradient, so it
follows automatically. The **vertex-only path is bit-for-bit unchanged** (no edge
DOFs → identical to before). All 237 tests pass.
### 4. ✅ FIXED — Spherical Hessian edge-DOF guard added
`spherical_hessian.hpp` builds only the vertex block; Java's spherical
`conformalHessian` includes edge terms. Same limitation as the Euclidean Hessian
but previously **without** the edge-DOF guard added in Finding 2.
**Fix:** added an always-compiled `throw std::logic_error` guard at the top of
`spherical_hessian` (mirrors Finding 2), so any attempt to use edge DOFs with the
spherical Hessian fails loudly instead of silently building a singular matrix.
All 237 tests pass.
### 5. NOTE — CP-Euclidean energy uses `clausen2`, Java uses `clausen`
`CPEuclideanFunctional.java:226` calls `Clausen.clausen` (the BORDERLINE-2.0944
polynomial); the C++ energy calls `clausen2` (the Chebyshev variant). Both compute
the same Cl₂ integral to ~machine precision, and the term appears only in the
energy (not the Newton-driving gradient). No functional impact. The C++ never
ported the `clausen()` variant at all — harmless.
### 6. ✅ FIXED — Period-matrix reduction now uses the faithful Java port
Two reductions coexist:
- `discrete_elliptic_utility.hpp::normalizeModulus` is a **faithful** line-by-line
port of Java `DiscreteEllipticUtility.normalizeModulus` — it folds τ into
**`0 ≤ Re(τ) ≤ ½`, `Im(τ) ≥ 0`, `|τ| ≥ 1`** (the extra `Re ≥ 0` fold uses the
mirror symmetry τ ≅ −τ̄).
- `period_matrix.hpp::reduce_to_fundamental_domain` reduces to the **standard**
SL(2,) domain **`−½ ≤ Re(τ) < ½`, `|τ| ≥ 1`** (no `Re ≥ 0` fold).
`compute_period_matrix` (the production path, and the one the `torus_8x8` τ test
exercises) calls **`reduce_to_fundamental_domain`**, so `normalizeModulus` is
**never used outside its own unit test** (verified by grep). Consequence: the C++
production τ can land with `Re(τ) < 0` where Java would report the mirrored
`+|Re(τ)|`. Same conformal type up to orientation, but **not equal to the Java
oracle value** — this will bite any golden-value cross-check (missing-test item 5).
**Fix (option a):** `compute_period_matrix` now calls `normalizeModulus`, so the
production τ matches the Java oracle exactly (folds into `0 ≤ Re ≤ ½`, `Im ≥ 0`,
`|τ| ≥ 1` via the mirror symmetry). `normalizeModulus` is no longer dead code. The
existing `torus_8x8` τ test still passes (`Re ∈ [0,½] ⊂ [−½,½]` so it satisfies
`is_in_fundamental_domain` too). `reduce_to_fundamental_domain` is retained for
callers that explicitly want the canonical (non-mirror-folded) SL(2,) domain.
### 7. NOTE — Cut graph is tree-cotree, not a canonical homology basis
`cut_graph.hpp` (`compute_cut_graph`) runs the EricksonWhittlesey tree-cotree
algorithm: primal BFS tree, dual BFS cotree, the remaining `2g` edges generate
H₁. Java's `CanonicalBasisUtility.getCanonicalHomologyBasis` goes further — it
builds the intersection form on the generators and solves for a **canonical
symplectic basis** `{a₁..a_g, b₁..b_g}` (and uses *weighted* shortest-path
cycles, not unweighted BFS).
- **Genus 1:** harmless — there is only one generator pair, and
`compute_period_matrix` reduces τ to the fundamental domain, so the basis choice
washes out. The common case matches.
- **Genus > 1:** the C++ `omega[]` ordering is arbitrary (edge-iteration order),
so `τ = ω₂/ω₁` is **not** the canonical Riemann period matrix. This is already
flagged in the `period_matrix.hpp` header ("Computing Ω from holonomy data
requires integration of holomorphic differentials — not implemented") so it is
consistent with the documented contract, not a regression.
### 8. NOTE — Holonomy translation assumes zero residual rotation
`detail::euclidean_holonomy` (`layout.hpp`) develops each face along a dual tree
that never crosses a cut edge, then reads each generator's translation as the
**midpoint displacement** `ω = midA midB` of the shared cut edge between its two
independent developments. This equals the true deck-translation **only when the
two developments differ by a pure translation** (linear part = identity), i.e. for
a perfectly flat cone metric (Θ ≡ 2π). When Newton has not fully converged (small
residual cone-angle defect), there is a residual rotation and the midpoint
displacement is a first-order approximation rather than the exact ω. A fully
robust version would fit the rigid motion mapping edge `(Bs,Bt)↦(At,As)` and read
its translation part. Acceptable for converged flat tori (the `torus_8x8` test
passes); worth tightening if higher-genus or under-converged inputs are used.
---
## Findings in non-ported (conformallab++-only) math modules
These modules have **no Java reference** — they were ported from the literature
(Luo 2004, Glickenstein 2011, BowersStephenson 2004) or are original additions.
They were audited against the cited formulas / first principles rather than Java.
### 9. ✅ FIXED — Inversive-distance gradient now uses limiting angles (consistent with Finding 1)
`inversive_distance_functional.hpp::inversive_distance_gradient` previously detected
a triangle-inequality-violating face two ways and **skipped it** in both:
- `if (l12sq <= 0 || l23sq <= 0 || l31sq <= 0) continue;` (non-real circle config)
- `if (!fa.valid) continue;` (degenerate-but-real triangle)
The second skip was the *same* situation Finding 1 fixed for Euclidean/Spherical:
`euclidean_angles` returns the **limiting angles** (π opposite the over-long edge,
0/0 for the others) with `valid=false`, but this code threw them away.
**Fix:** removed the `if (!fa.valid) continue;` skip so the limiting angles flow
into the gradient (the BPS-style convex C¹ extension), mirroring Finding 1; added a
comment explaining the rationale. The first skip (`l*sq <= 0`, a genuinely non-real
circle configuration with no limiting angle) is **kept**. All 237 tests pass.
### 10. NOTE — Inversive-distance edge length has no overflow centring
`id_detail::edge_length_squared` computes `ℓ² = rᵢ² + rⱼ² + 2·I·rᵢ·rⱼ` with
`r = exp(u)` directly — no log-centring like `euclidean_angles`'s `μ`. The *angle*
computation is still safe (it re-centres internally on `log ℓ²`), but the raw `ℓ²`
can overflow for extreme `|u|`. Cosmetic robustness only; not hit in practice.
### 11. NOTE — Verified correct (no action)
Audited against the literature and found faithful:
- **`trilaterate_2d / _sph / _hyp`** (`layout.hpp`) — circle-circle intersection
(ℝ²), spherical law of cosines via `α·pa+β·pb+γ·(pa×pb)`, and hyperbolic law of
cosines + Poincaré-disk Möbius placement. All three exact; left/CCW side correct.
- **`newton_solver.hpp`** — merit `f = ½‖G‖²`; Newton dir is always a descent dir
(`∇f·dx = ‖G‖²`), Armijo tests algebraically correct in both phases; spherical
solver factorises `H` (PSD) and `d_sd = H·G = ∇f` uses the un-negated H — all
consistent. SparseQR fallback handles the gauge null space (valid because
GaussBonnet makes `G ⟂ 𝟙`).
- **`gauss_bonnet.hpp`** — `χ=VE+F`, `g=(2χ)/2`, `enforce` shift
`δ=(lhsrhs)/V` makes the new sum equal `2π·χ` exactly.
- **Normalisations** — Euclidean PCA, spherical Rodrigues (mean→north),
hyperbolic weighted Fréchet-mean Möbius centring: all correct (spherical has a
benign antipodal-mean edge case that no-ops).
---
## Missing test cases (gaps observed during the audit)
1. **Degenerate / flipped-triangle gradient** — no test drives a face through the
triangle-inequality boundary to lock in Finding 1. Add: build a triangle with
one edge length > sum of the others, assert the gradient picks up the π corner
(not 0). Cover both Euclidean and Spherical.
2. **`euclidean_hessian` edge-DOF guard** — no test asserts the new `throw` fires
when `assign_euclidean_all_dof_indices` is used. Add an `EXPECT_THROW`.
3.**DONE (2026-05-29)** — Holonomy / period matrix end-to-end now covers
three tori of revolution (`HolonomyEndToEnd.{Torus4x4,TorusHex6x6,Torus8x8}`),
each asserting τ against the analytic modulus `i·√(R²r²)/r` and `Re(τ) ≈ 0`.
(This also fixed the Euclidean holonomy extraction — develop across the dual
tree only — see `layout.hpp` / `cut_graph.hpp`.)
4. ⚠️ **PARTLY DONE (2026-05-29)** — added `EdgeGradient_RegularTetClosedForm`:
an *independent closed-form* oracle that pins each edge-DOF gradient to
`π/3` (= 2·(2π/3) π) on the regular spherical tetrahedron, where the corner
angle 2π/3 follows from the spherical law of cosines. This locks the
Finding-3 formula `α_opp⁺ + α_opp⁻ θ_e` against a value the path-integral FD
check cannot detect (the energy is the integral of G, so FD only proves
curl-freeness). **Still open:** (i) Newton-to-convergence *with* edge DOFs is
blocked by the Finding-4 spherical-Hessian guard (`throw` on edge DOFs), so a
converged-metric test needs an FD-Hessian or guard relaxation first; (ii) a
live-Java golden-value oracle still requires running the upstream library.
5. **Cross-check vs Java numeric oracles** — there is no test that pins C++
functional/gradient values against recorded Java outputs on a fixed mesh. A
handful of golden-value tests (energy + gradient at a known `x`) would catch
any future silent divergence from the reference far more directly than the
self-consistent FD checks (which only verify curl-freeness, since the C++
energy is itself the path-integral of its own gradient).
6. **CP-Euclidean `clausen` vs `clausen2`** — a single test asserting
`|clausen(x) clausen2(x)| < 1e-12` across a sweep would document Finding 5
and guard against an approximation regression.
7. **Period-matrix domain convention (Finding 6)** — production now uses
`normalizeModulus` (folds `Re ≥ 0`). Add a torus whose true τ has `Re(τ) < 0`
before reduction and assert the resulting `Re(τ) ≥ 0` convention, so any future
switch back to `reduce_to_fundamental_domain` is caught. Pair with a golden τ
recorded from the Java `DiscreteEllipticUtility` on the same mesh.
8. **`normalizeModulus` vs Java oracle (Finding 6)** — the faithful
`normalizeModulus` is only checked against ad-hoc values. Add a few recorded
Java `normalizeModulus` outputs as golden values to lock the `Re ≥ 0` fold.
9. **Higher-genus cut graph (Finding 7)** — only genus-1 is exercised end-to-end.
Add a genus-2 mesh and assert `compute_cut_graph` returns exactly `2g = 4` cut
edges and `genus == 2`, documenting that the basis is *not* canonical (so no τ
correctness is claimed there).
10. **Inversive-distance degenerate face (Finding 9)** — no test drives an
inversive-distance triangle through the triangle-inequality boundary. Now that
Finding 9 mirrors Finding 1 (limiting angles used, not skipped), add the
analogue of missing-test item 1: assert the corner opposite the over-long edge
is picked up as π rather than silently skipped.

View File

@@ -293,6 +293,20 @@ The phase numbers match `doc/roadmap/phases.md`.
the cut-graph + holonomy infrastructure already in conformallab++). the cut-graph + holonomy infrastructure already in conformallab++).
* **Effort:** large (1014 days). * **Effort:** large (1014 days).
* **Status:** roadmap item, no PR yet. * **Status:** roadmap item, no PR yet.
* **Known prerequisite bug (latent, 2026-05-29):** the holonomy-extraction
blocks in `spherical_layout` and `hyper_ideal_layout` (`layout.hpp`)
repeat the flawed single-development pattern that produced garbage τ for
the Euclidean path before the 2026-05-29 fix. They read the
translation / Möbius deck transformation from one full-surface
development plus a one-sided apex trilateration, instead of developing
across only the **dual** spanning tree and measuring the shared-edge
displacement between two independent developments (as the corrected
`detail::euclidean_holonomy` now does). These blocks are currently dead
code — every caller passes `holonomy == nullptr` — but Phase 9c/10b will
exercise the hyperbolic path. Fix = add `detail::spherical_holonomy` /
`detail::hyperbolic_holonomy` mirroring `detail::euclidean_holonomy`.
The hyperbolic mirror additionally needs `cpp_dec_float_50` (group-relation
product ∏gᵢ = Id overflows `double`; see CLAUDE.md high-precision note).
--- ---