#pragma once // Copyright (c) 2024-2026 Tarik Moussa. // SPDX-License-Identifier: MIT // hyper_ideal_functional.hpp // // Energy and gradient of the hyper-ideal discrete conformal map functional // evaluated on a ConformalMesh (CGAL::Surface_mesh). // // Ported from de.varylab.discreteconformal.functional.HyperIdealFunctional. // // ┌─────────────────────────────────────────────────────────────────────────┐ // │ E(x) = Σ_faces U(f) − Σ_edges θ_e · a_e − Σ_vertices Θ_v · b_v │ // │ │ // │ ∂E/∂b_v = Σ_{faces adj. v} β_v(face) − Θ_v │ // │ ∂E/∂a_e = α_e(face⁺) + α_e(face⁻) − θ_e │ // └─────────────────────────────────────────────────────────────────────────┘ // // DOF vector layout (matches getDimension() ordering): // x[v_idx[v]] = b_v for each variable vertex (log scale factor) // x[e_idx[e]] = a_e for each variable edge (intersection angle) // -1 in v_idx / e_idx means "pinned" (ideal / fixed at 0). // // Usage // ───── // auto mesh = make_tetrahedron(); // auto maps = setup_hyper_ideal_maps(mesh); // int n = assign_hyper_ideal_all_dof_indices(mesh, maps); // all variable // std::vector x(n, 1.0); // auto res = evaluate_hyper_ideal(mesh, x, maps); // // res.energy, res.gradient #include "conformal_mesh.hpp" #include "constants.hpp" #include "hyper_ideal_geometry.hpp" #include "hyper_ideal_utility.hpp" #include #include #include #include #include namespace conformallab { // ── Property-map type aliases ───────────────────────────────────────────────── /// Property map vertex → `double` (HyperIdeal scalar-per-vertex data). using VMapD = ConformalMesh::Property_map; /// Property map vertex → `int` (HyperIdeal DOF indices). using VMapI = ConformalMesh::Property_map; /// Property map edge → `double` (HyperIdeal scalar-per-edge data). using EMapD = ConformalMesh::Property_map; /// Property map edge → `int` (HyperIdeal DOF indices). using EMapI = ConformalMesh::Property_map; // ── Persistent map bundle ───────────────────────────────────────────────────── /// Bundle of the four property maps consumed by the HyperIdeal functional. struct HyperIdealMaps { VMapI v_idx; ///< DOF index per vertex (−1 = pinned / ideal point). EMapI e_idx; ///< DOF index per edge (−1 = fixed). VMapD theta_v; ///< Target cone angle Θᵥ (parameter, not variable). EMapD theta_e; ///< Target intersection angle θₑ. }; /// Attach the four HyperIdeal property maps to `mesh` and return their /// handles. /// /// Defaults: /// * `v_idx[v] = -1` (ideal vertex — i.e. the corresponding `b_v` is fixed at 0) /// * `e_idx[e] = -1` (edge DOF fixed at 0) /// * `theta_v[v] = 2π` (regular cone target) /// * `theta_e[e] = π` (orthogonal-circle target) /// /// The map prefix `"v:"` / `"e:"` is intentionally generic for the /// HyperIdeal functional — it is the canonical / Phase 3b model. /// Other functionals use distinct prefixes (`"ev:"` Euclidean, `"sv:"` /// Spherical, `"cf:"`/`"ce:"` CP-Euclidean, `"iv:"`/`"ie:"` /// Inversive-Distance) so all models can coexist on the same mesh. inline HyperIdealMaps setup_hyper_ideal_maps(ConformalMesh& mesh) { HyperIdealMaps m; m.v_idx = mesh.add_property_map ("v:idx", -1 ).first; m.e_idx = mesh.add_property_map ("e:idx", -1 ).first; m.theta_v = mesh.add_property_map("v:theta", 2.0*PI ).first; m.theta_e = mesh.add_property_map("e:theta", PI ).first; return m; } /// Count free DOFs: `#variable_vertices + #variable_edges`. inline int hyper_ideal_dimension(const ConformalMesh& mesh, const HyperIdealMaps& m) { int dim = 0; for (auto v : mesh.vertices()) if (m.v_idx[v] >= 0) ++dim; for (auto e : mesh.edges()) if (m.e_idx[e] >= 0) ++dim; return dim; } /// Make every vertex hyper-ideal and every edge variable, assigning /// sequential DOF indices `0..n-1` (vertices first, edges after). /// /// This is the standard initialisation for the Springborn-2020 /// hyper-ideal functional — gauge fixing is **not** needed because /// the energy is strictly convex on the full DOF space (no /// rotational mode for an all-hyper-ideal configuration). /// /// \returns total DOF count = `num_vertices(mesh) + num_edges(mesh)`. inline int assign_hyper_ideal_all_dof_indices(ConformalMesh& mesh, HyperIdealMaps& m) { int idx = 0; for (auto v : mesh.vertices()) m.v_idx[v] = idx++; for (auto e : mesh.edges()) m.e_idx[e] = idx++; return idx; } /// \deprecated Use `assign_hyper_ideal_all_dof_indices` (API-naming audit A1). [[deprecated("renamed to assign_hyper_ideal_all_dof_indices")]] inline int assign_all_dof_indices(ConformalMesh& mesh, HyperIdealMaps& m) { return assign_hyper_ideal_all_dof_indices(mesh, m); } // ── Evaluation result ───────────────────────────────────────────────────────── /// Output of `evaluate_hyper_ideal()` — the energy value and (optionally) /// its gradient evaluated at the current DOF vector. struct HyperIdealResult { double energy = 0.0; ///< Functional value at the input DOFs. std::vector gradient; ///< Gradient ∇E; empty when not requested. }; // ── Internal helpers ────────────────────────────────────────────────────────── /// Read the DOF value from `x` for index `idx`; return 0 if pinned (idx < 0). static inline double dof_val(int idx, const std::vector& x) { return idx >= 0 ? x[static_cast(idx)] : 0.0; } // halfedge_to_index is defined in conformal_mesh.hpp. static inline std::size_t hidx(Halfedge_index h) { return halfedge_to_index(h); } // ── Pure-math face-angle kernel ────────────────────────────────────────────── // // Computes the six per-face angle outputs (β₁, β₂, β₃, α₁₂, α₂₃, α₃₁) from // the six local DOF inputs (b₁, b₂, b₃, a₁₂, a₂₃, a₃₁) and the variability // flags (vᵢb). This is the pure functional core of `compute_face_angles` // — no mesh, no property maps, no global x vector. // // Why exposed as a free function (Phase 9b): // ───────────────────────────────────────── // The block-FD Hessian (`hyper_ideal_hessian_block_fd`) perturbs only the // 6 DOFs adjacent to a single face at a time, recomputes the 6 angle // outputs of that face, and uses the local 6×6 Jacobian to scatter into // the global Hessian. Working through a pure 6→6 function (instead of // perturbing the full x and re-running the gradient over all faces) // reduces the cost of the Hessian from O(F·n) to O(F·36). // // The clamping logic (negative b → 0.01, negative a → 0) mirrors // HyperIdealFunctional.java's defensive behaviour (lines 122-127 of the // Java original); this keeps the FD perturbation regime well-defined. // ── Scale-floor clamp mode (N3 audit) ───────────────────────────────────────── // // The vertex scale b must stay positive for the hyper-ideal geometry to be // valid. Two ways to enforce that are offered, selectable per call: // // • HardJava (DEFAULT) — the original `b < 0 → HYPER_IDEAL_SCALE_FLOOR` snap. // Bit-for-bit faithful to HyperIdealFunctional.java, so the Java // golden-oracle parity tests hold exactly. It is only C⁰ at b = 0: a // Newton step that crosses the feasibility boundary sees a kink, which can // stall convergence (numerical-stability audit N3). This is the // production default precisely to preserve parity. // // • SmoothBarrier — a C¹ softplus floor // b ↦ floor + softplus_β(b − floor), softplus_β(x) = log(1+e^{βx})/β // which is ≈ b for b well above the floor (to machine precision once // β·(b−floor) ≳ 35) and decays smoothly to `floor` as b → −∞, with a // continuous derivative everywhere. This removes the N3 kink and is the // mathematically clean choice, but it perturbs values near the boundary // and therefore deviates from the Java oracle — opt in when robustness of // a boundary-crossing solve matters more than strict parity. // // Both modes share the same floor (`HYPER_IDEAL_SCALE_FLOOR`); SmoothBarrier // additionally uses `HYPER_IDEAL_SCALE_SHARPNESS` (β). The `a < 0 → 0` edge // clamp is unaffected (a = 0 is a genuine geometric floor, not flagged by N3). enum class HyperIdealScaleClamp { HardJava, ///< b<0 → floor. C⁰, Java-parity-faithful (default). SmoothBarrier ///< C¹ softplus floor; clean but deviates from Java near b=0. }; /// Apply the vertex-scale floor to `b` under the chosen clamp `mode`. /// `HardJava` reproduces the original snap; `SmoothBarrier` is the C¹ /// softplus floor (see `HyperIdealScaleClamp`). `variable` mirrors the /// call-site guard (only clamp DOFs that are actually free / variable). inline double clamp_hyper_ideal_scale(double b, bool variable, HyperIdealScaleClamp mode) noexcept { if (!variable) return b; if (mode == HyperIdealScaleClamp::HardJava) return b < 0.0 ? HYPER_IDEAL_SCALE_FLOOR : b; // SmoothBarrier: floor + softplus_β(b − floor), evaluated stably. const double beta = HYPER_IDEAL_SCALE_SHARPNESS; const double x = beta * (b - HYPER_IDEAL_SCALE_FLOOR); // softplus_β(x) = log1p(e^{βx})/β, with the standard large-x guard // (for x ≳ 35, log1p(e^x) == x to double precision) to avoid overflow. const double softplus = (x > 35.0) ? x : std::log1p(std::exp(x)); return HYPER_IDEAL_SCALE_FLOOR + softplus / beta; } /// Six per-face angle outputs computed from local DOFs (see /// `face_angles_from_local_dofs`). Used by the block-FD Hessian. struct FaceAngleOutputs { double beta1; ///< Interior angle at v₁. double beta2; ///< Interior angle at v₂. double beta3; ///< Interior angle at v₃. double alpha12; ///< Dihedral angle at edge e₁₂. double alpha23; ///< Dihedral angle at edge e₂₃. double alpha31; ///< Dihedral angle at edge e₃₁. }; /// Pure-math 6→6 kernel: given the six local DOFs (b₁,b₂,b₃,a₁₂,a₂₃,a₃₁) /// of one face plus the per-vertex variability flags, return the six /// HyperIdeal angle outputs. No mesh, no property maps — used by the /// per-face block-FD Hessian in `hyper_ideal_hessian.hpp`. inline FaceAngleOutputs face_angles_from_local_dofs( double b1, double b2, double b3, double a12, double a23, double a31, bool v1b, bool v2b, bool v3b, HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava) { // Same defensive clamps as compute_face_angles. if (v1b && v2b && a12 < 0.0) a12 = 0.0; if (v2b && v3b && a23 < 0.0) a23 = 0.0; if (v3b && v1b && a31 < 0.0) a31 = 0.0; b1 = clamp_hyper_ideal_scale(b1, v1b, clamp); b2 = clamp_hyper_ideal_scale(b2, v2b, clamp); b3 = clamp_hyper_ideal_scale(b3, v3b, clamp); double l12 = lij(b1, b2, a12, v1b, v2b); double l23 = lij(b2, b3, a23, v2b, v3b); double l31 = lij(b3, b1, a31, v3b, v1b); if (l12 < 1E-12 && l23 < 1E-12 && l31 < 1E-12) l12 = l23 = l31 = 1E-12; FaceAngleOutputs o; if (l12 > l23 + l31) { o.beta1 = 0.0; o.beta2 = 0.0; o.beta3 = PI; o.alpha12 = PI; o.alpha23 = 0.0; o.alpha31 = 0.0; } else if (l23 > l12 + l31) { o.beta1 = PI; o.beta2 = 0.0; o.beta3 = 0.0; o.alpha12 = 0.0; o.alpha23 = PI; o.alpha31 = 0.0; } else if (l31 > l12 + l23) { o.beta1 = 0.0; o.beta2 = PI; o.beta3 = 0.0; o.alpha12 = 0.0; o.alpha23 = 0.0; o.alpha31 = PI; } else { o.beta1 = zeta(l12, l31, l23); o.beta2 = zeta(l23, l12, l31); o.beta3 = zeta(l31, l23, l12); o.alpha12 = alpha_ij(a12, a23, a31, b1, b2, b3, o.beta1, o.beta2, o.beta3, v1b, v2b, v3b); o.alpha23 = alpha_ij(a23, a31, a12, b2, b3, b1, o.beta2, o.beta3, o.beta1, v2b, v3b, v1b); o.alpha31 = alpha_ij(a31, a12, a23, b3, b1, b2, o.beta3, o.beta1, o.beta2, v3b, v1b, v2b); } return o; } // ── Per-face angle kernel ───────────────────────────────────────────────────── /// Per-face angle bundle returned by `compute_face_angles()`. Carries /// the six output angles plus the six input DOFs (so the energy and /// gradient kernels can reuse them without re-reading the mesh). struct FaceAngles { double alpha12; ///< Dihedral angle at edge e₁₂. double alpha23; ///< Dihedral angle at edge e₂₃. double alpha31; ///< Dihedral angle at edge e₃₁. double beta1; ///< Interior angle at vertex v₁. double beta2; ///< Interior angle at vertex v₂. double beta3; ///< Interior angle at vertex v₃. double a12; ///< Edge DOF value at e₁₂. double a23; ///< Edge DOF value at e₂₃. double a31; ///< Edge DOF value at e₃₁. double b1; ///< Vertex DOF value at v₁. double b2; ///< Vertex DOF value at v₂. double b3; ///< Vertex DOF value at v₃. bool v1b; ///< `true` iff vertex v₁ is variable (not pinned). bool v2b; ///< `true` iff vertex v₂ is variable. bool v3b; ///< `true` iff vertex v₃ is variable. }; /// Compute the six per-face angles (+ remember the input DOFs) for face /// `f` of `mesh`, given the current DOF vector `x` and DOF-index maps. static FaceAngles compute_face_angles( const ConformalMesh& mesh, Face_index f, const std::vector& x, const HyperIdealMaps& m, HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava) { 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); FaceAngles fa; fa.v1b = m.v_idx[v1] >= 0; fa.v2b = m.v_idx[v2] >= 0; fa.v3b = m.v_idx[v3] >= 0; fa.a12 = dof_val(m.e_idx[e12], x); fa.a23 = dof_val(m.e_idx[e23], x); fa.a31 = dof_val(m.e_idx[e31], x); fa.b1 = dof_val(m.v_idx[v1], x); fa.b2 = dof_val(m.v_idx[v2], x); fa.b3 = dof_val(m.v_idx[v3], x); // Clamp invalid inputs (mirrors Java log.warning + clamp) if (fa.v1b && fa.v2b && fa.a12 < 0.0) fa.a12 = 0.0; if (fa.v2b && fa.v3b && fa.a23 < 0.0) fa.a23 = 0.0; if (fa.v3b && fa.v1b && fa.a31 < 0.0) fa.a31 = 0.0; fa.b1 = clamp_hyper_ideal_scale(fa.b1, fa.v1b, clamp); fa.b2 = clamp_hyper_ideal_scale(fa.b2, fa.v2b, clamp); fa.b3 = clamp_hyper_ideal_scale(fa.b3, fa.v3b, clamp); double l12 = lij(fa.b1, fa.b2, fa.a12, fa.v1b, fa.v2b); double l23 = lij(fa.b2, fa.b3, fa.a23, fa.v2b, fa.v3b); double l31 = lij(fa.b3, fa.b1, fa.a31, fa.v3b, fa.v1b); // Guard degenerate lengths if (l12 < 1E-12 && l23 < 1E-12 && l31 < 1E-12) l12 = l23 = l31 = 1E-12; // Check triangle inequalities; degenerate cases get extreme angles if (l12 > l23 + l31) { fa.beta1 = 0.0; fa.beta2 = 0.0; fa.beta3 = PI; fa.alpha12 = PI; fa.alpha23 = 0.0; fa.alpha31 = 0.0; } else if (l23 > l12 + l31) { fa.beta1 = PI; fa.beta2 = 0.0; fa.beta3 = 0.0; fa.alpha12 = 0.0; fa.alpha23 = PI; fa.alpha31 = 0.0; } else if (l31 > l12 + l23) { fa.beta1 = 0.0; fa.beta2 = PI; fa.beta3 = 0.0; fa.alpha12 = 0.0; fa.alpha23 = 0.0; fa.alpha31 = PI; } else { fa.beta1 = zeta(l12, l31, l23); fa.beta2 = zeta(l23, l12, l31); fa.beta3 = zeta(l31, l23, l12); fa.alpha12 = alpha_ij(fa.a12, fa.a23, fa.a31, fa.b1, fa.b2, fa.b3, fa.beta1, fa.beta2, fa.beta3, fa.v1b, fa.v2b, fa.v3b); fa.alpha23 = alpha_ij(fa.a23, fa.a31, fa.a12, fa.b2, fa.b3, fa.b1, fa.beta2, fa.beta3, fa.beta1, fa.v2b, fa.v3b, fa.v1b); fa.alpha31 = alpha_ij(fa.a31, fa.a12, fa.a23, fa.b3, fa.b1, fa.b2, fa.beta3, fa.beta1, fa.beta2, fa.v3b, fa.v1b, fa.v2b); } return fa; } /// Per-face energy contribution U(f) before subtracting the θ·a and Θ·b terms. /// /// Supported configurations (faithful port of HyperIdealFunctional.java): /// * All three vertices hyper-ideal (v?b = true) → Meyerhoff/Ushijima volume /// * Exactly one vertex ideal (v?b = false, other two true) → Kolpakov-Mednykh volume /// /// NOT supported — faces with two or three ideal vertices. The Java reference /// (HyperIdealFunctional.java lines 222-231) uses an if/else-if chain that /// silently applies the one-ideal-vertex formula to the first ideal vertex it /// finds, ignoring additional ideal vertices. That is mathematically wrong for /// two-ideal or three-ideal faces. Rather than silently computing a wrong result, /// this C++ port throws immediately so the caller can diagnose the problem. /// The correct volume formulas for semi-ideal and fully-ideal faces are not /// implemented in the Java reference and would require new research. static double face_energy(const FaceAngles& fa) { // Guard: reject configurations with 2 or 3 ideal vertices in one face. const int ideal_count = (!fa.v1b ? 1 : 0) + (!fa.v2b ? 1 : 0) + (!fa.v3b ? 1 : 0); if (ideal_count >= 2) throw std::logic_error( "face_energy: faces with 2 or 3 ideal (pinned) vertices are not " "supported. Only 0-ideal (all hyper-ideal) and 1-ideal faces are " "implemented, matching the Java HyperIdealFunctional reference. " "Check your v_idx assignments: at most one vertex per face may be " "pinned (v_idx = -1)."); double aa = fa.a12*fa.alpha12 + fa.a23*fa.alpha23 + fa.a31*fa.alpha31; double bb = fa.b1 *fa.beta1 + fa.b2 *fa.beta2 + fa.b3 *fa.beta3; double V = 0.0; if (fa.v1b && fa.v2b && fa.v3b) { // All three vertices are hyper-ideal. V = calculateTetrahedronVolume( fa.beta1, fa.beta2, fa.beta3, fa.alpha23, fa.alpha31, fa.alpha12); } else if (!fa.v1b) { // Exactly v1 is ideal (ideal_count == 1 guaranteed by guard above). V = calculateTetrahedronVolumeWithIdealVertexAtGamma( fa.beta1, fa.alpha31, fa.alpha12, fa.alpha23, fa.beta2, fa.beta3); } else if (!fa.v2b) { // Exactly v2 is ideal. V = calculateTetrahedronVolumeWithIdealVertexAtGamma( fa.beta2, fa.alpha12, fa.alpha23, fa.alpha31, fa.beta3, fa.beta1); } else { // Exactly v3 is ideal (!v3b, guaranteed by ideal_count == 1). V = calculateTetrahedronVolumeWithIdealVertexAtGamma( fa.beta3, fa.alpha23, fa.alpha31, fa.alpha12, fa.beta1, fa.beta2); } return aa + bb + 2.0 * V; } // ── Full evaluation ─────────────────────────────────────────────────────────── /// Evaluate the HyperIdeal functional at DOF vector `x`. Returns the /// energy value and (optionally) the gradient in a `HyperIdealResult`. /// /// \param mesh Triangle mesh carrying the DOF-index property maps. /// \param x Current DOF vector (length = `hyper_ideal_dimension(...)`). /// \param m Property-map bundle from `setup_hyper_ideal_maps(...)`. /// \param need_energy If `true`, fill `result.energy` (default: `true`). /// \param need_gradient If `true`, fill `result.gradient` (default: `true`). inline HyperIdealResult evaluate_hyper_ideal( ConformalMesh& mesh, const std::vector& x, const HyperIdealMaps& m, bool need_energy = true, bool need_gradient = true, HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava) { HyperIdealResult res; // Temporary per-halfedge storage for computed angles. // Indexed by the integer value of Halfedge_index. const std::size_t nh = mesh.number_of_halfedges(); std::vector h_alpha(nh, 0.0); // α_ij stored on halfedge std::vector h_beta (nh, 0.0); // β_i stored on opposite halfedge // ── Pass 1: angles + energy per face ───────────────────────────────────── for (auto f : mesh.faces()) { Halfedge_index h0 = mesh.halfedge(f); Halfedge_index h1 = mesh.next(h0); Halfedge_index h2 = mesh.next(h1); FaceAngles fa = compute_face_angles(mesh, f, x, m, clamp); // Store computed angles into temporary arrays. // h_alpha[h] = α for the edge of h in this face. h_alpha[hidx(h0)] = fa.alpha12; h_alpha[hidx(h1)] = fa.alpha23; h_alpha[hidx(h2)] = fa.alpha31; // h_beta[h] = β at the vertex OPPOSITE to h. // β1 (at v1 = source(h0)) is opposite to h1 = e23. h_beta[hidx(h1)] = fa.beta1; // h1 is across from v1 h_beta[hidx(h2)] = fa.beta2; // h2 is across from v2 h_beta[hidx(h0)] = fa.beta3; // h0 is across from v3 if (need_energy) res.energy += face_energy(fa); } // ── Pass 2: linear energy terms ────────────────────────────────────────── if (need_energy) { for (auto e : mesh.edges()) { int ie = m.e_idx[e]; if (ie >= 0) res.energy -= m.theta_e[e] * x[static_cast(ie)]; } for (auto v : mesh.vertices()) { int iv = m.v_idx[v]; if (iv >= 0) res.energy -= m.theta_v[v] * x[static_cast(iv)]; } } // ── Pass 3: gradient ───────────────────────────────────────────────────── if (need_gradient) { const int n = hyper_ideal_dimension(mesh, m); res.gradient.assign(static_cast(n), 0.0); // ∂E/∂b_v = Σ_{faces adj. v} β_v(face) − Θ_v // β_v(face) = h_beta[prev(h)] for the incoming halfedge h to v in that face. for (auto v : mesh.vertices()) { int iv = m.v_idx[v]; if (iv < 0) continue; for (auto h : CGAL::halfedges_around_target(v, mesh)) { if (mesh.is_border(h)) continue; res.gradient[static_cast(iv)] += h_beta[hidx(mesh.prev(h))]; } res.gradient[static_cast(iv)] -= m.theta_v[v]; } // ∂E/∂a_e = α_e(face⁺) + α_e(face⁻) − θ_e 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); if (!mesh.is_border(h)) res.gradient[static_cast(ie)] += h_alpha[hidx(h)]; if (!mesh.is_border(ho)) res.gradient[static_cast(ie)] += h_alpha[hidx(ho)]; res.gradient[static_cast(ie)] -= m.theta_e[e]; } } return res; } /// Finite-difference gradient check (central differences). /// /// Returns `true` iff `|G[i] − fd[i]| / max(1, |G[i]|) < tol` for every /// DOF. Defaults `eps = 1e-5`, `tol = 1e-4` match the Java `FunctionalTest`. inline bool gradient_check_hyper_ideal( ConformalMesh& mesh, const std::vector& x0, const HyperIdealMaps& m, double eps = 1E-5, double tol = 1E-4) { // Analytic gradient auto res = evaluate_hyper_ideal(mesh, x0, m, false, true); const auto& G = res.gradient; const int n = static_cast(G.size()); std::vector xp = x0, xm = x0; bool ok = true; for (int i = 0; i < n; ++i) { std::size_t si = static_cast(i); xp[si] = x0[si] + eps; xm[si] = x0[si] - eps; double Ep = evaluate_hyper_ideal(mesh, xp, m, true, false).energy; double Em = evaluate_hyper_ideal(mesh, xm, m, true, false).energy; xp[si] = xm[si] = x0[si]; double fd = (Ep - Em) / (2.0 * eps); double err = std::abs(G[si] - fd); double scale = std::max(1.0, std::abs(G[si])); if (err / scale > tol) ok = false; } return ok; } /// \deprecated Use `gradient_check_hyper_ideal` (API-naming audit A3). [[deprecated("renamed to gradient_check_hyper_ideal")]] inline bool gradient_check( ConformalMesh& mesh, const std::vector& x0, const HyperIdealMaps& m, double eps = 1E-5, double tol = 1E-4) { return gradient_check_hyper_ideal(mesh, x0, m, eps, tol); } } // namespace conformallab