#pragma once // Copyright (c) 2024-2026 Tarik Moussa. // SPDX-License-Identifier: MIT // hyper_ideal_hessian.hpp // // Phase 4a — Hessian of the hyper-ideal discrete conformal functional. // Phase 9b — Block-finite-difference Hessian (intermediate optimisation). // // ┌──────────────────────────────────────────────────────────────────────────┐ // │ Implementation strategy │ // │ │ // │ The hyper-ideal functional involves angle functions (ζ, σ, α, β) │ // │ composed through several nested layers (lij → ζ13/14/15 → β/α). │ // │ Deriving closed-form Hessian entries analytically through all these │ // │ layers is feasible but lengthy and is deferred to a future PR. │ // │ │ // │ TWO Hessian implementations are provided here: │ // │ │ // │ 1. `hyper_ideal_hessian` — full finite-difference baseline. │ // │ Cost ≈ n × (cost of full gradient evaluation) │ // │ = O(n · F) where n = #DOFs and F = #faces. │ // │ Used for correctness reference and small meshes. │ // │ │ // │ 2. `hyper_ideal_hessian_block_fd` — block-local finite-difference, │ // │ Phase 9b. Exploits the fact that each face contributes to the │ // │ gradient through exactly 6 DOFs (3 vertex b_i + 3 edge a_e). │ // │ Cost ≈ F × 6 × (cost of a single face-angle evaluation) │ // │ = O(36 · F). │ // │ Speed-up factor ≈ n / 36, i.e. typically 10–50× on V > 200. │ // │ │ // │ Both produce the same Hessian to O(ε²) and pass identical PSD checks. │ // │ The block-FD variant is the production default; the full-FD variant is │ // │ kept for cross-validation tests. │ // │ │ // │ An analytic Hessian via Schläfli-type differentiation through the chain │ // │ (bᵢ, aₑ) → lᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ / βᵢ │ // │ is deferred to a future PR (Phase 9b-analytic). Speed-up would be │ // │ another ~6×, taking the cost to O(F). │ // │ │ // │ The hyper-ideal energy is strictly convex (Springborn 2020), so H is │ // │ positive semi-definite everywhere and Eigen::SimplicialLDLT applies │ // │ directly to either Hessian variant. │ // └──────────────────────────────────────────────────────────────────────────┘ // // Note on the Java reference: HyperIdealFunctional.java line 295-298 declares // public boolean hasHessian() { return false; } // — i.e. the upstream Java implementation supplies NO Hessian, analytic or // numerical. Both `hyper_ideal_hessian` and `hyper_ideal_hessian_block_fd` // are conformallab++ additions beyond Java parity. #include "hyper_ideal_functional.hpp" #include #include #include #include namespace conformallab { // ── Full finite-difference Hessian (baseline, Phase 4a) ────────────────────── // // Returns the n×n sparse Hessian, where n = hyper_ideal_dimension(mesh, m). // eps: finite-difference step size (default 1e-5 gives ~1e-10 relative error). // // Cost: n × full-gradient evaluations ≈ O(n·F). Use for small meshes or // as a correctness reference for the block-FD variant below. inline Eigen::SparseMatrix hyper_ideal_hessian( ConformalMesh& mesh, const std::vector& x, const HyperIdealMaps& m, double eps = 1e-5) { const int n = hyper_ideal_dimension(mesh, m); std::vector> trips; trips.reserve(static_cast(n * n)); std::vector xp = x, xm = x; for (int j = 0; j < n; ++j) { const std::size_t sj = static_cast(j); xp[sj] = x[sj] + eps; xm[sj] = x[sj] - eps; auto Gp = evaluate_hyper_ideal(mesh, xp, m, /*energy=*/false).gradient; auto Gm = evaluate_hyper_ideal(mesh, xm, m, /*energy=*/false).gradient; xp[sj] = xm[sj] = x[sj]; // restore for (int i = 0; i < n; ++i) { double val = (Gp[static_cast(i)] - Gm[static_cast(i)]) / (2.0 * eps); if (std::abs(val) > 1e-15) trips.emplace_back(i, j, val); } } Eigen::SparseMatrix H(n, n); H.setFromTriplets(trips.begin(), trips.end()); return H; } // ── Symmetrised Hessian (full-FD variant) ──────────────────────────────────── // // The FD Hessian is symmetric in exact arithmetic; floating-point rounding // can introduce tiny asymmetries. This helper returns (H + Hᵀ)/2. inline Eigen::SparseMatrix hyper_ideal_hessian_sym( ConformalMesh& mesh, const std::vector& x, const HyperIdealMaps& m, double eps = 1e-5) { auto H = hyper_ideal_hessian(mesh, x, m, eps); Eigen::SparseMatrix Ht = H.transpose(); return (H + Ht) * 0.5; } // ── Block-FD Hessian (Phase 9b) ────────────────────────────────────────────── // // Computes the Hessian by FD on each face's 6×6 local block. The 6 local // DOFs of a face f are: // (b_{v1}, b_{v2}, b_{v3}, a_{e12}, a_{e23}, a_{e31}). // For each face we recompute the 6 output angles (β₁,β₂,β₃,α₁₂,α₂₃,α₃₁) // at x ± ε along each local axis and read off the 6×6 Jacobian. The result // scatters into the global Hessian via the DOF-index lookup. // // Why this is correct: // ───────────────────── // The global gradient decomposes by face: // G_b_v = Σ_{f ∋ v} β_v(f) − Θ_v // G_a_e = Σ_{f ∋ e} α_e(f) − θ_e // Since β and α at face f depend ONLY on the 6 local DOFs of f, the // Hessian also decomposes: // ∂G_x/∂y = Σ_{f: x,y ∈ local(f)} ∂(β or α)/∂y at f. // So accumulating per-face 6×6 blocks reproduces the full Hessian. // // Cost: F × 12 face-angle evaluations (6 DOFs × 2 directions). // On a tetrahedron (F=4, n≈10): 48 face evaluations // vs full-FD ≈ 80 → ~1.7× speed-up. // On cathead.obj (F=248, n≈400): 2976 face evaluations // vs full-FD ≈ 99,200 → ~33× speed-up. // On brezel.obj (F=13824, n≈14000): 165 888 face evaluations // vs full-FD ≈ 193 M → ~1166× speed-up. inline Eigen::SparseMatrix hyper_ideal_hessian_block_fd( ConformalMesh& mesh, const std::vector& x, const HyperIdealMaps& m, double eps = 1e-5) { const int n = hyper_ideal_dimension(mesh, m); std::vector> trips; trips.reserve(36 * mesh.number_of_faces()); for (auto f : mesh.faces()) { Halfedge_index h0 = mesh.halfedge(f); Halfedge_index h1 = mesh.next(h0); Halfedge_index h2 = mesh.next(h1); Vertex_index v1 = mesh.source(h0); Vertex_index v2 = mesh.source(h1); Vertex_index v3 = mesh.source(h2); Edge_index e12 = mesh.edge(h0); Edge_index e23 = mesh.edge(h1); Edge_index e31 = mesh.edge(h2); // Local DOF indices: (b1, b2, b3, a12, a23, a31). Pinned slots = -1. const int idx[6] = { m.v_idx[v1], m.v_idx[v2], m.v_idx[v3], m.e_idx[e12], m.e_idx[e23], m.e_idx[e31] }; const bool v1b = idx[0] >= 0; const bool v2b = idx[1] >= 0; const bool v3b = idx[2] >= 0; // Local DOF values (0 for pinned). const double vals[6] = { dof_val(idx[0], x), dof_val(idx[1], x), dof_val(idx[2], x), dof_val(idx[3], x), dof_val(idx[4], x), dof_val(idx[5], x) }; // For each free local DOF, evaluate the 6 outputs at ±ε. // We never perturb a pinned DOF (its column would be physically zero // because it is not part of the DOF vector at all). for (int j = 0; j < 6; ++j) { if (idx[j] < 0) continue; double vp[6], vm[6]; for (int k = 0; k < 6; ++k) { vp[k] = vm[k] = vals[k]; } vp[j] += eps; vm[j] -= eps; auto Op = face_angles_from_local_dofs( vp[0], vp[1], vp[2], vp[3], vp[4], vp[5], v1b, v2b, v3b); auto Om = face_angles_from_local_dofs( vm[0], vm[1], vm[2], vm[3], vm[4], vm[5], v1b, v2b, v3b); const double Gp[6] = { Op.beta1, Op.beta2, Op.beta3, Op.alpha12, Op.alpha23, Op.alpha31 }; const double Gm[6] = { Om.beta1, Om.beta2, Om.beta3, Om.alpha12, Om.alpha23, Om.alpha31 }; for (int i = 0; i < 6; ++i) { if (idx[i] < 0) continue; // pinned: contributes nothing const double val = (Gp[i] - Gm[i]) / (2.0 * eps); if (std::abs(val) > 1e-15) trips.emplace_back(idx[i], idx[j], val); } } } Eigen::SparseMatrix H(n, n); H.setFromTriplets(trips.begin(), trips.end()); return H; } // ── Symmetrised block-FD Hessian ───────────────────────────────────────────── // // The per-face block is symmetric to within FD rounding, but the // accumulation may amplify tiny asymmetries. This helper returns // (H + Hᵀ)/2, identical in spirit to `hyper_ideal_hessian_sym`. inline Eigen::SparseMatrix hyper_ideal_hessian_block_fd_sym( ConformalMesh& mesh, const std::vector& x, const HyperIdealMaps& m, double eps = 1e-5) { auto H = hyper_ideal_hessian_block_fd(mesh, x, m, eps); Eigen::SparseMatrix Ht = H.transpose(); return (H + Ht) * 0.5; } } // namespace conformallab