Merge pull request 'P1: Quick wins (CLI + quality measures + stereographic)' (#44) from feat/p1-quick-wins into main
All checks were successful
C++ Tests / test-fast (push) Successful in 2m25s
C++ Tests / quality-gates (push) Has been skipped
API Docs / doc-build (push) Has been skipped
Markdown link check / check (push) Has been skipped
Mirror to Codeberg / mirror (push) Successful in 38s
C++ Tests / test-cgal (push) Has been skipped
All checks were successful
C++ Tests / test-fast (push) Successful in 2m25s
C++ Tests / quality-gates (push) Has been skipped
API Docs / doc-build (push) Has been skipped
Markdown link check / check (push) Has been skipped
Mirror to Codeberg / mirror (push) Successful in 38s
C++ Tests / test-cgal (push) Has been skipped
This commit is contained in:
@@ -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:*)",
|
||||
|
||||
@@ -375,9 +375,12 @@ Full line-by-line audit of all math-critical headers against `de.varylab.discret
|
||||
|
||||
| Question | Document |
|
||||
|---|---|
|
||||
| Phases 1–10 with status and sub-tasks | `doc/roadmap/phases.md` |
|
||||
| Phases 1–13 with status, effort, and sub-tasks | `doc/roadmap/phases.md` |
|
||||
| Operational truth of which Java maths is in C++ today | `doc/roadmap/porting-status.md` |
|
||||
| Which Java classes are ported, which are planned, which are skipped? | `doc/roadmap/java-parity.md` |
|
||||
| New research items (beyond Java) — citations, acceptance criteria | `doc/roadmap/research-track.md` |
|
||||
| Phase orchestration — model assignments, priority, phase-session mapping | `doc/roadmap/phase-orchestration.md` |
|
||||
| Ready-to-paste session prompts for upcoming phases (P1–P4) | `doc/roadmap/session-prompts.md` |
|
||||
|
||||
### Tutorials & onboarding
|
||||
|
||||
@@ -397,6 +400,8 @@ Full line-by-line audit of all math-critical headers against `de.varylab.discret
|
||||
| Internal meeting agenda | `doc/reviewer/agenda.md` |
|
||||
| Reviewer landing index | `doc/reviewer/README.md` |
|
||||
| Hand-curated reviewer landing page (HTML, source-of-truth for codeberg pages) | `doc/reviewer/hub.html` |
|
||||
| Audit orchestration — model assignments, session sequence, status tracker | `doc/reviewer/finding-orchestration.md` |
|
||||
| Ready-to-paste session prompts for pending audit sessions (S3–S6) | `doc/reviewer/session-prompts.md` |
|
||||
|
||||
The published hub lives at https://tmoussa.codeberg.page/ConformalLabpp/ (Doxygen index at `/doxygen.html`). See the "Codeberg pages" quirk below for how it is republished.
|
||||
|
||||
@@ -419,6 +424,8 @@ Recommended loops when working in this repo. Prefer the cheapest gate that catch
|
||||
- **Before any commit**: run the four required gates locally — they mirror CI exactly and are seconds-cheap: `bash scripts/quality/license-headers.sh`, `python3 scripts/quality/cgal-conventions.py`, `bash scripts/quality/codespell.sh`, `bash scripts/quality/shellcheck.sh --strict`.
|
||||
- **Before tagging a release**: also run the two now-un-gated structural gates (test-cgal is disabled in CI): `BUILD_DIR=build bash scripts/check-test-counts.sh` and `bash scripts/try_it.sh`. Update `CHANGELOG.md`, `CITATION.cff`, and the `doc/api/tests.md` counts (single source of truth).
|
||||
- **Touching public-API headers**: rebuild Doxygen (`cmake --build build --target doc`) and re-check coverage (`bash scripts/doxygen-coverage.sh --threshold 100`); regenerate `doc/api/headers.md` via `python3 scripts/gen-headers-md.py` (or `bash scripts/regen-docs.sh`).
|
||||
- **Starting work on an audit finding**: open `doc/reviewer/finding-orchestration.md`, pick the next ⬜ pending session, copy its prompt from `doc/reviewer/session-prompts.md`, set the named model, and go. **S3 is next** (H3/H4/H5/V5/V6, Sonnet → Opus review).
|
||||
- **Starting work on a roadmap phase**: open `doc/roadmap/phase-orchestration.md`, pick the next ⬜ pending phase-session, copy its prompt from `doc/roadmap/session-prompts.md`, set the named model, and go. **P1 is next** (9g.1 + 9h.1 + 9h.2 + 9d.3, Haiku → Opus review).
|
||||
- **Landing to `main`** (origin is protected): branch → push to `origin` → open PR via `gh`/Gitea API → merge via API → also push `codeberg/main` directly → keep both remotes in sync.
|
||||
- **Republishing the reviewer hub**: see the Codeberg `pages` quirk below — manual force-push of an orphan branch; verify the live URL with a cache-bust query.
|
||||
- **Delegation**: this repo's heavy builds are slow on the ARM64 runner — when a task is genuinely parallelisable and independent, consider a background agent; otherwise handle inline. Always verify an agent's actual diff, not just its summary.
|
||||
|
||||
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
|
||||
@@ -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<double> x0(static_cast<std::size_t>(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<double> x0(static_cast<std::size_t>(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<double> 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<double> x0(static_cast<std::size_t>(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<std::size_t>(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<int>(mesh.number_of_vertices()),
|
||||
static_cast<int>(mesh.number_of_faces()),
|
||||
&layout);
|
||||
if (!out_xml.empty())
|
||||
cl::save_result_xml(out_xml, res, "cp_euclidean",
|
||||
static_cast<int>(mesh.number_of_vertices()),
|
||||
static_cast<int>(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<double> x0(static_cast<std::size_t>(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<std::size_t>(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<int>(mesh.number_of_vertices()),
|
||||
static_cast<int>(mesh.number_of_faces()),
|
||||
&layout);
|
||||
if (!out_xml.empty())
|
||||
cl::save_result_xml(out_xml, res, "inversive_distance",
|
||||
static_cast<int>(mesh.number_of_vertices()),
|
||||
static_cast<int>(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;
|
||||
|
||||
@@ -99,6 +99,17 @@ add_executable(conformallab_cgal_tests
|
||||
# Spherical, HyperIdeal, CircleP-Euclidean, Inversive-Distance via
|
||||
# <CGAL/Discrete_*.h> 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
|
||||
|
||||
240
code/tests/cgal/test_conformal_quality.cpp
Normal file
240
code/tests/cgal/test_conformal_quality.cpp
Normal file
@@ -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 <gtest/gtest.h>
|
||||
#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";
|
||||
}
|
||||
|
||||
@@ -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) <ConformalResult> root element with geometry= attribute on a separate line
|
||||
// (b) <DOFVector> with the '>' tag-open on a separate line
|
||||
// Both must throw std::runtime_error, never silently return zeros.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(Serialization, LoadResultXml_RejectsReformattedRootElement)
|
||||
{
|
||||
// V5: the <ConformalResult> 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 << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
<< "<ConformalResult\n" // tag name only — no geometry= here
|
||||
<< " geometry=\"euclidean\" vertices=\"3\" faces=\"1\">\n"
|
||||
<< " <Solver converged=\"true\" iterations=\"1\" grad_inf_norm=\"1e-10\"/>\n"
|
||||
<< " <DOFVector n=\"2\">0.1 0.2</DOFVector>\n"
|
||||
<< "</ConformalResult>\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 <DOFVector> tag's closing '>' is on a different line from
|
||||
// the opening '<DOFVector'. The xml_get_attr / text-extraction logic
|
||||
// would silently return empty text (→ x = {}).
|
||||
const std::string path = "/tmp/conflab_reformatted_dof.xml";
|
||||
{
|
||||
std::ofstream ofs(path);
|
||||
ofs << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
<< "<ConformalResult geometry=\"euclidean\" vertices=\"3\" faces=\"1\">\n"
|
||||
<< " <Solver converged=\"true\" iterations=\"1\" grad_inf_norm=\"1e-10\"/>\n"
|
||||
<< " <DOFVector\n" // tag open on its own line — no '>' here
|
||||
<< " n=\"2\">0.1 0.2</DOFVector>\n"
|
||||
<< "</ConformalResult>\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<double> x0(static_cast<std::size_t>(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<std::size_t>(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<int>(mesh.number_of_vertices()),
|
||||
static_cast<int>(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<double> 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<double> 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<double> 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)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<double> 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<int>(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<double> 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<int>(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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
256
code/tests/cgal/test_stereographic_layout.cpp
Normal file
256
code/tests/cgal/test_stereographic_layout.cpp
Normal file
@@ -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 <gtest/gtest.h>
|
||||
#include "conformal_mesh.hpp"
|
||||
#include "layout.hpp"
|
||||
#include "stereographic_layout.hpp"
|
||||
#include <Eigen/Dense>
|
||||
|
||||
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<double>(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<double>(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<double>(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";
|
||||
}
|
||||
|
||||
@@ -55,10 +55,10 @@ Status: ✅ done · 🔲 ready/planned · ⏸ planned-blocked-by-prereq · ⛔ b
|
||||
| 9a.1 CP-Euclidean | 🔌 | ✅ | — | — | — | done |
|
||||
| 9a.2 inversive-distance | 🔬 | ✅ | — | — | — | done |
|
||||
| 9b block-FD HyperIdeal Hessian | 🔬 | ✅ | — | — | — | done |
|
||||
| **9g.1 conformal-quality measures** | 🔌 | 🔲 **ready** | none | Porter · Sonnet | quick | ~3 d |
|
||||
| **9h.1 CLI --tol/--max-iter** | 🧱 | 🔲 **ready** | none | Haiku/Sonnet | quick | ~30 m |
|
||||
| **9h.2 CLI cp/inv-dist models** | 🧱 | 🔲 **ready** | 9a ✅ | Sonnet | quick | 2–4 h |
|
||||
| **9d.3 stereographic layout (S²→ℂ)** | 🔌 | 🔲 **ready** | none | Porter · Sonnet | sphere | ~3 d |
|
||||
| **9g.1 conformal-quality measures** | 🔌 | ✅ 1375878 | none | Porter · Sonnet | quick | ~3 d |
|
||||
| **9h.1 CLI --tol/--max-iter** | 🧱 | ✅ 1375878 | none | Haiku/Sonnet | quick | ~30 m |
|
||||
| **9h.2 CLI cp/inv-dist models** | 🧱 | ✅ 1375878 | 9a ✅ | Sonnet | quick | 2–4 h |
|
||||
| **9d.3 stereographic layout (S²→ℂ)** | 🔌 | ✅ 1375878 | none | Porter · Sonnet | sphere | ~3 d |
|
||||
| **9d.4 Möbius-centering functional** | 🔌 | 🔲 **ready** | Phase 7 ✅ | Porter · Sonnet | sphere | ~3 d |
|
||||
| **9e circle-pattern layout** | 🔌 | 🔲 **ready** | 9a.1 ✅ | Porter · Sonnet | — | ~1 wk |
|
||||
| **9d.1 ConesUtility (Euclidean)** | 🔌 | 🔲 **ready** | none | Porter · Sonnet | cones | ~1 wk |
|
||||
|
||||
@@ -10,6 +10,26 @@
|
||||
|
||||
---
|
||||
|
||||
## ◼ Current focus (as of 2026-06-01)
|
||||
|
||||
> **Agent entry point.** Read this box first. For model assignments, priority
|
||||
> order, and ready-to-paste prompts, see
|
||||
> [`phase-orchestration.md`](phase-orchestration.md) and
|
||||
> [`session-prompts.md`](session-prompts.md).
|
||||
> Audit-finding sessions run in parallel from `doc/reviewer/`.
|
||||
|
||||
| Track | Next session | Gating |
|
||||
|---|---|---|
|
||||
| **Audit findings** | S3: H3/H4/H5/V5/V6 — Sonnet → Opus review | None — start now |
|
||||
| **Phase quick-wins** | P1: 9g.1 + 9h.1 + 9h.2 + 9d.3 — Haiku → Opus review | None — start now |
|
||||
| **Research: Decorated DCE** | P2: Phase 12 — Sonnet → Opus review | None — start now |
|
||||
| **Circle pattern + convergence** | P3: 9e + 9d.4 + 9g.2 — Sonnet → Opus review | None — start now |
|
||||
| **Research: Analytic Hessian** | P4: Phase 9b-analytic — Opus | ⏸ awaiting reviewer Q3 |
|
||||
| **Java port: genus > 1** | P5: Phase 9c — Opus | ⏸ awaiting reviewer Q4 + G0 |
|
||||
| **CGAL packaging** | S6 (audit system) — Opus | ⛔ blocked on G0 (author reply pending) |
|
||||
|
||||
---
|
||||
|
||||
## ◼ Porting complete — Phases 1–7
|
||||
|
||||
```
|
||||
@@ -107,14 +127,16 @@ mesh type.
|
||||
Status: 🟡 PR #9 open, 7 tests passing, ~96× speed-up measured.
|
||||
|
||||
9b-analytic Full analytic HyperIdeal Hessian via Schläfli identity
|
||||
→ planned, see research-track.md
|
||||
→ planned, see research-track.md + session-prompts.md §P4
|
||||
Mathematical source: Springborn 2020 §4 + Schläfli 1858/60
|
||||
+ Rivin, Schlenker 1999 "The Schläfli formula in
|
||||
Einstein manifolds with boundary" (ERA-AMS 5, 18–23)
|
||||
+ Cho-Kim 1999 + Glickenstein 2011 §4
|
||||
Algorithm: explicit chain rule through (bᵢ,aₑ) → ℓᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ
|
||||
Includes: short LaTeX correctness note in doc/math/.
|
||||
Includes: 805-line LaTeX derivation in doc/math/hyperideal-hessian-derivation.md.
|
||||
Effort: 10–14 days net. Trigger: profiling on V > 5000.
|
||||
Complexity / scaling context: doc/math/complexity.md.
|
||||
⏸ GATED: awaiting reviewer Q3 answer ("worth ~2 weeks at your mesh sizes?").
|
||||
|
||||
9c — Genus g > 1 fundamental domain (Java port + research extensions)
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
@@ -414,6 +436,11 @@ poor input triangulations. Both share the same
|
||||
mathematical core (discrete conformal equivalence, Gauss–Bonnet,
|
||||
variational principle of Bobenko–Springborn 2004).
|
||||
|
||||
Full architecture-level comparison (overlap analysis, adoption rationale,
|
||||
scientific value): [`doc/architecture/geometry-central-comparison.md`](../architecture/geometry-central-comparison.md).
|
||||
Software landscape (libigl, CGAL, pmp-library, geometry-central):
|
||||
[`doc/math/software-landscape.md`](../math/software-landscape.md).
|
||||
|
||||
---
|
||||
|
||||
## ◼ Phase 10 — Genus g ≥ 2 (research with partial Java support)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Porting status overview
|
||||
|
||||
> **Snapshot date:** 2026-05-22 (commit graph at v0.9.0 + 2 open PRs).
|
||||
> **Snapshot date:** 2026-05-31 (post audit-sessions S1 + S2 on branch
|
||||
> `fix/b1-v3-c1-quick-wins`; 9 commits; 301/301 CGAL tests green).
|
||||
> For the authoritative live count see [`doc/api/tests.md`](../api/tests.md).
|
||||
>
|
||||
> **Audience.** External collaborators evaluating whether to use, extend,
|
||||
> or contribute to conformallab++. This document is the **operational
|
||||
@@ -55,8 +57,8 @@ legacy API (`code/include/*.hpp`) and the CGAL public API
|
||||
| **CP-Euclidean** (BPS) | face circles | **face** | CPEuclidean 260 LoC | analytic 2×2-per-edge | ✅ | `discrete_circle_packing_euclidean` | ⛔ N/A |
|
||||
| **Inversive Distance** | vertex circles| vertex | ❌ no Java (Luo 2004 + Glickenstein 2011 from literature) | FD (analytic planned) | ✅ | `discrete_inversive_distance_map` | ⛔ pending |
|
||||
|
||||
Total: 250+ tests covering all five models, 0 skipped. Per-suite
|
||||
breakdown: [`doc/api/tests.md`](../api/tests.md).
|
||||
Total test count: see [`doc/api/tests.md`](../api/tests.md) — single source
|
||||
of truth (counts change as sessions land; do not hardcode them here).
|
||||
|
||||
### What "UV out" means
|
||||
|
||||
@@ -99,6 +101,10 @@ into `pmap` — no separate user code needed. See
|
||||
| Newton with line search | ✅ | `newton_solver.hpp`, all five solvers |
|
||||
| SimplicialLDLT + SparseQR fallback | ✅ | gauge-singular meshes handled automatically |
|
||||
| Block-FD Hessian framework | ✅ | shipped for HyperIdeal (Phase 9b); 96× speed-up |
|
||||
| `newton_core` refactor — single exit path | ✅ | shipped S1 (2026-05-31); prerequisite for clean diagnostic propagation |
|
||||
| `NewtonStatus` enum (`Converged` / `MaxIterations` / `LinearSolverFailed` / `LineSearchStalled`) | ✅ | shipped S2 (2026-05-31); propagated into all three CGAL result types via `CGAL::Newton_status` alias |
|
||||
| Solver diagnostics: `sparse_qr_fallback_used`, `min_ldlt_pivot` | ✅ | shipped S2 (2026-05-31); available on all public CGAL result types |
|
||||
| Selectable Newton clamp mode (`HardJava` \| `SmoothBarrier`) | ✅ | shipped S1 (2026-05-31); `HardJava` is the default (Java-parity) |
|
||||
| Analytic Hessian via Schläfli | 🔲 | Phase 9b-analytic; derivation in [`hyperideal-hessian-derivation.md`](../math/hyperideal-hessian-derivation.md) |
|
||||
|
||||
---
|
||||
@@ -216,8 +222,12 @@ These are conformallab++ contributions beyond porting — the
|
||||
| Genus-2 test mesh + brezel2.obj scalability | ✅ shipped |
|
||||
| Memory-safe layout via `halfedge_uv` storage | ✅ shipped |
|
||||
| `doc/release-policy.md` formal release policy | ✅ shipped (PR #13) |
|
||||
| Analytic HyperIdeal Hessian via Schläfli | 🔲 derivation written (`hyperideal-hessian-derivation.md`); implementation Phase 9b-analytic |
|
||||
| Output UV map integrated into wrappers | ✅ shipped (PR #14) for 3 of 5 models |
|
||||
| `[[deprecated]]` aliases for pre-S1 API names (A1–A3 rename → `<verb>_<geom>_<rest>`) | ✅ shipped S1 (2026-05-31) |
|
||||
| Named numeric constants in `constants.hpp` (all thresholds / FD steps / guard values) | ✅ shipped S1 (2026-05-31) |
|
||||
| Kahan-compensated triangle area in `enforce_gauss_bonnet` | ✅ shipped S1 (2026-05-31) |
|
||||
| `NewtonStatus` enum + solver diagnostics on all public CGAL result types | ✅ shipped S2 (2026-05-31) |
|
||||
| Analytic HyperIdeal Hessian via Schläfli | 🔲 derivation written (`hyperideal-hessian-derivation.md`); implementation Phase 9b-analytic (P4) |
|
||||
| Full uniformisation for genus g ≥ 2 | 🔲 Phase 10c — fully new research |
|
||||
|
||||
---
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
> port-tracking sheet `doc/roadmap/java-parity.md` so that the porting
|
||||
> work and the research work can be planned independently.
|
||||
>
|
||||
> **Companion documents:**
|
||||
> - [`doc/math/novelty-statement.md`](../math/novelty-statement.md) — why these
|
||||
> contributions are novel and who the target audience is.
|
||||
> - [`doc/math/software-landscape.md`](../math/software-landscape.md) — how
|
||||
> conformallab++ relates to libigl, geometry-central, and CGAL (relevant for
|
||||
> deciding research vs. duplication at the boundary cases).
|
||||
> - [`phase-orchestration.md`](phase-orchestration.md) — model assignments and
|
||||
> session prompts for implementing items in this document.
|
||||
>
|
||||
> **Created:** 2026-05-21, after a full doc audit that identified four
|
||||
> items previously mislabelled as "ports". This document corrects the
|
||||
> record and extends it with the explicit research plan for Phase
|
||||
|
||||
208
doc/roadmap/session-prompts.md
Normal file
208
doc/roadmap/session-prompts.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Ready-to-paste session prompts (P1–P4 + review gate)
|
||||
|
||||
Copy one block into a fresh session, set the **model named in the prompt**, and go.
|
||||
Each prompt is self-contained: it names the phase(s), the detail doc to follow,
|
||||
the build/test commands, and the branch/push/PR + review-gate workflow.
|
||||
|
||||
Shared conventions (baked into each prompt):
|
||||
- Repo root: `/Users/tarikmoussa/Desktop/ConformalLabpp`, base branch `main`.
|
||||
- Branch + push to the **eulernest fork** = remote `origin`; open the PR via the
|
||||
Gitea API (`https://git.eulernest.eu/api/v1/repos/conformallab/ConformalLabpp/pulls`,
|
||||
basic-auth with the token embedded in the `origin` URL), base `main`.
|
||||
- Build/test command (CGAL suite):
|
||||
`cmake -S code -B build-cgal -DWITH_CGAL_TESTS=ON && cmake --build build-cgal --target conformallab_cgal_tests -j8 && ctest --test-dir build-cgal -R '^cgal\.'`
|
||||
- Phase details: `doc/roadmap/phases.md` (per-phase plan);
|
||||
`doc/roadmap/research-track.md` (research items with acceptance criteria).
|
||||
- Build flags reference: `CLAUDE.md` §Build commands (canonical source; use
|
||||
`-DCONFORMALLAB_LOW_MEMORY_BUILD=ON -j1` if on the Raspberry Pi runner).
|
||||
- After each implementation session, run the **Review gate** prompt (Opus).
|
||||
|
||||
---
|
||||
|
||||
## P1 — Quick wins (model: **Haiku**)
|
||||
|
||||
```
|
||||
Use the Haiku model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
|
||||
branch off main called `feat/p1-quick-wins`.
|
||||
|
||||
Implement four independent additions — full details (Java references, math
|
||||
references, acceptance criteria) in doc/roadmap/phases.md at the sections
|
||||
labelled 9h.1, 9h.2, 9g.1, 9d.3:
|
||||
|
||||
- 9h.1 Add --tol and --max-iter CLI options in code/src/conformallab_cli.cpp.
|
||||
Thread both through run_euclidean / run_spherical / run_hyper_ideal.
|
||||
Update doc/getting-started.md CLI parameter table.
|
||||
|
||||
- 9h.2 Add -g cp_euclidean and -g inversive_distance routes in the CLI,
|
||||
following the existing run_euclidean() pattern (~60 lines each).
|
||||
Add both geometry strings to the CLI::IsMember validator.
|
||||
Update README + doc/getting-started.md.
|
||||
|
||||
- 9g.1 Create code/include/conformal_quality.hpp implementing:
|
||||
IsothermicityMeasure, DiscreteConformalEquivalenceMeasure,
|
||||
FlippedTriangles, LengthCrossRatio, ConvergenceUtility measures.
|
||||
Java source classes are listed in phases.md §9g.1.
|
||||
Each function must have at least one sanity test in code/tests/cgal/
|
||||
(e.g. FlippedTriangles returns 0 on a valid layout; LengthCrossRatio
|
||||
is 1.0 on an equilateral triangle). Register in code/tests/cgal/CMakeLists.txt.
|
||||
|
||||
- 9d.3 Create code/include/stereographic_layout.hpp porting
|
||||
StereographicUnwrapper.java (266 LoC). See phases.md §9d.3 for the math
|
||||
(stereographic projection S²→ℂ∪{∞} + Möbius centring for genus-0 surfaces).
|
||||
Add at least one round-trip test (north pole → ∞; a unit-sphere point →
|
||||
expected complex value).
|
||||
|
||||
Run the full CGAL suite after all four are implemented:
|
||||
cmake -S code -B build-cgal -DWITH_CGAL_TESTS=ON && cmake --build build-cgal \
|
||||
--target conformallab_cgal_tests -j8 && ctest --test-dir build-cgal -R '^cgal\.'
|
||||
It must stay green with your new tests added.
|
||||
|
||||
Commit per phase (or in two logical commits) with trailer
|
||||
`Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>`.
|
||||
Push to origin and open a PR (base main) via the Gitea API using the token
|
||||
in the origin remote URL. Update doc/roadmap/phase-orchestration.md (mark each
|
||||
completed phase ✅ with the commit ref). Report the PR URL and test count.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P2 — Decorated DCE transition (model: **Sonnet**)
|
||||
|
||||
```
|
||||
Use the Sonnet model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
|
||||
branch off main called `feat/p2-decorated-dce`.
|
||||
|
||||
Implement Phase 12 — Decorated DCE & geometric transition.
|
||||
Full details and acceptance criteria in:
|
||||
doc/roadmap/phases.md §Phase 12
|
||||
doc/roadmap/research-track.md §Phase 12
|
||||
|
||||
Mathematical reference: Bobenko, Lutz 2025 "Decorated Discrete Conformal
|
||||
Equivalence in Non-Euclidean Geometries" (Discrete & Comput. Geom.;
|
||||
arXiv:2310.17529) §3 — Penner-coordinate decoration unifying the three
|
||||
background geometries.
|
||||
|
||||
Scope (from phases.md):
|
||||
1. Decoration layer — per-vertex circle/horocycle radius as Penner coordinate;
|
||||
implement the map ↔ existing inversive distance I_ij via
|
||||
ℓ² = r_i² + r_j² + 2 r_i r_j η.
|
||||
→ Create code/include/decorated_dce.hpp.
|
||||
2. Transition driver — deform background curvature κ ∈ {+,0,−} while holding
|
||||
the discrete conformal invariant fixed; call the three existing solvers
|
||||
(euclidean, spherical, hyper_ideal).
|
||||
3. Validation harness — code/tests/cgal/test_decorated_dce.cpp.
|
||||
|
||||
All four acceptance criteria from phases.md must be met:
|
||||
- Decoration round-trip I_ij ↔ (r_i, r_j, ℓ) at machine precision.
|
||||
- At κ=0: bit-for-bit match with existing euclidean / inversive path.
|
||||
- Gauss-Bonnet holds per geometry across the κ-transition.
|
||||
- Invariant (I_ij) is constant across the three-geometry transition to tol.
|
||||
|
||||
Run the full CGAL suite (must stay green with new tests).
|
||||
Commit with trailer `Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
|
||||
Push to origin, open a PR (base main) via the Gitea API. Update
|
||||
doc/roadmap/phase-orchestration.md (Phase 12 → ✅, session P2 + commit ref).
|
||||
Report the PR URL.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P3 — Circle pattern embedding + Möbius centring + convergence study (model: **Sonnet**)
|
||||
|
||||
```
|
||||
Use the Sonnet model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
|
||||
branch off main called `feat/p3-circle-pattern-convergence`.
|
||||
|
||||
Three items — full details in doc/roadmap/phases.md:
|
||||
|
||||
- 9e Create code/include/circle_pattern_layout.hpp porting
|
||||
CirclePatternLayout + CirclePatternUtility. Phase 9a.1 (the CP-Euclidean
|
||||
energy + solver) is a prerequisite and is already landed on main.
|
||||
Java references: unwrapper/circlepattern/{CirclePatternLayout,
|
||||
CirclePatternUtility,CPEuclideanRotation}.java (phases.md §9e).
|
||||
Required test: given ρ values from a solved CP-Euclidean system, verify
|
||||
that the embedded vertex positions are self-consistent (each face's three
|
||||
circle-intersection points form the correct intersection angles to tol).
|
||||
|
||||
- 9d.4 Upgrade normalise_hyperbolic() in code/include/layout.hpp to use the
|
||||
variational MobiusCenteringFunctional (Lorentz energy
|
||||
E = Σ log(-⟨x,p⟩/√(-⟨x,x⟩)), gradient + Hessian).
|
||||
Java reference: functional/MobiusCenteringFunctional.java (289 LoC).
|
||||
Retain the existing Fréchet mean as a fallback if Newton fails.
|
||||
Required test: compare old vs new centring output on brezel.obj; both
|
||||
must place the centroid within tol of the origin.
|
||||
|
||||
- 9g.2 Add code/tests/cgal/test_period_matrix_convergence.cpp (experiment,
|
||||
not a library feature — see phases.md §9g.2):
|
||||
Generate a genus-1 elliptic mesh with a known analytic τ; subdivide via
|
||||
igl::loop; add per-vertex Gaussian noise; compute |τ_discrete − τ_analytic|
|
||||
after each step. Assert that the residual decreases monotonically with
|
||||
refinement (the discrete period matrix converges).
|
||||
|
||||
Run the full CGAL suite (must stay green).
|
||||
Commit with trailer `Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
|
||||
Push to origin, open a PR (base main) via the Gitea API. Update
|
||||
doc/roadmap/phase-orchestration.md (9e/9d.4/9g.2 → ✅, P3 + commit ref).
|
||||
Report the PR URL and the convergence-study output.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P4 — Analytic HyperIdeal Hessian (model: **Opus**) — ⏸ GATED on reviewer Q3
|
||||
|
||||
```
|
||||
PRECONDITION: do NOT start this session until the reviewer has answered Q3
|
||||
("Is the ~6× speedup over block-FD worth ~2 weeks at your typical mesh sizes?").
|
||||
If the answer is "no" or "not a priority", stop — Phase 9b-analytic stays ⏸.
|
||||
If the answer is "yes" or "above V > X", proceed.
|
||||
|
||||
Use the Opus model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
|
||||
branch off main called `feat/p4-analytic-hessian`.
|
||||
|
||||
Implement Phase 9b-analytic — the full analytic HyperIdeal Hessian via the
|
||||
Schläfli identity. The complete chain-rule derivation (805-line LaTeX note) and
|
||||
the implementation plan are in:
|
||||
doc/math/hyperideal-hessian-derivation.md
|
||||
doc/roadmap/phases.md §9b-analytic
|
||||
doc/roadmap/research-track.md §Phase 9b-analytic
|
||||
|
||||
Chain: (bᵢ, aₑ) → ℓᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ → ∂²E/∂u².
|
||||
Replace the block-FD path in code/include/hyper_ideal_hessian.hpp with the
|
||||
analytic Hessian. Retain the block-FD path available as a compile-time flag
|
||||
(-DCONFORMALLAB_HYPER_IDEAL_FD_CHECK or runtime enum) for cross-validation.
|
||||
|
||||
Acceptance criteria:
|
||||
- All existing HyperIdeal golden-value tests pass bit-for-bit (HardJava clamp).
|
||||
- New test: analytic and block-FD Hessians agree to 1e-6 on the tetrahedron.
|
||||
- Benchmark: measure the analytic vs block-FD wall-time on the largest test
|
||||
mesh; report the ratio. Analytic must be faster for V > 500.
|
||||
|
||||
Run the full CGAL suite (must stay green). Commit; push; open PR via Gitea API.
|
||||
Update doc/roadmap/phase-orchestration.md (9b-analytic → ✅, P4 + commit ref).
|
||||
Report PR URL and the measured speed ratio.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review gate (run after P1 / P2 / P3) (model: **Opus**)
|
||||
|
||||
```
|
||||
Use the Opus model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp. Review
|
||||
the open PR <PR_URL / branch name> as an independent reviewer. Check, and report
|
||||
a pass/fail per item:
|
||||
|
||||
- Builds clean; full CGAL suite green with no count regression
|
||||
(ctest --test-dir build-cgal -R '^cgal\.'); count matches doc/api/tests.md.
|
||||
- Any new functional has a gradient-check test (pattern in CLAUDE.md §Test design patterns).
|
||||
- No Java golden-vector / parity test perturbed; HardJava clamp default intact.
|
||||
- Numeric changes are value-identical where claimed, or justified + covered by a test.
|
||||
- New public surface (result types, enums, CGAL headers) is intentional and
|
||||
documented in doc/api/headers.md and doc/api/contracts.md.
|
||||
- Commit messages attribute the implementing model (Co-Authored-By trailer).
|
||||
- Phase(s) marked ✅ in doc/roadmap/phase-orchestration.md with the commit ref.
|
||||
|
||||
Read the actual diff (git diff main...HEAD -- code/include/ code/tests/ doc/)
|
||||
and the phases.md entry for the phase(s) involved. If you find a real problem,
|
||||
fix it directly (small) or list precise required changes (larger), then re-run
|
||||
the suite. Conclude with an explicit APPROVE / CHANGES-REQUESTED.
|
||||
```
|
||||
Reference in New Issue
Block a user