Files
ConformalLabpp/code/tests/cgal/test_lawson_hyperideal.cpp
Tarik Moussa 5de95a7a93
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 1m56s
API Docs / doc-build (pull_request) Successful in 59s
C++ Tests / test-cgal (pull_request) Has been skipped
C++ Tests / quality-gates (pull_request) Successful in 2m32s
test(hyperideal): add Lawson branch-points golden-vector cross-validation
Second Lawson variant from HyperIdealConvergenceTest...WithBranchPoints.

- make_lawson_branch_points(): base + STELLAR subdivision (Java StellarLinear)
  via CGAL Euler::add_center_vertex — a centre vertex per quad fan-connected to
  its 4 corners (6 centres, 24 triangles, 36 edges). The 6 centres are IDEAL
  vertices (b=0, v_idx=-1); θ_e=π on the 12 base edges, θ_e=π/2 on the 24 spokes.
- BranchPointsGoldenVector_JavaXVal: newton_hyper_ideal converges to the Java
  golden (per symmetry class @1e-4):
    original vertices → 1.3169579
    base edges        → 2.2924317
    spoke edges       → 0   (the π/2 spokes collapse to ideal)
  Key insight: Java's index-based θ split (first 12 edges π, rest π/2) is
  geometric — base edges vs stellar spokes — so the symmetry shortcut applies.

createLawsonHyperelliptic() NOT ported: needs a reader for the Java
conformal-data XML (lawson_curve_source.xml) + a port of
HyperIdealHyperellipticUtility, and its golden vector is not class-symmetric.
Documented as a deferred task.

243/243 cgal tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 01:14:46 +02:00

258 lines
11 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// test_lawson_hyperideal.cpp
//
// Tier-3 Java cross-validation: HyperIdealConvergenceTest (Lawson square-tiled).
//
// Ports de.varylab.discreteconformal.functional.HyperIdealGenerator. The base is
// a genus-2 surface with 4 vertices and 12 edges → MULTI-EDGES (≥2 edges per
// vertex pair), so CGAL add_face/OFF/polygon-soup cannot build it; we use the
// low-level Surface_mesh half-edge API directly.
//
// Variants ported (vs HyperIdealConvergenceTest golden vectors):
// 1. createLawsonSquareTiled() → diagonal triangulation
// 2. createLawsonSquareTiledWithBranchPoints() → stellar subdivision + ideal
// branch-point centres
//
// NOT ported — createLawsonHyperelliptic(): loads `lawson_curve_source.xml`
// (a Java conformal-data HalfedgeEmbedding, not OBJ/OFF) and derives θ via
// HyperIdealHyperellipticUtility.calculateCircleIntersections. It needs a reader
// for that XML format + a port of that utility, and its golden vector is not
// class-symmetric (mixed ±values), so the symmetry shortcut does not apply.
// Deferred as its own task; see doc/reviewer/java-ignore-crossvalidation.md.
#include "conformal_mesh.hpp"
#include "hyper_ideal_functional.hpp"
#include "newton_solver.hpp"
#include <CGAL/Polygon_mesh_processing/triangulate_faces.h>
#include <CGAL/boost/graph/Euler_operations.h>
#include <gtest/gtest.h>
#include <array>
#include <vector>
#include <set>
#include <cmath>
using namespace conformallab;
namespace {
// Build the Lawson square-tiled BASE (4 vertices, 12 edges, 6 quad faces,
// genus 2) via the low-level half-edge API — multi-edges (≥2 edges per vertex
// pair) make add_face/OFF/polygon-soup impossible. Returns the 4 original
// vertices in `V`. Not yet triangulated.
void build_lawson_base(ConformalMesh& m, std::array<Vertex_index, 4>& V)
{
V = {
m.add_vertex(Point3(0, 0, 0)), // A
m.add_vertex(Point3(1, 0, 0)), // B
m.add_vertex(Point3(0, 1, 0)), // C
m.add_vertex(Point3(0, 0, 1)) // D
};
// Java half-edge target vertices (A=0,B=1,C=2,D=3), indices 0..23.
const int tgt[24] = {
0,1,3,2, 1,0,2,3, 3,2,0,1, 2,3,1,0, 0,1,3,2, 1,0,2,3
};
const int pairs[12][2] = { // Java linkOppositeEdge
{0,6},{1,21},{2,4},{3,23},{5,11},{7,9},
{8,14},{10,12},{13,19},{15,17},{16,22},{18,20}
};
const int cyc[6][4] = { // Java next-edge cycles → 6 quads
{0,1,2,3},{4,5,6,7},{8,9,10,11},{12,13,14,15},{16,17,18,19},{20,21,22,23}
};
std::array<Halfedge_index, 24> he;
for (const auto& p : pairs) {
Halfedge_index h = m.add_edge();
he[static_cast<std::size_t>(p[0])] = h;
he[static_cast<std::size_t>(p[1])] = m.opposite(h);
}
for (int j = 0; j < 24; ++j)
m.set_target(he[static_cast<std::size_t>(j)], V[static_cast<std::size_t>(tgt[j])]);
for (const auto& c : cyc) {
Face_index f = m.add_face();
m.set_halfedge(f, he[static_cast<std::size_t>(c[0])]);
for (int k = 0; k < 4; ++k) {
m.set_face(he[static_cast<std::size_t>(c[k])], f);
m.set_next(he[static_cast<std::size_t>(c[k])],
he[static_cast<std::size_t>(c[(k + 1) % 4])]);
}
}
for (int j = 0; j < 24; ++j)
m.set_halfedge(V[static_cast<std::size_t>(tgt[j])], he[static_cast<std::size_t>(j)]);
}
// Plain Lawson: base + diagonal triangulation (6 quads → 12 triangles).
// `original_edges` receives the 12 base edges (the 6 diagonals are "aux").
ConformalMesh make_lawson_square_tiled(std::set<Edge_index>* original_edges = nullptr)
{
ConformalMesh m;
std::array<Vertex_index, 4> V;
build_lawson_base(m, V);
if (original_edges) {
original_edges->clear();
for (auto e : m.edges()) original_edges->insert(e);
}
CGAL::Polygon_mesh_processing::triangulate_faces(m);
return m;
}
// Branch-points Lawson: base + STELLAR subdivision (Java StellarLinear) — a
// center vertex per quad fan-connected to its 4 corners (6 ideal "branch"
// centers, 24 triangles, 36 edges). `orig_verts` = the 4 real vertices;
// `base_edges` = the 12 base edges (θ=π); the 24 spokes are θ=π/2.
ConformalMesh make_lawson_branch_points(std::array<Vertex_index, 4>& orig_verts,
std::set<Edge_index>& base_edges)
{
ConformalMesh m;
build_lawson_base(m, orig_verts);
base_edges.clear();
for (auto e : m.edges()) base_edges.insert(e);
// Stellar-subdivide each of the 6 quads (collect halfedges first; the loop
// mutates the mesh).
std::vector<Halfedge_index> face_he;
for (auto f : m.faces()) face_he.push_back(m.halfedge(f));
for (auto h : face_he)
CGAL::Euler::add_center_vertex(h, m);
return m;
}
} // namespace
TEST(LawsonHyperIdeal, BuildsValidGenus2Mesh)
{
std::set<Edge_index> original;
ConformalMesh m = make_lawson_square_tiled(&original);
EXPECT_TRUE(m.is_valid(false)) << "Lawson square-tiled mesh is not a valid halfedge structure";
EXPECT_EQ(m.number_of_vertices(), 4u);
EXPECT_EQ(m.number_of_faces(), 12u); // 6 quads → 12 triangles
EXPECT_EQ(m.number_of_edges(), 18u); // 12 original + 6 diagonals
EXPECT_EQ(original.size(), 12u);
// Euler characteristic χ = V E + F = 4 18 + 12 = 2 ⇒ genus 2.
const int chi = static_cast<int>(m.number_of_vertices())
- static_cast<int>(m.number_of_edges())
+ static_cast<int>(m.number_of_faces());
EXPECT_EQ(chi, -2) << "expected genus 2 (χ = 2), got χ = " << chi;
}
// ════════════════════════════════════════════════════════════════════════════
// Golden-vector cross-validation against Java HyperIdealConvergenceTest
//
// Java sets Θ_v = 2π (vertices), θ_e = π/2 (the 12 original edges) and θ_e = π
// (the 6 triangulation/aux edges), then solves with TAO/BLMVM and asserts the
// converged solution vector. The solution is perfectly symmetric:
// vertices (×4) → 1.1462158341786262
// original edges (×12) → 1.7627471737467797
// aux/diagonal edges (×6) → 2.633915794495759
// We assert membership in these three classes (robust to DOF ordering, which
// differs between Java and CGAL).
// ════════════════════════════════════════════════════════════════════════════
TEST(LawsonHyperIdeal, ConvergenceGoldenVector_JavaXVal)
{
std::set<Edge_index> original;
ConformalMesh m = make_lawson_square_tiled(&original);
HyperIdealMaps maps = setup_hyper_ideal_maps(m); // Θ_v=2π, θ_e=π
const int n = assign_all_dof_indices(m, maps); // all vertices + edges
ASSERT_EQ(n, 4 + 18); // 4 b + 18 a = 22 DOFs
// θ_e = π/2 for the 12 original edges; the 6 diagonals keep the default π.
for (auto e : m.edges())
if (original.count(e)) maps.theta_e[e] = PI / 2.0;
// Solve from a positive interior point (HyperIdeal variables b,a > 0).
std::vector<double> x0(static_cast<std::size_t>(n), 1.0);
auto res = newton_hyper_ideal(m, x0, maps, /*tol=*/1e-10, /*max_iter=*/200);
ASSERT_TRUE(res.converged)
<< "HyperIdeal Newton did not converge; ||G||=" << res.grad_inf_norm;
constexpr double b_gold = 1.1462158341786262;
constexpr double a_orig_gold = 1.7627471737467797;
constexpr double a_aux_gold = 2.633915794495759;
const double tol = 1e-5;
for (auto v : m.vertices()) {
const int iv = maps.v_idx[v];
ASSERT_GE(iv, 0);
EXPECT_NEAR(res.x[static_cast<std::size_t>(iv)], b_gold, tol)
<< "vertex DOF " << iv << " off golden b";
}
for (auto e : m.edges()) {
const int ie = maps.e_idx[e];
ASSERT_GE(ie, 0);
const double gold = original.count(e) ? a_orig_gold : a_aux_gold;
EXPECT_NEAR(res.x[static_cast<std::size_t>(ie)], gold, tol)
<< "edge DOF " << ie << " off golden a ("
<< (original.count(e) ? "original π/2" : "aux π") << ")";
}
}
// ════════════════════════════════════════════════════════════════════════════
// Branch-points variant — Java HyperIdealConvergenceTest...WithBranchPoints
//
// Java applies StellarLinear (stellar subdivision: a center vertex per quad) to
// the base, makes the 4 original vertices variable and the 6 centers IDEAL
// (b = 0, solver index 1), with Θ_v = 2π, θ_e = π on the 12 base edges and
// θ_e = π/2 on the 24 spokes. The converged solution (symmetric per class):
// original vertices (×4) → 1.3169579…
// base edges (×12) → 2.2924317…
// spoke edges (×24) → 0 (the π/2 spokes collapse to ideal)
// ════════════════════════════════════════════════════════════════════════════
TEST(LawsonHyperIdeal, BranchPointsGoldenVector_JavaXVal)
{
std::array<Vertex_index, 4> orig;
std::set<Edge_index> base;
ConformalMesh m = make_lawson_branch_points(orig, base);
EXPECT_TRUE(m.is_valid(false));
EXPECT_EQ(m.number_of_vertices(), 10u); // 4 original + 6 stellar centers
EXPECT_EQ(m.number_of_faces(), 24u); // 6 quads × 4
EXPECT_EQ(m.number_of_edges(), 36u); // 12 base + 24 spokes
EXPECT_EQ(base.size(), 12u);
HyperIdealMaps maps = setup_hyper_ideal_maps(m); // Θ_v=2π, θ_e=π
const std::set<Vertex_index> origset(orig.begin(), orig.end());
// 4 original vertices variable; 6 centers ideal (1); all 36 edges variable.
int idx = 0;
for (auto v : m.vertices()) maps.v_idx[v] = origset.count(v) ? idx++ : -1;
for (auto e : m.edges()) maps.e_idx[e] = idx++;
const int n = idx;
ASSERT_EQ(n, 4 + 36);
// θ_e = π/2 on the 24 spokes (base edges keep the default π).
for (auto e : m.edges())
if (!base.count(e)) maps.theta_e[e] = PI / 2.0;
std::vector<double> x0(static_cast<std::size_t>(n), 1.0);
auto res = newton_hyper_ideal(m, x0, maps, /*tol=*/1e-10, /*max_iter=*/300);
ASSERT_TRUE(res.converged)
<< "branch-points Newton did not converge; ||G||=" << res.grad_inf_norm;
constexpr double b_gold = 1.3169579;
constexpr double a_base_gold = 2.2924317;
constexpr double a_spoke_gold = 0.0;
const double tol = 1e-4; // golden per-class spread is ~1e-7
for (auto v : m.vertices()) {
const int iv = maps.v_idx[v];
if (iv < 0) continue; // ideal centers
EXPECT_NEAR(res.x[static_cast<std::size_t>(iv)], b_gold, tol)
<< "branch-points vertex DOF " << iv << " off golden b";
}
for (auto e : m.edges()) {
const int ie = maps.e_idx[e];
const double gold = base.count(e) ? a_base_gold : a_spoke_gold;
EXPECT_NEAR(res.x[static_cast<std::size_t>(ie)], gold, tol)
<< "branch-points edge DOF " << ie << " off golden a ("
<< (base.count(e) ? "base π" : "spoke π/2") << ")";
}
}