Finding-B from doc/reviewer/external-audit-2026-05-30.md. The Euclidean Gauss–Bonnet identity Σ(2π−Θ_v) = 2π·χ is ONLY valid for flat/Euclidean and spherical metrics. For hyperbolic metrics (HyperIdeal) the correct identity is Σ(2π−Θ_v) − Area(M) = 2π·χ. The previously provided gauss_bonnet_sum(HyperIdealMaps) overload would silently pass the wrong LHS to check_gauss_bonnet, which would always throw "deficit = ±Area" for valid hyperbolic targets. Fix: - gauss_bonnet_sum(mesh, HyperIdealMaps) → = delete + explanation comment - enforce_gauss_bonnet(mesh, HyperIdealMaps&) → = delete + explanation comment - Header box comment rewritten with the correct hyperbolic Gauss–Bonnet identity and a clear "HyperIdeal: NOT SUPPORTED" section New test in test_phase6.cpp: - HyperIdeal_EuclideanSumDiscrepancy_DocumentsWhyCheckIsDeleted verifies numerically that the Euclidean sum = 0 but 2π·χ = 4π for a regular tetrahedron, documenting the −4π discrepancy that motivated the deletion - Three compile-time static_asserts (SFINAE) confirm the overload is not invocable with HyperIdealMaps but remains so with Euclidean/SphericalMaps 263/263 CGAL tests pass, 0 failed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
482 lines
19 KiB
C++
482 lines
19 KiB
C++
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// test_phase6.cpp
|
||
//
|
||
// Phase 6 — Tests for:
|
||
// - gauss_bonnet.hpp : euler_characteristic, genus, GB sum/rhs, check, enforce
|
||
// - cut_graph.hpp : compute_cut_graph (tree-cotree algorithm)
|
||
// - layout.hpp Phase6 : exact hyperbolic trilateration math
|
||
// normalise_euclidean (centroid + PCA)
|
||
// normalise_hyperbolic (Möbius centering)
|
||
|
||
#include "conformal_mesh.hpp"
|
||
#include "mesh_builder.hpp"
|
||
#include "euclidean_functional.hpp"
|
||
#include "spherical_functional.hpp"
|
||
#include "hyper_ideal_functional.hpp"
|
||
#include "newton_solver.hpp"
|
||
#include "gauss_bonnet.hpp"
|
||
#include "cut_graph.hpp"
|
||
#include "layout.hpp"
|
||
#include <gtest/gtest.h>
|
||
#include <cmath>
|
||
#include <complex>
|
||
#include <vector>
|
||
|
||
using namespace conformallab;
|
||
|
||
// mesh_builder.hpp provides make_triangle(), make_tetrahedron(), make_quad_strip().
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// GaussBonnet — topology helpers
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(GaussBonnet, EulerCharacteristic_Triangle)
|
||
{
|
||
// V=3 E=3 F=1 → χ = 1
|
||
auto m = make_triangle();
|
||
EXPECT_EQ(euler_characteristic(m), 1);
|
||
}
|
||
|
||
TEST(GaussBonnet, EulerCharacteristic_QuadStrip)
|
||
{
|
||
// V=4 E=5 F=2 → χ = 1
|
||
auto m = make_quad_strip();
|
||
EXPECT_EQ(euler_characteristic(m), 1);
|
||
}
|
||
|
||
TEST(GaussBonnet, EulerCharacteristic_Tetrahedron)
|
||
{
|
||
// V=4 E=6 F=4 → χ = 2
|
||
auto m = make_tetrahedron();
|
||
EXPECT_EQ(euler_characteristic(m), 2);
|
||
}
|
||
|
||
TEST(GaussBonnet, Genus_Tetrahedron_IsZero)
|
||
{
|
||
auto m = make_tetrahedron();
|
||
EXPECT_EQ(genus(m), 0);
|
||
}
|
||
|
||
TEST(GaussBonnet, RHS_Triangle)
|
||
{
|
||
// χ=1 → 2π·χ = 2π
|
||
auto m = make_triangle();
|
||
EXPECT_NEAR(gauss_bonnet_rhs(m), 2.0 * M_PI, 1e-12);
|
||
}
|
||
|
||
TEST(GaussBonnet, RHS_Tetrahedron)
|
||
{
|
||
// χ=2 → 2π·χ = 4π
|
||
auto m = make_tetrahedron();
|
||
EXPECT_NEAR(gauss_bonnet_rhs(m), 4.0 * M_PI, 1e-12);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// GaussBonnet — sum / deficit / check / enforce
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(GaussBonnet, DeficitIsNonZeroWithDefaultTheta)
|
||
{
|
||
// Default theta_v = 2π at all V=4 vertices of quad-strip →
|
||
// Σ(2π−2π) = 0, rhs = 2π·1 = 2π → deficit = −2π ≠ 0.
|
||
auto m = make_quad_strip();
|
||
auto maps = setup_euclidean_maps(m);
|
||
double def = gauss_bonnet_deficit(m, maps);
|
||
EXPECT_GT(std::abs(def), 1e-6);
|
||
}
|
||
|
||
TEST(GaussBonnet, EnforceAdjustsTheta_Deficit_BecomesZero)
|
||
{
|
||
auto m = make_quad_strip();
|
||
auto maps = setup_euclidean_maps(m);
|
||
// Set clearly wrong theta
|
||
for (auto v : m.vertices()) maps.theta_v[v] = M_PI;
|
||
EXPECT_GT(std::abs(gauss_bonnet_deficit(m, maps)), 1e-6);
|
||
|
||
enforce_gauss_bonnet(m, maps);
|
||
EXPECT_NEAR(gauss_bonnet_deficit(m, maps), 0.0, 1e-10);
|
||
}
|
||
|
||
TEST(GaussBonnet, CheckThrowsWhenViolated)
|
||
{
|
||
auto m = make_triangle();
|
||
auto maps = setup_euclidean_maps(m);
|
||
// theta_v = 2π everywhere → sum = 0, rhs = 2π → |deficit| = 2π
|
||
EXPECT_THROW(check_gauss_bonnet(m, maps), std::runtime_error);
|
||
}
|
||
|
||
TEST(GaussBonnet, CheckPassesAfterEnforce)
|
||
{
|
||
auto m = make_triangle();
|
||
auto maps = setup_euclidean_maps(m);
|
||
enforce_gauss_bonnet(m, maps);
|
||
EXPECT_NO_THROW(check_gauss_bonnet(m, maps));
|
||
}
|
||
|
||
TEST(GaussBonnet, SumAfterEnforce_EqualsRHS)
|
||
{
|
||
auto m = make_tetrahedron();
|
||
auto maps = setup_euclidean_maps(m);
|
||
// theta_v starts at 2π everywhere; sum = 0, rhs = 4π
|
||
enforce_gauss_bonnet(m, maps);
|
||
double lhs = gauss_bonnet_sum(m, maps);
|
||
double rhs = gauss_bonnet_rhs(m);
|
||
EXPECT_NEAR(lhs, rhs, 1e-10);
|
||
}
|
||
|
||
TEST(GaussBonnet, ManuallySetAnalyticalTheta_PassesCheck)
|
||
{
|
||
// Single triangle (χ=1): need Σ(2π−Θ_v) = 2π.
|
||
// Set each of the 3 boundary vertices to Θ_v = 4π/3.
|
||
// Then Σ(2π − 4π/3) = 3·(2π/3) = 2π. ✓
|
||
auto m = make_triangle();
|
||
auto maps = setup_euclidean_maps(m);
|
||
for (auto v : m.vertices()) maps.theta_v[v] = 4.0 * M_PI / 3.0;
|
||
EXPECT_NO_THROW(check_gauss_bonnet(m, maps));
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// GaussBonnet — HyperIdeal API guard (Finding-B from external-audit-2026-05-30)
|
||
//
|
||
// gauss_bonnet_sum(mesh, HyperIdealMaps) and
|
||
// enforce_gauss_bonnet(mesh, HyperIdealMaps) are intentionally DELETED.
|
||
//
|
||
// Reason: the correct hyperbolic Gauss–Bonnet identity is
|
||
// Σ_v (2π − Θ_v) − Area(M) = 2π · χ(M)
|
||
// not the Euclidean form Σ(2π−Θ_v) = 2π·χ. For a regular (Θ_v=2π) genus-2
|
||
// surface: Σ(2π−2π)=0 but 2π·χ=−4π, so the Euclidean check would always
|
||
// throw "deficit = 4π" for a perfectly valid HyperIdeal target.
|
||
//
|
||
// Compile-time enforcement: gauss_bonnet_sum / enforce_gauss_bonnet with
|
||
// HyperIdealMaps are = delete, so any accidental call is a compile error.
|
||
// The static_asserts below confirm this is wired correctly.
|
||
//
|
||
// The runtime test shows the discrepancy numerically: even for a regular
|
||
// tetrahedron where all Θ_v=2π (a valid all-hyper-ideal starting point),
|
||
// the "Euclidean sum" is 0 but the correct RHS for χ=2 is 4π — the
|
||
// Euclidean check would be off by 4π.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
// Invocability check via SFINAE: tries to form the call expression in an
|
||
// unevaluated context; a deleted function causes substitution failure.
|
||
namespace {
|
||
template <typename Maps>
|
||
auto try_gb_sum(int) -> decltype(gauss_bonnet_sum(
|
||
std::declval<const ConformalMesh&>(),
|
||
std::declval<const Maps&>()), std::true_type{});
|
||
template <typename>
|
||
std::false_type try_gb_sum(...);
|
||
} // namespace
|
||
|
||
static_assert(!decltype(try_gb_sum<HyperIdealMaps>(0))::value,
|
||
"gauss_bonnet_sum must NOT be invocable with HyperIdealMaps");
|
||
static_assert( decltype(try_gb_sum<EuclideanMaps>(0))::value,
|
||
"gauss_bonnet_sum must still be invocable with EuclideanMaps");
|
||
static_assert( decltype(try_gb_sum<SphericalMaps>(0))::value,
|
||
"gauss_bonnet_sum must still be invocable with SphericalMaps");
|
||
|
||
TEST(GaussBonnet, HyperIdeal_EuclideanSumDiscrepancy_DocumentsWhyCheckIsDeleted)
|
||
{
|
||
// On a tetrahedron (χ=2) with all Θ_v = 2π:
|
||
// Euclidean sum Σ(2π−2π) = 0
|
||
// but 2π·χ = 4π
|
||
// deficit (Euclidean formula) = 0 − 4π = −4π ← WRONG check for HyperIdeal
|
||
//
|
||
// The HyperIdeal identity is Σ(2π−Θ_v) − Area = 2π·χ.
|
||
// Area > 0 for any non-degenerate hyperbolic metric, so the real deficit
|
||
// would be much smaller. This test documents the mismatch numerically
|
||
// so any future re-introduction of the HyperIdeal overload is caught.
|
||
auto m = make_tetrahedron();
|
||
auto hi_maps = setup_hyper_ideal_maps(m);
|
||
// All theta_v default to 2π (regular vertex target).
|
||
|
||
// Access the raw property map directly (not via the deleted bundle overload)
|
||
// to compute the Euclidean-style sum — just for documentation purposes.
|
||
double euclid_sum = gauss_bonnet_sum(m, hi_maps.theta_v); // raw map: OK
|
||
double rhs = gauss_bonnet_rhs(m); // 2π·χ = 4π
|
||
|
||
EXPECT_NEAR(euclid_sum, 0.0, 1e-12) // Σ(2π−2π) = 0
|
||
<< "Expected Euclidean sum = 0 for all-regular HyperIdeal targets";
|
||
EXPECT_NEAR(rhs, 4.0 * M_PI, 1e-12) // 2π·χ(tetrahedron) = 4π
|
||
<< "Expected RHS = 4π for tetrahedron (χ=2)";
|
||
|
||
// The Euclidean deficit would be 0 − 4π = −4π: completely wrong for HyperIdeal.
|
||
// If check_gauss_bonnet were called with HyperIdealMaps it would ALWAYS throw
|
||
// here, even though Θ_v=2π is a valid regular-vertex HyperIdeal target.
|
||
EXPECT_NEAR(euclid_sum - rhs, -4.0 * M_PI, 1e-10)
|
||
<< "Euclidean deficit for HyperIdeal target = −4π: confirms the deleted API is correct";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// CutGraph — tree-cotree algorithm
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(CutGraph, Triangle_ZeroCutEdges)
|
||
{
|
||
// Open disk → genus 0 → 0 cut edges.
|
||
auto m = make_triangle();
|
||
auto cg = compute_cut_graph(m);
|
||
EXPECT_EQ(cg.cut_edge_indices.size(), 0u);
|
||
}
|
||
|
||
TEST(CutGraph, QuadStrip_ZeroCutEdges)
|
||
{
|
||
// Open disk → genus 0 → 0 cut edges.
|
||
auto m = make_quad_strip();
|
||
auto cg = compute_cut_graph(m);
|
||
EXPECT_EQ(cg.cut_edge_indices.size(), 0u);
|
||
}
|
||
|
||
TEST(CutGraph, Tetrahedron_ZeroCutEdges_ClosedSphere)
|
||
{
|
||
// Closed genus-0 surface → 2g = 0 cut edges.
|
||
auto m = make_tetrahedron();
|
||
auto cg = compute_cut_graph(m);
|
||
EXPECT_EQ(cg.cut_edge_indices.size(), 0u);
|
||
EXPECT_EQ(cg.genus, 0);
|
||
}
|
||
|
||
TEST(CutGraph, FlagsVsIndicesConsistent)
|
||
{
|
||
// Every index in cut_edge_indices has flag=true;
|
||
// count of true flags == number of indices.
|
||
auto m = make_tetrahedron();
|
||
auto cg = compute_cut_graph(m);
|
||
for (std::size_t idx : cg.cut_edge_indices)
|
||
EXPECT_TRUE(cg.cut_edge_flags[idx]);
|
||
|
||
std::size_t count = 0;
|
||
for (bool b : cg.cut_edge_flags) count += b ? 1u : 0u;
|
||
EXPECT_EQ(count, cg.cut_edge_indices.size());
|
||
}
|
||
|
||
TEST(CutGraph, IsCutMethodMatchesFlags)
|
||
{
|
||
auto m = make_tetrahedron();
|
||
auto cg = compute_cut_graph(m);
|
||
for (auto e : m.edges()) {
|
||
bool flag = cg.cut_edge_flags[static_cast<std::size_t>(e.idx())];
|
||
EXPECT_EQ(cg.is_cut(e), flag);
|
||
}
|
||
}
|
||
|
||
TEST(CutGraph, FlagsVectorHasCorrectSize)
|
||
{
|
||
auto m = make_quad_strip();
|
||
auto cg = compute_cut_graph(m);
|
||
EXPECT_EQ(cg.cut_edge_flags.size(), m.number_of_edges());
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Exact hyperbolic trilateration — Möbius + law of cosines
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
namespace {
|
||
|
||
/// Poincaré-disk hyperbolic distance.
|
||
double hyp_dist_disk(Eigen::Vector2d a, Eigen::Vector2d b)
|
||
{
|
||
double num = (a.x()-b.x())*(a.x()-b.x()) + (a.y()-b.y())*(a.y()-b.y());
|
||
double da = 1.0 - a.x()*a.x() - a.y()*a.y();
|
||
double db_ = 1.0 - b.x()*b.x() - b.y()*b.y();
|
||
double arg = 1.0 + 2.0*num/(da*db_);
|
||
return std::acosh(std::max(1.0, arg));
|
||
}
|
||
|
||
/// Reproduce the exact trilateration formula from layout.hpp detail namespace.
|
||
Eigen::Vector2d trilaterate_hyp(Eigen::Vector2d pa, Eigen::Vector2d pb,
|
||
double D, double da, double db)
|
||
{
|
||
if (D < 1e-12 || da < 1e-12) return pa;
|
||
if (std::sinh(da) * std::sinh(D) < 1e-14) return pa;
|
||
using C = std::complex<double>;
|
||
auto mobius_fwd = [](C z, C center) -> C {
|
||
return (z - center) / (C(1.0) - std::conj(center) * z);
|
||
};
|
||
auto mobius_inv = [](C w, C center) -> C {
|
||
return (w + center) / (C(1.0) + std::conj(center) * w);
|
||
};
|
||
C a(pa.x(), pa.y()), b(pb.x(), pb.y());
|
||
C b_mapped = mobius_fwd(b, a);
|
||
double theta_b = std::arg(b_mapped);
|
||
double cos_alpha = (std::cosh(da) * std::cosh(D) - std::cosh(db))
|
||
/ (std::sinh(da) * std::sinh(D));
|
||
cos_alpha = std::max(-1.0, std::min(1.0, cos_alpha));
|
||
double alpha = std::acos(cos_alpha);
|
||
C pc_origin = std::tanh(da * 0.5) * std::exp(C(0.0, theta_b + alpha));
|
||
C pc_c = mobius_inv(pc_origin, a);
|
||
return Eigen::Vector2d(pc_c.real(), pc_c.imag());
|
||
}
|
||
|
||
} // namespace
|
||
|
||
TEST(HyperbolicTrilateration, RecoveredDistances_Small)
|
||
{
|
||
// pa at origin, pb at tanh(D/2) on x-axis.
|
||
double D = 0.8, da = 0.5, db = 0.6;
|
||
Eigen::Vector2d pa(0.0, 0.0);
|
||
Eigen::Vector2d pb(std::tanh(D * 0.5), 0.0);
|
||
|
||
Eigen::Vector2d pc = trilaterate_hyp(pa, pb, D, da, db);
|
||
|
||
EXPECT_NEAR(hyp_dist_disk(pa, pb), D, 1e-10);
|
||
EXPECT_NEAR(hyp_dist_disk(pa, pc), da, 1e-9);
|
||
EXPECT_NEAR(hyp_dist_disk(pb, pc), db, 1e-9);
|
||
}
|
||
|
||
TEST(HyperbolicTrilateration, RecoveredDistances_Large)
|
||
{
|
||
double D = 2.0, da = 1.5, db = 1.0;
|
||
Eigen::Vector2d pa(0.0, 0.0);
|
||
Eigen::Vector2d pb(std::tanh(D * 0.5), 0.0);
|
||
|
||
Eigen::Vector2d pc = trilaterate_hyp(pa, pb, D, da, db);
|
||
|
||
EXPECT_NEAR(hyp_dist_disk(pa, pc), da, 1e-8);
|
||
EXPECT_NEAR(hyp_dist_disk(pb, pc), db, 1e-8);
|
||
}
|
||
|
||
TEST(HyperbolicTrilateration, ResultInsidePoincareDisk)
|
||
{
|
||
double D = 1.2, da = 0.9, db = 0.7;
|
||
Eigen::Vector2d pa(0.0, 0.0);
|
||
Eigen::Vector2d pb(std::tanh(D * 0.5), 0.0);
|
||
|
||
Eigen::Vector2d pc = trilaterate_hyp(pa, pb, D, da, db);
|
||
EXPECT_LT(pc.norm(), 1.0);
|
||
}
|
||
|
||
TEST(HyperbolicTrilateration, OffOrigin_StillCorrect)
|
||
{
|
||
// Place pa somewhere in the disk (not at origin) to test Möbius map.
|
||
double D = 0.6, da = 0.4, db = 0.5;
|
||
// pa at (0.3, 0.0) in Poincaré coords.
|
||
Eigen::Vector2d pa(0.3, 0.0);
|
||
// pb at distance D from pa — compute pb in Poincaré disk:
|
||
// Möbius inverse: tanh(D/2) maps back from origin frame.
|
||
using C = std::complex<double>;
|
||
C a_c(pa.x(), pa.y());
|
||
C pb_c = (std::tanh(D*0.5) + a_c) / (C(1.0) + std::conj(a_c) * std::tanh(D*0.5));
|
||
Eigen::Vector2d pb(pb_c.real(), pb_c.imag());
|
||
|
||
Eigen::Vector2d pc = trilaterate_hyp(pa, pb, D, da, db);
|
||
EXPECT_LT(pc.norm(), 1.0);
|
||
EXPECT_NEAR(hyp_dist_disk(pa, pc), da, 1e-8);
|
||
EXPECT_NEAR(hyp_dist_disk(pb, pc), db, 1e-8);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Normalisation — euclidean_layout (centroid) + hyper_ideal_layout (Möbius)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
/// Build a solved euclidean layout for the quad-strip at the natural equilibrium.
|
||
static Layout2D make_euclidean_layout_normalised(bool normalise)
|
||
{
|
||
auto mesh = make_quad_strip();
|
||
auto maps = setup_euclidean_maps(mesh);
|
||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||
|
||
// Pin first vertex; assign free DOF indices to all others.
|
||
auto vit = mesh.vertices().begin();
|
||
maps.v_idx[*vit++] = -1;
|
||
int idx = 0;
|
||
for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++;
|
||
const int n = idx;
|
||
|
||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||
// Adjust theta so G(x=0) = 0 (natural equilibrium).
|
||
auto G0 = euclidean_gradient(mesh, x0, maps);
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v];
|
||
if (iv >= 0) maps.theta_v[v] -= G0[static_cast<std::size_t>(iv)];
|
||
}
|
||
|
||
auto res = newton_euclidean(mesh, x0, maps, 1e-10, 100);
|
||
return euclidean_layout(mesh, res.x, maps, nullptr, nullptr, normalise);
|
||
}
|
||
|
||
TEST(Normalisation, Euclidean_CentroidAtOrigin)
|
||
{
|
||
auto L = make_euclidean_layout_normalised(true);
|
||
ASSERT_GT(L.uv.size(), 0u);
|
||
|
||
double cx = 0.0, cy = 0.0;
|
||
for (auto& p : L.uv) { cx += p.x(); cy += p.y(); }
|
||
int n = static_cast<int>(L.uv.size());
|
||
EXPECT_NEAR(cx / n, 0.0, 1e-9);
|
||
EXPECT_NEAR(cy / n, 0.0, 1e-9);
|
||
}
|
||
|
||
TEST(Normalisation, Euclidean_NormalisePreservesEdgeLengthRatios)
|
||
{
|
||
auto L_no = make_euclidean_layout_normalised(false);
|
||
auto L_yes = make_euclidean_layout_normalised(true);
|
||
|
||
// Ratio of the lengths of the first two edges must be preserved.
|
||
ASSERT_GE(L_no.uv.size(), 4u);
|
||
// edges 0→1 and 0→2 (the two edges from vertex 0 in the quad-strip)
|
||
double len0_no = (L_no.uv[1] - L_no.uv[0]).norm();
|
||
double len1_no = (L_no.uv[2] - L_no.uv[0]).norm();
|
||
double len0_yes = (L_yes.uv[1] - L_yes.uv[0]).norm();
|
||
double len1_yes = (L_yes.uv[2] - L_yes.uv[0]).norm();
|
||
|
||
if (len1_no > 1e-12 && len1_yes > 1e-12) {
|
||
double ratio_no = len0_no / len1_no;
|
||
double ratio_yes = len0_yes / len1_yes;
|
||
EXPECT_NEAR(ratio_no, ratio_yes, 1e-9);
|
||
}
|
||
}
|
||
|
||
/// Build a solved hyper-ideal layout for a triangle at natural equilibrium.
|
||
static Layout2D make_hyper_ideal_layout_normalised(bool normalise)
|
||
{
|
||
auto mesh = make_triangle();
|
||
auto maps = setup_hyper_ideal_maps(mesh);
|
||
int n = assign_all_dof_indices(mesh, maps);
|
||
|
||
std::vector<double> xbase(static_cast<std::size_t>(n));
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v]; if (iv >= 0) xbase[iv] = 1.0;
|
||
}
|
||
for (auto e : mesh.edges()) {
|
||
int ie = maps.e_idx[e]; if (ie >= 0) xbase[ie] = 0.5;
|
||
}
|
||
auto G0 = evaluate_hyper_ideal(mesh, xbase, maps, false).gradient;
|
||
for (auto v : mesh.vertices()) {
|
||
int iv = maps.v_idx[v]; if (iv >= 0) maps.theta_v[v] += G0[iv];
|
||
}
|
||
for (auto e : mesh.edges()) {
|
||
int ie = maps.e_idx[e]; if (ie >= 0) maps.theta_e[e] += G0[ie];
|
||
}
|
||
auto res = newton_hyper_ideal(mesh, xbase, maps, 1e-9, 100);
|
||
return hyper_ideal_layout(mesh, res.x, maps, nullptr, nullptr, normalise);
|
||
}
|
||
|
||
TEST(Normalisation, HyperIdeal_PositionsInsidePoincareDisk)
|
||
{
|
||
auto L = make_hyper_ideal_layout_normalised(true);
|
||
ASSERT_GT(L.uv.size(), 0u);
|
||
for (auto& p : L.uv) {
|
||
EXPECT_LT(p.norm(), 1.0 + 1e-9)
|
||
<< "Vertex outside Poincaré disk: r=" << p.norm();
|
||
}
|
||
}
|
||
|
||
TEST(Normalisation, HyperIdeal_CenteringReducesAverageRadius)
|
||
{
|
||
// After Möbius centering the average Euclidean radius inside the disk
|
||
// must be ≤ the unconstrained average.
|
||
auto L_no = make_hyper_ideal_layout_normalised(false);
|
||
auto L_yes = make_hyper_ideal_layout_normalised(true);
|
||
|
||
int n = static_cast<int>(L_no.uv.size());
|
||
double r_no = 0.0, r_yes = 0.0;
|
||
for (int i = 0; i < n; ++i) {
|
||
r_no += L_no.uv[i].norm();
|
||
r_yes += L_yes.uv[i].norm();
|
||
}
|
||
EXPECT_LE(r_yes / n, r_no / n + 1e-9);
|
||
}
|