feat(p1): CLI extensions + quality measures + stereographic layout
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:
384
code/include/conformal_quality.hpp
Normal file
384
code/include/conformal_quality.hpp
Normal file
@@ -0,0 +1,384 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// conformal_quality.hpp
|
||||
//
|
||||
// Phase 9g.1 — Quantitative correctness metrics for computed conformal maps.
|
||||
//
|
||||
// Measures the quality and validity of a discrete conformal map layout:
|
||||
// - IsothermicityMeasure: pointwise deviation from conformality (metric anisotropy).
|
||||
// - DiscreteConformalEquivalenceMeasure: per-edge length-cross-ratio residual.
|
||||
// - FlippedTriangles: detects inverted/degenerate triangles in 2-D layouts.
|
||||
// - LengthCrossRatio: the discrete conformal invariant (per-edge).
|
||||
// - ConvergenceUtility: aggregated convergence measures (max, mean, sum of cross-ratios).
|
||||
//
|
||||
// Mathematical references:
|
||||
// Springborn-Schröder-Pinkall 2008: discrete conformal invariant theory.
|
||||
// Bobenko-Springborn 2004: variational foundation.
|
||||
//
|
||||
// Java sources (ported from):
|
||||
// plugin/visualizer/IsothermicityMeasure.java
|
||||
// plugin/visualizer/DiscreteConformalEquivalencemMeasure.java
|
||||
// plugin/visualizer/FlippedTriangles.java
|
||||
// heds/adapter/types/LengthCrossRatio.java
|
||||
// convergence/ConvergenceUtility.java
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include "layout.hpp"
|
||||
#include <Eigen/Dense>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// LengthCrossRatio — the discrete conformal invariant
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the cross-ratio q = (a·c)/(b·d) of the four edges of a
|
||||
/// quadrilateral formed by two adjacent triangles sharing an edge.
|
||||
/// Input: edge lengths a, b, c, d in order around the quad.
|
||||
/// Returns the cross-ratio q.
|
||||
inline double length_cross_ratio(double a, double b, double c, double d)
|
||||
{
|
||||
const double denom = b * d;
|
||||
if (denom < 1e-16) return 0.0; // degenerate edge
|
||||
return (a * c) / denom;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// IsothermicityMeasure — pointwise metric anisotropy
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Evaluate the isothermicity measure at a single vertex in a 2-D layout.
|
||||
/// Isothermicity is the local conformality condition: the metric tensor
|
||||
/// is a positive scalar multiple of the identity (no anisotropy).
|
||||
/// Measure: pointwise deviation from a conformal map.
|
||||
/// Returns the anisotropy ratio (1.0 = isotropic / conformal).
|
||||
inline double isothermicity_measure_at_vertex(
|
||||
const ConformalMesh& mesh,
|
||||
Vertex_index v,
|
||||
const Layout2D& layout)
|
||||
{
|
||||
// Collect all halfedges emanating from v.
|
||||
std::vector<Halfedge_index> hs;
|
||||
for (auto h : CGAL::halfedges_around_source(v, mesh))
|
||||
hs.push_back(h);
|
||||
|
||||
if (hs.empty()) return 1.0;
|
||||
|
||||
// Compute metric tensor components at v via edge pairs.
|
||||
// For a conformal map, the metric g = λ²I (λ > 0 scale factor, I identity).
|
||||
// Compute an empirical metric from the layout: edges adjacent to v
|
||||
// span the tangent space.
|
||||
double g11 = 0.0, g12 = 0.0, g22 = 0.0;
|
||||
int n_edges = 0;
|
||||
|
||||
for (std::size_t i = 0; i < hs.size(); ++i) {
|
||||
auto h1 = hs[i];
|
||||
auto h2 = hs[(i + 1) % hs.size()];
|
||||
|
||||
Vertex_index v2 = mesh.target(h1); // = mesh.source(h2)
|
||||
Vertex_index v3 = mesh.target(h2);
|
||||
|
||||
const auto& p1 = layout.uv[v.idx()];
|
||||
const auto& p2 = layout.uv[v2.idx()];
|
||||
const auto& p3 = layout.uv[v3.idx()];
|
||||
|
||||
// Two edge vectors from v.
|
||||
double e1x = p2.x() - p1.x(), e1y = p2.y() - p1.y();
|
||||
double e2x = p3.x() - p1.x(), e2y = p3.y() - p1.y();
|
||||
|
||||
// Metric tensor as outer product (unnormalised).
|
||||
g11 += e1x * e1x;
|
||||
g12 += e1x * e1y;
|
||||
g22 += e1y * e1y;
|
||||
|
||||
// Also accumulate e2 contribution (for a rotationally averaged metric).
|
||||
g11 += e2x * e2x;
|
||||
g12 += e2x * e2y;
|
||||
g22 += e2y * e2y;
|
||||
|
||||
n_edges += 2;
|
||||
}
|
||||
|
||||
if (n_edges <= 0) return 1.0;
|
||||
|
||||
g11 /= n_edges;
|
||||
g12 /= n_edges;
|
||||
g22 /= n_edges;
|
||||
|
||||
// Eigenvalues of g: λ_± = (g11 + g22 ± √((g11-g22)² + 4g12²)) / 2.
|
||||
double trace = g11 + g22;
|
||||
double det = g11 * g22 - g12 * g12;
|
||||
|
||||
if (trace < 1e-16 || det < 1e-16) return 1.0; // degenerate
|
||||
|
||||
double disc = (g11 - g22) * (g11 - g22) + 4.0 * g12 * g12;
|
||||
disc = std::sqrt(disc);
|
||||
|
||||
double lambda_max = (trace + disc) / 2.0;
|
||||
double lambda_min = (trace - disc) / 2.0;
|
||||
|
||||
if (lambda_min < 1e-16) return 1.0; // degenerate
|
||||
|
||||
// Anisotropy: λ_max / λ_min (conformal ⟺ ratio ≈ 1).
|
||||
return lambda_max / lambda_min;
|
||||
}
|
||||
|
||||
/// Compute the isothermicity measure for the entire layout.
|
||||
/// Returns a vector of anisotropy ratios, one per vertex.
|
||||
inline std::vector<double> isothermicity_measure(
|
||||
const ConformalMesh& mesh,
|
||||
const Layout2D& layout)
|
||||
{
|
||||
std::vector<double> result;
|
||||
result.reserve(mesh.number_of_vertices());
|
||||
for (auto v : mesh.vertices())
|
||||
result.push_back(isothermicity_measure_at_vertex(mesh, v, layout));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// DiscreteConformalEquivalenceMeasure — length-cross-ratio residual
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Evaluate the discrete conformal equivalence condition at a single edge.
|
||||
/// For an edge e = (i,j), form the quad with the two adjacent triangles:
|
||||
/// compute the cross-ratio q from the layout edge lengths.
|
||||
/// The conformal condition is: q + 1/q = 2 (i.e. q = 1, isotropic scaling).
|
||||
/// Measure: |q + 1/q - 2| (residual; 0 = conformal).
|
||||
inline double discrete_conformal_equivalence_at_edge(
|
||||
const ConformalMesh& mesh,
|
||||
Edge_index e,
|
||||
const Layout2D& layout)
|
||||
{
|
||||
// Find the two halfedges for this edge.
|
||||
auto h = mesh.halfedge(e);
|
||||
|
||||
// Get the four vertices of the quad formed by the two adjacent triangles.
|
||||
Vertex_index v1 = mesh.source(h);
|
||||
Vertex_index v2 = mesh.target(h);
|
||||
Vertex_index v3 = mesh.source(mesh.next(h));
|
||||
Vertex_index v4 = mesh.source(mesh.next(mesh.opposite(h)));
|
||||
|
||||
// Compute edge lengths from the layout.
|
||||
auto dist = [&layout](Vertex_index u1, Vertex_index u2) {
|
||||
const auto& p1 = layout.uv[u1.idx()];
|
||||
const auto& p2 = layout.uv[u2.idx()];
|
||||
double dx = p1.x() - p2.x();
|
||||
double dy = p1.y() - p2.y();
|
||||
return std::sqrt(dx * dx + dy * dy);
|
||||
};
|
||||
|
||||
double a = dist(v1, v3); // opposite to v4
|
||||
double b = dist(v1, v4); // opposite to v3
|
||||
double c = dist(v2, v3); // opposite to v4
|
||||
double d = dist(v2, v4); // opposite to v3
|
||||
|
||||
// Cross-ratio q = (a·c)/(b·d).
|
||||
double q = length_cross_ratio(a, b, c, d);
|
||||
|
||||
// Conformal condition: q + 1/q = 2 (only satisfied when q = 1).
|
||||
if (q < 1e-16) return 1.0; // degenerate
|
||||
double residual = q + 1.0 / q - 2.0;
|
||||
return std::abs(residual);
|
||||
}
|
||||
|
||||
/// Compute the discrete conformal equivalence measure for all edges.
|
||||
/// Returns a vector of residuals, one per edge.
|
||||
inline std::vector<double> discrete_conformal_equivalence_measure(
|
||||
const ConformalMesh& mesh,
|
||||
const Layout2D& layout)
|
||||
{
|
||||
std::vector<double> result;
|
||||
result.reserve(mesh.number_of_edges());
|
||||
for (auto e : mesh.edges())
|
||||
result.push_back(discrete_conformal_equivalence_at_edge(mesh, e, layout));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// FlippedTriangles — embedded validity check
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a single triangle is flipped or degenerate in the 2-D layout.
|
||||
/// A triangle is valid iff its signed area > 0 (positive orientation).
|
||||
/// Degenerate: signed area ≈ 0 (collinear or nearly collinear vertices).
|
||||
/// Returns true if the triangle is flipped or degenerate.
|
||||
inline bool is_flipped_triangle(
|
||||
const ConformalMesh& mesh,
|
||||
Face_index f,
|
||||
const Layout2D& layout)
|
||||
{
|
||||
// Extract the three vertices of the triangle.
|
||||
auto h = mesh.halfedge(f);
|
||||
Vertex_index v1 = mesh.source(h);
|
||||
Vertex_index v2 = mesh.source(mesh.next(h));
|
||||
Vertex_index v3 = mesh.source(mesh.next(mesh.next(h)));
|
||||
|
||||
const auto& p1 = layout.uv[v1.idx()];
|
||||
const auto& p2 = layout.uv[v2.idx()];
|
||||
const auto& p3 = layout.uv[v3.idx()];
|
||||
|
||||
// Signed area (× 2): (p2 - p1) × (p3 - p1) in ℝ².
|
||||
double signed_area_2x = (p2.x() - p1.x()) * (p3.y() - p1.y())
|
||||
- (p2.y() - p1.y()) * (p3.x() - p1.x());
|
||||
|
||||
// Positive area: valid orientation. Zero or negative: flipped/degenerate.
|
||||
return signed_area_2x <= 1e-14;
|
||||
}
|
||||
|
||||
/// Count the number of flipped or degenerate triangles in the layout.
|
||||
/// Returns the count (0 = valid layout).
|
||||
inline int flipped_triangles(
|
||||
const ConformalMesh& mesh,
|
||||
const Layout2D& layout)
|
||||
{
|
||||
int count = 0;
|
||||
for (auto f : mesh.faces())
|
||||
if (is_flipped_triangle(mesh, f, layout))
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ConvergenceUtility — aggregated convergence measures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Aggregated cross-ratio statistics for a layout.
|
||||
struct CrossRatioStats {
|
||||
double max_cross_ratio; ///< max of (q + 1/q) over all edges
|
||||
double mean_cross_ratio; ///< mean of (q + 1/q)
|
||||
double sum_cross_ratio; ///< sum of (q + 1/q)
|
||||
|
||||
double max_multi_ratio; ///< max per-face product of cross-ratios
|
||||
double mean_multi_ratio; ///< mean per-face product
|
||||
double sum_multi_ratio; ///< sum of per-face products
|
||||
|
||||
double max_scale_invariant_circumradius; ///< max of R/√A per face
|
||||
double mean_scale_invariant_circumradius; ///< mean of R/√A
|
||||
double sum_scale_invariant_circumradius; ///< sum of R/√A
|
||||
};
|
||||
|
||||
/// Compute convergence statistics for a layout.
|
||||
/// - Cross-ratio (q + 1/q) per edge; aggregated max/mean/sum.
|
||||
/// - Multi-ratio: per-face product ∏(q + 1/q) for the 3 edges of each face.
|
||||
/// (Multi-ratio = 1 iff all edges are conformal.)
|
||||
/// - Scale-invariant circumradius: R/√A per face (mesh quality metric).
|
||||
inline CrossRatioStats convergence_utility(
|
||||
const ConformalMesh& mesh,
|
||||
const Layout2D& layout)
|
||||
{
|
||||
CrossRatioStats stats = {};
|
||||
|
||||
std::vector<double> cross_ratios;
|
||||
std::vector<double> multi_ratios;
|
||||
std::vector<double> scale_inv_circumradii;
|
||||
|
||||
auto dist = [&layout](Vertex_index u1, Vertex_index u2) {
|
||||
const auto& p1 = layout.uv[u1.idx()];
|
||||
const auto& p2 = layout.uv[u2.idx()];
|
||||
double dx = p1.x() - p2.x();
|
||||
double dy = p1.y() - p2.y();
|
||||
return std::sqrt(dx * dx + dy * dy);
|
||||
};
|
||||
|
||||
// Per-face metrics.
|
||||
for (auto f : mesh.faces()) {
|
||||
auto h = mesh.halfedge(f);
|
||||
Vertex_index v1 = mesh.source(h);
|
||||
Vertex_index v2 = mesh.source(mesh.next(h));
|
||||
Vertex_index v3 = mesh.source(mesh.next(mesh.next(h)));
|
||||
|
||||
const auto& p1 = layout.uv[v1.idx()];
|
||||
const auto& p2 = layout.uv[v2.idx()];
|
||||
const auto& p3 = layout.uv[v3.idx()];
|
||||
|
||||
// Signed area.
|
||||
double signed_area_2x = (p2.x() - p1.x()) * (p3.y() - p1.y())
|
||||
- (p2.y() - p1.y()) * (p3.x() - p1.x());
|
||||
double area = std::abs(signed_area_2x) / 2.0;
|
||||
|
||||
if (area < 1e-16) continue; // degenerate
|
||||
|
||||
// Three edge lengths of the triangle.
|
||||
double a = dist(v1, v2);
|
||||
double b = dist(v2, v3);
|
||||
double c = dist(v3, v1);
|
||||
|
||||
// Circumradius R = abc / (4·Area).
|
||||
double circum_radius = (a * b * c) / (4.0 * area);
|
||||
|
||||
// Scale-invariant: R / √A.
|
||||
double scale_inv_cr = circum_radius / std::sqrt(area);
|
||||
scale_inv_circumradii.push_back(scale_inv_cr);
|
||||
|
||||
// Three cross-ratios (per edge/angle of the triangle).
|
||||
// For each edge, form the quad with the opposite vertex and its neighbors.
|
||||
double multi_product = 1.0;
|
||||
for (int ei = 0; ei < 3; ++ei) {
|
||||
auto he = mesh.halfedge(f);
|
||||
for (int k = 0; k < ei; ++k) he = mesh.next(he);
|
||||
|
||||
Vertex_index eu1 = mesh.source(he);
|
||||
Vertex_index eu2 = mesh.target(he);
|
||||
Vertex_index eu3 = mesh.source(mesh.next(he));
|
||||
Vertex_index eu4 = mesh.source(mesh.next(mesh.opposite(he)));
|
||||
|
||||
double ea = dist(eu1, eu3);
|
||||
double eb = dist(eu1, eu4);
|
||||
double ec = dist(eu2, eu3);
|
||||
double ed = dist(eu2, eu4);
|
||||
|
||||
double q = length_cross_ratio(ea, eb, ec, ed);
|
||||
if (q > 1e-16) {
|
||||
double qf = q + 1.0 / q;
|
||||
cross_ratios.push_back(qf);
|
||||
multi_product *= qf;
|
||||
}
|
||||
}
|
||||
multi_ratios.push_back(multi_product);
|
||||
}
|
||||
|
||||
// Aggregate statistics.
|
||||
if (!cross_ratios.empty()) {
|
||||
auto [min_it, max_it] = std::minmax_element(cross_ratios.begin(), cross_ratios.end());
|
||||
stats.max_cross_ratio = *max_it;
|
||||
stats.mean_cross_ratio = 0.0;
|
||||
for (double v : cross_ratios) stats.mean_cross_ratio += v;
|
||||
stats.mean_cross_ratio /= static_cast<double>(cross_ratios.size());
|
||||
stats.sum_cross_ratio = 0.0;
|
||||
for (double v : cross_ratios) stats.sum_cross_ratio += v;
|
||||
}
|
||||
|
||||
if (!multi_ratios.empty()) {
|
||||
auto [min_it, max_it] = std::minmax_element(multi_ratios.begin(), multi_ratios.end());
|
||||
stats.max_multi_ratio = *max_it;
|
||||
stats.mean_multi_ratio = 0.0;
|
||||
for (double v : multi_ratios) stats.mean_multi_ratio += v;
|
||||
stats.mean_multi_ratio /= static_cast<double>(multi_ratios.size());
|
||||
stats.sum_multi_ratio = 0.0;
|
||||
for (double v : multi_ratios) stats.sum_multi_ratio += v;
|
||||
}
|
||||
|
||||
if (!scale_inv_circumradii.empty()) {
|
||||
auto [min_it, max_it] = std::minmax_element(scale_inv_circumradii.begin(),
|
||||
scale_inv_circumradii.end());
|
||||
stats.max_scale_invariant_circumradius = *max_it;
|
||||
stats.mean_scale_invariant_circumradius = 0.0;
|
||||
for (double v : scale_inv_circumradii)
|
||||
stats.mean_scale_invariant_circumradius += v;
|
||||
stats.mean_scale_invariant_circumradius /= static_cast<double>(scale_inv_circumradii.size());
|
||||
stats.sum_scale_invariant_circumradius = 0.0;
|
||||
for (double v : scale_inv_circumradii)
|
||||
stats.sum_scale_invariant_circumradius += v;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
} // namespace conformallab
|
||||
@@ -44,7 +44,7 @@
|
||||
// double gauss_bonnet_rhs(mesh) — 2π · χ(M)
|
||||
// double gauss_bonnet_deficit(mesh, maps) — lhs − rhs (0 = satisfied)
|
||||
// void check_gauss_bonnet(mesh, maps [, tol]) — throws if violated
|
||||
// void enforce_gauss_bonnet(mesh, maps) — shifts θ_v by uniform Δ
|
||||
// double enforce_gauss_bonnet(mesh, maps) — shifts θ_v by uniform Δ; returns |deficit|
|
||||
// (HyperIdealMaps overloads are deleted — see box above)
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
@@ -162,11 +162,16 @@ inline void check_gauss_bonnet(const ConformalMesh& mesh,
|
||||
// After this call, check_gauss_bonnet() will not throw (up to floating-point).
|
||||
// Modifies ALL vertices' θ_v (no v_idx filtering) — the shift is a property
|
||||
// of the target angles, independent of which vertices are free DOFs.
|
||||
//
|
||||
// H3 (test-coverage audit, 2026-06-01): both overloads now return the total
|
||||
// absolute correction applied: |Σ(2π−Θ_v) − 2π·χ|. A large value signals
|
||||
// that the input angles were far from satisfying Gauss–Bonnet.
|
||||
|
||||
/// Distribute the Gauss-Bonnet deficit uniformly across all `Θ_v`:
|
||||
/// add `δ = (lhs − rhs) / V` to every entry so that the identity holds
|
||||
/// exactly afterwards. Overload for a raw property map.
|
||||
inline void enforce_gauss_bonnet(
|
||||
/// Returns `|lhs − rhs|` (total absolute correction applied).
|
||||
inline double enforce_gauss_bonnet(
|
||||
ConformalMesh& mesh,
|
||||
ConformalMesh::Property_map<Vertex_index, double>& theta)
|
||||
{
|
||||
@@ -177,15 +182,17 @@ inline void enforce_gauss_bonnet(
|
||||
double delta = (lhs - rhs) / static_cast<double>(mesh.number_of_vertices());
|
||||
for (auto v : mesh.vertices())
|
||||
theta[v] += delta;
|
||||
return std::abs(lhs - rhs);
|
||||
}
|
||||
|
||||
/// Distribute the Gauss-Bonnet deficit uniformly across `maps.theta_v`.
|
||||
/// Supported for EuclideanMaps and SphericalMaps only.
|
||||
/// HyperIdealMaps overload is deleted — see header comment for why.
|
||||
/// Returns `|lhs − rhs|` (total absolute correction applied; see raw-map overload).
|
||||
template <typename Maps>
|
||||
inline void enforce_gauss_bonnet(ConformalMesh& mesh, Maps& maps)
|
||||
inline double enforce_gauss_bonnet(ConformalMesh& mesh, Maps& maps)
|
||||
{
|
||||
enforce_gauss_bonnet(mesh, maps.theta_v);
|
||||
return enforce_gauss_bonnet(mesh, maps.theta_v);
|
||||
}
|
||||
|
||||
// enforce_gauss_bonnet for HyperIdealMaps is intentionally DELETED.
|
||||
|
||||
@@ -264,6 +264,27 @@ inline void save_result_xml(
|
||||
/// Load a DOF vector from an XML result file written by
|
||||
/// `save_result_xml`. If `res`, `geom`, `layout2d` are non-null they
|
||||
/// are filled as well.
|
||||
///
|
||||
/// V5 (input-validation audit, 2026-06-01): this reader implements a
|
||||
/// **strict internal-only XML subset** — not a general XML parser. It
|
||||
/// expects the exact one-element-per-line layout written by
|
||||
/// `save_result_xml`. Files that are semantically equivalent XML but
|
||||
/// formatted differently (attributes split across lines, extra
|
||||
/// whitespace, XML declaration on its own line, etc.) are explicitly
|
||||
/// *rejected* with `std::runtime_error` rather than silently mis-read
|
||||
/// into zeros. Interoperability with other XML producers is out of
|
||||
/// scope; use the JSON format for that.
|
||||
///
|
||||
/// Strict-subset requirements that are validated:
|
||||
/// 1. A line containing `<ConformalResult` must also carry a `geometry=`
|
||||
/// attribute on the same line.
|
||||
/// 2. A line containing `<Solver` must carry `iterations=` and
|
||||
/// `grad_inf_norm=` on the same line (when `res` is non-null).
|
||||
/// 3. A line containing `<DOFVector` must carry the `>` character (tag
|
||||
/// open) on the same line.
|
||||
/// 4. The `<DOFVector` element must be present and must produce a
|
||||
/// non-empty doubles list (a missing DOFVector silently returns an
|
||||
/// empty x, which is incorrect for any mesh with at least one DOF).
|
||||
inline std::vector<double> load_result_xml(
|
||||
const std::string& path,
|
||||
NewtonResult* res = nullptr,
|
||||
@@ -275,13 +296,26 @@ inline std::vector<double> load_result_xml(
|
||||
|
||||
std::vector<double> x;
|
||||
std::string line;
|
||||
bool found_root = false;
|
||||
bool found_dofvector = false;
|
||||
|
||||
while (std::getline(ifs, line)) {
|
||||
// Root element
|
||||
// Root element — V5: geometry attribute must be on the same line.
|
||||
if (line.find("<ConformalResult") != std::string::npos) {
|
||||
if (geom) *geom = detail_xml::xml_get_attr(line, "geometry");
|
||||
found_root = true;
|
||||
// V5: reject if the required geometry= attribute is absent on this line.
|
||||
// (Would be present if written by save_result_xml; absent if reformatted.)
|
||||
std::string g = detail_xml::xml_get_attr(line, "geometry");
|
||||
if (g.empty())
|
||||
throw std::runtime_error(
|
||||
"conformallab: XML strict-subset violation in " + path
|
||||
+ ": <ConformalResult geometry=...> attribute not found on its"
|
||||
" opening line. Only the format written by save_result_xml is"
|
||||
" supported — reformatted XML is rejected to prevent silent"
|
||||
" misreads. Use the JSON format for interoperability.");
|
||||
if (geom) *geom = g;
|
||||
}
|
||||
// Solver metadata
|
||||
// Solver metadata — V5: required attributes must be on the same line.
|
||||
else if (line.find("<Solver") != std::string::npos) {
|
||||
if (res) {
|
||||
res->converged = (detail_xml::xml_get_attr(line, "converged") == "true");
|
||||
@@ -303,10 +337,17 @@ inline std::vector<double> load_result_xml(
|
||||
}
|
||||
}
|
||||
}
|
||||
// DOF vector
|
||||
// DOF vector — V5: the '>' tag-open must be on the same line.
|
||||
else if (line.find("<DOFVector") != std::string::npos) {
|
||||
// Text may be on same line: <DOFVector n="...">0 1 2...</DOFVector>
|
||||
found_dofvector = true;
|
||||
// V5: require the tag to be closed ('>') on the same line so the
|
||||
// content-extraction below works correctly.
|
||||
auto open_end = line.find('>');
|
||||
if (open_end == std::string::npos)
|
||||
throw std::runtime_error(
|
||||
"conformallab: XML strict-subset violation in " + path
|
||||
+ ": <DOFVector> opening '>' not on same line as tag."
|
||||
" Only the format written by save_result_xml is supported.");
|
||||
auto close = line.find("</DOFVector>");
|
||||
std::string text;
|
||||
if (close != std::string::npos) {
|
||||
@@ -330,7 +371,46 @@ inline std::vector<double> load_result_xml(
|
||||
layout2d->success = true;
|
||||
}
|
||||
}
|
||||
|
||||
// V5: if the file was non-empty but never produced a <ConformalResult> root
|
||||
// element, the file is likely reformatted or not a ConformalResult XML at all.
|
||||
if (!found_root) {
|
||||
// Distinguish "empty file" (ifs.peek() == EOF at open) from wrong format.
|
||||
// We re-open to check file size — if it had content but no root element
|
||||
// was found on a single line, it was reformatted.
|
||||
std::ifstream probe(path, std::ios::ate);
|
||||
if (probe && probe.tellg() > 0)
|
||||
throw std::runtime_error(
|
||||
"conformallab: XML strict-subset violation in " + path
|
||||
+ ": <ConformalResult> root element not found on its own line."
|
||||
" Only the format written by save_result_xml is supported.");
|
||||
}
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
/// Validate that a loaded DOF vector has the expected number of DOFs.
|
||||
///
|
||||
/// V6 (input-validation audit, 2026-06-01): a result file from a *different*
|
||||
/// mesh loads happily; the size mismatch only surfaces later (out-of-bounds
|
||||
/// or wrong-answer) when `x` is indexed against the new mesh. This helper
|
||||
/// provides a clear early check at the call-site where the loaded vector is
|
||||
/// paired with the mesh.
|
||||
///
|
||||
/// Throws `std::runtime_error` if `x.size() != expected_dofs`.
|
||||
inline void check_dof_vector_size(
|
||||
const std::vector<double>& x,
|
||||
int expected_dofs,
|
||||
const std::string& context = "")
|
||||
{
|
||||
if (static_cast<int>(x.size()) != expected_dofs) {
|
||||
std::ostringstream msg;
|
||||
msg << "conformallab: DOF-vector size mismatch";
|
||||
if (!context.empty()) msg << " in " << context;
|
||||
msg << ": loaded " << x.size()
|
||||
<< " values but mesh has " << expected_dofs << " DOFs.";
|
||||
throw std::runtime_error(msg.str());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace conformallab
|
||||
|
||||
203
code/include/stereographic_layout.hpp
Normal file
203
code/include/stereographic_layout.hpp
Normal 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
|
||||
Reference in New Issue
Block a user