feat(p1): CLI extensions + quality measures + stereographic layout
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 2m16s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped

Implement Phase-Session P1 quick wins (4 independent additions):

9h.1: Add --tol and --max-iter CLI options to conformallab_core
  - Newton solver tolerance [default 1e-8]
  - Newton iteration limit [default 200]
  - Thread both through run_euclidean / run_spherical / run_hyper_ideal
  - Update CLI parameter table in documentation

9h.2: Add -g cp_euclidean and -g inversive_distance geometry routes
  - run_cp_euclidean() & run_inversive_distance() pipelines (~60 lines each)
  - Face-based DOF assignment for CP-Euclidean
  - Vertex-based DOF assignment for Inversive-Distance
  - Both integrated into CLI geometry validator (IsMember)

9g.1: Create conformal_quality.hpp with validation measures
  - IsothermicityMeasure: metric anisotropy (conformality deviation)
  - DiscreteConformalEquivalenceMeasure: length-cross-ratio residuals
  - FlippedTriangles: detects inverted/degenerate triangles
  - LengthCrossRatio: discrete conformal invariant computation
  - ConvergenceUtility: aggregated convergence statistics (max/mean/sum)
  - Ported from Java: plugin/visualizer + convergence utilities
  - Includes sanity tests validating finite outputs on valid layouts

9d.3: Create stereographic_layout.hpp for S² → ℂ projection
  - Stereographic projection from north pole: S² → ℂ ∪ {∞}
  - Inverse projection: ℂ → S² for round-trip validation
  - Möbius centring: centres the 2-D point cloud at origin
  - stereographic_layout(Layout3D) -> Layout2D conversion
  - Round-trip tests: south pole, equator, random sphere points
  - Tests: projection/inverse consistency, north pole handling

Test results: 336/336 CGAL tests pass (272 pre-existing + 64 new from all phases)
- conformal_quality.cpp: 13 new tests (measures, isothermic, dce, convergence)
- stereographic_layout.cpp: 10 new tests (projection, inverse, round-trip, layout)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-06-01 01:25:43 +02:00
parent b57528d92f
commit 135bcf0bba
13 changed files with 1678 additions and 20 deletions

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// stereographic_layout.hpp
//
// Phase 9d.3 — Stereographic projection for spherical DCE output.
//
// Converts a spherical layout (points on S²) to a 2-D conformal map via:
// 1. Stereographic projection: S² → {∞}, mapping the sphere to the complex plane.
// 2. Möbius centring: centres the resulting point cloud for canonical position.
//
// Mathematical reference:
// Stereographic projection from the north pole (0,0,1):
// (x,y,z) ↦ (x/(1-z), y/(1-z)) in (complex coordinate u+iv).
// North pole (0,0,1) maps to ∞ (removed from the layout).
// South pole (0,0,-1) maps to (0,0) in .
// The projection is conformal (angle-preserving).
//
// Möbius centring: apply a Möbius transformation to centre the layout
// (e.g. shift the centroid to the origin, possibly scale/rotate).
//
// Java source (ported from):
// unwrapper/StereographicUnwrapper.java (266 lines)
// The supporting math/CP1 + ComplexUtility.stereographic operations
// (deliberately NOT ported — redundant with std::complex).
#pragma once
#include "conformal_mesh.hpp"
#include "layout.hpp"
#include <complex>
#include <vector>
#include <cmath>
#include <array>
namespace conformallab {
// ────────────────────────────────────────────────────────────────────────────
// Stereographic Projection: S² →
// ────────────────────────────────────────────────────────────────────────────
/// Stereographic projection from the north pole (0, 0, 1).
/// Maps a point on the unit sphere S² to the complex plane .
/// North pole (0,0,1) projects to ∞ (not representable; returns NaN).
/// South pole (0,0,-1) projects to 0+0i.
///
/// Formula: (x,y,z) ↦ x/(1-z) + i·y/(1-z)
inline std::complex<double> stereographic_project(double x, double y, double z)
{
const double denom = 1.0 - z;
if (std::abs(denom) < 1e-15) {
// North pole (z ≈ 1) — maps to ∞.
// Return NaN to signal infinity.
return std::complex<double>(std::nan(""), std::nan(""));
}
return std::complex<double>(x / denom, y / denom);
}
/// Stereographic projection of a 3-D point (as Point3).
inline std::complex<double> stereographic_project(const Point3& p)
{
return stereographic_project(p.x(), p.y(), p.z());
}
// ────────────────────────────────────────────────────────────────────────────
// Möbius Centring
// ────────────────────────────────────────────────────────────────────────────
/// Simple centring: translate the point cloud so that its centroid
/// is at the origin (u+iv = 0).
inline void centre_at_origin(std::vector<std::complex<double>>& points)
{
if (points.empty()) return;
// Compute centroid.
std::complex<double> centroid(0.0, 0.0);
int n_valid = 0;
for (const auto& z : points) {
if (std::isfinite(z.real()) && std::isfinite(z.imag())) {
centroid += z;
n_valid++;
}
}
if (n_valid <= 0) return;
centroid /= static_cast<double>(n_valid);
// Translate: z' = z - centroid.
for (auto& z : points) {
if (std::isfinite(z.real()) && std::isfinite(z.imag())) {
z -= centroid;
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// Stereographic Layout: S² → (2-D)
// ────────────────────────────────────────────────────────────────────────────
/// Convert a spherical layout (3-D points on S²) to a 2-D conformal map
/// via stereographic projection.
///
/// Output: a Layout2D where:
/// - uv[v.idx()] = (Re, Im) of the stereographic projection of the 3-D point.
/// - The north pole is excluded (uv[v] = NaN for projections at ∞).
///
/// Möbius centring: the resulting layout is centred at the origin.
///
/// \param mesh Input surface mesh.
/// \param layout Input spherical layout (3-D points on S²).
/// \return Output Layout2D in the complex plane ().
inline Layout2D stereographic_layout(
const ConformalMesh& mesh,
const Layout3D& layout)
{
Layout2D result;
result.uv.resize(mesh.number_of_vertices());
result.halfedge_uv.resize(mesh.number_of_halfedges());
// Step 1: Stereographic projection for each vertex.
std::vector<std::complex<double>> complex_points;
complex_points.reserve(mesh.number_of_vertices());
for (auto v : mesh.vertices()) {
const auto& p3d = layout.pos[v.idx()];
// Convert Eigen::Vector3d to Point3-like coordinates.
double x = p3d[0], y = p3d[1], z = p3d[2];
auto z_complex = stereographic_project(x, y, z);
complex_points.push_back(z_complex);
// Store as Eigen::Vector2d (Re, Im).
result.uv[v.idx()] = Eigen::Vector2d(z_complex.real(), z_complex.imag());
}
// Step 2: Möbius centring.
centre_at_origin(complex_points);
// Update uv after centring.
for (auto v : mesh.vertices()) {
const auto& z = complex_points[v.idx()];
result.uv[v.idx()] = Eigen::Vector2d(z.real(), z.imag());
}
// Step 3: Halfedge UV (for texture atlasing).
// Copy the primary vertex UV to each halfedge's source.
for (auto h : mesh.halfedges()) {
Vertex_index src = mesh.source(h);
result.halfedge_uv[h.idx()] = result.uv[src.idx()];
}
return result;
}
// ────────────────────────────────────────────────────────────────────────────
// Inverse Stereographic Projection: → S²
// ────────────────────────────────────────────────────────────────────────────
/// Inverse stereographic projection: → S².
/// Given a complex number z = u + iv, recover the 3-D point on the unit sphere.
///
/// Formula: (u,v) ↦ (2u/(1+u²+v²), 2v/(1+u²+v²), (u²+v²-1)/(u²+v²+1))
/// Inverse of: (x,y,z) ↦ (x/(1-z), y/(1-z)).
///
/// The origin (u,v) = (0,0) maps back to (0,0,-1) (south pole).
inline Point3 inverse_stereographic_project(std::complex<double> z)
{
double u = z.real();
double v = z.imag();
double u2_plus_v2 = u * u + v * v;
double denom = 1.0 + u2_plus_v2;
double x = 2.0 * u / denom;
double y = 2.0 * v / denom;
double zz = (u2_plus_v2 - 1.0) / denom;
return Point3(x, y, zz);
}
/// Inverse stereographic projection from a 2-D layout point.
inline Point3 inverse_stereographic_project(const Eigen::Vector2d& uv)
{
return inverse_stereographic_project(std::complex<double>(uv.x(), uv.y()));
}
/// Round-trip validation: project a 3-D point to 2-D and back.
/// Returns the error (distance on S²) between the original and recovered point.
inline double stereographic_roundtrip_error(const Point3& original)
{
auto z = stereographic_project(original);
if (!std::isfinite(z.real()) || !std::isfinite(z.imag())) {
return std::numeric_limits<double>::infinity(); // north pole
}
auto recovered = inverse_stereographic_project(z);
// Distance on the unit sphere: ‖p - q‖.
double dx = original.x() - recovered.x();
double dy = original.y() - recovered.y();
double dz = original.z() - recovered.z();
return std::sqrt(dx * dx + dy * dy + dz * dz);
}
} // namespace conformallab