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