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>
This commit is contained in:
@@ -5,17 +5,28 @@
|
|||||||
//
|
//
|
||||||
// Tier-3 Java cross-validation: HyperIdealConvergenceTest (Lawson square-tiled).
|
// Tier-3 Java cross-validation: HyperIdealConvergenceTest (Lawson square-tiled).
|
||||||
//
|
//
|
||||||
// Ports de.varylab.discreteconformal.functional.HyperIdealGenerator
|
// Ports de.varylab.discreteconformal.functional.HyperIdealGenerator. The base is
|
||||||
// .createLawsonSquareTiled() — a genus-2 surface with 4 vertices and 12 edges
|
// a genus-2 surface with 4 vertices and 12 edges → MULTI-EDGES (≥2 edges per
|
||||||
// (so it has MULTI-EDGES: CGAL add_face/OFF cannot build it; we use the
|
// vertex pair), so CGAL add_face/OFF/polygon-soup cannot build it; we use the
|
||||||
// low-level Surface_mesh half-edge API directly), triangulated to 12 faces.
|
// low-level Surface_mesh half-edge API directly.
|
||||||
//
|
//
|
||||||
// Step A (this commit): build the mesh, verify is_valid + V/E/F + genus 2.
|
// 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 "conformal_mesh.hpp"
|
||||||
#include "hyper_ideal_functional.hpp"
|
#include "hyper_ideal_functional.hpp"
|
||||||
#include "newton_solver.hpp"
|
#include "newton_solver.hpp"
|
||||||
#include <CGAL/Polygon_mesh_processing/triangulate_faces.h>
|
#include <CGAL/Polygon_mesh_processing/triangulate_faces.h>
|
||||||
|
#include <CGAL/boost/graph/Euler_operations.h>
|
||||||
#include <gtest/gtest.h>
|
#include <gtest/gtest.h>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -26,16 +37,13 @@ using namespace conformallab;
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Build the Lawson square-tiled base (4 vertices, 12 edges, 6 quad faces,
|
// Build the Lawson square-tiled BASE (4 vertices, 12 edges, 6 quad faces,
|
||||||
// genus 2) via the low-level half-edge API, then triangulate. Returns the
|
// genus 2) via the low-level half-edge API — multi-edges (≥2 edges per vertex
|
||||||
// mesh; `original_edges` receives the 12 pre-triangulation edges (the 6 added
|
// pair) make add_face/OFF/polygon-soup impossible. Returns the 4 original
|
||||||
// diagonals are the "aux" edges).
|
// vertices in `V`. Not yet triangulated.
|
||||||
ConformalMesh make_lawson_square_tiled(std::set<Edge_index>* original_edges = nullptr)
|
void build_lawson_base(ConformalMesh& m, std::array<Vertex_index, 4>& V)
|
||||||
{
|
{
|
||||||
ConformalMesh m;
|
V = {
|
||||||
|
|
||||||
// 4 vertices A,B,C,D (positions are dummy — only combinatorics matter).
|
|
||||||
std::array<Vertex_index, 4> V = {
|
|
||||||
m.add_vertex(Point3(0, 0, 0)), // A
|
m.add_vertex(Point3(0, 0, 0)), // A
|
||||||
m.add_vertex(Point3(1, 0, 0)), // B
|
m.add_vertex(Point3(1, 0, 0)), // B
|
||||||
m.add_vertex(Point3(0, 1, 0)), // C
|
m.add_vertex(Point3(0, 1, 0)), // C
|
||||||
@@ -46,27 +54,22 @@ ConformalMesh make_lawson_square_tiled(std::set<Edge_index>* original_edges = nu
|
|||||||
const int tgt[24] = {
|
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
|
0,1,3,2, 1,0,2,3, 3,2,0,1, 2,3,1,0, 0,1,3,2, 1,0,2,3
|
||||||
};
|
};
|
||||||
// Java opposite-edge pairings (linkOppositeEdge).
|
const int pairs[12][2] = { // Java linkOppositeEdge
|
||||||
const int pairs[12][2] = {
|
|
||||||
{0,6},{1,21},{2,4},{3,23},{5,11},{7,9},
|
{0,6},{1,21},{2,4},{3,23},{5,11},{7,9},
|
||||||
{8,14},{10,12},{13,19},{15,17},{16,22},{18,20}
|
{8,14},{10,12},{13,19},{15,17},{16,22},{18,20}
|
||||||
};
|
};
|
||||||
// Java next-edge cycles → the 6 quad faces.
|
const int cyc[6][4] = { // Java next-edge cycles → 6 quads
|
||||||
const int cyc[6][4] = {
|
|
||||||
{0,1,2,3},{4,5,6,7},{8,9,10,11},{12,13,14,15},{16,17,18,19},{20,21,22,23}
|
{0,1,2,3},{4,5,6,7},{8,9,10,11},{12,13,14,15},{16,17,18,19},{20,21,22,23}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Allocate 12 edges; map Java half-edge id → CGAL Halfedge_index.
|
|
||||||
std::array<Halfedge_index, 24> he;
|
std::array<Halfedge_index, 24> he;
|
||||||
for (const auto& p : pairs) {
|
for (const auto& p : pairs) {
|
||||||
Halfedge_index h = m.add_edge();
|
Halfedge_index h = m.add_edge();
|
||||||
he[static_cast<std::size_t>(p[0])] = h;
|
he[static_cast<std::size_t>(p[0])] = h;
|
||||||
he[static_cast<std::size_t>(p[1])] = m.opposite(h);
|
he[static_cast<std::size_t>(p[1])] = m.opposite(h);
|
||||||
}
|
}
|
||||||
// Targets.
|
|
||||||
for (int j = 0; j < 24; ++j)
|
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])]);
|
m.set_target(he[static_cast<std::size_t>(j)], V[static_cast<std::size_t>(tgt[j])]);
|
||||||
// Faces + next links.
|
|
||||||
for (const auto& c : cyc) {
|
for (const auto& c : cyc) {
|
||||||
Face_index f = m.add_face();
|
Face_index f = m.add_face();
|
||||||
m.set_halfedge(f, he[static_cast<std::size_t>(c[0])]);
|
m.set_halfedge(f, he[static_cast<std::size_t>(c[0])]);
|
||||||
@@ -76,17 +79,45 @@ ConformalMesh make_lawson_square_tiled(std::set<Edge_index>* original_edges = nu
|
|||||||
he[static_cast<std::size_t>(c[(k + 1) % 4])]);
|
he[static_cast<std::size_t>(c[(k + 1) % 4])]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Each vertex points to one incoming half-edge.
|
|
||||||
for (int j = 0; j < 24; ++j)
|
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)]);
|
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) {
|
if (original_edges) {
|
||||||
original_edges->clear();
|
original_edges->clear();
|
||||||
for (auto e : m.edges()) original_edges->insert(e);
|
for (auto e : m.edges()) original_edges->insert(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Triangulate the 6 quads → 12 triangles (adds 6 diagonal "aux" edges).
|
|
||||||
CGAL::Polygon_mesh_processing::triangulate_faces(m);
|
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;
|
return m;
|
||||||
}
|
}
|
||||||
@@ -162,3 +193,65 @@ TEST(LawsonHyperIdeal, ConvergenceGoldenVector_JavaXVal)
|
|||||||
<< (original.count(e) ? "original π/2" : "aux π") << ")";
|
<< (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") << ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user