// 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 #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)); } // ════════════════════════════════════════════════════════════════════════════ // 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); }