feat(cgal): propagate Newton status + conditioning diagnostics into CGAL results

Follow-up to S2: surface the new NewtonResult diagnostics through the public
CGAL result types so callers of the high-level API see them too.

- Add `CGAL::Newton_status` (alias of conformallab::NewtonStatus) and three
  fields — `status`, `sparse_qr_fallback_used`, `min_ldlt_pivot` — to
  Conformal_map_result, Hyper_ideal_map_result and Circle_packing_result.
  (sparse_qr_fallback_used already existed on Conformal_map_result but was never
  populated; it is now wired through.)
- Map them from NewtonResult at all five entry points (euclidean / spherical /
  hyper_ideal / inversive_distance / cp_euclidean).
- Test: SingleTriangleConverges now asserts status == Converged and the
  diagnostics propagate.

Additive only — existing `converged`/`iterations`/`gradient_norm` semantics
unchanged. 301/301 CGAL tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-31 18:54:50 +02:00
parent 4f8530628b
commit 56f13d7c4f
4 changed files with 52 additions and 0 deletions

View File

@@ -96,6 +96,9 @@ struct Default_cp_euclidean_traits<CGAL::Surface_mesh<typename K::Point_3>, K>
Result of `discrete_circle_packing_euclidean`. Carries face DOFs
`ρ_f = log R_f` rather than the vertex DOFs of the classical modes.
*/
/// Why the Newton solve terminated (re-exported from the solver; I1 audit).
using Newton_status = ::conformallab::NewtonStatus;
template <typename FT = double>
struct Circle_packing_result
{
@@ -108,6 +111,14 @@ struct Circle_packing_result
FT gradient_norm = FT(0);
/// `true` iff `gradient_norm < gradient_tolerance` at exit.
bool converged = false;
/// Why the solve stopped: `Converged` / `MaxIterations` /
/// `LinearSolverFailed` / `LineSearchStalled`.
Newton_status status = Newton_status::MaxIterations;
/// `true` iff any Newton step fell back to SparseQR (gauge singularity).
bool sparse_qr_fallback_used = false;
/// Smallest `|Dᵢᵢ|` of the last LDLT (near-singularity proxy; 0 if none).
FT min_ldlt_pivot = FT(0);
};
// ── Entry function ────────────────────────────────────────────────────────────
@@ -188,6 +199,9 @@ auto discrete_circle_packing_euclidean(
result.iterations = nr.iterations;
result.gradient_norm = nr.grad_inf_norm;
result.converged = nr.converged;
result.status = nr.status;
result.sparse_qr_fallback_used = nr.sparse_qr_fallback_used;
result.min_ldlt_pivot = static_cast<FT>(nr.min_ldlt_pivot);
// ── output_uv_map (Phase 8b-Lite extension) ────────────────────────────
//

View File

@@ -78,6 +78,10 @@ namespace CGAL {
// Result type
// ════════════════════════════════════════════════════════════════════════════
/// Why the Newton solve terminated (re-exported from the solver; I1 audit).
/// `Converged` / `MaxIterations` / `LinearSolverFailed` / `LineSearchStalled`.
using Newton_status = ::conformallab::NewtonStatus;
/*!
\ingroup PkgConformalMapRef
@@ -100,9 +104,18 @@ struct Conformal_map_result
/// `true` iff `gradient_norm < gradient_tolerance`.
bool converged = false;
/// Why the solve stopped (more informative than `converged` alone): one of
/// `Converged` / `MaxIterations` / `LinearSolverFailed` / `LineSearchStalled`.
Newton_status status = Newton_status::MaxIterations;
/// `true` iff the linear solver used the SparseQR fallback at any
/// Newton step (gauge mode on closed mesh without pinned vertex).
bool sparse_qr_fallback_used = false;
/// Smallest `|Dᵢᵢ|` of the last LDLT factorisation — a cheap
/// near-singularity proxy (small ⇒ ill-conditioned solve). `0` if LDLT
/// never succeeded.
FT min_ldlt_pivot = FT(0);
};
@@ -272,6 +285,9 @@ auto discrete_conformal_map_euclidean(
result.iterations = nr.iterations;
result.gradient_norm = nr.grad_inf_norm;
result.converged = nr.converged;
result.status = nr.status;
result.sparse_qr_fallback_used = nr.sparse_qr_fallback_used;
result.min_ldlt_pivot = static_cast<FT>(nr.min_ldlt_pivot);
// ── 8. Optional layout step (Phase 8b-Lite extension) ──────────────────
//
@@ -410,6 +426,9 @@ auto discrete_conformal_map_spherical(
result.iterations = nr.iterations;
result.gradient_norm = nr.grad_inf_norm;
result.converged = nr.converged;
result.status = nr.status;
result.sparse_qr_fallback_used = nr.sparse_qr_fallback_used;
result.min_ldlt_pivot = static_cast<FT>(nr.min_ldlt_pivot);
// Optional 3-D layout step (point on S²)
auto uv_param = parameters::get_parameter(
@@ -459,6 +478,14 @@ struct Hyper_ideal_map_result
FT gradient_norm = FT(0);
/// `true` iff `gradient_norm < gradient_tolerance` at exit.
bool converged = false;
/// Why the solve stopped: `Converged` / `MaxIterations` /
/// `LinearSolverFailed` / `LineSearchStalled`.
Newton_status status = Newton_status::MaxIterations;
/// `true` iff any Newton step fell back to SparseQR (gauge singularity).
bool sparse_qr_fallback_used = false;
/// Smallest `|Dᵢᵢ|` of the last LDLT (near-singularity proxy; 0 if none).
FT min_ldlt_pivot = FT(0);
};
/*!
@@ -538,6 +565,9 @@ auto discrete_conformal_map_hyper_ideal(
result.iterations = nr.iterations;
result.gradient_norm = nr.grad_inf_norm;
result.converged = nr.converged;
result.status = nr.status;
result.sparse_qr_fallback_used = nr.sparse_qr_fallback_used;
result.min_ldlt_pivot = static_cast<FT>(nr.min_ldlt_pivot);
// Optional Poincaré-disk layout (2-D in the unit disk).
auto uv_param = parameters::get_parameter(

View File

@@ -207,6 +207,9 @@ auto discrete_inversive_distance_map(
result.iterations = nr.iterations;
result.gradient_norm = nr.grad_inf_norm;
result.converged = nr.converged;
result.status = nr.status;
result.sparse_qr_fallback_used = nr.sparse_qr_fallback_used;
result.min_ldlt_pivot = static_cast<FT>(nr.min_ldlt_pivot);
// ── Optional layout step (Phase 8b-Lite extension) ─────────────────────
//

View File

@@ -111,6 +111,11 @@ TEST(CGALDiscreteConformalMap, SingleTriangleConverges)
EXPECT_GE(result.iterations, 0);
EXPECT_EQ(result.u_per_vertex.size(), num_vertices(mesh));
// I1/N7 propagated through the CGAL layer: status enum + diagnostics.
EXPECT_EQ(result.status, CGAL::Newton_status::Converged);
EXPECT_FALSE(result.sparse_qr_fallback_used); // pinned vertex → no gauge mode
EXPECT_GE(result.min_ldlt_pivot, 0.0);
// With the default flat-disc target curvature and the first vertex pinned,
// the natural-theta equilibrium is at u = 0 — Newton should accept x0=0.
for (double u : result.u_per_vertex)