// Copyright (c) 2024-2026 Tarik Moussa. // SPDX-License-Identifier: MIT // test_inversive_distance_functional.cpp // // Phase 9a.2 — Inversive-distance functional (Luo 2004) tests. // // Validation against three mathematical references: // // [Luo 2004] ℓ_ij² = exp(2u_i) + exp(2u_j) + 2 I_ij exp(u_i+u_j) // ∂E/∂u_v = Θ_v − Σ α_v (Lemma 3.1) // // [BS 2004] I_ij = (ℓ² − r_i² − r_j²) / (2 r_i r_j) // I = 1 ⇒ tangential circles // I = 0 ⇒ orthogonal circles // // [Glickenstein 2011 §5] // correspondence to BPS-2010 face-based CP: // I_ij = cos θ_e on the face-dual mesh // // No Java reference exists for this functional in // de.varylab.discreteconformal. Cross-validation is done via: // 1. FD-vs-analytic gradient check (numerical), // 2. Luo's edge-length identity check (mathematical), // 3. Tangential-limit identity I=1 ⇒ ℓ = r_i+r_j (geometric). #include "inversive_distance_functional.hpp" #include "euclidean_functional.hpp" #include "mesh_builder.hpp" #include "conformal_mesh.hpp" #include #include #include #include using namespace conformallab; // ════════════════════════════════════════════════════════════════════════════ // 1. Edge-length formula (Luo 2004 §3) // // ℓ² = exp(2u_i) + exp(2u_j) + 2 I exp(u_i+u_j) // = r_i² + r_j² + 2 I r_i r_j // // Special cases: // I = 1 ⇒ ℓ² = (r_i + r_j)² ⇒ ℓ = r_i + r_j (tangential) // I = 0 ⇒ ℓ² = r_i² + r_j² (orthogonal — circles meet at 90°) // I = −1 ⇒ ℓ² = (r_i − r_j)² ⇒ ℓ = |r_i − r_j| (inside-tangent) // ════════════════════════════════════════════════════════════════════════════ TEST(InversiveDistanceFunctional, EdgeLengthFormula_TangentialLimit) { // ui = 0 ⇒ ri = 1; uj = log(2) ⇒ rj = 2; I = 1 (tangential): // ℓ² = 1 + 4 + 2·1·1·2 = 9 ⇒ ℓ = 3 = r_i + r_j ✓ double l2 = id_detail::edge_length_squared(0.0, std::log(2.0), 1.0); EXPECT_NEAR(std::sqrt(l2), 3.0, 1e-12); } TEST(InversiveDistanceFunctional, EdgeLengthFormula_OrthogonalLimit) { // r_i = 3, r_j = 4, I = 0: ℓ² = 9 + 16 = 25 ⇒ ℓ = 5 (Pythagorean) double l2 = id_detail::edge_length_squared(std::log(3.0), std::log(4.0), 0.0); EXPECT_NEAR(std::sqrt(l2), 5.0, 1e-12); } TEST(InversiveDistanceFunctional, EdgeLengthFormula_InsideTangentLimit) { // r_i = 2, r_j = 5, I = −1: ℓ² = (5 − 2)² = 9 ⇒ ℓ = 3 double l2 = id_detail::edge_length_squared(std::log(2.0), std::log(5.0), -1.0); EXPECT_NEAR(std::sqrt(l2), 3.0, 1e-12); } TEST(InversiveDistanceFunctional, EdgeLengthFormula_DegenerateReturnsMinusOne) { // r_i = r_j = 1, I = −2: ℓ² = 1 + 1 − 4 = −2 (impossible packing) double l2 = id_detail::edge_length_squared(0.0, 0.0, -2.0); EXPECT_EQ(l2, -1.0) << "should signal degenerate packing"; } // ════════════════════════════════════════════════════════════════════════════ // 2. Bowers-Stephenson identity round-trip // // Given (ℓ, r_i, r_j), the I_ij that compute_init produces must satisfy // Luo's edge-length formula exactly: ℓ²(I_ij, r_i, r_j) = ℓ². // ════════════════════════════════════════════════════════════════════════════ TEST(InversiveDistanceFunctional, BowersStephensonRoundTrip) { auto mesh = make_triangle(); // (0,0,0)-(1,0,0)-(0,1,0) auto m = setup_inversive_distance_maps(mesh); compute_inversive_distance_init_from_mesh(mesh, m); // At u = 0, exp(u) = r0. Reconstruct ℓ from (r_i, r_j, I_ij) and compare // to the 3-D Euclidean edge length from the mesh. for (auto e : mesh.edges()) { auto h = mesh.halfedge(e); auto p1 = mesh.point(mesh.source(h)); auto p2 = mesh.point(mesh.target(h)); double dx = p1.x() - p2.x(); double dy = p1.y() - p2.y(); double dz = p1.z() - p2.z(); double l_3d = std::sqrt(dx*dx + dy*dy + dz*dz); double ri = m.r0[mesh.source(h)]; double rj = m.r0[mesh.target(h)]; double l2_reconstructed = ri*ri + rj*rj + 2.0 * m.I_e[e] * ri * rj; EXPECT_NEAR(std::sqrt(l2_reconstructed), l_3d, 1e-12) << "Bowers-Stephenson round-trip failed for an edge"; } } // ════════════════════════════════════════════════════════════════════════════ // 3. Properties of the init step // ════════════════════════════════════════════════════════════════════════════ TEST(InversiveDistanceFunctional, InitProducesValidPositiveRadii) { auto mesh = make_tetrahedron(); auto m = setup_inversive_distance_maps(mesh); compute_inversive_distance_init_from_mesh(mesh, m); for (auto v : mesh.vertices()) { EXPECT_GT(m.r0[v], 0.0) << "init radius must be positive"; EXPECT_TRUE(std::isfinite(m.r0[v])); } for (auto e : mesh.edges()) { EXPECT_TRUE(std::isfinite(m.I_e[e])); // I > −1 is required for any valid inversive-distance packing. EXPECT_GT(m.I_e[e], -1.0); } } // ════════════════════════════════════════════════════════════════════════════ // 4. Gradient at the "natural equilibrium" is zero by construction // // Same trick as in test_euclidean_functional.cpp: // • Set u = 0 ⇒ r = r0 ⇒ ℓ = ℓ_3d (Bowers-Stephenson round-trip) // • Compute G(0) — that's the angle defect Θ − Σ_actual. // • Subtract G(0) from Θ → new G(0) is zero. // This means u = 0 is now the Newton equilibrium of the functional, just // like in the euclidean functional natural-theta trick. // ════════════════════════════════════════════════════════════════════════════ TEST(InversiveDistanceFunctional, NaturalThetaGivesZeroGradientAtU0) { auto mesh = make_triangle(); auto m = setup_inversive_distance_maps(mesh); compute_inversive_distance_init_from_mesh(mesh, m); // Assign DOFs to all vertices. int n = 0; for (auto v : mesh.vertices()) m.v_idx[v] = n++; std::vector x(static_cast(n), 0.0); auto G0 = inversive_distance_gradient(mesh, x, m); for (auto v : mesh.vertices()) { int i = m.v_idx[v]; m.theta_v[v] -= G0[static_cast(i)]; } auto G_eq = inversive_distance_gradient(mesh, x, m); for (double g : G_eq) EXPECT_NEAR(g, 0.0, 1e-13); } // ════════════════════════════════════════════════════════════════════════════ // 5. FD-vs-analytic gradient check (the main acceptance test for the port) // // Pattern: identical to test_euclidean_functional.cpp's // GradientCheck_TriangleVertex (lines 137-149). The energy is the path // integral of the gradient (by construction); a consistent FD-vs-analytic // match validates both energy and gradient implementations together. // ════════════════════════════════════════════════════════════════════════════ TEST(InversiveDistanceFunctional, FDGradientCheck_Triangle) { auto mesh = make_triangle(); auto m = setup_inversive_distance_maps(mesh); compute_inversive_distance_init_from_mesh(mesh, m); int n = 0; for (auto v : mesh.vertices()) m.v_idx[v] = n++; // Small perturbation u_v ≈ −0.1 keeps every triangle valid. std::vector x(static_cast(n), -0.1); EXPECT_TRUE(gradient_check_inversive_distance(mesh, x, m)) << "FD gradient mismatch on single triangle (u = −0.1)"; } TEST(InversiveDistanceFunctional, FDGradientCheck_QuadStrip) { auto mesh = make_quad_strip(); auto m = setup_inversive_distance_maps(mesh); compute_inversive_distance_init_from_mesh(mesh, m); int n = 0; for (auto v : mesh.vertices()) m.v_idx[v] = n++; std::vector x(static_cast(n), -0.15); EXPECT_TRUE(gradient_check_inversive_distance(mesh, x, m)) << "FD gradient mismatch on quad strip"; } TEST(InversiveDistanceFunctional, FDGradientCheck_Tetrahedron) { auto mesh = make_tetrahedron(); auto m = setup_inversive_distance_maps(mesh); compute_inversive_distance_init_from_mesh(mesh, m); int n = 0; for (auto v : mesh.vertices()) m.v_idx[v] = n++; std::vector x(static_cast(n), -0.2); EXPECT_TRUE(gradient_check_inversive_distance(mesh, x, m)) << "FD gradient mismatch on regular tetrahedron"; } // ════════════════════════════════════════════════════════════════════════════ // 6. Cross-validation with euclidean_functional.hpp // // The two functionals are DIFFERENT geometric models. At u = 0 with their // natural inits both produce a valid triangulation, but the per-edge length // is different: // • Euclidean: ℓ = ℓ_3d (exact, by lambda0 init) // • Inversive distance: ℓ = ℓ_3d (exact, by BS round-trip) // // HOWEVER the GRADIENT at u = 0 differs because the chain rule ∂ℓ/∂u is // different. Specifically: // • Euclidean: ∂(2 log ℓ)/∂u_i = 1 // • Inversive distance: ∂(2 log ℓ)/∂u_i = (r_i² + I r_i r_j) / ℓ² // // This test pins one quantitative consequence: at u = 0 both gradients have // the SAME angle-defect structure Θ − Σ_actual. After applying the natural- // theta trick on each, both must be at equilibrium with G(0) = 0. // ════════════════════════════════════════════════════════════════════════════ TEST(InversiveDistanceFunctional, AngleDefectAtU0_AgreesWithEuclideanAtU0) { auto mesh = make_quad_strip(); // ── Inversive distance side ──────────────────────────────────────────── auto m_id = setup_inversive_distance_maps(mesh); compute_inversive_distance_init_from_mesh(mesh, m_id); int n_id = 0; for (auto v : mesh.vertices()) m_id.v_idx[v] = n_id++; std::vector x_id(static_cast(n_id), 0.0); auto G_id = inversive_distance_gradient(mesh, x_id, m_id); // ── Euclidean side (same mesh, same DOF order) ───────────────────────── auto m_eu = setup_euclidean_maps(mesh); compute_euclidean_lambda0_from_mesh(mesh, m_eu); int n_eu = 0; for (auto v : mesh.vertices()) m_eu.v_idx[v] = n_eu++; std::vector x_eu(static_cast(n_eu), 0.0); auto G_eu = euclidean_gradient(const_cast(mesh), x_eu, m_eu); // Both should report the same actual angle sum per vertex at u = 0 // (since both reproduce ℓ = ℓ_3d at u = 0). Therefore Θ − Σ_actual // is identical for the two functionals (Θ default 2π in both). ASSERT_EQ(G_id.size(), G_eu.size()); for (std::size_t i = 0; i < G_id.size(); ++i) { EXPECT_NEAR(G_id[i], G_eu[i], 1e-10) << "angle-defect mismatch at u=0, DOF " << i << ": id=" << G_id[i] << " eu=" << G_eu[i]; } }