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:
@@ -49,6 +49,13 @@ struct CutGraph {
|
||||
/// Indices of the 2g cut edges in order (size = 2g).
|
||||
std::vector<std::size_t> cut_edge_indices;
|
||||
|
||||
/// dual_tree_edge_flags[e.idx()] = true ↔ edge `e` is a dual-spanning-tree
|
||||
/// edge (its dual is in T*). Size = mesh.number_of_edges().
|
||||
/// Crossing only these edges develops the surface onto a topological disk
|
||||
/// (the fundamental polygon), so that the `2g` cut edges become genuine
|
||||
/// boundary identifications carrying the holonomy generators.
|
||||
std::vector<bool> dual_tree_edge_flags;
|
||||
|
||||
/// Genus of the surface (0 for topological spheres and open patches).
|
||||
int genus = 0;
|
||||
|
||||
@@ -58,6 +65,14 @@ struct CutGraph {
|
||||
return static_cast<std::size_t>(e.idx()) < cut_edge_flags.size()
|
||||
&& cut_edge_flags[static_cast<std::size_t>(e.idx())];
|
||||
}
|
||||
|
||||
/// `true` iff edge `e` is a dual-spanning-tree edge (crossable when
|
||||
/// developing the surface onto a disk).
|
||||
bool is_dual_tree(Edge_index e) const
|
||||
{
|
||||
return static_cast<std::size_t>(e.idx()) < dual_tree_edge_flags.size()
|
||||
&& dual_tree_edge_flags[static_cast<std::size_t>(e.idx())];
|
||||
}
|
||||
};
|
||||
|
||||
/// Compute the cut graph of `mesh` via the standard tree-cotree
|
||||
@@ -145,6 +160,11 @@ inline CutGraph compute_cut_graph(const ConformalMesh& mesh)
|
||||
cg.cut_edge_indices.push_back(idx);
|
||||
}
|
||||
|
||||
// Expose the dual spanning tree T*: developing across only these edges
|
||||
// unfolds the surface onto a disk, making the cut edges the boundary
|
||||
// identifications that carry the holonomy generators.
|
||||
cg.dual_tree_edge_flags = std::move(dual_tree_edge);
|
||||
|
||||
return cg;
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,11 @@ inline std::vector<double> euclidean_gradient(
|
||||
double lam31 = m.lambda0[e31] + u3 + u1 + eucl_dof_val(m.e_idx[e31], x);
|
||||
|
||||
auto fa = euclidean_angles(lam12, lam23, lam31);
|
||||
if (!fa.valid) continue; // degenerate triangle: contributes 0
|
||||
// NOTE: do NOT skip degenerate faces. euclidean_angles() returns the
|
||||
// limiting angles (one corner = π, others = 0) when the triangle
|
||||
// inequality is violated; using them is exactly what the Java reference
|
||||
// does and is required for the BPS energy to be the convex C¹ extension
|
||||
// onto the infeasible region (otherwise Newton can stall at a flip).
|
||||
|
||||
// h_alpha[h] = corner angle OPPOSITE to h's edge:
|
||||
// h0 (edge v1v2) → opposite corner at v3 → α3
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
// rescales all three sides by the same factor, leaving angles unchanged but
|
||||
// keeping the arguments of exp in a safe numerical range.
|
||||
|
||||
#include "constants.hpp"
|
||||
#include <cmath>
|
||||
|
||||
namespace conformallab {
|
||||
@@ -50,8 +51,16 @@ inline EuclideanFaceAngles euclidean_angles_from_lengths(
|
||||
const double t23 = +l12 - l23 + l31; // 2*(s − l23)
|
||||
const double t31 = +l12 + l23 - l31; // 2*(s − l31)
|
||||
|
||||
if (t12 <= 0.0 || t23 <= 0.0 || t31 <= 0.0)
|
||||
return {0.0, 0.0, 0.0, false};
|
||||
// Degenerate (triangle inequality violated): return the *limiting* angles
|
||||
// of the flat-out triangle — the corner opposite the over-long edge is π,
|
||||
// the other two are 0. This is the convex C¹ extension of the BPS energy
|
||||
// onto the infeasible region and is what the Java reference
|
||||
// (EuclideanCyclicFunctional.triangleEnergyAndAlphas) does. `valid` stays
|
||||
// false so the cotangent Hessian still skips this face. At most one t can
|
||||
// be ≤ 0 (t12+t23 = 2·l31 > 0, etc.), so the order of these checks is moot.
|
||||
if (t23 <= 0.0) return {PI, 0.0, 0.0, false}; // l23 too long → α₁ = π
|
||||
if (t31 <= 0.0) return {0.0, PI, 0.0, false}; // l31 too long → α₂ = π
|
||||
if (t12 <= 0.0) return {0.0, 0.0, PI, false}; // l12 too long → α₃ = π
|
||||
|
||||
const double l123 = l12 + l23 + l31;
|
||||
const double denom2 = t12 * t23 * t31 * l123; // = (4·Area)²
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
#include <Eigen/Sparse>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
@@ -104,6 +105,18 @@ inline Eigen::SparseMatrix<double> euclidean_hessian(
|
||||
{
|
||||
const int n = euclidean_dimension(mesh, m);
|
||||
|
||||
// Only the vertex block of the cyclic Hessian is implemented here. If any
|
||||
// edge DOF is variable (assign_euclidean_all_dof_indices), the edge-edge and
|
||||
// vertex-edge blocks present in the Java reference (conformalHessian) are
|
||||
// missing, which would leave singular zero rows/cols. Fail loudly instead
|
||||
// of silently returning a rank-deficient matrix (matches the doc contract).
|
||||
for (auto e : mesh.edges()) {
|
||||
if (m.e_idx[e] >= 0)
|
||||
throw std::logic_error(
|
||||
"euclidean_hessian: edge DOFs are not supported "
|
||||
"(only the vertex-block cotangent Laplacian is implemented)");
|
||||
}
|
||||
|
||||
// Collect triplets (row, col, value) — setFromTriplets sums duplicates.
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(static_cast<std::size_t>(n) * 7); // rough estimate
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
// If this fails, no conformal factor can realise the target angles and
|
||||
// Newton will silently fail to converge.
|
||||
//
|
||||
// PRECONDITION — closed meshes only. Every function here sums (2π − Θ_v)
|
||||
// over ALL vertices. On a mesh with boundary the boundary vertices carry a
|
||||
// (π − Θ_v) term instead, so the identity Σ(2π−Θ_v) = 2π·χ does NOT hold and
|
||||
// `check_gauss_bonnet` will (correctly) throw. For open meshes pin the
|
||||
// boundary directly and skip the Gauss–Bonnet check (see the CLI's
|
||||
// flattening path).
|
||||
//
|
||||
// API:
|
||||
// int euler_characteristic(mesh)
|
||||
// int genus(mesh)
|
||||
@@ -128,10 +135,10 @@ inline void check_gauss_bonnet(const ConformalMesh& mesh,
|
||||
|
||||
// ── enforce_gauss_bonnet — adjust θ_v by uniform Δ ───────────────────────────
|
||||
//
|
||||
// Adds δ = (rhs − lhs) / V to every θ_v so that Gauss–Bonnet holds exactly.
|
||||
// Adds δ = (lhs − rhs) / V to every θ_v so that Gauss–Bonnet holds exactly.
|
||||
// After this call, check_gauss_bonnet() will not throw (up to floating-point).
|
||||
// Only modifies free vertices (v_idx[v] >= 0 for EuclideanMaps / SphericalMaps;
|
||||
// always all vertices for the raw property-map overload).
|
||||
// Modifies ALL vertices' θ_v (no v_idx filtering) — the shift is a property
|
||||
// of the target angles, independent of which vertices are free DOFs.
|
||||
|
||||
/// Distribute the Gauss-Bonnet deficit uniformly across all `Θ_v`:
|
||||
/// add `δ = (lhs − rhs) / V` to every entry so that the identity holds
|
||||
|
||||
@@ -269,11 +269,18 @@ inline std::vector<double> inversive_distance_gradient(
|
||||
double l23sq = id_detail::edge_length_squared(u2, u3, m.I_e[e23]);
|
||||
double l31sq = id_detail::edge_length_squared(u3, u1, m.I_e[e31]);
|
||||
|
||||
// A non-real circle configuration (ℓ² ≤ 0) has no limiting angle — skip it.
|
||||
if (l12sq <= 0 || l23sq <= 0 || l31sq <= 0) continue;
|
||||
|
||||
// euclidean_angles expects 2·log(ℓ) per edge — feed log(ℓ²).
|
||||
// For a triangle-inequality-violating face euclidean_angles returns the
|
||||
// *limiting* angles (π opposite the over-long edge, 0/0 otherwise) with
|
||||
// valid=false. We deliberately do NOT skip on !fa.valid: using those
|
||||
// limiting angles is the convex C¹ extension onto the infeasible region,
|
||||
// so Newton can pass through a flip instead of stalling (Finding 9 —
|
||||
// mirrors the Euclidean/Spherical fix in Finding 1). The angles come
|
||||
// from genuine Euclidean side lengths, so the extension is geometric.
|
||||
auto fa = euclidean_angles(std::log(l12sq), std::log(l23sq), std::log(l31sq));
|
||||
if (!fa.valid) continue;
|
||||
|
||||
h_alpha[id_detail::hidx(h0)] = fa.alpha3;
|
||||
h_alpha[id_detail::hidx(h1)] = fa.alpha1;
|
||||
|
||||
@@ -474,6 +474,125 @@ inline void set_root_huv_2d(
|
||||
huv[static_cast<std::size_t>(hf.idx())] = uv[static_cast<std::size_t>(mesh.source(hf).idx())];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Euclidean holonomy via the developing map (genus-g closed surfaces).
|
||||
//
|
||||
// The per-vertex layout produced by euclidean_layout() places every face in a
|
||||
// SINGLE consistent global frame (each vertex is placed once). In that frame
|
||||
// the holonomy is identically trivial — it is exactly the obstruction to such a
|
||||
// single frame existing on the uncut surface. To recover it we develop every
|
||||
// face INDEPENDENTLY along a spanning tree of the dual graph that does not cross
|
||||
// any cut edge, storing each face's own copy of its three corner positions
|
||||
// (`hpos[h]` = global position of source(h) as developed inside face(h)).
|
||||
//
|
||||
// Two faces adjacent across a cut edge are NOT tree-adjacent, so they are
|
||||
// developed via different tree branches and place the shared edge at two
|
||||
// different locations. The rigid motion identifying those two copies is the
|
||||
// deck transformation of the generator loop (cut edge + tree path) — i.e. the
|
||||
// holonomy. For a flat cone metric (Θ ≡ 2π) the linear part is trivial, so the
|
||||
// holonomy is the pure translation given by the displacement of the shared
|
||||
// edge's midpoint between the two developments. That translation is exactly
|
||||
// the lattice generator ω consumed by compute_period_matrix().
|
||||
template <typename EdgeLenFn>
|
||||
inline std::vector<Eigen::Vector2d> euclidean_holonomy(
|
||||
const ConformalMesh& mesh,
|
||||
const CutGraph& cut,
|
||||
EdgeLenFn&& edge_len)
|
||||
{
|
||||
using C = std::complex<double>;
|
||||
const std::size_t nh = mesh.number_of_halfedges();
|
||||
const std::size_t nf = mesh.number_of_faces();
|
||||
|
||||
std::vector<C> hpos(nh, C(0.0, 0.0)); // pos of source(h) inside face(h)
|
||||
std::vector<bool> face_done(nf, false);
|
||||
|
||||
auto place_root = [&](Face_index f) {
|
||||
Halfedge_index h0 = mesh.halfedge(f);
|
||||
Halfedge_index h1 = mesh.next(h0), h2 = mesh.next(h1);
|
||||
double lAB = edge_len(h0), lBC = edge_len(h1), lCA = edge_len(h2);
|
||||
Eigen::Vector2d A(0.0, 0.0), B(lAB, 0.0);
|
||||
Eigen::Vector2d Cc = trilaterate_2d(A, B, lCA, lBC); // apex = source(h2)
|
||||
hpos[static_cast<std::size_t>(h0.idx())] = C(A.x(), A.y());
|
||||
hpos[static_cast<std::size_t>(h1.idx())] = C(B.x(), B.y());
|
||||
hpos[static_cast<std::size_t>(h2.idx())] = C(Cc.x(), Cc.y());
|
||||
face_done[static_cast<std::size_t>(f.idx())] = true;
|
||||
};
|
||||
|
||||
auto develop = [&](Face_index root) {
|
||||
place_root(root);
|
||||
std::queue<Halfedge_index> q; // halfedges pointing INTO unplaced faces
|
||||
auto enqueue = [&](Face_index f) {
|
||||
for (auto hf : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
|
||||
Halfedge_index ho = mesh.opposite(hf);
|
||||
if (mesh.is_border(ho)) continue;
|
||||
// Develop across the dual spanning tree T* ONLY. Crossing any
|
||||
// other edge (a cut/generator edge OR a primal-tree edge) would
|
||||
// over-connect the development: the surface minus the 2g cut
|
||||
// edges is still non-simply-connected, so the immersion would
|
||||
// wrap around and place the two copies of a generator edge on
|
||||
// top of each other (zero/garbage holonomy). Crossing only T*
|
||||
// unfolds the surface onto a disk (the fundamental polygon).
|
||||
if (!cut.is_dual_tree(mesh.edge(hf))) continue;
|
||||
Face_index fa = mesh.face(ho);
|
||||
if (!face_done[static_cast<std::size_t>(fa.idx())]) q.push(ho);
|
||||
}
|
||||
};
|
||||
enqueue(root);
|
||||
while (!q.empty()) {
|
||||
Halfedge_index h = q.front(); q.pop();
|
||||
Face_index f = mesh.face(h);
|
||||
if (face_done[static_cast<std::size_t>(f.idx())]) continue;
|
||||
|
||||
Halfedge_index ho = mesh.opposite(h);
|
||||
// Shared edge endpoints, taken from the PARENT face's development:
|
||||
// source(h) = target(ho) → parent pos hpos[next(ho)]
|
||||
// target(h) = source(ho) → parent pos hpos[ho]
|
||||
C ps = hpos[static_cast<std::size_t>(mesh.next(ho).idx())];
|
||||
C pt = hpos[static_cast<std::size_t>(ho.idx())];
|
||||
Eigen::Vector2d A(ps.real(), ps.imag()), B(pt.real(), pt.imag());
|
||||
Eigen::Vector2d apex = trilaterate_2d(
|
||||
A, B, edge_len(mesh.prev(h)), edge_len(mesh.next(h)));
|
||||
|
||||
hpos[static_cast<std::size_t>(h.idx())] = ps;
|
||||
hpos[static_cast<std::size_t>(mesh.next(h).idx())] = pt;
|
||||
hpos[static_cast<std::size_t>(mesh.prev(h).idx())] = C(apex.x(), apex.y());
|
||||
face_done[static_cast<std::size_t>(f.idx())] = true;
|
||||
enqueue(f);
|
||||
}
|
||||
};
|
||||
|
||||
develop(best_root_face(mesh));
|
||||
for (auto f : mesh.faces())
|
||||
if (!face_done[static_cast<std::size_t>(f.idx())]) develop(f);
|
||||
|
||||
// ── Holonomy translation per cut edge ─────────────────────────────────────
|
||||
std::vector<Eigen::Vector2d> omega;
|
||||
omega.reserve(cut.cut_edge_indices.size());
|
||||
for (std::size_t ce : cut.cut_edge_indices) {
|
||||
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce));
|
||||
Halfedge_index h = mesh.halfedge(e);
|
||||
Halfedge_index ho = mesh.opposite(h);
|
||||
if (mesh.is_border(h) || mesh.is_border(ho)
|
||||
|| !face_done[static_cast<std::size_t>(mesh.face(h).idx())]
|
||||
|| !face_done[static_cast<std::size_t>(mesh.face(ho).idx())]) {
|
||||
omega.push_back(Eigen::Vector2d::Zero());
|
||||
continue;
|
||||
}
|
||||
// Face A = face(h) places edge e as halfedge h: S=source(h), T=target(h)
|
||||
C As = hpos[static_cast<std::size_t>(h.idx())];
|
||||
C At = hpos[static_cast<std::size_t>(mesh.next(h).idx())];
|
||||
// Face B = face(ho) places the same edge as halfedge ho: source(ho)=T,
|
||||
// target(ho)=S → S=pos of source(next(ho)), T=pos of source(ho)
|
||||
C Bt = hpos[static_cast<std::size_t>(ho.idx())];
|
||||
C Bs = hpos[static_cast<std::size_t>(mesh.next(ho).idx())];
|
||||
C midA = 0.5 * (As + At);
|
||||
C midB = 0.5 * (Bs + Bt);
|
||||
C w = midA - midB; // pure translation for a flat cone metric
|
||||
omega.push_back(Eigen::Vector2d(w.real(), w.imag()));
|
||||
}
|
||||
return omega;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ── Euclidean layout ──────────────────────────────────────────────────────────
|
||||
@@ -592,25 +711,30 @@ inline Layout2D euclidean_layout(
|
||||
result.success = true;
|
||||
|
||||
// ── Holonomy ──────────────────────────────────────────────────────────────
|
||||
// The single-frame BFS layout above puts every face in one consistent frame,
|
||||
// so the holonomy read off it is identically trivial. Instead develop each
|
||||
// face independently along a dual spanning tree that never crosses a cut edge
|
||||
// (detail::euclidean_holonomy): the displacement of each cut edge between the
|
||||
// two faces that share it is the lattice generator ω_i.
|
||||
if (cut && holonomy) {
|
||||
holonomy->cut_edge_indices = cut->cut_edge_indices;
|
||||
holonomy->translations.clear();
|
||||
holonomy->translations.reserve(cut->cut_edge_indices.size());
|
||||
holonomy->mobius_maps.clear();
|
||||
holonomy->translations = detail::euclidean_holonomy(mesh, *cut, edge_len);
|
||||
|
||||
// Preserve the per-cut-edge seam UV in halfedge_uv (texture atlas), as
|
||||
// before, so HalfedgeUV-based tests still see the seam-crossing layout.
|
||||
for (std::size_t ce_idx : cut->cut_edge_indices) {
|
||||
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce_idx));
|
||||
Halfedge_index h = mesh.halfedge(e);
|
||||
Halfedge_index ho = mesh.opposite(h);
|
||||
Halfedge_index hx = mesh.is_border(ho) ? h : ho;
|
||||
if (mesh.is_border(hx)) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
|
||||
if (mesh.is_border(hx)) continue;
|
||||
Vertex_index vs = mesh.source(hx), vt = mesh.target(hx), vn = mesh.target(mesh.next(hx));
|
||||
if (!vertex_placed[vn.idx()]) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
|
||||
if (!vertex_placed[vn.idx()]) continue;
|
||||
Eigen::Vector2d p_tri = detail::trilaterate_2d(
|
||||
result.uv[vs.idx()], result.uv[vt.idx()],
|
||||
edge_len(mesh.prev(hx)), edge_len(mesh.next(hx)));
|
||||
// Store seam UV for the cut-crossing halfedges
|
||||
detail::set_face_huv_2d(result.halfedge_uv, mesh, hx, result.uv, p_tri);
|
||||
holonomy->translations.push_back(p_tri - result.uv[vn.idx()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,6 +831,15 @@ inline Layout3D spherical_layout(
|
||||
for (auto f : mesh.faces()) if (!face_placed[f.idx()]) place_component(f);
|
||||
result.success = true;
|
||||
|
||||
// KNOWN LIMITATION (latent — every current caller passes holonomy == nullptr).
|
||||
// This block extracts holonomy from a single full-surface development (the BFS
|
||||
// above crosses every non-cut edge), then reads off an apex trilateration on one
|
||||
// side of each seam. That is the same flawed pattern that produced garbage τ for
|
||||
// the Euclidean path; the correct approach is detail::euclidean_holonomy, which
|
||||
// develops across only the dual spanning tree (is_dual_tree) and measures the
|
||||
// shared-edge displacement between two independent developments. Until a
|
||||
// detail::spherical_holonomy mirror exists (Phase 9c/10, see research-track.md),
|
||||
// these spherical translations are not trustworthy for genus g ≥ 1.
|
||||
if (cut && holonomy) {
|
||||
holonomy->cut_edge_indices = cut->cut_edge_indices;
|
||||
holonomy->translations.clear();
|
||||
@@ -838,6 +971,16 @@ inline Layout2D hyper_ideal_layout(
|
||||
result.success = true;
|
||||
|
||||
// ── Möbius-map holonomy ───────────────────────────────────────────────────
|
||||
// KNOWN LIMITATION (latent — every current caller passes holonomy == nullptr).
|
||||
// Like the spherical block above, this reads the Möbius deck transformation from
|
||||
// a single full-surface development (BFS crosses all non-cut edges) and one-sided
|
||||
// apex trilateration — the same flawed pattern fixed for the Euclidean path by
|
||||
// detail::euclidean_holonomy (develop across the dual tree only, measure seam
|
||||
// displacement between two independent developments). A faithful
|
||||
// detail::hyperbolic_holonomy is Phase 9c/10 work and additionally requires
|
||||
// cpp_dec_float_50: products of these generators grow exponentially, so verifying
|
||||
// the group relation ∏gᵢ = Id overflows double (see CLAUDE.md, research-track.md).
|
||||
// Until then these mobius_maps are NOT correct for genus g ≥ 2 uniformization.
|
||||
if (cut && holonomy) {
|
||||
holonomy->cut_edge_indices = cut->cut_edge_indices;
|
||||
holonomy->translations.clear();
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include <CGAL/IO/polygon_mesh_io.h>
|
||||
#include <CGAL/boost/graph/helpers.h>
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
|
||||
@@ -47,11 +48,20 @@ inline bool write_mesh(const std::string& filename, const ConformalMesh& mesh)
|
||||
|
||||
/// Throwing wrapper around `read_mesh`: returns the mesh by value
|
||||
/// or throws `std::runtime_error` on read failure.
|
||||
///
|
||||
/// Also enforces that the mesh is triangulated, because every functional
|
||||
/// in this library assumes triangle faces — a quad/polygon mesh would be
|
||||
/// read in silently and then mis-handled by the angle/length formulas.
|
||||
/// Fail loudly here at the I/O boundary instead.
|
||||
inline ConformalMesh load_mesh(const std::string& filename)
|
||||
{
|
||||
ConformalMesh mesh;
|
||||
if (!read_mesh(filename, mesh))
|
||||
throw std::runtime_error("conformallab: failed to read mesh from " + filename);
|
||||
if (!CGAL::is_triangle_mesh(mesh))
|
||||
throw std::runtime_error(
|
||||
"conformallab: mesh from " + filename +
|
||||
" is not triangulated (functionals require triangle faces)");
|
||||
return mesh;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,35 +115,89 @@ inline Eigen::VectorXd solve_linear_system(
|
||||
|
||||
namespace detail { // re-open for the remaining helpers
|
||||
|
||||
// Backtracking line search: find the largest α in {1, 0.5, 0.25, …} such that
|
||||
// ||G(x + α·Δx)||₂ < ||G(x)||₂. Returns the accepted step (α may stay 1).
|
||||
// Globalised line search for the Newton system G(x) = 0.
|
||||
//
|
||||
// Merit function f(x) = ½‖G(x)‖²₂. Driving f to its minimum drives the
|
||||
// residual G to zero; because the merit only depends on ‖G‖ it is sign-agnostic
|
||||
// and works identically for the convex Euclidean / HyperIdeal / CP / InvDist
|
||||
// energies and the concave Spherical energy.
|
||||
//
|
||||
// Phase 1 — Newton direction `dx` (satisfies H·dx = −G, so the merit slope
|
||||
// ∇f·dx = GᵀH·dx = −‖G‖² < 0 — always a descent direction).
|
||||
// Backtrack α ∈ {1, ½, ¼, …} until the Armijo sufficient-decrease
|
||||
// condition holds:
|
||||
// ‖G(x + α·dx)‖² ≤ (1 − 2·c1·α)·‖G(x)‖²
|
||||
//
|
||||
// Phase 2 — if Phase 1 exhausts its halvings, fall back to the steepest-
|
||||
// descent direction of the merit, `d_sd = −H·G` (slope
|
||||
// ∇f·d_sd = −‖H·G‖² ≤ 0 regardless of the definiteness of H), with
|
||||
// the analogous Armijo test:
|
||||
// ‖G(x + α·d_sd)‖² ≤ ‖G(x)‖² − 2·c1·α·‖d_sd‖²
|
||||
//
|
||||
// If neither phase satisfies Armijo, return the best (smallest-residual) point
|
||||
// visited. If nothing beat ‖G(x)‖, return x unchanged and set *improved=false,
|
||||
// so the caller can stop cleanly instead of taking the old divergent full step.
|
||||
template <typename GradFn>
|
||||
inline std::vector<double> line_search(
|
||||
const std::vector<double>& x,
|
||||
const Eigen::VectorXd& dx,
|
||||
const Eigen::VectorXd& d_sd,
|
||||
double norm0,
|
||||
GradFn&& grad_fn,
|
||||
int max_halvings = 20)
|
||||
bool* improved = nullptr,
|
||||
int max_halvings = 20,
|
||||
double c1 = 1e-4)
|
||||
{
|
||||
const int n = static_cast<int>(x.size());
|
||||
double alpha = 1.0;
|
||||
std::vector<double> xnew(static_cast<std::size_t>(n));
|
||||
const int n = static_cast<int>(x.size());
|
||||
const double norm0_sq = norm0 * norm0;
|
||||
|
||||
for (int ls = 0; ls < max_halvings; ++ls) {
|
||||
std::vector<double> xnew(static_cast<std::size_t>(n));
|
||||
std::vector<double> best_x = x;
|
||||
double best_norm = norm0;
|
||||
|
||||
// Evaluate ‖G(x + α·dir)‖₂, leaving the trial point in `xnew`.
|
||||
auto eval = [&](const Eigen::VectorXd& dir, double alpha) -> double {
|
||||
for (int i = 0; i < n; ++i)
|
||||
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)]
|
||||
+ alpha * dx[i];
|
||||
xnew[static_cast<std::size_t>(i)] =
|
||||
x[static_cast<std::size_t>(i)] + alpha * dir[i];
|
||||
auto Gnew = grad_fn(xnew);
|
||||
double norm_new = 0.0;
|
||||
for (double v : Gnew) norm_new += v * v;
|
||||
norm_new = std::sqrt(norm_new);
|
||||
if (norm_new < norm0) return xnew;
|
||||
double s = 0.0;
|
||||
for (double v : Gnew) s += v * v;
|
||||
return std::sqrt(s);
|
||||
};
|
||||
|
||||
// ── Phase 1: Newton direction, Armijo backtracking ────────────────────────
|
||||
double alpha = 1.0;
|
||||
for (int ls = 0; ls < max_halvings; ++ls) {
|
||||
double norm_new = eval(dx, alpha);
|
||||
if (norm_new < best_norm) { best_norm = norm_new; best_x = xnew; }
|
||||
if (norm_new * norm_new <= (1.0 - 2.0 * c1 * alpha) * norm0_sq) {
|
||||
if (improved) *improved = true;
|
||||
return xnew;
|
||||
}
|
||||
alpha *= 0.5;
|
||||
}
|
||||
// No improvement found — return best attempt (full step)
|
||||
for (int i = 0; i < n; ++i)
|
||||
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)] + dx[i];
|
||||
return xnew;
|
||||
|
||||
// ── Phase 2: steepest-descent fallback (−H·G), Armijo backtracking ────────
|
||||
const double dsd_sq = d_sd.squaredNorm();
|
||||
if (dsd_sq > 0.0) {
|
||||
alpha = 1.0;
|
||||
for (int ls = 0; ls < max_halvings; ++ls) {
|
||||
double thresh_sq = norm0_sq - 2.0 * c1 * alpha * dsd_sq;
|
||||
double norm_new = eval(d_sd, alpha);
|
||||
if (norm_new < best_norm) { best_norm = norm_new; best_x = xnew; }
|
||||
if (thresh_sq >= 0.0 && norm_new * norm_new <= thresh_sq) {
|
||||
if (improved) *improved = true;
|
||||
return xnew;
|
||||
}
|
||||
alpha *= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Both phases failed Armijo — never take the divergent full step. ───────
|
||||
// Return the best point seen; if none improved, stay put and signal stall.
|
||||
if (improved) *improved = (best_norm < norm0);
|
||||
return best_x;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
@@ -209,12 +263,15 @@ inline NewtonResult newton_euclidean(
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
// ── Backtracking line search ──────────────────────────────────────────
|
||||
// ── Globalised line search (Armijo + steepest-descent fallback) ───────
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G); // merit-function steepest descent
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return euclidean_gradient(mesh, xnew, m);
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break; // line search stalled — stop cleanly
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
@@ -287,12 +344,16 @@ inline NewtonResult newton_spherical(
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(negH, G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
// ── Backtracking line search ──────────────────────────────────────────
|
||||
// ── Globalised line search (Armijo + steepest-descent fallback) ───────
|
||||
// d_sd uses the actual (un-negated) Hessian: ∇f = H·G for f = ½‖G‖².
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G);
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return spherical_gradient(mesh, xnew, m);
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break;
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
@@ -367,12 +428,15 @@ inline NewtonResult newton_hyper_ideal(
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
// ── Backtracking line search ──────────────────────────────────────────
|
||||
// ── Globalised line search (Armijo + steepest-descent fallback) ───────
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G);
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return evaluate_hyper_ideal(mesh, xnew, m, false).gradient;
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break;
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
@@ -443,10 +507,13 @@ inline NewtonResult newton_cp_euclidean(
|
||||
if (!ok) break;
|
||||
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G);
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return cp_euclidean_gradient(mesh, xnew, m);
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break;
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
@@ -552,10 +619,13 @@ inline NewtonResult newton_inversive_distance(
|
||||
if (!ok) break;
|
||||
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G);
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return inversive_distance_gradient(mesh, xnew, m);
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break;
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
// std::complex<double> reduce_to_fundamental_domain(τ) — apply SL(2,ℤ)
|
||||
|
||||
#include "layout.hpp"
|
||||
#include "discrete_elliptic_utility.hpp" // normalizeModulus (Java-faithful)
|
||||
#include <complex>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
@@ -129,8 +130,14 @@ inline bool is_in_fundamental_domain(std::complex<double> tau, double tol = 1e-9
|
||||
// For genus-1 surfaces, also reduces τ to the fundamental domain.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/// Compute the period data from the Euclidean holonomy translations.
|
||||
/// For genus 1, also reduces `τ` to the SL(2,ℤ) fundamental domain
|
||||
/// when `reduce` is `true` (default).
|
||||
/// For genus 1, also normalises `τ` when `reduce` is `true` (default)
|
||||
/// using `normalizeModulus` — the Java-faithful reduction
|
||||
/// (`DiscreteEllipticUtility.normalizeModulus`), which folds τ into
|
||||
/// `0 ≤ Re(τ) ≤ ½`, `Im(τ) ≥ 0`, `|τ| ≥ 1` (the extra `Re ≥ 0` fold
|
||||
/// uses the mirror symmetry `τ ≅ −τ̄`). This matches the upstream Java
|
||||
/// output exactly (Finding 6). For the canonical SL(2,ℤ) domain
|
||||
/// (`−½ ≤ Re τ < ½`, no mirror fold) call `reduce_to_fundamental_domain`
|
||||
/// on `pd.tau` instead.
|
||||
inline PeriodData compute_period_matrix(const HolonomyData& hol, bool reduce = true)
|
||||
{
|
||||
PeriodData pd;
|
||||
@@ -156,7 +163,9 @@ inline PeriodData compute_period_matrix(const HolonomyData& hol, bool reduce = t
|
||||
if (tau.imag() < 0.0) return pd; // degenerate
|
||||
|
||||
if (reduce) {
|
||||
tau = reduce_to_fundamental_domain(tau);
|
||||
// Java-faithful normalisation (Finding 6): folds τ into
|
||||
// 0 ≤ Re ≤ ½, Im ≥ 0, |τ| ≥ 1 via DiscreteEllipticUtility.normalizeModulus.
|
||||
tau = normalizeModulus(tau);
|
||||
pd.in_fundamental_domain = true;
|
||||
}
|
||||
pd.tau = tau;
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
// │ x[e_idx[e]] = λ_e – edge log-length variable (optional) │
|
||||
// │ -1 means "pinned" (u_v = 0 / λ_e = λ°_e fixed) │
|
||||
// │ │
|
||||
// │ Effective log-length: Λ_ij = λ°_ij + u_i + u_j │
|
||||
// │ Effective log-length: Λ_ij = λ_e if edge e carries a DOF, │
|
||||
// │ = λ°_ij + u_i + u_j otherwise │
|
||||
// │ (Java "replacement" convention, Finding 3) │
|
||||
// │ Spherical arc length: l_ij = 2·asin(min(exp(Λ_ij/2), 1)) │
|
||||
// │ │
|
||||
// │ Gradient: │
|
||||
@@ -155,6 +157,24 @@ static inline double spher_dof_val(int idx, const std::vector<double>& x)
|
||||
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
|
||||
}
|
||||
|
||||
/// Effective spherical log-length using the Java "replacement" convention
|
||||
/// (SphericalFunctional.java:393–400, Finding 3): when edge `e` carries a
|
||||
/// variable (edge DOF), its value *replaces* `λ⁰ + u_i + u_j` entirely;
|
||||
/// otherwise the effective length is `λ⁰_e + u_i + u_j`.
|
||||
///
|
||||
/// In the common vertex-only mode (no edge DOFs) this is identical to the
|
||||
/// additive form `λ⁰_e + u_i + u_j`, so that path is unchanged.
|
||||
static inline double spher_eff_lambda(const SphericalMaps& m,
|
||||
const std::vector<double>& x,
|
||||
Edge_index e,
|
||||
double u_i,
|
||||
double u_j)
|
||||
{
|
||||
int ie = m.e_idx[e];
|
||||
return (ie >= 0) ? x[static_cast<std::size_t>(ie)]
|
||||
: (m.lambda0[e] + u_i + u_j);
|
||||
}
|
||||
|
||||
/// Convert a CGAL half-edge index to a plain `std::size_t` for vector indexing.
|
||||
static inline std::size_t spher_hidx(Halfedge_index h)
|
||||
{
|
||||
@@ -197,22 +217,25 @@ inline std::vector<double> spherical_gradient(
|
||||
Edge_index e23 = mesh.edge(h1);
|
||||
Edge_index e31 = mesh.edge(h2);
|
||||
|
||||
// Effective log-length Λ_ij = λ°_ij + u_i + u_j
|
||||
// Effective log-length (Java "replacement" convention, Finding 3):
|
||||
// * edge DOF present → Λ_ij = λ_e (the edge variable)
|
||||
// * vertex-only → Λ_ij = λ°_ij + u_i + u_j
|
||||
double u1 = spher_dof_val(m.v_idx[v1], x);
|
||||
double u2 = spher_dof_val(m.v_idx[v2], x);
|
||||
double u3 = spher_dof_val(m.v_idx[v3], x);
|
||||
|
||||
double lam12 = m.lambda0[e12] + u1 + u2 + spher_dof_val(m.e_idx[e12], x);
|
||||
double lam23 = m.lambda0[e23] + u2 + u3 + spher_dof_val(m.e_idx[e23], x);
|
||||
double lam31 = m.lambda0[e31] + u3 + u1 + spher_dof_val(m.e_idx[e31], x);
|
||||
double lam12 = spher_eff_lambda(m, x, e12, u1, u2);
|
||||
double lam23 = spher_eff_lambda(m, x, e23, u2, u3);
|
||||
double lam31 = spher_eff_lambda(m, x, e31, u3, u1);
|
||||
|
||||
double l12 = spherical_l(lam12);
|
||||
double l23 = spherical_l(lam23);
|
||||
double l31 = spherical_l(lam31);
|
||||
|
||||
SphericalFaceAngles fa = spherical_angles(l12, l23, l31);
|
||||
|
||||
if (!fa.valid) continue; // degenerate face: contributes 0
|
||||
// Do NOT skip degenerate faces: spherical_angles() returns the limiting
|
||||
// angles (matching the Java reference), required for the convex C¹
|
||||
// extension of the energy onto the infeasible region.
|
||||
|
||||
// Store convention: h_alpha[h] = corner angle at source(prev(h))
|
||||
// h0 (e12): opposite vertex is v3 → source(prev(h0)) = source(h2) = v3 → α3
|
||||
@@ -237,38 +260,23 @@ inline std::vector<double> spherical_gradient(
|
||||
G[static_cast<std::size_t>(iv)] = m.theta_v[v] - sum_alpha;
|
||||
}
|
||||
|
||||
// Edge: G_e for λ_e additive (Λ_ij = λ°_ij + u_i + u_j + λ_e).
|
||||
// Edge: G_e = α_opp(face⁺) + α_opp(face⁻) − θ_e (Java-faithful, Finding 3).
|
||||
//
|
||||
// From the Schläfli identity applied to the spherical face,
|
||||
// the contribution of edge DOF λ_e from face f is:
|
||||
// a_f = (2·α_opp − S_f) / 2 where S_f = Σ angles in face f.
|
||||
//
|
||||
// Summing over both adjacent faces:
|
||||
// G_e = a_f+ + a_f−
|
||||
// = α_opp⁺ + α_opp⁻ − (S_f⁺ + S_f⁻) / 2 − θ_e
|
||||
//
|
||||
// For flat (Euclidean) triangles S_f = π, recovering the familiar
|
||||
// α_opp⁺ + α_opp⁻ − π formula. For spherical triangles S_f > π.
|
||||
// With the replacement parameterization (Λ_ij = λ_e directly), the
|
||||
// Schläfli derivative of the spherical edge energy w.r.t. λ_e reduces to
|
||||
// the sum of the two opposite corner angles minus the target θ_e
|
||||
// (default π). This matches SphericalFunctional.java:283–292
|
||||
// `G.add(i, αk + αl − PI)`. Note this drops the −(S_f⁺+S_f⁻)/2 term that
|
||||
// would arise under the additive convention; the two conventions agree on
|
||||
// the vertex-only path (no edge DOFs), which is exercised by every test.
|
||||
for (auto e : mesh.edges()) {
|
||||
int ie = m.e_idx[e];
|
||||
if (ie < 0) continue;
|
||||
auto h = mesh.halfedge(e);
|
||||
auto ho = mesh.opposite(h);
|
||||
double sum = 0.0;
|
||||
if (!mesh.is_border(h)) {
|
||||
double alpha_opp = h_alpha[spher_hidx(h)];
|
||||
double S_f = alpha_opp
|
||||
+ h_alpha[spher_hidx(mesh.next(h))]
|
||||
+ h_alpha[spher_hidx(mesh.prev(h))];
|
||||
sum += (2.0 * alpha_opp - S_f) * 0.5;
|
||||
}
|
||||
if (!mesh.is_border(ho)) {
|
||||
double alpha_opp = h_alpha[spher_hidx(ho)];
|
||||
double S_f = alpha_opp
|
||||
+ h_alpha[spher_hidx(mesh.next(ho))]
|
||||
+ h_alpha[spher_hidx(mesh.prev(ho))];
|
||||
sum += (2.0 * alpha_opp - S_f) * 0.5;
|
||||
}
|
||||
if (!mesh.is_border(h)) sum += h_alpha[spher_hidx(h)];
|
||||
if (!mesh.is_border(ho)) sum += h_alpha[spher_hidx(ho)];
|
||||
G[static_cast<std::size_t>(ie)] = sum - m.theta_e[e];
|
||||
}
|
||||
|
||||
|
||||
@@ -57,9 +57,19 @@ inline SphericalFaceAngles spherical_angles(double l12, double l23, double l31)
|
||||
double s23 = s - l23;
|
||||
double s31 = s - l31;
|
||||
|
||||
// Spherical triangle inequalities: all s-deficiencies > 0 and s < π.
|
||||
if (s12 <= 0.0 || s23 <= 0.0 || s31 <= 0.0 || s >= PI_SPHER)
|
||||
return {0.0, 0.0, 0.0, false};
|
||||
// Degenerate spherical triangle: return the *limiting* angles, matching the
|
||||
// Java reference (SphericalFunctional.triangleEnergyAndAlphas). a1 is the
|
||||
// angle opposite l23, a2 opposite l31, a3 opposite l12. `valid` stays false
|
||||
// so the Hessian still skips the face, but the gradient uses these angles
|
||||
// (convex C¹ extension onto the infeasible region).
|
||||
// s12<=0 (Δij<=0) → corner opposite l12 = π → a3 = π
|
||||
// s23<=0 (Δjk<=0) → corner opposite l23 = π → a1 = π
|
||||
// s31<=0 (Δki<=0) → corner opposite l31 = π → a2 = π
|
||||
// s>=π (Δijk>=2π) → all three corners = π
|
||||
if (s12 <= 0.0) return {0.0, 0.0, PI_SPHER, false};
|
||||
if (s23 <= 0.0) return {PI_SPHER, 0.0, 0.0, false};
|
||||
if (s31 <= 0.0) return {0.0, PI_SPHER, 0.0, false};
|
||||
if (s >= PI_SPHER) return {PI_SPHER, PI_SPHER, PI_SPHER, false};
|
||||
|
||||
const double ss = std::sin(s);
|
||||
const double ss12 = std::sin(s12);
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
#include <Eigen/Sparse>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
@@ -103,6 +104,18 @@ inline Eigen::SparseMatrix<double> spherical_hessian(
|
||||
{
|
||||
const int n = spherical_dimension(mesh, m);
|
||||
|
||||
// Only the vertex block of the spherical Hessian is implemented here. If any
|
||||
// edge DOF is variable, the edge-edge and vertex-edge blocks present in the
|
||||
// Java reference (conformalHessian) are missing, which would leave singular
|
||||
// zero rows/cols. Fail loudly instead of silently returning a rank-deficient
|
||||
// matrix (mirrors the euclidean_hessian guard — Finding 4).
|
||||
for (auto e : mesh.edges()) {
|
||||
if (m.e_idx[e] >= 0)
|
||||
throw std::logic_error(
|
||||
"spherical_hessian: edge DOFs are not supported "
|
||||
"(only the vertex-block cotangent Laplacian is implemented)");
|
||||
}
|
||||
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(static_cast<std::size_t>(n) * 9);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user