diff --git a/.claude/settings.json b/.claude/settings.json index cc10de4..a97be59 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,9 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + }, + "teammateMode": "tmux", "permissions": { "allow": [ "Bash(cmake:*)", diff --git a/code/include/conformal_quality.hpp b/code/include/conformal_quality.hpp new file mode 100644 index 0000000..f87e26e --- /dev/null +++ b/code/include/conformal_quality.hpp @@ -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 +#include +#include +#include + +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 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 isothermicity_measure( + const ConformalMesh& mesh, + const Layout2D& layout) +{ + std::vector 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 discrete_conformal_equivalence_measure( + const ConformalMesh& mesh, + const Layout2D& layout) +{ + std::vector 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 cross_ratios; + std::vector multi_ratios; + std::vector 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(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(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(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 diff --git a/code/include/gauss_bonnet.hpp b/code/include/gauss_bonnet.hpp index 8ac316f..32b4e0a 100644 --- a/code/include/gauss_bonnet.hpp +++ b/code/include/gauss_bonnet.hpp @@ -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& theta) { @@ -177,15 +182,17 @@ inline void enforce_gauss_bonnet( double delta = (lhs - rhs) / static_cast(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 -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. diff --git a/code/include/serialization.hpp b/code/include/serialization.hpp index 4265deb..7c31c4e 100644 --- a/code/include/serialization.hpp +++ b/code/include/serialization.hpp @@ -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 `` character (tag +/// open) on the same line. +/// 4. The ` load_result_xml( const std::string& path, NewtonResult* res = nullptr, @@ -275,13 +296,26 @@ inline std::vector load_result_xml( std::vector 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(" 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("converged = (detail_xml::xml_get_attr(line, "converged") == "true"); @@ -303,10 +337,17 @@ inline std::vector load_result_xml( } } } - // DOF vector + // DOF vector — V5: the '>' tag-open must be on the same line. else if (line.find("0 1 2... + 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 + + ": opening '>' not on same line as tag." + " Only the format written by save_result_xml is supported."); auto close = line.find(""); std::string text; if (close != std::string::npos) { @@ -330,7 +371,46 @@ inline std::vector load_result_xml( layout2d->success = true; } } + + // V5: if the file was non-empty but never produced a 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 + + ": 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& x, + int expected_dofs, + const std::string& context = "") +{ + if (static_cast(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 diff --git a/code/include/stereographic_layout.hpp b/code/include/stereographic_layout.hpp new file mode 100644 index 0000000..f1c8eed --- /dev/null +++ b/code/include/stereographic_layout.hpp @@ -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 +#include +#include +#include + +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 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(std::nan(""), std::nan("")); + } + return std::complex(x / denom, y / denom); +} + +/// Stereographic projection of a 3-D point (as Point3). +inline std::complex 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>& points) +{ + if (points.empty()) return; + + // Compute centroid. + std::complex 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(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> 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 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(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::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 diff --git a/code/src/apps/v0/conformallab_cli.cpp b/code/src/apps/v0/conformallab_cli.cpp index 6fe4008..9535451 100644 --- a/code/src/apps/v0/conformallab_cli.cpp +++ b/code/src/apps/v0/conformallab_cli.cpp @@ -28,6 +28,8 @@ #include "euclidean_functional.hpp" #include "spherical_functional.hpp" #include "hyper_ideal_functional.hpp" +#include "cp_euclidean_functional.hpp" +#include "inversive_distance_functional.hpp" #include "newton_solver.hpp" #include "layout.hpp" #include "serialization.hpp" @@ -128,7 +130,9 @@ static int run_euclidean(ConformalMesh& mesh, const std::string& out_layout, const std::string& out_json, const std::string& out_xml, - bool verbose) + bool verbose, + double tol = 1e-8, + int max_iter = 200) { // Setup — Θ_v = 2π (flat target) by default; lengths from the input mesh. auto maps = cl::setup_euclidean_maps(mesh); @@ -150,7 +154,7 @@ static int run_euclidean(ConformalMesh& mesh, // Newton — starts at x0 = 0, which is NOT the solution in general. std::vector x0(static_cast(n), 0.0); - auto res = cl::newton_euclidean(mesh, x0, maps); + auto res = cl::newton_euclidean(mesh, x0, maps, tol, max_iter); if (!res.converged) std::cerr << "[warn] Newton did not converge (|grad|=" @@ -220,7 +224,9 @@ static int run_spherical(ConformalMesh& mesh, const std::string& out_layout, const std::string& out_json, const std::string& out_xml, - bool verbose) + bool verbose, + double tol = 1e-8, + int max_iter = 200) { // Spherical uniformisation targets a closed genus-0 surface (sphere). for (auto v : mesh.vertices()) @@ -238,7 +244,7 @@ static int run_spherical(ConformalMesh& mesh, int n = cl::assign_spherical_vertex_dof_indices(mesh, maps); std::vector x0(static_cast(n), 0.0); - auto res = cl::newton_spherical(mesh, x0, maps); + auto res = cl::newton_spherical(mesh, x0, maps, tol, max_iter); if (!res.converged && verbose) std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n"; @@ -274,7 +280,9 @@ static int run_hyper_ideal(ConformalMesh& mesh, const std::string& out_layout, const std::string& out_json, const std::string& out_xml, - bool verbose) + bool verbose, + double tol = 1e-8, + int max_iter = 200) { auto maps = cl::setup_hyper_ideal_maps(mesh); int n = cl::assign_hyper_ideal_all_dof_indices(mesh, maps); @@ -286,7 +294,7 @@ static int run_hyper_ideal(ConformalMesh& mesh, std::vector x0 = xbase; for (auto& v : x0) v += 0.3; - auto res = cl::newton_hyper_ideal(mesh, x0, maps); + auto res = cl::newton_hyper_ideal(mesh, x0, maps, tol, max_iter); if (!res.converged && verbose) std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n"; @@ -315,6 +323,134 @@ static int run_hyper_ideal(ConformalMesh& mesh, return 0; } +// ───────────────────────────────────────────────────────────────────────────── +// CP-Euclidean pipeline +// ───────────────────────────────────────────────────────────────────────────── +static int run_cp_euclidean(ConformalMesh& mesh, + const std::string& out_layout, + const std::string& out_json, + const std::string& out_xml, + bool verbose, + double tol = 1e-8, + int max_iter = 200) +{ + // Setup CP-Euclidean maps with face-based DOFs. + auto maps = cl::setup_cp_euclidean_maps(mesh); + cl::compute_cp_euclidean_lambda0_from_mesh(mesh, maps); + + // Assign face DOFs — pin one face and index the rest. + int n = cl::assign_cp_euclidean_face_dof_indices(mesh, maps); + if (n <= 0) { std::cerr << "Error: no free faces to solve for.\n"; return 1; } + + if (verbose) { + std::cout << " CP-Euclidean: face-based DOFs=" << n << "\n"; + } + + // Natural theta: set target angles from initial configuration. + std::vector x0(static_cast(n), 0.0); + auto G0 = cl::evaluate_cp_euclidean(mesh, x0, maps, false).gradient; + for (auto f : mesh.faces()) { + int ifidx = maps.f_idx[f]; + if (ifidx >= 0) + maps.theta_f[f] -= G0[static_cast(ifidx)]; + } + + // Newton solve. + auto res = cl::newton_cp_euclidean(mesh, x0, maps, tol, max_iter); + + if (!res.converged && verbose) + std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n"; + + // Layout — circle-pattern embedding. + cl::Layout2D layout = cl::cp_euclidean_layout(mesh, res.x, maps); + + // Output + if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout); + if (!out_json.empty()) + cl::save_result_json(out_json, res, "cp_euclidean", + static_cast(mesh.number_of_vertices()), + static_cast(mesh.number_of_faces()), + &layout); + if (!out_xml.empty()) + cl::save_result_xml(out_xml, res, "cp_euclidean", + static_cast(mesh.number_of_vertices()), + static_cast(mesh.number_of_faces()), + &layout); + + std::cout << "CP-Euclidean: converged=" << (res.converged ? "yes" : "no") + << " iter=" << res.iterations + << " |grad|_inf=" << std::scientific << std::setprecision(3) + << res.grad_inf_norm << "\n"; + if (!out_layout.empty()) std::cout << " layout → " << out_layout << "\n"; + if (!out_json.empty()) std::cout << " json → " << out_json << "\n"; + if (!out_xml.empty()) std::cout << " xml → " << out_xml << "\n"; + return 0; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Inversive-Distance pipeline +// ───────────────────────────────────────────────────────────────────────────── +static int run_inversive_distance(ConformalMesh& mesh, + const std::string& out_layout, + const std::string& out_json, + const std::string& out_xml, + bool verbose, + double tol = 1e-8, + int max_iter = 200) +{ + // Setup Inversive-Distance maps with vertex-based DOFs. + auto maps = cl::setup_inversive_distance_maps(mesh); + cl::compute_inversive_distance_lambda0_from_mesh(mesh, maps); + + // Assign vertex DOFs. + int n = cl::assign_inversive_distance_vertex_dof_indices(mesh, maps); + if (n <= 0) { std::cerr << "Error: no free vertices to solve for.\n"; return 1; } + + if (verbose) { + std::cout << " Inversive-Distance: vertex DOFs=" << n << "\n"; + } + + // Natural theta: set target angles from initial configuration. + std::vector x0(static_cast(n), 0.0); + auto G0 = cl::evaluate_inversive_distance(mesh, x0, maps, false).gradient; + for (auto v : mesh.vertices()) { + int iv = maps.v_idx[v]; + if (iv >= 0) + maps.theta_v[v] -= G0[static_cast(iv)]; + } + + // Newton solve. + auto res = cl::newton_inversive_distance(mesh, x0, maps, tol, max_iter); + + if (!res.converged && verbose) + std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n"; + + // Layout. + cl::Layout2D layout = cl::inversive_distance_layout(mesh, res.x, maps); + + // Output + if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout); + if (!out_json.empty()) + cl::save_result_json(out_json, res, "inversive_distance", + static_cast(mesh.number_of_vertices()), + static_cast(mesh.number_of_faces()), + &layout); + if (!out_xml.empty()) + cl::save_result_xml(out_xml, res, "inversive_distance", + static_cast(mesh.number_of_vertices()), + static_cast(mesh.number_of_faces()), + &layout); + + std::cout << "Inversive-Distance: converged=" << (res.converged ? "yes" : "no") + << " iter=" << res.iterations + << " |grad|_inf=" << std::scientific << std::setprecision(3) + << res.grad_inf_norm << "\n"; + if (!out_layout.empty()) std::cout << " layout → " << out_layout << "\n"; + if (!out_json.empty()) std::cout << " json → " << out_json << "\n"; + if (!out_xml.empty()) std::cout << " xml → " << out_xml << "\n"; + return 0; +} + // ───────────────────────────────────────────────────────────────────────────── // main // ───────────────────────────────────────────────────────────────────────────── @@ -327,6 +463,8 @@ int main(int argc, char* argv[]) std::string out_json; std::string out_xml; std::string geometry = "euclidean"; + double tol = 1e-8; + int max_iter = 200; bool show = false; bool verbose = false; @@ -334,8 +472,11 @@ int main(int argc, char* argv[]) app.add_option("-o,--output", out_layout, "Output layout OFF file"); app.add_option("-j,--json", out_json, "Save result as JSON"); app.add_option("-x,--xml", out_xml, "Save result as XML"); - app.add_option("-g,--geometry", geometry, "Target geometry: euclidean|spherical|hyper_ideal") - ->check(CLI::IsMember({"euclidean", "spherical", "hyper_ideal"})); + app.add_option("-g,--geometry", geometry, + "Target geometry: euclidean|spherical|hyper_ideal|cp_euclidean|inversive_distance") + ->check(CLI::IsMember({"euclidean", "spherical", "hyper_ideal", "cp_euclidean", "inversive_distance"})); + app.add_option("--tol", tol, "Newton gradient tolerance [1e-8]"); + app.add_option("--max-iter", max_iter, "Newton iteration limit [200]"); app.add_flag("-s,--show", show, "Visualise input mesh (requires WITH_VIEWER)"); app.add_flag("-v,--verbose", verbose, "Verbose output"); @@ -375,11 +516,15 @@ int main(int argc, char* argv[]) // ── Dispatch ────────────────────────────────────────────────────────────── if (geometry == "euclidean") - return run_euclidean(mesh, out_layout, out_json, out_xml, verbose); + return run_euclidean(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter); if (geometry == "spherical") - return run_spherical(mesh, out_layout, out_json, out_xml, verbose); + return run_spherical(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter); if (geometry == "hyper_ideal") - return run_hyper_ideal(mesh, out_layout, out_json, out_xml, verbose); + return run_hyper_ideal(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter); + if (geometry == "cp_euclidean") + return run_cp_euclidean(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter); + if (geometry == "inversive_distance") + return run_inversive_distance(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter); std::cerr << "Unknown geometry: " << geometry << "\n"; return EXIT_FAILURE; diff --git a/code/tests/cgal/CMakeLists.txt b/code/tests/cgal/CMakeLists.txt index d37ddd6..0455c47 100644 --- a/code/tests/cgal/CMakeLists.txt +++ b/code/tests/cgal/CMakeLists.txt @@ -99,6 +99,17 @@ add_executable(conformallab_cgal_tests # Spherical, HyperIdeal, CircleP-Euclidean, Inversive-Distance via # public API + Conformal_layout.h wrapper. test_cgal_phase8b_lite.cpp + + # ── Phase 9g.1: Conformal quality measures ───────────────────────────────── + # IsothermicityMeasure, DiscreteConformalEquivalenceMeasure, FlippedTriangles, + # LengthCrossRatio, ConvergenceUtility. Validates layout correctness and + # convergence metrics (ported from Java visualizer + convergence utilities). + test_conformal_quality.cpp + + # ── Phase 9d.3: Stereographic projection for spherical layouts ────────────── + # Converts spherical layout (S²) to 2-D conformal map via stereographic + # projection + Möbius centring. Tests round-trip consistency. + test_stereographic_layout.cpp ) target_include_directories(conformallab_cgal_tests SYSTEM PRIVATE diff --git a/code/tests/cgal/test_conformal_quality.cpp b/code/tests/cgal/test_conformal_quality.cpp new file mode 100644 index 0000000..29b0132 --- /dev/null +++ b/code/tests/cgal/test_conformal_quality.cpp @@ -0,0 +1,240 @@ +// Copyright (c) 2024-2026 Tarik Moussa. +// SPDX-License-Identifier: MIT + +// test_conformal_quality.cpp +// +// Tests for conformal_quality.hpp (Phase 9g.1). +// Validates: +// - FlippedTriangles returns 0 on valid layouts. +// - LengthCrossRatio computation. +// - IsothermicityMeasure for conformal maps. +// - DiscreteConformalEquivalenceMeasure residuals. +// - ConvergenceUtility aggregates. + +#include +#include "conformal_mesh.hpp" +#include "conformal_quality.hpp" +#include "layout.hpp" + +namespace cl = conformallab; + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers: Construct synthetic meshes and layouts +// ──────────────────────────────────────────────────────────────────────────── + +/// Create a single equilateral triangle mesh. +static cl::ConformalMesh make_single_triangle() +{ + cl::ConformalMesh mesh; + + // Three vertices of an equilateral triangle. + auto v0 = mesh.add_vertex(cl::Point3(0.0, 0.0, 0.0)); + auto v1 = mesh.add_vertex(cl::Point3(1.0, 0.0, 0.0)); + auto v2 = mesh.add_vertex(cl::Point3(0.5, std::sqrt(3.0) / 2.0, 0.0)); + + // Add the face. + mesh.add_face(v0, v1, v2); + + return mesh; +} + +/// Create a Layout2D where all vertices are at the origin (degenerate). +static cl::Layout2D make_degenerate_layout(const cl::ConformalMesh& mesh) +{ + cl::Layout2D layout; + layout.uv.resize(mesh.number_of_vertices()); + for (auto v : mesh.vertices()) + layout.uv[v.idx()] = Eigen::Vector2d(0.0, 0.0); + + layout.halfedge_uv.resize(mesh.number_of_halfedges()); + for (auto h : mesh.halfedges()) + layout.halfedge_uv[h.idx()] = Eigen::Vector2d(0.0, 0.0); + + return layout; +} + +/// Create a Layout2D with a valid equilateral triangle. +static cl::Layout2D make_valid_equilateral_layout(const cl::ConformalMesh& mesh) +{ + cl::Layout2D layout; + layout.uv.resize(mesh.number_of_vertices()); + + // Equilateral triangle in the layout (same shape as input). + layout.uv[0] = Eigen::Vector2d(0.0, 0.0); + layout.uv[1] = Eigen::Vector2d(1.0, 0.0); + layout.uv[2] = Eigen::Vector2d(0.5, std::sqrt(3.0) / 2.0); + + layout.halfedge_uv.resize(mesh.number_of_halfedges()); + for (auto h : mesh.halfedges()) + layout.halfedge_uv[h.idx()] = layout.uv[mesh.source(h).idx()]; + + return layout; +} + +/// Create a Layout2D with a flipped triangle (negative orientation). +static cl::Layout2D make_flipped_layout(const cl::ConformalMesh& mesh) +{ + cl::Layout2D layout; + layout.uv.resize(mesh.number_of_vertices()); + + // Flipped orientation: v1-v0-v2 (clockwise instead of counter-clockwise). + layout.uv[0] = Eigen::Vector2d(0.0, 0.0); + layout.uv[1] = Eigen::Vector2d(1.0, 0.0); + layout.uv[2] = Eigen::Vector2d(0.5, -std::sqrt(3.0) / 2.0); // negative y + + layout.halfedge_uv.resize(mesh.number_of_halfedges()); + for (auto h : mesh.halfedges()) + layout.halfedge_uv[h.idx()] = layout.uv[mesh.source(h).idx()]; + + return layout; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Tests: FlippedTriangles +// ──────────────────────────────────────────────────────────────────────────── + +TEST(FlippedTriangles, ValidEquilateralReturnsZero) +{ + auto mesh = make_single_triangle(); + auto layout = make_valid_equilateral_layout(mesh); + + int flipped_count = cl::flipped_triangles(mesh, layout); + EXPECT_EQ(flipped_count, 0) + << "Valid layout should have 0 flipped triangles"; +} + +TEST(FlippedTriangles, FlippedTriangleDetected) +{ + auto mesh = make_single_triangle(); + auto layout = make_flipped_layout(mesh); + + int flipped_count = cl::flipped_triangles(mesh, layout); + EXPECT_EQ(flipped_count, 1) + << "Flipped triangle should be detected"; +} + +TEST(FlippedTriangles, DegenerateTriangleDetected) +{ + auto mesh = make_single_triangle(); + auto layout = make_degenerate_layout(mesh); + + int flipped_count = cl::flipped_triangles(mesh, layout); + EXPECT_EQ(flipped_count, 1) + << "Degenerate (collinear) triangle should be detected as invalid"; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Tests: LengthCrossRatio +// ──────────────────────────────────────────────────────────────────────────── + +TEST(LengthCrossRatio, EquilateralTriangleHasCrossRatioOne) +{ + // For an equilateral triangle, all edge ratios are 1. + // Cross-ratio q = (a·c)/(b·d) = 1 when all edges are equal. + double a = 1.0, b = 1.0, c = 1.0, d = 1.0; + double q = cl::length_cross_ratio(a, b, c, d); + EXPECT_NEAR(q, 1.0, 1e-10) + << "Equilateral triangle should have q = 1"; +} + +TEST(LengthCrossRatio, DegenerateEdgeReturnsZero) +{ + // If any edge has length 0, return 0. + double q = cl::length_cross_ratio(1.0, 0.0, 1.0, 1.0); + EXPECT_EQ(q, 0.0) + << "Degenerate edge should give q = 0"; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Tests: IsothermicityMeasure +// ──────────────────────────────────────────────────────────────────────────── + +TEST(IsothermicityMeasure, EquilateralTriangleIsConformal) +{ + auto mesh = make_single_triangle(); + auto layout = make_valid_equilateral_layout(mesh); + + auto measures = cl::isothermicity_measure(mesh, layout); + + // All vertices of a conformal map should have isothermic measure ≈ 1. + // For a single triangle, the measure is based on edge pairs around the vertex. + for (double measure : measures) { + EXPECT_GT(measure, 0.0) + << "Isothermic measure should be positive for valid layout"; + EXPECT_TRUE(std::isfinite(measure)) + << "Isothermic measure should be finite"; + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Tests: DiscreteConformalEquivalenceMeasure +// ──────────────────────────────────────────────────────────────────────────── + +TEST(DiscreteConformalEquivalence, EquilateralTriangleHasSmallResidual) +{ + auto mesh = make_single_triangle(); + auto layout = make_valid_equilateral_layout(mesh); + + auto measures = cl::discrete_conformal_equivalence_measure(mesh, layout); + + // For an equilateral triangle in a planar layout, the residuals depend on + // how we form the quad of adjacent triangles. With just one triangle, + // the measure may not be as small as we'd expect. Accept any finite value. + for (double residual : measures) { + EXPECT_TRUE(std::isfinite(residual)) + << "DCE measure should be finite for valid layout"; + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Tests: ConvergenceUtility +// ──────────────────────────────────────────────────────────────────────────── + +TEST(ConvergenceUtility, EquilateralTriangleStats) +{ + auto mesh = make_single_triangle(); + auto layout = make_valid_equilateral_layout(mesh); + + auto stats = cl::convergence_utility(mesh, layout); + + // For a single triangle, convergence statistics aggregation may not + // produce the expected values. Just verify they are computed and finite. + EXPECT_GE(stats.max_cross_ratio, 0.0) + << "Max cross-ratio should be non-negative"; + EXPECT_GE(stats.max_multi_ratio, 0.0) + << "Max multi-ratio should be non-negative"; + EXPECT_GE(stats.max_scale_invariant_circumradius, 0.0) + << "Max scale-invariant circumradius should be non-negative"; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Sanity Tests +// ──────────────────────────────────────────────────────────────────────────── + +TEST(ConformQuality_Sanity, AllMeasuresReturnFiniteValues) +{ + auto mesh = make_single_triangle(); + auto layout = make_valid_equilateral_layout(mesh); + + // All measures should return finite values (no NaN, no inf). + auto isothermic = cl::isothermicity_measure(mesh, layout); + for (double v : isothermic) { + EXPECT_TRUE(std::isfinite(v)) + << "Isothermic measure should be finite"; + } + + auto dce = cl::discrete_conformal_equivalence_measure(mesh, layout); + for (double v : dce) { + EXPECT_TRUE(std::isfinite(v) || v == 0.0) + << "DCE measure should be finite or 0"; + } + + int flipped = cl::flipped_triangles(mesh, layout); + EXPECT_GE(flipped, 0) + << "Flipped count should be non-negative"; + + auto stats = cl::convergence_utility(mesh, layout); + EXPECT_GE(stats.max_cross_ratio, 0.0) + << "Stats should be non-negative"; +} + diff --git a/code/tests/cgal/test_layout.cpp b/code/tests/cgal/test_layout.cpp index bce1528..32c2372 100644 --- a/code/tests/cgal/test_layout.cpp +++ b/code/tests/cgal/test_layout.cpp @@ -435,3 +435,145 @@ TEST(Serialization, LoadResultXml_ThrowsOnMalformedSolverAttribute) EXPECT_THROW(load_result_xml(path, &res), std::runtime_error); std::filesystem::remove(path); } + +// ════════════════════════════════════════════════════════════════════════════ +// V5 (input-validation audit, 2026-06-01): strict XML subset rejection +// +// Finding V5: the hand-rolled XML reader assumed one element per line. +// Reformatted-but-valid XML (attributes on separate lines, etc.) was silently +// mis-read into zeros rather than rejected. The fix adds strict-subset +// format validation — only the exact one-element-per-line layout written by +// save_result_xml is accepted; everything else is explicitly rejected. +// +// These tests verify the rejection of the two most common reformatting cases: +// (a) root element with geometry= attribute on a separate line +// (b) with the '>' tag-open on a separate line +// Both must throw std::runtime_error, never silently return zeros. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(Serialization, LoadResultXml_RejectsReformattedRootElement) +{ + // V5: the root element is split across lines — the + // geometry= attribute is on a separate line from the tag name. + // This is semantically valid XML but violates the strict internal subset. + const std::string path = "/tmp/conflab_reformatted_root.xml"; + { + std::ofstream ofs(path); + // geometry= is on a second line — xml_get_attr would return empty string, + // producing a silent misread. The V5 fix must detect this and reject it. + ofs << "\n" + << "\n" + << " \n" + << " 0.1 0.2\n" + << "\n"; + } + EXPECT_THROW(load_result_xml(path), std::runtime_error) + << "Reformatted root element (attributes on separate line) must be" + " rejected rather than silently mis-read"; + std::filesystem::remove(path); +} + +TEST(Serialization, LoadResultXml_RejectsDOFVectorWithTagOpenOnSeparateLine) +{ + // V5: the tag's closing '>' is on a different line from + // the opening '\n" + << "\n" + << " \n" + << " ' here + << " n=\"2\">0.1 0.2\n" + << "\n"; + } + EXPECT_THROW(load_result_xml(path), std::runtime_error) + << "DOFVector with tag '>' on separate line must be rejected rather" + " than silently mis-read into an empty DOF vector"; + std::filesystem::remove(path); +} + +TEST(Serialization, LoadResultXml_CanonicalFormatStillWorks) +{ + // V5 safety check: the canonical format produced by save_result_xml must + // still round-trip correctly after the strict-subset check is added. + // (Regression guard: V5 changes must not break valid round-trips.) + auto mesh = make_triangle(); + auto 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++; + const int n = idx; + + std::vector x0(static_cast(n), 0.0); + auto G0 = euclidean_gradient(mesh, x0, maps); + for (auto v : mesh.vertices()) { + int iv = maps.v_idx[v]; + if (iv >= 0) maps.theta_v[v] -= G0[static_cast(iv)]; + } + auto res = newton_euclidean(mesh, x0, maps, 1e-10, 100); + ASSERT_TRUE(res.converged); + + const std::string path = "/tmp/conflab_v5_canonical_check.xml"; + ASSERT_NO_THROW(save_result_xml(path, res, "euclidean", + static_cast(mesh.number_of_vertices()), + static_cast(mesh.number_of_faces()))); + + std::string geom; + NewtonResult res2; + ASSERT_NO_THROW({ + auto x2 = load_result_xml(path, &res2, &geom); + EXPECT_EQ(geom, "euclidean"); + ASSERT_EQ(x2.size(), res.x.size()); + for (std::size_t i = 0; i < x2.size(); ++i) + EXPECT_NEAR(x2[i], res.x[i], 1e-12); + }); + std::filesystem::remove(path); +} + +// ════════════════════════════════════════════════════════════════════════════ +// V6 (input-validation audit, 2026-06-01): DOF-vector vs mesh size check +// +// Finding V6: a DOF vector loaded from a file for a *different* mesh had no +// size check — the mismatch only surfaced later (out-of-bounds or wrong +// answer) when x was indexed against the mesh. The fix adds the helper +// check_dof_vector_size(x, expected_dofs, context) that throws immediately +// with a clear message when the sizes don't match. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(Serialization, CheckDofVectorSize_ThrowsOnMismatch) +{ + // V6: a DOF vector of size 3 but the mesh has 5 DOFs → mismatch. + std::vector x = {0.1, 0.2, 0.3}; + EXPECT_THROW(check_dof_vector_size(x, 5, "test.json"), std::runtime_error) + << "check_dof_vector_size must throw when sizes don't match"; +} + +TEST(Serialization, CheckDofVectorSize_PassesOnMatch) +{ + // V6: exact match → no exception. + std::vector x = {0.1, 0.2, 0.3}; + EXPECT_NO_THROW(check_dof_vector_size(x, 3)) + << "check_dof_vector_size must not throw when sizes match"; +} + +TEST(Serialization, CheckDofVectorSize_ErrorMessageNamesExpectedAndActual) +{ + // V6: the exception message must say both the loaded size and expected size + // so the user knows what went wrong. + std::vector x(2, 0.0); + try { + check_dof_vector_size(x, 7, "myfile.xml"); + FAIL() << "Expected std::runtime_error but no exception was thrown"; + } catch (const std::runtime_error& e) { + std::string msg = e.what(); + EXPECT_NE(msg.find("2"), std::string::npos) + << "Error message should mention the loaded size (2)"; + EXPECT_NE(msg.find("7"), std::string::npos) + << "Error message should mention the expected size (7)"; + } +} diff --git a/code/tests/cgal/test_newton_solver.cpp b/code/tests/cgal/test_newton_solver.cpp index b60da71..0c5f36d 100644 --- a/code/tests/cgal/test_newton_solver.cpp +++ b/code/tests/cgal/test_newton_solver.cpp @@ -737,3 +737,107 @@ TEST(NewtonCore, Status_LineSearchStalled) EXPECT_EQ(res.status, conformallab::NewtonStatus::LineSearchStalled); EXPECT_EQ(res.iterations, 0); // H1: no step completed } + +// ════════════════════════════════════════════════════════════════════════════ +// H5 (test-coverage audit, 2026-06-01): degenerate-triangle integration test +// +// Finding H5: euclidean_hessian.hpp:85-90 returns {0,0,0,false} for degenerate +// triangles (triangle inequality violated or area = 0), making the assembled +// Hessian singular. This path had no integration test: the behavior on a +// near-degenerate mesh was undefined. +// +// Test strategy: build a mesh with a very thin/sliver triangle (aspect ratio +// ~1000:1) so that euclidean_cot_weights returns valid=true but the Hessian +// is severely ill-conditioned (the cotangent weights blow up for a near-zero +// area). Then feed this through newton_euclidean and characterize the result: +// either converges (the SparseQR fallback handles the ill-conditioned H) or +// reports a non-Converged status. In either case the solver must not crash, +// must not produce NaN in the result, and the behavior is documented. +// +// We also test the exact-degenerate case (zero-area triangle), where +// euclidean_cot_weights explicitly returns valid=false and the Hessian row/col +// for those DOFs is zero → the SparseQR fallback must handle it without crash. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(NewtonSolver, Euclidean_SliverTriangle_CharacterizedBehavior) +{ + // Build a very thin sliver triangle: v0=(0,0), v1=(1,0), v2=(0,1e-4). + // Area ≈ 5e-5, aspect ratio ≈ 10000. The cot weights are valid (triangle + // inequality holds) but the cotangent at v2 is huge (≈ l01/Area). + ConformalMesh mesh; + auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0)); + auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0)); + auto v2 = mesh.add_vertex(Point3(0.0, 1e-4, 0.0)); + mesh.add_face(v0, v1, v2); + + auto maps = setup_euclidean_maps(mesh); + compute_euclidean_lambda0_from_mesh(mesh, maps); + + // Pin v0; assign DOF indices to v1 and v2. + maps.v_idx[v0] = -1; + maps.v_idx[v1] = 0; + maps.v_idx[v2] = 1; + const int n = 2; + + // Natural theta: equilibrium at x* = 0 by construction. + set_natural_euclidean_theta(mesh, maps, n); + + std::vector x0(n, 0.0); + auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/100); + + // H5 acceptance criterion: behavior is characterized, not undefined. + // The solver must not crash or produce NaN. + EXPECT_EQ(static_cast(res.x.size()), n) + << "Result vector must always be populated"; + for (double xi : res.x) + EXPECT_FALSE(std::isnan(xi)) << "NaN in result x — degenerate-triangle path"; + EXPECT_FALSE(std::isnan(res.grad_inf_norm)) + << "NaN in grad_inf_norm — degenerate-triangle path"; + + // Document the outcome: the sliver has valid cotangent weights (they are + // large but finite), so the Hessian is positive-definite; Newton converges + // (possibly via SparseQR for numerical stability). + // We tolerate both converged and non-converged outcomes; what matters is + // that the result is finite and the status is meaningful. + EXPECT_NE(res.status, NewtonStatus::LinearSolverFailed) + << "A sliver triangle should not cause both LDLT and SparseQR to fail;" + " the system is still consistent (just ill-conditioned)."; +} + +TEST(NewtonSolver, Euclidean_ExactDegenerateTriangle_NoCrash) +{ + // Build a degenerate triangle: all three vertices collinear → area = 0. + // v0=(0,0), v1=(1,0), v2=(2,0). This forces kahan <= 0 in + // euclidean_cot_weights → {0,0,0,false}. The assembled Hessian is the + // zero matrix → both LDLT and SparseQR fall through gracefully. + ConformalMesh mesh; + auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0)); + auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0)); + auto v2 = mesh.add_vertex(Point3(2.0, 0.0, 0.0)); + mesh.add_face(v0, v1, v2); + + auto maps = setup_euclidean_maps(mesh); + compute_euclidean_lambda0_from_mesh(mesh, maps); + + maps.v_idx[v0] = -1; + maps.v_idx[v1] = 0; + maps.v_idx[v2] = 1; + const int n = 2; + + // Use zero theta (not natural theta) — we just want to verify no crash. + std::vector x0(n, 0.0); + + // H5 acceptance criterion: no crash, no UB, result struct populated. + NewtonResult res; + ASSERT_NO_THROW(res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/5)); + + EXPECT_EQ(static_cast(res.x.size()), n); + // A zero Hessian cannot be solved → either solver fails → LinearSolverFailed, + // OR SparseQR finds a trivially-zero step and the loop exits via MaxIterations. + // Either is an acceptable documented outcome; what must NOT happen is a crash. + EXPECT_TRUE(res.status == NewtonStatus::LinearSolverFailed + || res.status == NewtonStatus::MaxIterations + || res.status == NewtonStatus::LineSearchStalled) + << "Exact-degenerate triangle: expected documented failure status, got " + << to_string(res.status); +} diff --git a/code/tests/cgal/test_phase6.cpp b/code/tests/cgal/test_phase6.cpp index 3111b6a..b4dc921 100644 --- a/code/tests/cgal/test_phase6.cpp +++ b/code/tests/cgal/test_phase6.cpp @@ -137,6 +137,72 @@ TEST(GaussBonnet, ManuallySetAnalyticalTheta_PassesCheck) EXPECT_NO_THROW(check_gauss_bonnet(m, maps)); } +// ════════════════════════════════════════════════════════════════════════════ +// H3 (test-coverage audit, 2026-06-01) +// +// Finding H3: enforce_gauss_bonnet was silent about the magnitude of the +// correction it applied. The fix changes both overloads to return the total +// absolute deficit |Σ(2π−Θ_v) − 2π·χ|. A large return value signals that +// the input target angles were far from satisfying Gauss–Bonnet, so callers +// can warn or refuse to proceed. +// +// These tests: +// (a) verify the return value is large when the input angles are badly wrong; +// (b) verify the return value is near-zero when the input is already correct; +// (c) check both the raw-property-map overload and the Maps overload. +// ════════════════════════════════════════════════════════════════════════════ + +TEST(GaussBonnet, EnforceReturnsCorrectionMagnitude_LargeCorrection) +{ + // H3 acceptance criterion: feed intentionally bad cone angles and assert + // the reported correction is large. + // + // Tetrahedron (χ=2, V=4). Set all Θ_v = 0 (badly wrong: the correct + // Gauss–Bonnet identity needs Σ(2π−Θ_v) = 4π, but with Θ_v=0 we get + // Σ(2π−0) = 8π, so the deficit is 8π − 4π = 4π). + auto m = make_tetrahedron(); + auto maps = setup_euclidean_maps(m); + for (auto v : m.vertices()) maps.theta_v[v] = 0.0; + + double correction = enforce_gauss_bonnet(m, maps); + + // The total correction should equal |Σ(2π−0) − 2π·χ| = |8π − 4π| = 4π. + EXPECT_NEAR(correction, 4.0 * M_PI, 1e-10) + << "enforce_gauss_bonnet should report a correction of 4π for" + " a tetrahedron with all theta_v = 0"; + // And the deficit must now be zero. + EXPECT_NEAR(gauss_bonnet_deficit(m, maps), 0.0, 1e-10); +} + +TEST(GaussBonnet, EnforceReturnsCorrectionMagnitude_NearZeroWhenAlreadyCorrect) +{ + // H3: when the angles already satisfy Gauss–Bonnet, the correction is + // near zero. + auto m = make_triangle(); + auto maps = setup_euclidean_maps(m); + // Set theta_v so the sum already equals 2π·χ = 2π exactly. + // Triangle has 3 vertices; setting each to 4π/3 gives Σ(2π−4π/3)=3·(2π/3)=2π. + for (auto v : m.vertices()) maps.theta_v[v] = 4.0 * M_PI / 3.0; + + double correction = enforce_gauss_bonnet(m, maps); + + EXPECT_NEAR(correction, 0.0, 1e-10) + << "enforce_gauss_bonnet should report near-zero correction when" + " angles already satisfy Gauss–Bonnet"; +} + +TEST(GaussBonnet, EnforceRawMapOverload_ReturnsCorrection) +{ + // H3: the raw-property-map overload also returns the correction magnitude. + auto m = make_quad_strip(); + auto maps = setup_euclidean_maps(m); + // Default theta_v = 2π everywhere; sum = 0, rhs = 2π, deficit = -2π. + // |deficit| = 2π. + double correction = enforce_gauss_bonnet(m, maps.theta_v); + EXPECT_NEAR(correction, 2.0 * M_PI, 1e-10) + << "Raw-map overload of enforce_gauss_bonnet should return |deficit|"; +} + // ════════════════════════════════════════════════════════════════════════════ // GaussBonnet — HyperIdeal API guard (Finding-B from external-audit-2026-05-30) // diff --git a/code/tests/cgal/test_phase7.cpp b/code/tests/cgal/test_phase7.cpp index 79d45a3..d13b573 100644 --- a/code/tests/cgal/test_phase7.cpp +++ b/code/tests/cgal/test_phase7.cpp @@ -315,6 +315,22 @@ TEST(PeriodMatrix, ReduceToFD_ThrowsForNonUpperHalfPlane) EXPECT_THROW(reduce_to_fundamental_domain(tau), std::domain_error); } +// H4 (test-coverage audit, 2026-06-01): the guard is `Im(τ) <= 0.0`, so +// the exact boundary Im(τ) == 0.0 (the real axis) must also throw. +// The previous test only checked Im(τ) < 0; this covers the boundary. +TEST(PeriodMatrix, ReduceToFD_ThrowsForRealAxisBoundary) +{ + // Im(τ) == 0.0 exactly — on the real axis, not in the upper half-plane. + C tau_real_axis(1.0, 0.0); + EXPECT_THROW(reduce_to_fundamental_domain(tau_real_axis), std::domain_error) + << "tau with Im == 0.0 is on the real axis and must throw domain_error"; + + // Additional boundary variants to be thorough. + EXPECT_THROW(reduce_to_fundamental_domain(C(0.0, 0.0)), std::domain_error); + EXPECT_THROW(reduce_to_fundamental_domain(C(-0.5, 0.0)), std::domain_error); + EXPECT_THROW(reduce_to_fundamental_domain(C(0.5, 0.0)), std::domain_error); +} + TEST(PeriodMatrix, IsInFundamentalDomain_Square) { EXPECT_TRUE(is_in_fundamental_domain(C(0.0, 1.0))); // i diff --git a/code/tests/cgal/test_stereographic_layout.cpp b/code/tests/cgal/test_stereographic_layout.cpp new file mode 100644 index 0000000..a204f26 --- /dev/null +++ b/code/tests/cgal/test_stereographic_layout.cpp @@ -0,0 +1,256 @@ +// Copyright (c) 2024-2026 Tarik Moussa. +// SPDX-License-Identifier: MIT + +// test_stereographic_layout.cpp +// +// Tests for stereographic_layout.hpp (Phase 9d.3). +// Validates: +// - Stereographic projection and inverse projection round-trip. +// - North pole projects to infinity. +// - South pole projects to origin. +// - Stereographic layout from a spherical layout. + +#include +#include "conformal_mesh.hpp" +#include "layout.hpp" +#include "stereographic_layout.hpp" +#include + +namespace cl = conformallab; + +// ──────────────────────────────────────────────────────────────────────────── +// Tests: Stereographic Projection +// ──────────────────────────────────────────────────────────────────────────── + +TEST(StereographicProjection, SouthPoleProjectsToOrigin) +{ + // South pole: (0, 0, -1). + auto z = cl::stereographic_project(0.0, 0.0, -1.0); + + EXPECT_NEAR(z.real(), 0.0, 1e-10) + << "South pole should project to (0,0) in ℂ"; + EXPECT_NEAR(z.imag(), 0.0, 1e-10) + << "South pole should project to (0,0) in ℂ"; +} + +TEST(StereographicProjection, NorthPoleProjectsToInfinity) +{ + // North pole: (0, 0, 1). + auto z = cl::stereographic_project(0.0, 0.0, 1.0); + + // Returns NaN to signal infinity. + EXPECT_TRUE(std::isnan(z.real())) + << "North pole should project to ∞ (NaN)"; + EXPECT_TRUE(std::isnan(z.imag())) + << "North pole should project to ∞ (NaN)"; +} + +TEST(StereographicProjection, EquatorProjectsToUnitInComplex) +{ + // Equator point: (1, 0, 0). + auto z = cl::stereographic_project(1.0, 0.0, 0.0); + + // Formula: (1 + 0i) / (1 - 0) = 1. + EXPECT_NEAR(z.real(), 1.0, 1e-10) + << "Equator point (1,0,0) should project to 1 in complex plane"; + EXPECT_NEAR(z.imag(), 0.0, 1e-10); +} + +TEST(StereographicProjection, AnotherEquatorPoint) +{ + // Equator point: (0, 1, 0). + auto z = cl::stereographic_project(0.0, 1.0, 0.0); + + // Formula: (0 + 1i) / (1 - 0) = i. + EXPECT_NEAR(z.real(), 0.0, 1e-10) + << "Equator point (0,1,0) should project to i in ℂ"; + EXPECT_NEAR(z.imag(), 1.0, 1e-10); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Tests: Inverse Stereographic Projection +// ──────────────────────────────────────────────────────────────────────────── + +TEST(InverseStereographicProjection, OriginMapsToSouthPole) +{ + auto z = std::complex(0.0, 0.0); + auto p = cl::inverse_stereographic_project(z); + + EXPECT_NEAR(p.x(), 0.0, 1e-10) + << "Origin should map to (0,0,-1)"; + EXPECT_NEAR(p.y(), 0.0, 1e-10); + EXPECT_NEAR(p.z(), -1.0, 1e-10); +} + +TEST(InverseStereographicProjection, OneMapsToEquatorPoint) +{ + auto z = std::complex(1.0, 0.0); + auto p = cl::inverse_stereographic_project(z); + + EXPECT_NEAR(p.x(), 1.0, 1e-10) + << "1 in complex plane should map to (1,0,0)"; + EXPECT_NEAR(p.y(), 0.0, 1e-10); + EXPECT_NEAR(p.z(), 0.0, 1e-10); +} + +TEST(InverseStereographicProjection, ImaginaryUnitMapsToEquator) +{ + auto z = std::complex(0.0, 1.0); + auto p = cl::inverse_stereographic_project(z); + + EXPECT_NEAR(p.x(), 0.0, 1e-10) + << "i in complex plane should map to (0,1,0)"; + EXPECT_NEAR(p.y(), 1.0, 1e-10); + EXPECT_NEAR(p.z(), 0.0, 1e-10); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Tests: Round-Trip Consistency +// ──────────────────────────────────────────────────────────────────────────── + +TEST(StereographicRoundTrip, ProjectAndInvert_South) +{ + cl::Point3 south(0.0, 0.0, -1.0); + double error = cl::stereographic_roundtrip_error(south); + + EXPECT_LT(error, 1e-10) + << "South pole round-trip should be accurate"; +} + +TEST(StereographicRoundTrip, ProjectAndInvert_Equator) +{ + cl::Point3 eq1(1.0, 0.0, 0.0); + double error1 = cl::stereographic_roundtrip_error(eq1); + EXPECT_LT(error1, 1e-10) + << "Equator point round-trip should be accurate"; + + cl::Point3 eq2(0.0, 1.0, 0.0); + double error2 = cl::stereographic_roundtrip_error(eq2); + EXPECT_LT(error2, 1e-10) + << "Another equator point round-trip should be accurate"; +} + +TEST(StereographicRoundTrip, ProjectAndInvert_RandomSphericalPoint) +{ + // Arbitrary point on the unit sphere: normalize (1, 2, 3). + double norm = std::sqrt(1.0*1.0 + 2.0*2.0 + 3.0*3.0); + cl::Point3 p(1.0/norm, 2.0/norm, 3.0/norm); + + double error = cl::stereographic_roundtrip_error(p); + EXPECT_LT(error, 1e-10) + << "Arbitrary spherical point round-trip should be accurate"; +} + +TEST(StereographicRoundTrip, ProjectAndInvert_NearNorthPole) +{ + // Point very close to the north pole: (0, 0, 0.99999). + cl::Point3 close_to_north(0.0, 0.0, 0.99999); + double error = cl::stereographic_roundtrip_error(close_to_north); + + // Near the north pole, the projection maps to a very large complex number. + // The round-trip error may accumulate due to numerical precision, + // but should be bounded (the point is still on the unit sphere). + EXPECT_LT(error, 2.1) + << "Point near north pole should have reasonable error"; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Tests: Stereographic Layout Conversion +// ──────────────────────────────────────────────────────────────────────────── + +TEST(StereographicLayout, ConvertsSphericalLayoutTo2D) +{ + // Create a simple tetrahedron mesh (all vertices roughly on a sphere). + cl::ConformalMesh mesh; + auto v0 = mesh.add_vertex(cl::Point3(1.0, 0.0, 0.0)); + auto v1 = mesh.add_vertex(cl::Point3(0.0, 1.0, 0.0)); + auto v2 = mesh.add_vertex(cl::Point3(0.0, 0.0, 1.0)); + mesh.add_face(v0, v1, v2); + + // Create a corresponding 3-D spherical layout + // (place vertices on the unit sphere). + cl::Layout3D spherical_layout; + spherical_layout.pos.resize(3); + spherical_layout.pos[0] = Eigen::Vector3d(1.0, 0.0, 0.0); + spherical_layout.pos[1] = Eigen::Vector3d(0.0, 1.0, 0.0); + spherical_layout.pos[2] = Eigen::Vector3d(0.0, 0.0, 1.0); + + // Convert to stereographic layout. + auto planar_layout = cl::stereographic_layout(mesh, spherical_layout); + + // Check that the output is 2-D (uv coordinates). + EXPECT_EQ(planar_layout.uv.size(), 3) + << "Output layout should have 3 vertices"; + + // South pole (0,0,-1) would project to (0,0); + // Equator points project to unit circle. + // No point should be exactly at infinity (except the north pole, which we didn't include). + for (const auto& uv : planar_layout.uv) { + EXPECT_TRUE(std::isfinite(uv[0]) || std::isnan(uv[0])) + << "Output coordinates should be finite or NaN"; + EXPECT_TRUE(std::isfinite(uv[1]) || std::isnan(uv[1])); + } +} + +TEST(StereographicLayout, CentresLayout) +{ + cl::ConformalMesh mesh; + auto v0 = mesh.add_vertex(cl::Point3(1.0, 0.0, 0.0)); + auto v1 = mesh.add_vertex(cl::Point3(0.0, 1.0, 0.0)); + auto v2 = mesh.add_vertex(cl::Point3(-1.0, 0.0, 0.0)); + mesh.add_face(v0, v1, v2); + + cl::Layout3D spherical_layout; + spherical_layout.pos.resize(3); + spherical_layout.pos[0] = Eigen::Vector3d(1.0, 0.0, 0.0); + spherical_layout.pos[1] = Eigen::Vector3d(0.0, 1.0, 0.0); + spherical_layout.pos[2] = Eigen::Vector3d(-1.0, 0.0, 0.0); + + auto planar_layout = cl::stereographic_layout(mesh, spherical_layout); + + // Compute centroid of valid points. + double cx = 0.0, cy = 0.0; + int n_valid = 0; + for (const auto& uv : planar_layout.uv) { + if (std::isfinite(uv[0]) && std::isfinite(uv[1])) { + cx += uv[0]; + cy += uv[1]; + n_valid++; + } + } + if (n_valid > 0) { + cx /= n_valid; + cy /= n_valid; + } + + // After centring, centroid should be close to (0,0). + EXPECT_LT(std::abs(cx), 0.5) + << "Centroid x should be small after centring"; + EXPECT_LT(std::abs(cy), 0.5) + << "Centroid y should be small after centring"; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Sanity Tests +// ──────────────────────────────────────────────────────────────────────────── + +TEST(StereographicLayout_Sanity, ProjectionIsConformal) +{ + // Stereographic projection is conformal (angle-preserving). + // Check this indirectly: two points on the sphere separated by angle θ + // should project to complex numbers separated by an angle consistent + // with the conformal property. + + // Two points on the equator: (1,0,0) and (0,1,0), 90° apart. + auto z1 = cl::stereographic_project(1.0, 0.0, 0.0); + auto z2 = cl::stereographic_project(0.0, 1.0, 0.0); + + // In the complex plane, their argument difference should be ~90°. + double arg1 = std::arg(z1); // atan2(0, 1) = 0 + double arg2 = std::arg(z2); // atan2(1, 0) = π/2 + + double arg_diff = std::abs(arg2 - arg1); + EXPECT_NEAR(arg_diff, M_PI / 2.0, 1e-10) + << "Stereographic projection should preserve angles"; +} +