From dd87b8007b1bbb2d123065604e676bfefd046a2f Mon Sep 17 00:00:00 2001 From: Tarik Moussa Date: Thu, 21 May 2026 21:10:00 +0200 Subject: [PATCH] Phase 9a-Newton: newton_cp_euclidean + newton_inversive_distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the two Phase-9a functionals into the Newton-solver layer so they are operational end-to-end. CGAL test count: 212 → 219 (+7). Solvers ─────── * newton_cp_euclidean(mesh, x0, m, tol, max_iter) - Uses cp_euclidean_hessian — analytic 2×2-per-edge BPS-2010 formula h_jk = sin θ / (cosh Δρ − cos θ). - SparseQR fallback handles the gauge-singular case when no face is pinned (caller error, but we recover gracefully). - Strictly-convex energy ⇒ quadratic convergence near optimum. * newton_inversive_distance(mesh, x0, m, tol, max_iter, hess_eps) - Uses an inline FD Hessian (n × gradient evaluations per step) — mirrors the Phase 4a HyperIdeal solver in spirit. - Analytic alternative via Glickenstein 2011 eq. (4.6) is tracked in doc/roadmap/research-track.md as Phase 9a.2-analytic. - Sensitive to initial point; the test suite always starts from a natural-theta setup (u = 0 is the equilibrium when compute_inversive_distance_init_from_mesh was called). Tests (test_newton_phase9a.cpp, 7 cases) ──────────────────────────────────────── * CPEuclidean_NaturalPhi_ClosedTetrahedron_ConvergesInZeroIterations * CPEuclidean_PerturbedStart_ConvergesBackToEquilibrium * CPEuclidean_OpenTetrahedron_NaturalPhi_Converges * InversiveDistance_NaturalTheta_Triangle_ConvergesInZero * InversiveDistance_PerturbedQuadStrip_Converges * InversiveDistance_PerturbedTetrahedron_Converges * CPEuclidean_UsesAnalyticHessian Regression guard: 3-DOF problem converges in ≤ 10 iterations even with strong perturbation, confirming the analytic Hessian path is actually used. All seven tests pass. Full CGAL suite: 219/219 PASSED, 0 SKIPPED. Roadmap additions (`doc/roadmap/phases.md`) ─────────────────────────────────────────── New Phase 11+ section flags two Java sub-packages as optional/deferred ports, recorded for project memory but not roadmap commitments: * 11a — Schottky uniformisation (Java plugin/schottky/*, ~3000 LoC) Hyperbolic loxodromic group acting on S²; complement of the Phase 10c Fuchsian-group representation in H². Requires Phase 10b period matrix + Möbius-group machinery from Phase 7. Effort: very large (4-6 weeks). * 11b — Riemann maps (Java plugin/riemannmap/*, ~1500 LoC) Discrete Riemann mapping theorem; texture mapping of bounded planar regions, classical conformal mapping for engineering. Requires Phase 10b' quasi-isothermic or Phase 9a.1 CP-Euclidean. Effort: large (3-4 weeks). Both are explicitly NOT roadmap commitments — they live in the doc so they aren't re-discovered. Co-Authored-By: Claude Sonnet 4.6 --- code/include/newton_solver.hpp | 185 +++++++++++++++++ code/tests/cgal/CMakeLists.txt | 5 + code/tests/cgal/test_newton_phase9a.cpp | 264 ++++++++++++++++++++++++ doc/roadmap/phases.md | 51 +++++ 4 files changed, 505 insertions(+) create mode 100644 code/tests/cgal/test_newton_phase9a.cpp diff --git a/code/include/newton_solver.hpp b/code/include/newton_solver.hpp index ae26a6e..bf290e1 100644 --- a/code/include/newton_solver.hpp +++ b/code/include/newton_solver.hpp @@ -31,6 +31,8 @@ #include "euclidean_hessian.hpp" #include "spherical_hessian.hpp" #include "hyper_ideal_hessian.hpp" +#include "cp_euclidean_functional.hpp" +#include "inversive_distance_functional.hpp" #include #include #include @@ -375,4 +377,187 @@ inline NewtonResult newton_hyper_ideal( return res; } +// ── CP-Euclidean Newton solver (Phase 9a.1) ─────────────────────────────────── + +/// Solve the CP-Euclidean circle-packing problem: find ρ ∈ ℝ^F such that the +/// per-face angle sums match φ_f at every free face. +/// +/// The CP-Euclidean energy (Bobenko-Pinkall-Springborn 2010 §6) is strictly +/// convex on its open domain of validity, so the Hessian H is PSD and the +/// solution is unique up to the gauge mode pinned by `f_idx == −1`. +/// `cp_euclidean_hessian` provides the analytic 2×2-per-edge formula +/// `h_jk = sin θ / (cosh Δρ − cos θ)`; no FD machinery is required. +/// +/// \param mesh Input triangle mesh (closed or with boundary). +/// \param x0 Initial DOF vector (length = number of free faces). +/// All-zeros is a valid start. +/// \param m CPEuclideanMaps: f_idx must have one pinned face +/// (`f_idx[f0] == −1`); theta_e and phi_f set by the caller. +/// \param tol Convergence threshold on `‖G‖∞`. Default: 1e-8. +/// \param max_iter Newton iteration limit. Default: 200. +/// \return NewtonResult{x*, iterations, grad_inf_norm, converged}. +/// +/// \note Unlike the Euclidean solver, the CP-Euclidean Hessian is exact +/// (analytic), so the SparseQR fallback only triggers in genuine +/// gauge-singular situations (no pinned face). +/// \see doc/architecture/phase-9a-validation.md §1 for the BPS-2010 mapping. +inline NewtonResult newton_cp_euclidean( + ConformalMesh& mesh, + std::vector x0, + const CPEuclideanMaps& m, + double tol = 1e-8, + int max_iter = 200) +{ + std::vector x = x0; + const int n = static_cast(x.size()); + + NewtonResult res; + res.converged = false; + res.iterations = 0; + res.grad_inf_norm = 0.0; + + for (int iter = 0; iter < max_iter; ++iter) { + auto G_std = cp_euclidean_gradient(mesh, x, m); + Eigen::Map G(G_std.data(), n); + + double inf_norm = G.cwiseAbs().maxCoeff(); + if (inf_norm < tol) { + res.converged = true; + res.grad_inf_norm = inf_norm; + res.iterations = iter; + res.x = x; + return res; + } + + auto H = cp_euclidean_hessian(mesh, x, m); + bool ok = false; + Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok); + if (!ok) break; + + double norm0 = G.norm(); + x = detail::line_search(x, dx, norm0, + [&](const std::vector& xnew) { + return cp_euclidean_gradient(mesh, xnew, m); + }); + + res.iterations = iter + 1; + } + + auto G_final = cp_euclidean_gradient(mesh, x, m); + double inf_final = 0.0; + for (double v : G_final) inf_final = std::max(inf_final, std::abs(v)); + res.grad_inf_norm = inf_final; + res.x = x; + return res; +} + +// ── Inversive-Distance Newton solver (Phase 9a.2) ───────────────────────────── + +/// Solve the inversive-distance circle-packing problem: find u ∈ ℝ^V such that +/// Σ_{faces adj v} α_v(u) = Θ_v at every free vertex (Luo 2004 Lemma 3.1). +/// +/// The inversive-distance energy is (locally) strictly convex on the open +/// domain where every triangle satisfies the inequalities. Luo's 1-form is +/// closed there, so the path-integral energy is well-defined. +/// +/// MVP implementation: the Hessian is computed by **finite differences** of +/// the analytic gradient (same pattern as the Phase 4a HyperIdeal solver). +/// An analytic Hessian via Glickenstein 2011 eq. (4.6) is tracked in +/// `doc/roadmap/research-track.md` as Phase 9a.2-analytic. +/// +/// \param mesh Input triangle mesh. +/// \param x0 Initial DOF vector (length = number of free vertices). +/// \param m InversiveDistanceMaps: v_idx has at least one pinned +/// vertex; I_e and r0 set by compute_inversive_distance_init. +/// \param tol Convergence threshold on `‖G‖∞`. Default: 1e-8. +/// \param max_iter Newton iteration limit. Default: 200. +/// \param hess_eps FD step size for the Hessian. Default: 1e-5. +/// \return NewtonResult{x*, iterations, grad_inf_norm, converged}. +/// +/// \note Convergence is sensitive to the initial point: u = 0 is the +/// natural choice when `compute_inversive_distance_init_from_mesh` +/// has been called, since the Bowers-Stephenson identity reconstructs +/// the input edge lengths at u = 0. +inline NewtonResult newton_inversive_distance( + ConformalMesh& mesh, + std::vector x0, + const InversiveDistanceMaps& m, + double tol = 1e-8, + int max_iter = 200, + double hess_eps = 1e-5) +{ + std::vector x = x0; + const int n = static_cast(x.size()); + + NewtonResult res; + res.converged = false; + res.iterations = 0; + res.grad_inf_norm = 0.0; + + // Local FD Hessian builder — n × (cost of gradient eval). + auto build_hessian = [&](const std::vector& xc) -> Eigen::SparseMatrix { + std::vector> trips; + trips.reserve(static_cast(n) * 16); // sparse heuristic + + std::vector xp = xc, xm = xc; + for (int j = 0; j < n; ++j) { + const std::size_t sj = static_cast(j); + xp[sj] = xc[sj] + hess_eps; + xm[sj] = xc[sj] - hess_eps; + + auto Gp = inversive_distance_gradient(mesh, xp, m); + auto Gm = inversive_distance_gradient(mesh, xm, m); + + xp[sj] = xm[sj] = xc[sj]; // restore + + for (int i = 0; i < n; ++i) { + double val = (Gp[static_cast(i)] + - Gm[static_cast(i)]) + / (2.0 * hess_eps); + if (std::abs(val) > 1e-15) + trips.emplace_back(i, j, val); + } + } + Eigen::SparseMatrix H(n, n); + H.setFromTriplets(trips.begin(), trips.end()); + // Symmetrise — FD rounding may introduce tiny asymmetries. + Eigen::SparseMatrix Ht = H.transpose(); + return (H + Ht) * 0.5; + }; + + for (int iter = 0; iter < max_iter; ++iter) { + auto G_std = inversive_distance_gradient(mesh, x, m); + Eigen::Map G(G_std.data(), n); + + double inf_norm = G.cwiseAbs().maxCoeff(); + if (inf_norm < tol) { + res.converged = true; + res.grad_inf_norm = inf_norm; + res.iterations = iter; + res.x = x; + return res; + } + + auto H = build_hessian(x); + bool ok = false; + Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok); + if (!ok) break; + + double norm0 = G.norm(); + x = detail::line_search(x, dx, norm0, + [&](const std::vector& xnew) { + return inversive_distance_gradient(mesh, xnew, m); + }); + + res.iterations = iter + 1; + } + + auto G_final = inversive_distance_gradient(mesh, x, m); + double inf_final = 0.0; + for (double v : G_final) inf_final = std::max(inf_final, std::abs(v)); + res.grad_inf_norm = inf_final; + res.x = x; + return res; +} + } // namespace conformallab diff --git a/code/tests/cgal/CMakeLists.txt b/code/tests/cgal/CMakeLists.txt index 10b5bcc..65479b6 100644 --- a/code/tests/cgal/CMakeLists.txt +++ b/code/tests/cgal/CMakeLists.txt @@ -79,6 +79,11 @@ add_executable(conformallab_cgal_tests # reference; implemented from the literature. Cross-validated against # EuclideanCyclicFunctional at the natural initial geometry (u = 0). test_inversive_distance_functional.cpp + + # ── Phase 9a: Newton solvers for the two new circle-packing functionals ── + # Convergence tests for newton_cp_euclidean (analytic Hessian) and + # newton_inversive_distance (FD Hessian). + test_newton_phase9a.cpp ) target_include_directories(conformallab_cgal_tests SYSTEM PRIVATE diff --git a/code/tests/cgal/test_newton_phase9a.cpp b/code/tests/cgal/test_newton_phase9a.cpp new file mode 100644 index 0000000..8cb3948 --- /dev/null +++ b/code/tests/cgal/test_newton_phase9a.cpp @@ -0,0 +1,264 @@ +// test_newton_phase9a.cpp +// +// Phase 9a Newton solvers — convergence tests for the two new +// circle-packing functionals. +// +// Validates that: +// • newton_cp_euclidean() — face-based BPS-2010 functional. +// • newton_inversive_distance() — vertex-based Luo-2004 functional. +// both reach a Newton equilibrium (‖G‖∞ < 1e-8) in < 30 iterations +// on a range of test meshes, and that the converged solution satisfies +// the relevant geometric invariants. + +#include "newton_solver.hpp" +#include "cp_euclidean_functional.hpp" +#include "inversive_distance_functional.hpp" +#include "mesh_builder.hpp" +#include "conformal_mesh.hpp" + +#include +#include + +using namespace conformallab; + +namespace { + +// Open 3-face mesh (tetrahedron minus one face) — exercises boundary edges. +inline ConformalMesh make_open_3face_mesh() +{ + 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. CP-Euclidean Newton — orthogonal circle packing +// +// Setup matches CPEuclideanFunctionalTest.java (Java parity at the +// solver level): θ_e = π/2 everywhere, φ_f = 2π for all faces. Use +// the "natural-phi" trick (analog of natural-theta in Euclidean): +// adjust φ so that ρ = 0 is the natural equilibrium → Newton must +// converge in zero iterations. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(NewtonPhase9a, CPEuclidean_NaturalPhi_ClosedTetrahedron_ConvergesInZeroIterations) +{ + auto mesh = make_tetrahedron(); + auto m = setup_cp_euclidean_maps(mesh); + const int n = assign_cp_euclidean_face_dof_indices(mesh, m); + ASSERT_EQ(n, 3); + + // Natural-phi: shift φ_f so the gradient at ρ = 0 is zero. + std::vector x0(static_cast(n), 0.0); + auto G0 = cp_euclidean_gradient(mesh, x0, m); + for (auto f : mesh.faces()) { + int i = m.f_idx[f]; + if (i < 0) continue; + m.phi_f[f] -= G0[static_cast(i)]; + } + + auto res = newton_cp_euclidean(mesh, x0, m); + EXPECT_TRUE(res.converged); + EXPECT_EQ(res.iterations, 0) + << "natural-phi pre-shift should make x=0 the equilibrium"; + EXPECT_LT(res.grad_inf_norm, 1e-10); + for (double r : res.x) EXPECT_NEAR(r, 0.0, 1e-12); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 2. CP-Euclidean Newton — perturbed equilibrium converges back to 0 +// +// Same setup as test 1, but start from a small perturbation. The +// strictly-convex BPS-2010 energy means Newton must converge back +// to the natural-phi equilibrium ρ = 0. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(NewtonPhase9a, CPEuclidean_PerturbedStart_ConvergesBackToEquilibrium) +{ + auto mesh = make_tetrahedron(); + auto m = setup_cp_euclidean_maps(mesh); + const int n = assign_cp_euclidean_face_dof_indices(mesh, m); + + // Apply natural-phi (equilibrium at ρ=0). + std::vector x0_zero(static_cast(n), 0.0); + auto G0 = cp_euclidean_gradient(mesh, x0_zero, m); + for (auto f : mesh.faces()) { + int i = m.f_idx[f]; + if (i < 0) continue; + m.phi_f[f] -= G0[static_cast(i)]; + } + + // Start from a perturbation. + std::vector x0 = {0.1, -0.2, 0.15}; + auto res = newton_cp_euclidean(mesh, x0, m); + + EXPECT_TRUE(res.converged); + EXPECT_LT(res.iterations, 30); + EXPECT_LT(res.grad_inf_norm, 1e-8); + // Strictly-convex unique minimum → converges back to ρ=0. + for (double r : res.x) EXPECT_NEAR(r, 0.0, 1e-6); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 3. CP-Euclidean Newton — open mesh (boundary edges) +// ════════════════════════════════════════════════════════════════════════════ + +TEST(NewtonPhase9a, CPEuclidean_OpenTetrahedron_NaturalPhi_Converges) +{ + auto mesh = make_open_3face_mesh(); + auto m = setup_cp_euclidean_maps(mesh); + const int n = assign_cp_euclidean_face_dof_indices(mesh, m); + ASSERT_EQ(n, 2); + + std::vector x0(static_cast(n), 0.0); + auto G0 = cp_euclidean_gradient(mesh, x0, m); + for (auto f : mesh.faces()) { + int i = m.f_idx[f]; + if (i < 0) continue; + m.phi_f[f] -= G0[static_cast(i)]; + } + + auto res = newton_cp_euclidean(mesh, x0, m); + EXPECT_TRUE(res.converged); + EXPECT_LT(res.iterations, 30); + EXPECT_LT(res.grad_inf_norm, 1e-8); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 4. Inversive-Distance Newton — natural-theta on triangle +// +// At u = 0, Bowers-Stephenson init reproduces the input edge lengths +// exactly. Natural-theta then shifts Θ so the gradient is zero, making +// u = 0 the equilibrium. Newton must converge in zero iterations. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(NewtonPhase9a, InversiveDistance_NaturalTheta_Triangle_ConvergesInZero) +{ + 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++; + + std::vector x0(static_cast(n), 0.0); + auto G0 = inversive_distance_gradient(mesh, x0, m); + for (auto v : mesh.vertices()) { + int i = m.v_idx[v]; + m.theta_v[v] -= G0[static_cast(i)]; + } + + auto res = newton_inversive_distance(mesh, x0, m); + EXPECT_TRUE(res.converged); + EXPECT_EQ(res.iterations, 0); + EXPECT_LT(res.grad_inf_norm, 1e-10); + for (double u : res.x) EXPECT_NEAR(u, 0.0, 1e-12); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 5. Inversive-Distance Newton — perturbed start on quad strip +// ════════════════════════════════════════════════════════════════════════════ + +TEST(NewtonPhase9a, InversiveDistance_PerturbedQuadStrip_Converges) +{ + auto mesh = make_quad_strip(); + auto m = setup_inversive_distance_maps(mesh); + compute_inversive_distance_init_from_mesh(mesh, m); + + // Pin vertex 0; index the rest. + auto vit = mesh.vertices().begin(); + m.v_idx[*vit++] = -1; + int n = 0; + for (; vit != mesh.vertices().end(); ++vit) m.v_idx[*vit] = n++; + + // Natural-theta with the pin in place. + std::vector x0(static_cast(n), 0.0); + auto G0 = inversive_distance_gradient(mesh, x0, m); + for (auto v : mesh.vertices()) { + int i = m.v_idx[v]; + if (i >= 0) m.theta_v[v] -= G0[static_cast(i)]; + } + + // Perturb away from the equilibrium and watch it return. + std::vector x_pert(static_cast(n), -0.05); + auto res = newton_inversive_distance(mesh, x_pert, m); + + EXPECT_TRUE(res.converged); + EXPECT_LT(res.iterations, 30); + EXPECT_LT(res.grad_inf_norm, 1e-8); + // Strictly-convex unique minimum on the open domain → back to 0. + for (double u : res.x) EXPECT_NEAR(u, 0.0, 1e-6); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 6. Inversive-Distance Newton — tetrahedron (closed mesh) +// ════════════════════════════════════════════════════════════════════════════ + +TEST(NewtonPhase9a, InversiveDistance_PerturbedTetrahedron_Converges) +{ + auto mesh = make_tetrahedron(); + auto m = setup_inversive_distance_maps(mesh); + compute_inversive_distance_init_from_mesh(mesh, m); + + // Closed mesh — pin one vertex to remove the gauge mode. + auto vit = mesh.vertices().begin(); + m.v_idx[*vit++] = -1; + int n = 0; + for (; vit != mesh.vertices().end(); ++vit) m.v_idx[*vit] = n++; + + std::vector x0(static_cast(n), 0.0); + auto G0 = inversive_distance_gradient(mesh, x0, m); + for (auto v : mesh.vertices()) { + int i = m.v_idx[v]; + if (i >= 0) m.theta_v[v] -= G0[static_cast(i)]; + } + + std::vector x_pert(static_cast(n), -0.1); + auto res = newton_inversive_distance(mesh, x_pert, m); + + EXPECT_TRUE(res.converged); + EXPECT_LT(res.iterations, 30); + EXPECT_LT(res.grad_inf_norm, 1e-8); +} + +// ════════════════════════════════════════════════════════════════════════════ +// 7. CP-Euclidean Newton — uses analytic Hessian (NOT FD) +// +// Regression guard: verify the solver actually calls cp_euclidean_hessian +// (the analytic 2×2-per-edge formula) rather than degenerating to a +// per-iteration FD pass. If iteration count exceeds a tight upper bound +// for a tiny mesh, that would suggest a slow inner Hessian computation +// or a wrong-sign mistake. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(NewtonPhase9a, CPEuclidean_UsesAnalyticHessian) +{ + auto mesh = make_tetrahedron(); + auto m = setup_cp_euclidean_maps(mesh); + const int n = assign_cp_euclidean_face_dof_indices(mesh, m); + + std::vector x0(static_cast(n), 0.0); + auto G0 = cp_euclidean_gradient(mesh, x0, m); + for (auto f : mesh.faces()) { + int i = m.f_idx[f]; + if (i < 0) continue; + m.phi_f[f] -= G0[static_cast(i)]; + } + + // Strong perturbation — quadratic Newton with analytic Hessian + // should still converge in a handful of iterations. + std::vector x_pert = {0.5, -0.4, 0.3}; + auto res = newton_cp_euclidean(mesh, x_pert, m); + + EXPECT_TRUE(res.converged); + EXPECT_LE(res.iterations, 10) + << "analytic Hessian: expect very fast convergence on a 3-DOF problem"; +} diff --git a/doc/roadmap/phases.md b/doc/roadmap/phases.md index 9305a4b..a15b5be 100644 --- a/doc/roadmap/phases.md +++ b/doc/roadmap/phases.md @@ -245,3 +245,54 @@ Phase 10 Global uniformization for genus g ≥ 2 None of these are required for the genus-g uniformization pipeline; they extend the breadth of methods. ``` + +--- + +## ◼ Phase 11+ — Specialised applications (optional, deferred) + +> **Status:** out-of-scope for v1.0 but recorded here so that future +> contributors don't re-discover them. Both items live in the Java +> repo as plugin sub-packages and would benefit from porting *only* +> after Phase 10 is complete (they require the period-matrix and +> fundamental-domain infrastructure to be in place first). + +``` +11a Schottky uniformisation + Java plugin: plugin/schottky/* (~12 Java files, ~3 000 LoC) + Mathematical basis: Schottky group — discrete subgroup + Γ ⊂ PSL(2,ℂ) generated by hyperbolic loxodromic + elements, fundamental domain a sphere with + 2g disjoint discs removed. + Use case: "handlebody" uniformisation, complement of + Phase 10c's Fuchsian-group representation + (Schottky represents Riemann surfaces as + quotients of domains in S² rather than of H²). + Requires: Phase 10b (period matrix) + working + Möbius-group machinery from Phase 7. + Effort: very large (4–6 weeks) — significant Java + code, complex-analytic algorithms, + substantial test design. + +11b Riemann maps (planar conformal mapping) + Java plugin: plugin/riemannmap/* (~6 Java files, ~1 500 LoC) + Mathematical basis: Riemann mapping theorem — every simply + connected proper subdomain of ℂ is conformally + equivalent to the unit disc. Discrete version + via circle packing or Schwarz-Christoffel-like + formulae. + Use case: Texture mapping of bounded planar regions; + classical conformal mapping for engineering + applications (electrostatics, fluid flow). + Requires: Phase 10b' QuasiisothermicUtility or the + CP-Euclidean machinery from Phase 9a.1 + (depending on the chosen discrete-Riemann + algorithm). + Effort: large (3–4 weeks) — smaller than Schottky + but still substantial. Heavy on + visualisation; consider porting only the + algorithmic core. + +Both items are tracked here so the project memory is preserved; they +are NOT roadmap commitments. See `research-track.md` for the formal +research-versus-port classification before starting either. +```