// Copyright (c) 2024-2026 Tarik Moussa. // SPDX-License-Identifier: MIT // test_cp_euclidean_functional.cpp // // Phase 9a.1 — CPEuclideanFunctional (BPS 2010) tests. // // Replicates de.varylab.discreteconformal.functional.CPEuclideanFunctionalTest // (88 lines) and adds boundary-edge coverage plus a closed-mesh case. // // Java test pattern (lines 50-87): // 1. Build dodecahedron via HalfEdgeUtils.addDodecahedron. // 2. Remove face 0 to produce an open mesh. // 3. theta_e = π/2 for every edge. (orthogonal circle packing) // 4. phi_f = 2π for every face. (flat target) // 5. Random ρ ∈ [−0.5, 0.5] (seed 1). // 6. FunctionalTest.setXGradient(ρ) → FD-vs-analytic gradient check. // 7. FunctionalTest.setXHessian(ρ) → FD-vs-analytic Hessian check. // // C++ port uses the tetrahedron (4 faces) instead of the dodecahedron (12 faces) // because the analytic structure is identical and the smaller mesh keeps the // test fast and human-inspectable. We exercise the boundary-edge code path // by additionally testing a tetrahedron with one face removed (3 faces, 3 // boundary edges, 3 interior edges). #include "cp_euclidean_functional.hpp" #include "mesh_builder.hpp" #include "conformal_mesh.hpp" #include #include #include #include using namespace conformallab; // ════════════════════════════════════════════════════════════════════════════ // 1. Helper: explicit values for p(θ*, Δρ) at known inputs // // p(θ*, 0) = 0 (tanh 0 = 0) // p(π, Δρ) = π·sign(Δρ) (tan(π/2) = ∞, atan saturates to ±π/2) // p(0, Δρ) = 0 (tan(0) = 0) // p odd in Δρ (tanh is odd). // ════════════════════════════════════════════════════════════════════════════ TEST(CPEuclideanFunctional, PFunctionKnownValues) { using cp_detail::p_function; constexpr double PI_ = 3.14159265358979323846; // p(any, 0) = 0 EXPECT_NEAR(p_function(PI_ / 4, 0.0), 0.0, 1e-15); EXPECT_NEAR(p_function(PI_ / 2, 0.0), 0.0, 1e-15); // Odd in Δρ const double thStar = PI_ / 3; for (double dr : {0.1, 0.5, 1.0, 2.0}) { EXPECT_NEAR(p_function(thStar, dr) + p_function(thStar, -dr), 0.0, 1e-12) << "p(θ*, Δρ) should be odd in Δρ"; } } // ════════════════════════════════════════════════════════════════════════════ // 2. Property-map setup defaults // ════════════════════════════════════════════════════════════════════════════ TEST(CPEuclideanFunctional, SetupDefaults) { auto mesh = make_tetrahedron(); auto m = setup_cp_euclidean_maps(mesh); constexpr double PI_ = 3.14159265358979323846; for (auto e : mesh.edges()) EXPECT_NEAR(m.theta_e[e], PI_ / 2, 1e-15); for (auto f : mesh.faces()) EXPECT_NEAR(m.phi_f[f], 2.0 * PI_, 1e-15); for (auto f : mesh.faces()) EXPECT_EQ(m.f_idx[f], -1) << "all faces start pinned"; } TEST(CPEuclideanFunctional, AssignDofIndices_PinsOneFace) { auto mesh = make_tetrahedron(); auto m = setup_cp_euclidean_maps(mesh); const int n = assign_cp_euclidean_face_dof_indices(mesh, m); EXPECT_EQ(n, 3) << "tetrahedron has 4 faces; 1 pinned ⇒ 3 free DOFs"; int pinned_count = 0; int max_idx = -1; for (auto f : mesh.faces()) { if (m.f_idx[f] == -1) ++pinned_count; else max_idx = std::max(max_idx, m.f_idx[f]); } EXPECT_EQ(pinned_count, 1); EXPECT_EQ(max_idx, 2); } // ════════════════════════════════════════════════════════════════════════════ // 3. Tangential limit (θ = 0): p = 0, energy collapses, gradient = φ_f // ════════════════════════════════════════════════════════════════════════════ TEST(CPEuclideanFunctional, TangentialLimitGradientEqualsPhi) { auto mesh = make_tetrahedron(); auto m = setup_cp_euclidean_maps(mesh); for (auto e : mesh.edges()) m.theta_e[e] = 0.0; // tangential limit const int n = assign_cp_euclidean_face_dof_indices(mesh, m); // At θ = 0: θ* = π. Interior edge contribution: −(p+θ*) where p = π·sign(Δρ). // Boundary contribution: −2π. At ρ = 0, Δρ = 0 so p = 0; each interior face // contributes −π per incident interior halfedge; for a tetrahedron each face // has 3 interior halfedges ⇒ −3π. Net gradient: 2π − 3π = −π per free face. std::vector x(static_cast(n), 0.0); auto G = cp_euclidean_gradient(mesh, x, m); constexpr double PI_ = 3.14159265358979323846; for (double g : G) EXPECT_NEAR(g, -PI_, 1e-10); } // ════════════════════════════════════════════════════════════════════════════ // 4. FD gradient check on closed tetrahedron at random ρ // // Java parity: this is exactly the structure of CPEuclideanFunctionalTest. // ════════════════════════════════════════════════════════════════════════════ TEST(CPEuclideanFunctional, FDGradientCheck_ClosedTetrahedron_RandomRho) { auto mesh = make_tetrahedron(); auto m = setup_cp_euclidean_maps(mesh); const int n = assign_cp_euclidean_face_dof_indices(mesh, m); // Java: rnd.setSeed(1); rho_i = rnd.nextDouble() − 0.5 std::mt19937 rng(1); std::uniform_real_distribution u(-0.5, 0.5); std::vector rho(static_cast(n)); for (auto& r : rho) r = u(rng); EXPECT_TRUE(gradient_check_cp_euclidean(mesh, rho, m)) << "FD vs analytic gradient mismatch on closed tetrahedron"; } // ════════════════════════════════════════════════════════════════════════════ // 5. FD Hessian check on closed tetrahedron at random ρ // ════════════════════════════════════════════════════════════════════════════ TEST(CPEuclideanFunctional, FDHessianCheck_ClosedTetrahedron_RandomRho) { auto mesh = make_tetrahedron(); auto m = setup_cp_euclidean_maps(mesh); const int n = assign_cp_euclidean_face_dof_indices(mesh, m); std::mt19937 rng(1); std::uniform_real_distribution u(-0.5, 0.5); std::vector rho(static_cast(n)); for (auto& r : rho) r = u(rng); EXPECT_TRUE(hessian_check_cp_euclidean(mesh, rho, m)) << "FD vs analytic Hessian mismatch on closed tetrahedron"; } // ════════════════════════════════════════════════════════════════════════════ // 6. Boundary-edge coverage: open mesh (tetrahedron with one face removed) // // Java test does this via `hds.removeFace(hds.getFace(0))`. In CGAL we get // an equivalent open mesh by skipping the construction of one face. // ════════════════════════════════════════════════════════════════════════════ inline ConformalMesh make_open_tetrahedron() { ConformalMesh mesh; auto v0 = mesh.add_vertex(Point3( 1, 1, 1)); auto v1 = mesh.add_vertex(Point3( 1, -1, -1)); auto v2 = mesh.add_vertex(Point3(-1, 1, -1)); auto v3 = mesh.add_vertex(Point3(-1, -1, 1)); // Three faces (omit the one opposite v0): mesh.add_face(v0, v2, v1); mesh.add_face(v0, v1, v3); mesh.add_face(v0, v3, v2); return mesh; } TEST(CPEuclideanFunctional, FDGradientCheck_OpenTetrahedron_RandomRho) { auto mesh = make_open_tetrahedron(); auto m = setup_cp_euclidean_maps(mesh); const int n = assign_cp_euclidean_face_dof_indices(mesh, m); EXPECT_EQ(n, 2); // 3 faces, 1 pinned ⇒ 2 free DOFs std::mt19937 rng(1); std::uniform_real_distribution u(-0.5, 0.5); std::vector rho(static_cast(n)); for (auto& r : rho) r = u(rng); EXPECT_TRUE(gradient_check_cp_euclidean(mesh, rho, m)) << "FD vs analytic gradient mismatch on open tetrahedron"; } TEST(CPEuclideanFunctional, FDHessianCheck_OpenTetrahedron_RandomRho) { auto mesh = make_open_tetrahedron(); auto m = setup_cp_euclidean_maps(mesh); const int n = assign_cp_euclidean_face_dof_indices(mesh, m); std::mt19937 rng(1); std::uniform_real_distribution u(-0.5, 0.5); std::vector rho(static_cast(n)); for (auto& r : rho) r = u(rng); EXPECT_TRUE(hessian_check_cp_euclidean(mesh, rho, m)) << "FD vs analytic Hessian mismatch on open tetrahedron"; } // ════════════════════════════════════════════════════════════════════════════ // 7. Hessian is symmetric positive-semidefinite (BPS-2010 §6 convexity) // // The energy is convex in ρ on its domain of validity. Hence H is PSD with // a 1-dim null space (constant shift of all ρ, removed by gauge pin). // ════════════════════════════════════════════════════════════════════════════ TEST(CPEuclideanFunctional, HessianIsPSD) { auto mesh = make_tetrahedron(); auto m = setup_cp_euclidean_maps(mesh); const int n = assign_cp_euclidean_face_dof_indices(mesh, m); std::vector rho(static_cast(n), 0.1); auto H = cp_euclidean_hessian(mesh, rho, m); // Symmetry Eigen::MatrixXd Hd(H); EXPECT_NEAR((Hd - Hd.transpose()).cwiseAbs().maxCoeff(), 0.0, 1e-15); // Smallest eigenvalue ≥ 0 (PSD) Eigen::SelfAdjointEigenSolver es(Hd); EXPECT_GE(es.eigenvalues().minCoeff(), -1e-12) << "Hessian must be PSD (BPS-2010 §6)"; } // ════════════════════════════════════════════════════════════════════════════ // 8. At equilibrium (Newton-converged ρ*), the gradient is zero by construction // // We do not run a full Newton solver here; we set up the "natural-theta" trick: // adjust φ_f so that ρ = 0 is the equilibrium. This is the analog of the // natural-theta convention already used in euclidean_functional tests // (see test_euclidean_functional.cpp lines 159-189). // ════════════════════════════════════════════════════════════════════════════ TEST(CPEuclideanFunctional, NaturalPhiMakesZeroTheEquilibrium) { auto mesh = make_tetrahedron(); auto m = setup_cp_euclidean_maps(mesh); const int n = assign_cp_euclidean_face_dof_indices(mesh, m); std::vector rho(static_cast(n), 0.0); // Step 1: gradient at ρ = 0 with default φ. auto G0 = cp_euclidean_gradient(mesh, rho, m); // Step 2: adjust φ_f so the new gradient at ρ = 0 is zero. // ∂E/∂ρ_f = φ_f − (sum of edge contributions) // To zero G_f: subtract G_f from φ_f. for (auto f : mesh.faces()) { int i = m.f_idx[f]; if (i < 0) continue; m.phi_f[f] -= G0[static_cast(i)]; } // Step 3: gradient at ρ = 0 should now be ~zero. auto G_eq = cp_euclidean_gradient(mesh, rho, m); for (double g : G_eq) EXPECT_NEAR(g, 0.0, 1e-13); }