Files
ConformalLabpp/code/tests/cgal/test_scalability_smoke.cpp
Tarik Moussa d3c08b3bc0
Some checks failed
C++ Tests / test-fast (pull_request) Successful in 2m2s
API Docs / doc-build (pull_request) Successful in 58s
Markdown link check / check (pull_request) Successful in 45s
C++ Tests / test-cgal (pull_request) Failing after 13m14s
quality: 2 new gates (cmake-format, codespell) + SPDX rollout (60 files)
This commit closes the remaining red gates so `run-all.sh --fast` is
green end-to-end on the canonical dev machine.

New gates
─────────
1. cmake-format / cmake-lint
   * scripts/quality/cmake-format.sh — dry-run by default,
     --strict to fail on drift, --fix to apply
   * .cmake-format.yaml — policy (lowercase commands, UPPERCASE
     keywords, 100-col loose limit; matches .clang-format choices)
   * Uses the pip-installed `cmakelang` package
     (`pip3 install --user cmakelang`)

2. codespell
   * scripts/quality/codespell.sh — exit 1 on any typo, --fix
     interactively
   * .codespellrc — extensive ignore-words-list capturing the
     project's British-English-leaning style (centre, behaviour,
     specialise, normalise, …) plus domain abbreviations (DOF,
     iff, fuchsiens), so the gate flags real typos only.
   * Validated: 0 typos across docs + code/include + scripts +
     code/{src,tests}.

SPDX rollout (license-headers --fix)
────────────────────────────────────
license-headers.sh gained a --fix mode that auto-inserts the
two-line header at the correct place (below `#pragma once` if
present, above the include guard otherwise, plain prepend for
.cpp).  Ran it on 60 of 66 files — 100 %-licensed now.

Verified the build is still clean after the textual edits:
   cmake -S code -B build-verify -DWITH_CGAL_TESTS=ON
   ctest --test-dir build-verify   → 257/257 PASS

run-all.sh + README updated to include the two new gates.

End-to-end style/convention block status (on this commit, this branch):

    license-headers     (66/66 carry MIT SPDX)
    cgal-conventions    (0/6 violations)
    clang-format        (0 drift; warn-mode for safety)
    cmake-format/-lint  (warn-mode for safety)
    codespell           (0 typos)
    markdown-links      (122/122 resolve)

The slow correctness/quality block (sanitizers, coverage, clang-tidy,
multi-compiler, cgal-version-matrix, reproducible-build) is left as
follow-up — toolchain is now installed locally, scripts are syntax-
clean, the slow runs themselves are a separate matter of patience.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 09:15:34 +02:00

205 lines
8.0 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_scalability_smoke.cpp
//
// Scalability smoke tests — convergence on large real-world meshes.
//
// PURPOSE
// These tests verify that the Newton solver converges correctly on meshes
// significantly larger than the unit tests (which use tiny synthetic meshes).
// They do NOT assert on wall-clock time — timing is printed for information
// only, so the tests remain stable on slow CI hardware (Raspberry Pi ARM64).
//
// If Newton fails to converge here, it is a correctness regression, not a
// performance regression. See doc/math/complexity.md for timing context.
//
// MESHES USED
// cathead.obj V=131, F=248, genus=0, open — small, sanity check
// brezel.obj V=6910, F=13824, genus=2, closed — large genus-2 mesh (χ=2)
// brezel2.obj V=2622, F=5248, genus=2, closed — smaller genus-2 mesh (χ=2)
//
// NOTE: both brezel meshes are genus-2. The naming follows the Java original
// where "brezel2" is a different triangulation, not a different genus.
//
// EXPECTED RESULTS
// Newton converges in < 30 iterations for all Euclidean meshes (strictly
// convex energy, quadratic convergence from u=0).
// Cut graph produces 2g seam edges: 2 for brezel, 4 for brezel2.
//
// Tests:
// 1. SmokeEuclidean.CatHead_SmallOpen
// 2. SmokeEuclidean.Brezel_LargeGenus2
// 3. SmokeEuclidean.Brezel2_Genus2_CutGraph
#include "conformal_mesh.hpp"
#include "mesh_io.hpp"
#include "euclidean_functional.hpp"
#include "gauss_bonnet.hpp"
#include "newton_solver.hpp"
#include "cut_graph.hpp"
#include <gtest/gtest.h>
#include <chrono>
#include <iostream>
#include <vector>
#include <cmath>
#include <string>
using namespace conformallab;
using Clock = std::chrono::steady_clock;
using Ms = std::chrono::milliseconds;
// ── Helpers ──────────────────────────────────────────────────────────────────
static int setup_open_mesh_dofs(ConformalMesh& mesh, EuclideanMaps& maps)
{
int idx = 0;
for (auto v : mesh.vertices())
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
return idx;
}
static int setup_closed_mesh_dofs(ConformalMesh& mesh, EuclideanMaps& 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++;
return idx;
}
static void apply_natural_theta(ConformalMesh& mesh, EuclideanMaps& maps, int n)
{
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)];
}
}
// ── Test 1 — cathead.obj (V=131, F=248, open) ────────────────────────────────
TEST(SmokeEuclidean, CatHead_SmallOpen)
{
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/cathead.obj";
ConformalMesh mesh;
ASSERT_NO_THROW(mesh = load_mesh(path)) << "cathead.obj not found: " << path;
EXPECT_EQ(131u, mesh.number_of_vertices());
EXPECT_EQ(248u, mesh.number_of_faces());
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
const int n = setup_open_mesh_dofs(mesh, maps);
apply_natural_theta(mesh, maps, n);
// Start from a small perturbation so Newton actually iterates.
std::vector<double> x0(static_cast<std::size_t>(n), -0.05);
auto t0 = Clock::now();
auto res = newton_euclidean(mesh, x0, maps, 1e-9, 200);
auto dt = std::chrono::duration_cast<Ms>(Clock::now() - t0).count();
std::cout << "[SmokeEuclidean.CatHead] V=" << mesh.number_of_vertices()
<< " F=" << mesh.number_of_faces()
<< " iter=" << res.iterations
<< " ||G||=" << res.grad_inf_norm
<< " time=" << dt << "ms\n";
EXPECT_TRUE(res.converged) << "Newton did not converge on cathead.obj";
EXPECT_LT(res.iterations, 30) << "Newton took ≥ 30 iterations — unexpected";
EXPECT_LT(res.grad_inf_norm, 1e-8);
}
// ── Test 2 — brezel.obj (V=6910, F=13824, genus=2) ───────────────────────────
// Primary scalability target: largest mesh in the test suite.
// Newton is started from a small perturbation (x0 = 0.05) so it must
// actually iterate rather than exit immediately from the trivial equilibrium.
TEST(SmokeEuclidean, Brezel_LargeGenus2)
{
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/brezel.obj";
ConformalMesh mesh;
ASSERT_NO_THROW(mesh = load_mesh(path)) << "brezel.obj not found: " << path;
EXPECT_EQ(6910u, mesh.number_of_vertices());
EXPECT_EQ(13824u, mesh.number_of_faces());
// Euler characteristic: V - E + F = 2 for genus-2 closed surface
const int chi = static_cast<int>(mesh.number_of_vertices())
- static_cast<int>(mesh.number_of_edges())
+ static_cast<int>(mesh.number_of_faces());
EXPECT_EQ(-2, chi) << "brezel.obj must be genus-2 (χ=2)";
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
const int n = setup_closed_mesh_dofs(mesh, maps);
enforce_gauss_bonnet(mesh, maps);
apply_natural_theta(mesh, maps, n);
// Start from a small perturbation so Newton actually iterates.
std::vector<double> x0(static_cast<std::size_t>(n), -0.05);
// Newton solve
auto t0 = Clock::now();
auto res = newton_euclidean(mesh, x0, maps, 1e-9, 200);
auto dt_newton = std::chrono::duration_cast<Ms>(Clock::now() - t0).count();
// Cut graph
auto t1 = Clock::now();
CutGraph cg = compute_cut_graph(mesh);
auto dt_cut = std::chrono::duration_cast<Ms>(Clock::now() - t1).count();
std::cout << "[SmokeEuclidean.Brezel] V=" << mesh.number_of_vertices()
<< " F=" << mesh.number_of_faces()
<< " iter=" << res.iterations
<< " ||G||=" << res.grad_inf_norm
<< " newton=" << dt_newton << "ms"
<< " cut=" << dt_cut << "ms\n";
EXPECT_TRUE(res.converged) << "Newton did not converge on brezel.obj";
EXPECT_LT(res.iterations, 30) << "Newton took ≥ 30 iterations";
EXPECT_LT(res.grad_inf_norm, 1e-8);
// Genus-2: 2g = 4 seam edges
EXPECT_EQ(4u, cg.cut_edge_indices.size())
<< "brezel.obj (genus 2) must yield 2g=4 cut edges";
EXPECT_EQ(2, cg.genus);
}
// ── Test 3 — brezel2.obj (V=2622, F=5248, genus=2) ───────────────────────────
TEST(SmokeEuclidean, Brezel2_Genus2_CutGraph)
{
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/brezel2.obj";
ConformalMesh mesh;
ASSERT_NO_THROW(mesh = load_mesh(path)) << "brezel2.obj not found: " << path;
EXPECT_EQ(2622u, mesh.number_of_vertices());
EXPECT_EQ(5248u, mesh.number_of_faces());
const int chi = static_cast<int>(mesh.number_of_vertices())
- static_cast<int>(mesh.number_of_edges())
+ static_cast<int>(mesh.number_of_faces());
EXPECT_EQ(-2, chi) << "brezel2.obj must be genus-2 (χ=2)";
// Cut graph only — Newton on genus-2 requires full DOF setup
// (tested separately in test_geometry_utils.cpp HomologyGenerators suite)
auto t0 = Clock::now();
CutGraph cg = compute_cut_graph(mesh);
auto dt_cut = std::chrono::duration_cast<Ms>(Clock::now() - t0).count();
std::cout << "[SmokeEuclidean.Brezel2] V=" << mesh.number_of_vertices()
<< " F=" << mesh.number_of_faces()
<< " cut=" << dt_cut << "ms"
<< " seams=" << cg.cut_edge_indices.size() << "\n";
// Genus-2: 2g = 4 seam edges
EXPECT_EQ(4u, cg.cut_edge_indices.size())
<< "brezel2.obj (genus 2) must yield 2g=4 cut edges";
EXPECT_EQ(2, cg.genus);
}