// Copyright (c) 2024-2026 Tarik Moussa. // SPDX-License-Identifier: MIT // test_phase7.cpp // // Phase 7 — Tests for Java-parity layout features: // - MobiusMap : identity, inverse, compose, from_three, is_identity // - best_root_face : selects a valid face; interior bonus // - halfedge_uv : size, non-seam consistency, seam divergence // - Priority BFS : vertex ordering / depth correctness // - normalise_euclidean : halfedge_uv centroid at origin // - period_matrix.hpp : τ in upper half-plane, SL(2,ℤ) reduction // - fundamental_domain.hpp: parallelogram CCW, generators, tiling_copy #include "conformal_mesh.hpp" #include "mesh_builder.hpp" #include "euclidean_functional.hpp" #include "hyper_ideal_functional.hpp" #include "newton_solver.hpp" #include "layout.hpp" #include "period_matrix.hpp" #include "fundamental_domain.hpp" #include "mesh_io.hpp" #include "cut_graph.hpp" #include "gauss_bonnet.hpp" #include #include #include #include #include using namespace conformallab; using C = std::complex; // ════════════════════════════════════════════════════════════════════════════ // MobiusMap // ════════════════════════════════════════════════════════════════════════════ TEST(MobiusMap, Identity_AppliesAsIdentity) { MobiusMap id = MobiusMap::identity(); C z(0.3, 0.7); C w = id.apply(z); EXPECT_NEAR(w.real(), z.real(), 1e-12); EXPECT_NEAR(w.imag(), z.imag(), 1e-12); } TEST(MobiusMap, Identity_IsIdentity) { EXPECT_TRUE(MobiusMap::identity().is_identity()); } TEST(MobiusMap, NonIdentity_IsNotIdentity) { // T(z) = z + 1 — translation, clearly not identity MobiusMap T{ C(1), C(1), C(0), C(1) }; EXPECT_FALSE(T.is_identity()); } TEST(MobiusMap, Inverse_ComposeIsIdentity) { // T(z) = (2z + 1) / (z + 3) MobiusMap T{ C(2), C(1), C(1), C(3) }; MobiusMap TinvT = T.inverse().compose(T); EXPECT_TRUE(TinvT.is_identity(1e-9)); } TEST(MobiusMap, Compose_OrderCorrect) { // S: z ↦ z + 1, T: z ↦ 2z // S.compose(T) means S applied after T: z ↦ 2z + 1 MobiusMap S{ C(1), C(1), C(0), C(1) }; // z + 1 MobiusMap T{ C(2), C(0), C(0), C(1) }; // 2z MobiusMap ST = S.compose(T); C z(1.0, 0.0); // S(T(z)) = S(2) = 3 EXPECT_NEAR(ST.apply(z).real(), 3.0, 1e-12); EXPECT_NEAR(ST.apply(z).imag(), 0.0, 1e-12); } TEST(MobiusMap, FromThree_RecoversMap) { // Known map T(z) = (z + i) / (1 + 0·z) — translation by i C w1 = C(0, 1) + C(0, 1); // T(i) = 2i C w2 = C(1, 0) + C(0, 1); // T(1) = 1 + i C w3 = C(-1, 0) + C(0, 1); // T(-1) = -1 + i MobiusMap T = MobiusMap::from_three(C(0, 1), w1, C(1, 0), w2, C(-1, 0), w3); // Verify T maps a fourth point correctly: T(0) = i C result = T.apply(C(0, 0)); EXPECT_NEAR(result.real(), 0.0, 1e-9); EXPECT_NEAR(result.imag(), 1.0, 1e-9); } TEST(MobiusMap, FromThree_DegenerateReturnsIdentity) { // Three coincident points → singular system → identity fallback C z(0.5, 0.5); MobiusMap T = MobiusMap::from_three(z, z, z, z, z, z); // Should not crash; returns identity (or at least a valid map) // We just check the result is finite C w = T.apply(C(0.1, 0.2)); EXPECT_FALSE(std::isnan(w.real())); EXPECT_FALSE(std::isnan(w.imag())); } TEST(MobiusMap, Apply_Vector2d) { MobiusMap id = MobiusMap::identity(); Eigen::Vector2d p(0.4, 0.6); Eigen::Vector2d q = id.apply(p); EXPECT_NEAR(q.x(), p.x(), 1e-12); EXPECT_NEAR(q.y(), p.y(), 1e-12); } // ════════════════════════════════════════════════════════════════════════════ // best_root_face // ════════════════════════════════════════════════════════════════════════════ TEST(BestRootFace, ReturnsValidFace_Triangle) { auto mesh = make_triangle(); Face_index f = detail::best_root_face(mesh); EXPECT_NE(f, Face_index()); EXPECT_GE(f.idx(), 0); } TEST(BestRootFace, ReturnsValidFace_Tetrahedron) { auto mesh = make_tetrahedron(); Face_index f = detail::best_root_face(mesh); EXPECT_NE(f, Face_index()); // Tetrahedron has 4 faces — best is one of them EXPECT_LT(static_cast(f.idx()), mesh.number_of_faces()); } // ════════════════════════════════════════════════════════════════════════════ // halfedge_uv — size and non-seam consistency // ════════════════════════════════════════════════════════════════════════════ // Helper: build equilibrium Euclidean layout for a given mesh. // Uses x = 0 (identity scale factor) which is the equilibrium for natural edge lengths. static Layout2D make_euclidean_layout(ConformalMesh& mesh) { EuclideanMaps maps = setup_euclidean_maps(mesh); compute_euclidean_lambda0_from_mesh(mesh, maps); // Pin first vertex (DOF = -1); assign sequential indices to the rest. auto vit = mesh.vertices().begin(); maps.v_idx[*vit++] = -1; int idx = 0; for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++; std::vector x(static_cast(idx), 0.0); return euclidean_layout(mesh, x, maps); } TEST(HalfedgeUV, Size_EqualsNumberOfHalfedges_Triangle) { auto mesh = make_triangle(); auto lay = make_euclidean_layout(mesh); EXPECT_EQ(lay.halfedge_uv.size(), mesh.number_of_halfedges()); } TEST(HalfedgeUV, Size_EqualsNumberOfHalfedges_QuadStrip) { auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); EXPECT_EQ(lay.halfedge_uv.size(), mesh.number_of_halfedges()); } TEST(HalfedgeUV, NonBorderHalfedges_MatchUV) { // For an open mesh with no cut graph the layout has no seams. // Every non-border halfedge h must satisfy: // halfedge_uv[h] == uv[source(h)] auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); for (auto h : mesh.halfedges()) { if (mesh.is_border(h)) continue; std::size_t hi = static_cast(h.idx()); std::size_t vi = static_cast(mesh.source(h).idx()); EXPECT_NEAR(lay.halfedge_uv[hi].x(), lay.uv[vi].x(), 1e-10) << "halfedge " << hi << " source vertex " << vi; EXPECT_NEAR(lay.halfedge_uv[hi].y(), lay.uv[vi].y(), 1e-10) << "halfedge " << hi << " source vertex " << vi; } } TEST(HalfedgeUV, BorderHalfedges_AreZero) { auto mesh = make_triangle(); auto lay = make_euclidean_layout(mesh); bool found_border = false; for (auto h : mesh.halfedges()) { if (!mesh.is_border(h)) continue; std::size_t hi = static_cast(h.idx()); EXPECT_NEAR(lay.halfedge_uv[hi].x(), 0.0, 1e-12); EXPECT_NEAR(lay.halfedge_uv[hi].y(), 0.0, 1e-12); found_border = true; } EXPECT_TRUE(found_border); } // ════════════════════════════════════════════════════════════════════════════ // Priority BFS — depth ordering // ════════════════════════════════════════════════════════════════════════════ TEST(PriorityBFS, Layout_SucceedsOnOpenMesh) { auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); EXPECT_TRUE(lay.success); } TEST(PriorityBFS, Layout_NoSeamOnOpenMesh) { auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); EXPECT_FALSE(lay.has_seam); } TEST(PriorityBFS, AllVerticesPlaced) { auto mesh = make_tetrahedron(); // Tetrahedron is closed; layout without cut graph will have a seam EuclideanMaps maps = setup_euclidean_maps(mesh); compute_euclidean_lambda0_from_mesh(mesh, maps); auto vit = mesh.vertices().begin(); maps.v_idx[*vit++] = -1; int idx = 0; for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++; std::vector x(static_cast(idx), 0.0); auto lay = euclidean_layout(mesh, x, maps); EXPECT_TRUE(lay.success); // All UVs must be finite for (auto& p : lay.uv) { EXPECT_FALSE(std::isnan(p.x())); EXPECT_FALSE(std::isnan(p.y())); } } // ════════════════════════════════════════════════════════════════════════════ // normalise_euclidean — centroid + PCA applied to both uv and halfedge_uv // ════════════════════════════════════════════════════════════════════════════ TEST(NormaliseEuclidean, UVCentroidAtOrigin) { auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); normalise_euclidean(lay); Eigen::Vector2d mean = Eigen::Vector2d::Zero(); for (auto& p : lay.uv) mean += p; mean /= static_cast(lay.uv.size()); EXPECT_NEAR(mean.x(), 0.0, 1e-10); EXPECT_NEAR(mean.y(), 0.0, 1e-10); } TEST(NormaliseEuclidean, HalfedgeUVCentroidAlsoShifted) { // After normalisation: the non-border halfedge_uv entries should also be // centred (since they are shifted by the same mean as uv). // We verify that the mean of non-border halfedge_uv is near (0,0). auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); normalise_euclidean(lay); Eigen::Vector2d mean = Eigen::Vector2d::Zero(); int count = 0; for (auto h : mesh.halfedges()) { if (mesh.is_border(h)) continue; mean += lay.halfedge_uv[static_cast(h.idx())]; ++count; } if (count > 0) mean /= static_cast(count); EXPECT_NEAR(mean.x(), 0.0, 1e-9); EXPECT_NEAR(mean.y(), 0.0, 1e-9); } // ════════════════════════════════════════════════════════════════════════════ // PeriodMatrix — reduce_to_fundamental_domain // ════════════════════════════════════════════════════════════════════════════ TEST(PeriodMatrix, ReduceToFD_AlreadyInFD) { // τ = i is in F (|i|=1, Re(i)=0, Im(i)=1>0) C tau(0.0, 1.0); C reduced = reduce_to_fundamental_domain(tau); EXPECT_TRUE(is_in_fundamental_domain(reduced)); EXPECT_NEAR(reduced.real(), 0.0, 1e-10); EXPECT_NEAR(reduced.imag(), 1.0, 1e-10); } TEST(PeriodMatrix, ReduceToFD_ShiftsRealPart) { // τ = 2 + 3i → T step: τ -= 2 → 3i (|3i|=3≥1, Re=0) C tau(2.0, 3.0); C reduced = reduce_to_fundamental_domain(tau); EXPECT_TRUE(is_in_fundamental_domain(reduced, 1e-9)); EXPECT_NEAR(reduced.real(), 0.0, 1e-10); EXPECT_NEAR(reduced.imag(), 3.0, 1e-10); } TEST(PeriodMatrix, ReduceToFD_InvertsSmallTau) { // τ = 0.5i → |0.5i|=0.5<1 → S: τ↦-1/(0.5i) = 2i C tau(0.0, 0.5); C reduced = reduce_to_fundamental_domain(tau); EXPECT_TRUE(is_in_fundamental_domain(reduced, 1e-9)); EXPECT_NEAR(reduced.real(), 0.0, 1e-10); EXPECT_NEAR(reduced.imag(), 2.0, 1e-10); } TEST(PeriodMatrix, ReduceToFD_ThrowsForNonUpperHalfPlane) { C tau(0.5, -1.0); // Im < 0 → not in upper half-plane EXPECT_THROW(reduce_to_fundamental_domain(tau), std::domain_error); } TEST(PeriodMatrix, IsInFundamentalDomain_Square) { EXPECT_TRUE(is_in_fundamental_domain(C(0.0, 1.0))); // i EXPECT_TRUE(is_in_fundamental_domain(C(0.3, 1.5))); // inside EXPECT_FALSE(is_in_fundamental_domain(C(0.6, 1.5))); // Re > 1/2 EXPECT_FALSE(is_in_fundamental_domain(C(0.0, 0.5))); // |τ| < 1 } TEST(PeriodMatrix, ComputePeriodMatrix_UnitSquare) { // ω_1 = (1, 0), ω_2 = (0, 1) → τ = i HolonomyData hol; hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.0, 1.0) }; PeriodData pd = compute_period_matrix(hol, /*reduce=*/false); EXPECT_EQ(pd.genus(), 1); EXPECT_GT(pd.tau.imag(), 0.0); EXPECT_NEAR(pd.tau.real(), 0.0, 1e-10); EXPECT_NEAR(pd.tau.imag(), 1.0, 1e-10); } TEST(PeriodMatrix, ComputePeriodMatrix_ReducedTau_InFD) { // ω_1 = (1, 0), ω_2 = (0.5, 0.25) → τ = 0.5 + 0.25i // |τ| = sqrt(0.25 + 0.0625) ≈ 0.559 < 1 → needs S step HolonomyData hol; hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.5, 0.25) }; PeriodData pd = compute_period_matrix(hol, /*reduce=*/true); EXPECT_TRUE(pd.in_fundamental_domain); EXPECT_TRUE(is_in_fundamental_domain(pd.tau, 1e-9)); } // ════════════════════════════════════════════════════════════════════════════ // End-to-end holonomy → τ on real genus-1 torus meshes // // Regression test for the holonomy-extraction bug: euclidean_holonomy() developed // the cut surface along a BFS dual tree that crossed the primal-tree edges freely. // Relative to that tree the cut graph's 2g generator edges were NOT generators — // some were null-homotopic — so the two developed copies of a "cut" edge landed // on top of each other and compute_period_matrix() got ω ≈ 0 (→ τ = 0 / NaN / // huge). The fix develops across the cut graph's OWN dual spanning tree T* only // (CutGraph::is_dual_tree), unfolding the surface onto a true disk so the cut // edges become the boundary identifications that carry the lattice generators. // // Analytic target. The bundled meshes are tori of REVOLUTION (major radius R, // minor radius r, R > r), not abstract square/hexagonal flat tori. Their // conformal modulus is purely imaginary, // // τ = i · √(R² − r²) / r (reduced so |τ| ≥ 1) // // derived from the flat-conformal change of variable dψ = r/(R + r cos φ) dφ on // the induced metric ds² = (R + r cos φ)² dθ² + r² dφ²; the ψ-period is // 2πr/√(R²−r²), giving the rectangular lattice ratio above. Re(τ) = 0 follows // from the meridian ⟂ longitude reflection symmetry. The coarse polygonal cross // sections (square/hex/octagon) approximate the circular value from above; the // gap shrinks as the cross section gains sides. // ════════════════════════════════════════════════════════════════════════════ namespace { // Run the full pipeline solve → cut → layout → period matrix on a torus mesh and // return the reduced τ together with the two raw holonomy generators. struct TorusTau { std::complex tau; std::vector omega; bool converged = false; }; TorusTau run_torus_pipeline(const std::string& file) { const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/off/" + file; ConformalMesh mesh = load_mesh(path); EuclideanMaps maps = setup_euclidean_maps(mesh); // Θ_v = 2π (flat target) compute_euclidean_lambda0_from_mesh(mesh, maps); int idx = 0; bool pinned = false; for (auto v : mesh.vertices()) { if (!pinned) { maps.v_idx[v] = -1; pinned = true; } else maps.v_idx[v] = idx++; } enforce_gauss_bonnet(mesh, maps); std::vector x0(static_cast(idx), 0.0); auto res = newton_euclidean(mesh, x0, maps); CutGraph cg = compute_cut_graph(mesh); HolonomyData hol; euclidean_layout(mesh, res.x, maps, &cg, &hol, /*normalise=*/false); PeriodData pd = compute_period_matrix(hol, /*reduce=*/true); return TorusTau{pd.tau, hol.translations, res.converged}; } // Reduced conformal modulus of a torus of revolution (major R, minor r). double revolution_tau_imag(double R, double r) { return std::sqrt(R * R - r * r) / r; // ≥ 1 form (|τ| ≥ 1) } void check_torus(const std::string& file, double R, double r, double rel_tol) { TorusTau t = run_torus_pipeline(file); ASSERT_TRUE(t.converged) << file << ": Newton did not converge"; // Generators must be non-degenerate (the bug collapsed them to ~0). ASSERT_EQ(t.omega.size(), 2u); EXPECT_GT(t.omega[0].norm(), 1e-3) << file << ": ω₁ degenerate"; EXPECT_GT(t.omega[1].norm(), 1e-3) << file << ": ω₂ degenerate"; EXPECT_TRUE(std::isfinite(t.tau.real()) && std::isfinite(t.tau.imag())) << file << ": τ is not finite (" << t.tau.real() << "+" << t.tau.imag() << "i)"; EXPECT_GT(t.tau.imag(), 0.0) << file << ": τ must lie in the upper half-plane"; EXPECT_TRUE(is_in_fundamental_domain(t.tau, 1e-6)) << file << ": τ = " << t.tau.real() << "+" << t.tau.imag() << "i not in F"; // Re(τ) = 0 by the meridian ⟂ longitude reflection symmetry. EXPECT_NEAR(t.tau.real(), 0.0, 0.05) << file << ": Re(τ) should vanish for a torus of revolution"; const double expected = revolution_tau_imag(R, r); EXPECT_NEAR(t.tau.imag(), expected, rel_tol * expected) << file << ": Im(τ) = " << t.tau.imag() << " vs analytic i·√(R²−r²)/r = " << expected; } } // namespace // 4×4 torus of revolution: R = 2, r = 1 → τ = i√3 ≈ 1.732i. // Square (4-gon) cross section → coarsest circle approximation, looser tolerance. TEST(HolonomyEndToEnd, Torus4x4_TauMatchesRevolutionModulus) { check_torus("torus_4x4.off", /*R=*/2.0, /*r=*/1.0, /*rel_tol=*/0.10); } // Hexagonal 6×6 torus of revolution: R = 3, r = 1 → τ = i√8 ≈ 2.828i. TEST(HolonomyEndToEnd, TorusHex6x6_TauMatchesRevolutionModulus) { check_torus("torus_hex_6x6.off", /*R=*/3.0, /*r=*/1.0, /*rel_tol=*/0.05); } // Octagonal 8×8 torus of revolution: R = 3, r = 1 → τ = i√8 ≈ 2.828i. TEST(HolonomyEndToEnd, Torus8x8_TauMatchesRevolutionModulus) { check_torus("torus_8x8.off", /*R=*/3.0, /*r=*/1.0, /*rel_tol=*/0.05); } // ════════════════════════════════════════════════════════════════════════════ // FundamentalDomain — genus-1 parallelogram // ════════════════════════════════════════════════════════════════════════════ TEST(FundamentalDomain, Genus1_HasFourVertices) { HolonomyData hol; hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.0, 1.0) }; FundamentalDomain fd = compute_fundamental_domain_genus1(hol); EXPECT_EQ(fd.vertices.size(), 4u); EXPECT_TRUE(fd.is_valid()); } TEST(FundamentalDomain, Genus1_VerticesMatchGenerators_UnitSquare) { Eigen::Vector2d w1(1.0, 0.0), w2(0.0, 1.0); HolonomyData hol; hol.translations = { w1, w2 }; FundamentalDomain fd = compute_fundamental_domain_genus1(hol); // Expected (CCW): origin, w1, w1+w2, w2 EXPECT_NEAR(fd.vertices[0].x(), 0.0, 1e-12); EXPECT_NEAR(fd.vertices[0].y(), 0.0, 1e-12); EXPECT_NEAR(fd.vertices[1].x(), w1.x(), 1e-12); EXPECT_NEAR(fd.vertices[1].y(), w1.y(), 1e-12); EXPECT_NEAR(fd.vertices[2].x(), (w1 + w2).x(), 1e-12); EXPECT_NEAR(fd.vertices[2].y(), (w1 + w2).y(), 1e-12); EXPECT_NEAR(fd.vertices[3].x(), w2.x(), 1e-12); EXPECT_NEAR(fd.vertices[3].y(), w2.y(), 1e-12); } TEST(FundamentalDomain, Genus1_CCWOrientation) { // After possible swap, the signed area = cross(v1-v0, v3-v0) > 0 (CCW) HolonomyData hol; hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.0, 1.0) }; FundamentalDomain fd = compute_fundamental_domain_genus1(hol); Eigen::Vector2d v0 = fd.vertices[0], v1 = fd.vertices[1], v3 = fd.vertices[3]; double cross = (v1 - v0).x() * (v3 - v0).y() - (v1 - v0).y() * (v3 - v0).x(); EXPECT_GT(cross, 0.0); } TEST(FundamentalDomain, Genus1_CCWEnforced_WhenInputIsCW) { // If we give CW generators (w2 × w1 < 0), the polygon must still be CCW. // w1 = (0,1), w2 = (1,0): cross w1×w2 = 0*0 - 1*1 = -1 < 0 → should swap HolonomyData hol; hol.translations = { Eigen::Vector2d(0.0, 1.0), Eigen::Vector2d(1.0, 0.0) }; FundamentalDomain fd = compute_fundamental_domain_genus1(hol); Eigen::Vector2d v0 = fd.vertices[0], v1 = fd.vertices[1], v3 = fd.vertices[3]; double cross = (v1 - v0).x() * (v3 - v0).y() - (v1 - v0).y() * (v3 - v0).x(); EXPECT_GT(cross, 0.0); } TEST(FundamentalDomain, Genus1_EdgeIdentifications) { HolonomyData hol; hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.0, 1.0) }; FundamentalDomain fd = compute_fundamental_domain_genus1(hol); EXPECT_EQ(fd.edge_identifications.size(), 2u); // bottom ≡ top: (0,2) EXPECT_EQ(fd.edge_identifications[0].first, 0); EXPECT_EQ(fd.edge_identifications[0].second, 2); // right ≡ left: (1,3) EXPECT_EQ(fd.edge_identifications[1].first, 1); EXPECT_EQ(fd.edge_identifications[1].second, 3); } TEST(FundamentalDomain, Genus1_GeneratorsStored) { Eigen::Vector2d w1(2.0, 1.0), w2(-1.0, 3.0); HolonomyData hol; hol.translations = { w1, w2 }; FundamentalDomain fd = compute_fundamental_domain_genus1(hol); EXPECT_EQ(fd.generators.size(), 2u); // Generators are w1 and w2 (possibly swapped to ensure CCW) // Their sum of norms matches the originals double norm_gen = fd.generators[0].norm() + fd.generators[1].norm(); double norm_in = w1.norm() + w2.norm(); EXPECT_NEAR(norm_gen, norm_in, 1e-10); } TEST(FundamentalDomain, HigherGenus_ReturnsEmpty) { HolonomyData hol; hol.translations = { Eigen::Vector2d(1, 0), Eigen::Vector2d(0, 1), Eigen::Vector2d(2, 0), Eigen::Vector2d(0, 2) // g=2, 4 generators }; FundamentalDomain fd = compute_fundamental_domain(hol); // g > 1 returns empty (TODO Phase 8) EXPECT_FALSE(fd.is_valid()); } // ════════════════════════════════════════════════════════════════════════════ // tiling_copy / tiling_neighbourhood // ════════════════════════════════════════════════════════════════════════════ TEST(TilingCopy, ShiftAppliedToAllUV) { auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); Eigen::Vector2d w1(3.0, 0.0), w2(0.0, 2.0); // m=1, n=2 → expected shift = w1 + 2*w2 = (3, 4) Layout2D copy = tiling_copy(lay, w1, w2, 1, 2); Eigen::Vector2d expected_shift(3.0, 4.0); for (std::size_t i = 0; i < lay.uv.size(); ++i) { EXPECT_NEAR(copy.uv[i].x(), lay.uv[i].x() + expected_shift.x(), 1e-12); EXPECT_NEAR(copy.uv[i].y(), lay.uv[i].y() + expected_shift.y(), 1e-12); } } TEST(TilingCopy, ZeroShift_IsSameAsCopy) { auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); Eigen::Vector2d w1(1, 0), w2(0, 1); Layout2D copy = tiling_copy(lay, w1, w2, 0, 0); for (std::size_t i = 0; i < lay.uv.size(); ++i) { EXPECT_NEAR(copy.uv[i].x(), lay.uv[i].x(), 1e-12); EXPECT_NEAR(copy.uv[i].y(), lay.uv[i].y(), 1e-12); } } TEST(TilingNeighbourhood, CorrectCount) { auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); HolonomyData hol; hol.translations = { Eigen::Vector2d(1, 0), Eigen::Vector2d(0, 1) }; // m_max=1, n_max=1 → (2*1+1) * (2*1+1) = 9 tiles auto tiles = tiling_neighbourhood(lay, hol, 1, 1); EXPECT_EQ(tiles.size(), 9u); } TEST(TilingNeighbourhood, EmptyHolonomy_ReturnsSingleTile) { auto mesh = make_quad_strip(); auto lay = make_euclidean_layout(mesh); HolonomyData hol; // no translations auto tiles = tiling_neighbourhood(lay, hol); EXPECT_EQ(tiles.size(), 1u); }