// Copyright (c) 2024-2026 Tarik Moussa. // SPDX-License-Identifier: MIT // test_cgal_phase8b_lite.cpp // // Phase 8b-Lite — Smoke tests for the four new CGAL-style entry functions // added on top of the Phase 8a MVP (`discrete_conformal_map_euclidean`). // // All entries are thin wrappers around the legacy Newton solvers; the // purpose of these tests is to verify: // • the wrapper compiles + dispatches correctly // • named parameters pass through (gradient_tolerance, max_iterations) // • the returned Result struct contains the expected DOF vector // • Newton convergence happens end-to-end via the public API #include #include #include #include #include "mesh_builder.hpp" #include "conformal_mesh.hpp" #include #include using namespace conformallab; namespace { // Mesh helper — closed regular tetrahedron, used for spherical / hyper-ideal / // circle-packing tests. inline ConformalMesh make_closed_tet() { return make_tetrahedron(); } // Open 3-face tetrahedron-minus-face, for layout testing. inline ConformalMesh make_open_3face() { 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)); mesh.add_face(v0, v2, v1); mesh.add_face(v0, v1, v3); mesh.add_face(v0, v3, v2); return mesh; } } // anonymous // ════════════════════════════════════════════════════════════════════════════ // 1. Spherical entry — closed genus-0 tetrahedron, natural-theta default // ════════════════════════════════════════════════════════════════════════════ TEST(CGALPhase8bLite, Spherical_ClosedTetrahedron_NaturalThetaConverges) { auto mesh = make_closed_tet(); auto res = CGAL::discrete_conformal_map_spherical(mesh); EXPECT_TRUE(res.converged); EXPECT_LT(res.gradient_norm, 1e-8); EXPECT_EQ(res.u_per_vertex.size(), num_vertices(mesh)); // Natural-theta ⇒ u = 0 is the equilibrium ⇒ all values ≈ 0. for (double u : res.u_per_vertex) EXPECT_NEAR(u, 0.0, 1e-8); } TEST(CGALPhase8bLite, Spherical_NamedParametersTakeEffect) { auto mesh = make_closed_tet(); auto res = CGAL::discrete_conformal_map_spherical( mesh, CGAL::parameters::max_iterations(0)); EXPECT_EQ(res.iterations, 0); } // ════════════════════════════════════════════════════════════════════════════ // 2. Hyper-ideal entry — wrapper compiles + runs, returns both b_v and a_e // ════════════════════════════════════════════════════════════════════════════ TEST(CGALPhase8bLite, HyperIdeal_Tetrahedron_ReturnsBothVertexAndEdgeDOFs) { auto mesh = make_closed_tet(); auto res = CGAL::discrete_conformal_map_hyper_ideal( mesh, CGAL::parameters::max_iterations(20)); // Newton on default targets (Θ=2π, θ=π) from the "natural" b=1, a=0.5 // start may or may not converge in 20 iterations — but the wrapper must // populate the result struct in any case. EXPECT_EQ(res.b_per_vertex.size(), num_vertices(mesh)); EXPECT_EQ(res.a_per_edge.size(), num_edges (mesh)); EXPECT_GE(res.iterations, 0); EXPECT_TRUE(std::isfinite(res.gradient_norm)); } // ════════════════════════════════════════════════════════════════════════════ // 3. Circle-packing (face-based) entry — natural-phi convergence // ════════════════════════════════════════════════════════════════════════════ TEST(CGALPhase8bLite, CirclePacking_ClosedTetrahedron_NaturalPhiConverges) { auto mesh = make_closed_tet(); auto res = CGAL::discrete_circle_packing_euclidean(mesh); EXPECT_TRUE(res.converged); EXPECT_LT(res.gradient_norm, 1e-8); EXPECT_EQ(res.rho_per_face.size(), num_faces(mesh)); // Pinned face is at index 0 (first iterated face); its ρ is 0 by gauge. // After natural-phi the equilibrium is ρ_f = 0 for every face. for (double r : res.rho_per_face) EXPECT_NEAR(r, 0.0, 1e-8); } TEST(CGALPhase8bLite, CirclePacking_GradientToleranceTakesEffect) { auto mesh = make_closed_tet(); auto res_loose = CGAL::discrete_circle_packing_euclidean( mesh, CGAL::parameters::gradient_tolerance(1e-4)); EXPECT_TRUE(res_loose.converged); auto mesh2 = make_closed_tet(); auto res_strict = CGAL::discrete_circle_packing_euclidean( mesh2, CGAL::parameters::gradient_tolerance(1e-12)); EXPECT_TRUE(res_strict.converged); EXPECT_LT(res_strict.gradient_norm, 1e-10); } // ════════════════════════════════════════════════════════════════════════════ // 4. Inversive-distance (vertex-based) entry — natural-theta convergence // ════════════════════════════════════════════════════════════════════════════ TEST(CGALPhase8bLite, InversiveDistance_Triangle_NaturalThetaConverges) { auto mesh = make_triangle(); auto res = CGAL::discrete_inversive_distance_map(mesh); EXPECT_TRUE(res.converged); EXPECT_LT(res.gradient_norm, 1e-8); EXPECT_EQ(res.u_per_vertex.size(), num_vertices(mesh)); for (double u : res.u_per_vertex) EXPECT_NEAR(u, 0.0, 1e-8); } TEST(CGALPhase8bLite, InversiveDistance_QuadStrip_NamedParametersWork) { auto mesh = make_quad_strip(); // Named-parameter chaining (`a.b().c()`) is not currently supported on // the package-local tags; pass one parameter per call instead. auto res = CGAL::discrete_inversive_distance_map( mesh, CGAL::parameters::max_iterations(50)); EXPECT_TRUE(res.converged); EXPECT_LE(res.iterations, 50); } // ════════════════════════════════════════════════════════════════════════════ // 5. Layout wrapper — end-to-end through CGAL API on an open mesh // // Uses the legacy maps explicitly because the wrappers return the // Newton-converged x vector but not the maps. This exercises that the // `CGAL::euclidean_layout` shim works as expected. // ════════════════════════════════════════════════════════════════════════════ TEST(CGALPhase8bLite, Layout_EuclideanWrapper_RoundTrip) { auto mesh = make_open_3face(); // Set up the maps + run Newton via the CGAL Euclidean entry. auto res = CGAL::discrete_conformal_map_euclidean(mesh); ASSERT_TRUE(res.converged); // The wrapper does its own DOF assignment internally; we re-fetch // the (now-populated) EuclideanMaps from the mesh's property maps // to feed the layout wrapper. auto maps = setup_euclidean_maps(mesh); compute_euclidean_lambda0_from_mesh(mesh, maps); // Pin first vertex (mirrors the wrapper's gauge choice). 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(idx, 0.0); // wrapper's natural-theta equilibrium auto layout = CGAL::euclidean_layout(mesh, x, maps); EXPECT_EQ(layout.uv.size(), num_vertices(mesh)); // All UVs finite — basic sanity that the layout ran. for (auto& uv : layout.uv) { EXPECT_TRUE(std::isfinite(uv.x())); EXPECT_TRUE(std::isfinite(uv.y())); } } // ════════════════════════════════════════════════════════════════════════════ // 6. output_uv_map named parameter — integrated layout step // // Phase 8b-Lite extension (2026-05-22): if the caller supplies a property // map via `CGAL::parameters::output_uv_map(pmap)`, the entry function runs // the appropriate `*_layout()` after Newton and writes the per-vertex // coordinates into `pmap`. This closes the prior UX gap where users had // to call the wrapper, then re-set up maps, then call the legacy layout // API separately. // ════════════════════════════════════════════════════════════════════════════ TEST(CGALPhase8bLite, OutputUvMap_Euclidean_PopulatesPmap) { using K = CGAL::Simple_cartesian; auto mesh = make_quad_strip(); auto uv_map = mesh.add_property_map( "v:test_uv", K::Point_2(0, 0)).first; auto res = CGAL::discrete_conformal_map_euclidean( mesh, CGAL::parameters::output_uv_map(uv_map)); ASSERT_TRUE(res.converged); // The map must be populated with finite values. for (auto v : mesh.vertices()) { const auto& p = uv_map[v]; EXPECT_TRUE(std::isfinite(p.x())) << "non-finite UV.x at vertex " << v.idx(); EXPECT_TRUE(std::isfinite(p.y())) << "non-finite UV.y at vertex " << v.idx(); } // At least one vertex must have moved off the origin (the layout // did NOT just return defaults). bool any_nonzero = false; for (auto v : mesh.vertices()) { const auto& p = uv_map[v]; if (std::abs(p.x()) + std::abs(p.y()) > 1e-10) { any_nonzero = true; break; } } EXPECT_TRUE(any_nonzero) << "every UV is exactly (0,0) — layout did not run"; } TEST(CGALPhase8bLite, OutputUvMap_Spherical_PopulatesXyz) { using K = CGAL::Simple_cartesian; auto mesh = make_tetrahedron(); auto xyz_map = mesh.add_property_map( "v:test_xyz", K::Point_3(0, 0, 0)).first; auto res = CGAL::discrete_conformal_map_spherical( mesh, CGAL::parameters::output_uv_map(xyz_map)); ASSERT_TRUE(res.converged); // Every output point must lie on (or very near) the unit sphere. for (auto v : mesh.vertices()) { const auto& p = xyz_map[v]; const double r = std::sqrt(p.x()*p.x() + p.y()*p.y() + p.z()*p.z()); EXPECT_NEAR(r, 1.0, 1e-6) << "vertex " << v.idx() << " not on unit sphere"; } } TEST(CGALPhase8bLite, OutputUvMap_HyperIdeal_PointsInPoincareDisk) { using K = CGAL::Simple_cartesian; auto mesh = make_tetrahedron(); auto uv_map = mesh.add_property_map( "v:test_uv_hyp", K::Point_2(0, 0)).first; // Named-parameter chaining is not supported yet — pass output_uv_map only. auto res = CGAL::discrete_conformal_map_hyper_ideal( mesh, CGAL::parameters::output_uv_map(uv_map)); // The wrapper must complete and return a well-formed result struct // regardless of whether Newton fully converges with the default // Θ/θ targets in 200 iterations. We only verify that *if* the // layout step ran (which happens only on converged Newton), the // output is finite — Poincaré-disk geometric check is conditional. EXPECT_EQ(res.b_per_vertex.size(), num_vertices(mesh)); EXPECT_EQ(res.a_per_edge.size(), num_edges(mesh)); if (res.converged) { for (auto v : mesh.vertices()) { const auto& p = uv_map[v]; const double r2 = p.x()*p.x() + p.y()*p.y(); EXPECT_LE(r2, 1.0 + 1e-6) << "vertex " << v.idx() << " outside Poincaré disk (|p|² = " << r2 << ")"; } } // (else: Newton did not reach equilibrium; UV pmap is left at its // default (0,0) per the wrapper's "if (nr.converged)" guard. // No assertion needed; this is documented behaviour.) } TEST(CGALPhase8bLite, OutputUvMap_InversiveDistance_PopulatesPmap) { // Inversive-Distance: per-vertex u_i = log r_i. With output_uv_map // the entry function reconstructs effective Euclidean edge lengths via // the Bowers-Stephenson identity and reuses the euclidean_layout // priority-BFS to populate per-vertex Point_2 coordinates. using K = CGAL::Simple_cartesian; auto mesh = make_quad_strip(); auto uv_map = mesh.add_property_map( "v:test_uv_id", K::Point_2(0, 0)).first; auto res = CGAL::discrete_inversive_distance_map( mesh, CGAL::parameters::output_uv_map(uv_map)); ASSERT_TRUE(res.converged) << "ID Newton did not converge on quad_strip"; EXPECT_EQ(res.u_per_vertex.size(), num_vertices(mesh)); // Every UV must be finite; not all zero. bool any_nonzero = false; for (auto v : mesh.vertices()) { const auto& p = uv_map[v]; ASSERT_TRUE(std::isfinite(p.x())); ASSERT_TRUE(std::isfinite(p.y())); if (std::abs(p.x()) > 1e-9 || std::abs(p.y()) > 1e-9) any_nonzero = true; } EXPECT_TRUE(any_nonzero) << "all UVs are zero — layout did not run"; } TEST(CGALPhase8bLite, OutputUvMap_CPEuclidean_ThrowsClearly) { // CP-Euclidean is face-based; its natural layout is a per-face // circle packing in ℝ², not a per-vertex Point_2 map. The entry // throws std::runtime_error with a helpful message rather than // silently producing nonsense. See doc/architecture/locked-vs-flexible.md. using K = CGAL::Simple_cartesian; auto mesh = make_quad_strip(); auto uv_map = mesh.add_property_map( "v:test_uv_cp", K::Point_2(0, 0)).first; EXPECT_THROW( CGAL::discrete_circle_packing_euclidean( mesh, CGAL::parameters::output_uv_map(uv_map)), std::runtime_error) << "expected discrete_circle_packing_euclidean to reject " "`output_uv_map(...)` (face-based DOF, Phase 9c)."; // Sanity: without output_uv_map the entry function still works fine. auto res = CGAL::discrete_circle_packing_euclidean(mesh); // Convergence depends on the mesh; we only check no-throw + a sane // shape of the result struct. EXPECT_EQ(res.rho_per_face.size(), num_faces(mesh)); } TEST(CGALPhase8bLite, OutputUvMap_Absent_DoesNotRunLayout) { // Sanity: without the parameter, no layout work happens. Verified // here only via the fact that the call still succeeds and produces // the same u-vector as before. auto mesh = make_quad_strip(); auto res = CGAL::discrete_conformal_map_euclidean(mesh); EXPECT_TRUE(res.converged); EXPECT_EQ(res.u_per_vertex.size(), num_vertices(mesh)); } TEST(CGALPhase8bLite, OutputUvMap_NormaliseLayout_TakesEffect) { using K = CGAL::Simple_cartesian; auto mesh = make_quad_strip(); auto uv_raw = mesh.add_property_map( "v:test_uv_raw", K::Point_2(0, 0)).first; auto uv_norm = mesh.add_property_map( "v:test_uv_norm", K::Point_2(0, 0)).first; auto res1 = CGAL::discrete_conformal_map_euclidean( mesh, CGAL::parameters::output_uv_map(uv_raw)); auto res2 = CGAL::discrete_conformal_map_euclidean( mesh, CGAL::parameters::output_uv_map(uv_norm)); // (We can only pass one named parameter at a time without chaining; // test the toggle by running the wrapper twice and verifying the // raw call works. The normalise_layout flag is exercised in // internal unit tests via direct calls to normalise_euclidean.) ASSERT_TRUE(res1.converged); ASSERT_TRUE(res2.converged); // Both maps populated to finite values. for (auto v : mesh.vertices()) { EXPECT_TRUE(std::isfinite(uv_raw[v].x())); EXPECT_TRUE(std::isfinite(uv_norm[v].x())); } } // ════════════════════════════════════════════════════════════════════════════ // 7. Named-parameter chaining via pipe-operator // // CGAL's `.a().b().c()` chaining requires modifying CGAL upstream, which // we don't do. conformallab++ provides a `|` operator that achieves the // same effect by left-to-right composition. These tests verify that the // chain is read back correctly by the entry functions. // ════════════════════════════════════════════════════════════════════════════ TEST(CGALPhase8bLite, NamedParamPipe_MultipleParamsTakeEffect) { using K = CGAL::Simple_cartesian; auto mesh = make_quad_strip(); auto uv = mesh.add_property_map( "v:pipe_uv", K::Point_2(0, 0)).first; // Chain three parameters using `|`. auto params = CGAL::parameters::gradient_tolerance(1e-12) | CGAL::parameters::max_iterations(500) | CGAL::parameters::output_uv_map(uv); auto res = CGAL::discrete_conformal_map_euclidean(mesh, params); EXPECT_TRUE(res.converged); EXPECT_LT(res.gradient_norm, 1e-10); // tight tolerance applied EXPECT_LE(res.iterations, 500); // UV pmap was populated. bool any_nonzero = false; for (auto v : mesh.vertices()) { if (std::abs(uv[v].x()) + std::abs(uv[v].y()) > 1e-10) { any_nonzero = true; break; } } EXPECT_TRUE(any_nonzero); } TEST(CGALPhase8bLite, NamedParamPipe_TwoParams) { // Pipe two parameters and verify both take effect. auto mesh = make_triangle(); auto params = CGAL::parameters::max_iterations(0) | CGAL::parameters::gradient_tolerance(1e-6); auto res = CGAL::discrete_conformal_map_euclidean(mesh, params); EXPECT_EQ(res.iterations, 0); // max_iterations(0) blocks the loop }