Compare commits
127 Commits
v0.9.0
...
docs/roadm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc77afc55f | ||
|
|
83a61b0228 | ||
|
|
2e1541be4b | ||
|
|
5f3c6aa0d5 | ||
|
|
c4442a22c3 | ||
|
|
56f13d7c4f | ||
|
|
4f8530628b | ||
|
|
1bf1defc70 | ||
|
|
74d41e99a8 | ||
|
|
2fc465f5cf | ||
|
|
202b9a108d | ||
|
|
a1a7f216e0 | ||
|
|
2dc4ddcc32 | ||
|
|
a5718c0326 | ||
|
|
51d9844f7a | ||
|
|
b0946c8704 | ||
|
|
101f3138ce | ||
| 505dd4b0a5 | |||
| aff2d7b9c8 | |||
|
|
8298c5aacc | ||
| 99a82d36ee | |||
|
|
b274591aa8 | ||
|
|
65fc8ac816 | ||
| dc978cf5d3 | |||
| e13e7ea8e7 | |||
|
|
b213ad2265 | ||
|
|
bad3c4a9ab | ||
|
|
7902ae8e2c | ||
|
|
d5a4d0e8ff | ||
|
|
d4ea5fdd2e | ||
|
|
a7b966c850 | ||
|
|
37b538aae4 | ||
|
|
ccbfd852b2 | ||
|
|
d1399ca82f | ||
|
|
b8d6e23adf | ||
|
|
b20a835b82 | ||
|
|
02a3745b67 | ||
|
|
fb9a15ab4a | ||
| d725166c6a | |||
|
|
5fbd1b20a5 | ||
|
|
7df47c0435 | ||
|
|
018484a94d | ||
|
|
2b456abc78 | ||
|
|
449c5899c0 | ||
|
|
59a26123c8 | ||
|
|
2325328f77 | ||
|
|
adbf682f0f | ||
|
|
7534c62c3d | ||
|
|
cfbbc1b21f | ||
|
|
bef5a0ceb7 | ||
|
|
46c0b63de8 | ||
|
|
faf96ee28c | ||
|
|
9afefcbb7b | ||
|
|
7f1a077269 | ||
| 6b3497f451 | |||
|
|
8619043b79 | ||
| aec7f489e5 | |||
|
|
c472748e46 | ||
|
|
082649ef40 | ||
|
|
267ae965ab | ||
|
|
822a27da69 | ||
|
|
ea5f01d73d | ||
|
|
5d74a94b78 | ||
|
|
41ad4a84d2 | ||
| adfcb7b931 | |||
| 76942418fb | |||
|
|
330515ea90 | ||
|
|
149da15c64 | ||
| a993a101e9 | |||
| 03e8000ac6 | |||
|
|
5de95a7a93 | ||
|
|
0c502536b9 | ||
|
|
26f4f0637d | ||
|
|
ba83974525 | ||
| 18b9c61492 | |||
|
|
a3ee9576d4 | ||
| 6f50b81f3e | |||
|
|
ca936b7652 | ||
|
|
1ff5382c8b | ||
| 0048d71f11 | |||
|
|
569a03dc08 | ||
| 79a54a3182 | |||
|
|
048875c002 | ||
| 520bc40927 | |||
|
|
11d660af49 | ||
| 2f89939e93 | |||
|
|
f9418ca540 | ||
| ed9ae844d1 | |||
|
|
09a68a4569 | ||
|
|
e874f73e29 | ||
| 3953d1549b | |||
|
|
3f508adf18 | ||
|
|
bc40a13e8d | ||
|
|
9ea7d15aa0 | ||
|
|
5fbc4bcc7f | ||
|
|
0c520046fb | ||
|
|
9be11eca4e | ||
|
|
36596c7c79 | ||
|
|
72503a3518 | ||
|
|
f93292e815 | ||
|
|
ab07f90653 | ||
|
|
068df474b1 | ||
|
|
f25174ed69 | ||
|
|
32253a8f5e | ||
| 704f42bbfd | |||
| 63f2b9799d | |||
|
|
8e9ec9eccf | ||
|
|
8d34be76a7 | ||
|
|
1aa3493e7d | ||
|
|
d3c08b3bc0 | ||
|
|
f1d77aa293 | ||
|
|
a2eee9c279 | ||
|
|
62b02f88b9 | ||
|
|
f6722d7e84 | ||
|
|
e04515c423 | ||
| 8869ead3c9 | |||
|
|
b0c67af922 | ||
|
|
ba5c9303a3 | ||
|
|
0b1bf07232 | ||
| b569daa388 | |||
|
|
039cc26e36 | ||
|
|
eb393537f3 | ||
|
|
ff9c9ec11b | ||
|
|
b7e837815f | ||
|
|
79f6757646 | ||
|
|
84258921df | ||
|
|
0f78d181e1 |
76
.clang-format
Normal file
76
.clang-format
Normal file
@@ -0,0 +1,76 @@
|
||||
# conformallab++ formatting policy
|
||||
#
|
||||
# Captures the style already present in code/include/. Documented here
|
||||
# so clang-format can enforce it locally (scripts/quality/clang-format.sh)
|
||||
# and so new contributors get the same output their editor would on save.
|
||||
#
|
||||
# This is NOT the upstream CGAL clang-format (there isn't one published);
|
||||
# it's the style our tree already uses, mechanically extracted.
|
||||
|
||||
BasedOnStyle: LLVM
|
||||
Language: Cpp
|
||||
Standard: c++17
|
||||
|
||||
IndentWidth: 4
|
||||
TabWidth: 4
|
||||
UseTab: Never
|
||||
ColumnLimit: 100 # loose; readability over hard wrap
|
||||
|
||||
# Brace placement — matches the project tree:
|
||||
# functions / methods → opening brace on a new line (CGAL convention)
|
||||
# structs / classes → opening brace on a new line
|
||||
# else / catch → on the same line as the closing brace of the preceding block
|
||||
BreakBeforeBraces: Custom
|
||||
BraceWrapping:
|
||||
AfterClass: true
|
||||
AfterStruct: true
|
||||
AfterEnum: true
|
||||
AfterFunction: true
|
||||
AfterNamespace: false
|
||||
AfterUnion: true
|
||||
AfterControlStatement: false
|
||||
BeforeElse: false
|
||||
BeforeCatch: false
|
||||
IndentBraces: false
|
||||
SplitEmptyFunction: false
|
||||
SplitEmptyRecord: false
|
||||
SplitEmptyNamespace: true
|
||||
|
||||
# Reference & pointer modifiers attach to the type (`int& x`, not `int &x`).
|
||||
PointerAlignment: Left
|
||||
ReferenceAlignment: Left
|
||||
|
||||
# Aligned `using = ...` blocks are intentional in the trait classes.
|
||||
AlignConsecutiveDeclarations: AcrossEmptyLines
|
||||
AlignConsecutiveAssignments: AcrossEmptyLines
|
||||
AlignTrailingComments: true
|
||||
AlignAfterOpenBracket: Align
|
||||
|
||||
AllowShortFunctionsOnASingleLine: Inline
|
||||
AllowShortIfStatementsOnASingleLine: Never
|
||||
AllowShortLoopsOnASingleLine: false
|
||||
AllowShortBlocksOnASingleLine: Never
|
||||
AllowShortLambdasOnASingleLine: Inline
|
||||
|
||||
# Template-related: break before each parameter when the template line
|
||||
# would otherwise exceed ColumnLimit (matches existing
|
||||
# `template <typename TriangleMesh, typename ...>` patterns).
|
||||
BreakBeforeBinaryOperators: NonAssignment
|
||||
BinPackParameters: false
|
||||
BinPackArguments: false
|
||||
AlwaysBreakTemplateDeclarations: Yes
|
||||
SpaceAfterTemplateKeyword: true
|
||||
|
||||
NamespaceIndentation: None
|
||||
AccessModifierOffset: -4
|
||||
IndentCaseLabels: false
|
||||
|
||||
# Includes: keep manual ordering — re-ordering can break Eigen / CGAL
|
||||
# transitive-include assumptions in subtle ways. We just enforce no
|
||||
# accidental duplicate blank lines.
|
||||
SortIncludes: Never
|
||||
MaxEmptyLinesToKeep: 1
|
||||
KeepEmptyLinesAtTheStartOfBlocks: false
|
||||
|
||||
# Comments: don't touch.
|
||||
ReflowComments: false
|
||||
45
.clang-tidy
Normal file
45
.clang-tidy
Normal file
@@ -0,0 +1,45 @@
|
||||
# conformallab++ clang-tidy policy
|
||||
#
|
||||
# Curated, deliberately small. The CGAL header tree triggers tens of
|
||||
# thousands of warnings under default settings (CGAL's chosen style is
|
||||
# pre-C++17 in many places). Restricting to checks that fire on OUR
|
||||
# code, not on transitive CGAL/Eigen/Boost headers, keeps the signal
|
||||
# meaningful.
|
||||
#
|
||||
# Promotion gate: a check moves into this list only when (a) it fires
|
||||
# on code we authored AND (b) the fix is mechanical (no algorithmic
|
||||
# rewrite required). Anything algorithmic belongs in a code review,
|
||||
# not in a static analyser.
|
||||
|
||||
Checks: >
|
||||
-*,
|
||||
bugprone-too-small-loop-variable,
|
||||
bugprone-use-after-move,
|
||||
bugprone-undefined-memory-manipulation,
|
||||
bugprone-integer-division,
|
||||
bugprone-suspicious-string-compare,
|
||||
bugprone-misplaced-widening-cast,
|
||||
bugprone-sizeof-expression,
|
||||
cppcoreguidelines-init-variables,
|
||||
cppcoreguidelines-pro-type-member-init,
|
||||
performance-for-range-copy,
|
||||
performance-implicit-conversion-in-loop,
|
||||
performance-unnecessary-copy-initialization,
|
||||
performance-unnecessary-value-param,
|
||||
readability-misleading-indentation,
|
||||
readability-redundant-smartptr-get,
|
||||
modernize-use-nullptr,
|
||||
modernize-use-override,
|
||||
modernize-deprecated-headers
|
||||
|
||||
# Only emit warnings on our own headers. CGAL/Eigen/etc. live under
|
||||
# `code/deps/` (vendored) or are installed system-wide; we never want
|
||||
# clang-tidy fixes for them.
|
||||
HeaderFilterRegex: '^.*/code/include/(?!deps/).*$'
|
||||
|
||||
WarningsAsErrors: ''
|
||||
|
||||
CheckOptions:
|
||||
- { key: cppcoreguidelines-init-variables.IgnoreArrays, value: true }
|
||||
- { key: performance-for-range-copy.WarnOnAllAutoCopies, value: true }
|
||||
- { key: performance-unnecessary-value-param.AllowedTypes, value: 'Eigen::Vector.*;Eigen::Matrix.*' }
|
||||
36
.claude/settings.json
Normal file
36
.claude/settings.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cmake:*)",
|
||||
"Bash(ctest:*)",
|
||||
"Bash(nice:*)",
|
||||
"Bash(./build/conformallab_tests:*)",
|
||||
"Bash(./build/conformallab_cgal_tests:*)",
|
||||
"Bash(bash scripts/quality/*)",
|
||||
"Bash(bash scripts/*)",
|
||||
"Bash(python3 scripts/*)",
|
||||
"Bash(codespell:*)",
|
||||
"Bash(shellcheck:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(git ls-remote:*)",
|
||||
"Bash(git ls-files:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(curl -s*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force* origin main*)",
|
||||
"Bash(git push -f* origin main*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(rm -rf /*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
62
.claude/token-hygiene.md
Normal file
62
.claude/token-hygiene.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Token-Hygiene — User Guide (Tier 3: Cache-Disziplin)
|
||||
|
||||
Eine kurze Anleitung **für dich** (den Nutzer), wie du die Prompt-Cache-Mechanik
|
||||
auf deiner Seite richtig bedienst. Claude liest diese Datei mit und **erinnert
|
||||
dich aktiv**, wenn es eines der unten genannten Anti-Patterns beobachtet.
|
||||
|
||||
Hintergrund: Der stabile Anfang jeder Anfrage (System-Prompt + `CLAUDE.md`) wird
|
||||
von Anthropic **gecacht**, mit einer **TTL von 5 Minuten**. Solange der Cache
|
||||
warm ist, zahlst du diesen Teil nicht erneut. Ein Cache-Miss bedeutet: der ganze
|
||||
Kontext wird uncached neu gelesen — langsamer und teurer.
|
||||
|
||||
---
|
||||
|
||||
## Die drei Regeln
|
||||
|
||||
### 1. Stabilen Präfix stabil halten
|
||||
`CLAUDE.md` und Projekt-Config **nicht mitten in einer Arbeitssession editieren**.
|
||||
Jede Änderung an der Datei bustet den Cache für **alle** folgenden Turns dieser
|
||||
Session. Sammle Doku-/Config-Änderungen und mach sie in **einem eigenen
|
||||
Durchgang** (so wie dieser hier), nicht verstreut zwischen Code-Arbeit.
|
||||
|
||||
> **Reminder-Trigger für Claude:** Wenn der Nutzer mitten in einer laufenden
|
||||
> Code-/Debug-Aufgabe eine Änderung an `CLAUDE.md` oder `.claude/settings.json`
|
||||
> verlangt → kurz darauf hinweisen, dass das den Cache bustet, und anbieten, es
|
||||
> zu bündeln/ans Ende zu legen.
|
||||
|
||||
### 2. Keine Kette kurzer Wartepausen
|
||||
Eine Pause **> 5 Minuten** kostet beim nächsten Turn einen Cache-Miss. Eine
|
||||
*einzelne* lange Pause ist egal. Schlecht ist, viele kurze „warte mal kurz"-Turns
|
||||
aneinanderzuhängen, bei denen jeder nach >5 Min den Kontext neu liest. Wenn du
|
||||
auf etwas Externes wartest (CI, Deploy), lieber **einmal** lang warten als
|
||||
mehrfach pollen.
|
||||
|
||||
> **Reminder-Trigger für Claude:** Wenn der Nutzer wiederholt kurze
|
||||
> „warte/poll mal"-Anweisungen mit Lücken um die 5-Minuten-Grenze gibt → einen
|
||||
> einzelnen längeren Wartezyklus (oder eine Hintergrund-/Monitor-Lösung)
|
||||
> vorschlagen statt mehrfachem Pollen.
|
||||
|
||||
### 3. Eine Session pro Thema
|
||||
Lange Multi-Themen-Sessions sind der größte versteckte Kostenfaktor (jeder Turn
|
||||
schickt die ganze History neu). Nach einer abgeschlossenen Aufgabe: **neue
|
||||
Session** (`/clear`) statt zum nächsten, unzusammenhängenden Thema im selben
|
||||
Thread zu wechseln. Das ist eigentlich Tier 1, aber es interagiert direkt mit
|
||||
dem Cache: ein frischer Thread = kleiner, vollständig gecachter Präfix.
|
||||
|
||||
> **Reminder-Trigger für Claude:** Wenn die Session erkennbar das Thema wechselt
|
||||
> (z.B. von „Pages-Fix" zu „neues Feature implementieren") → vorschlagen, eine
|
||||
> frische Session zu starten, und optional die wichtigsten Entscheidungen vorher
|
||||
> in Memory/Decision-Records festhalten.
|
||||
|
||||
---
|
||||
|
||||
## Schnell-Check vor einer langen Session
|
||||
|
||||
- [ ] Ist das ein **einzelnes** Thema? Wenn nein → splitten.
|
||||
- [ ] Stehen anstehende `CLAUDE.md`-Änderungen an? → **vorher** erledigen, dann stabil lassen.
|
||||
- [ ] Erwarte ich lange Wartezeiten (CI/Build)? → Hintergrund-Job + **eine** lange Wartephase, kein Polling.
|
||||
|
||||
## Verwandte Hebel (nicht Cache, aber gleiche Wirkung)
|
||||
Tier 1 (Session-Schnitt) und Tier 2 (Kommando-Disziplin: Output filtern, Pfad-
|
||||
Scope, nicht zurücklesen) stehen als operative Regeln im Abschnitt
|
||||
*„Agentic workflow patterns" → „Token hygiene"* in der `CLAUDE.md`.
|
||||
28
.cmake-format.yaml
Normal file
28
.cmake-format.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
# conformallab++ cmake-format policy
|
||||
#
|
||||
# Drives the cmake-format / cmake-lint tools used by
|
||||
# scripts/quality/cmake-format.sh. The defaults are deliberately
|
||||
# permissive — the goal is to catch the obvious style drift (mixed
|
||||
# 2-vs-4-space indent, inconsistent argument wrapping, undocumented
|
||||
# options) without forcing a rewrite of every CMakeLists.txt we have.
|
||||
|
||||
format:
|
||||
line_width: 100 # match .clang-format
|
||||
tab_size: 4
|
||||
use_tabchars: false
|
||||
separate_ctrl_name_with_space: false
|
||||
separate_fn_name_with_space: false
|
||||
dangle_parens: false
|
||||
command_case: lower # lowercase commands (cgal/upstream convention)
|
||||
keyword_case: upper # KEYWORDS like PUBLIC/PRIVATE/INTERFACE in caps
|
||||
|
||||
lint:
|
||||
# Whitelist the disables we explicitly accept.
|
||||
disabled_codes:
|
||||
- C0103 # invalid variable name — we use CGAL_/conformallab_ prefixes
|
||||
- C0301 # line too long — handled by line_width, not as a lint error
|
||||
- C0111 # missing docstring on a function — most of ours are obvious
|
||||
|
||||
# Maximum allowed nesting of conditional blocks. 3 is conservative;
|
||||
# raise if we ever genuinely need deeper.
|
||||
max_conditionals_custom_parser: 3
|
||||
88
.codespellrc
Normal file
88
.codespellrc
Normal file
@@ -0,0 +1,88 @@
|
||||
# conformallab++ codespell policy
|
||||
#
|
||||
# Driven by scripts/quality/codespell.sh. We scan code comments + docs
|
||||
# for common typos; vendored dependencies + the build tree are excluded.
|
||||
#
|
||||
# False positives go into ignore-words-list (lowercase, comma-separated).
|
||||
# Math-heavy projects accumulate them quickly — names of mathematicians,
|
||||
# differential operators, etc.
|
||||
|
||||
[codespell]
|
||||
skip = code/deps,build,build-*,build_T_*,test-reports,doc/doxygen,.git,*.svg,*.lock,*.pdf,*.png,*.jpg,Doxyfile,*.bib
|
||||
|
||||
# Words codespell considers misspellings but we intentionally keep:
|
||||
# bessel — Bessel functions (math)
|
||||
# ist — German for "is", appears in German doc paragraphs
|
||||
# sinces — appears in "sinces 1858" style historical refs (false positive)
|
||||
# nd — short-form ordinal, e.g. "2nd"
|
||||
# te — appears in greek transliteration "θ → te"
|
||||
# inout — common parameter direction word
|
||||
# nin — math symbol ∉ accidental match
|
||||
# numer — "numerical/numerator" abbreviation in headers
|
||||
# neet — German "neet" / accidental matches
|
||||
# anc — appears in "anc(ient)" math literature refs
|
||||
# sinks — "sinks" can hit Sinkhorn
|
||||
ignore-words-list = bessel,ist,sinces,nd,te,inout,nin,numer,neet,anc,sinks,doubleClick,
|
||||
centre,centres,centered,centering,centring,
|
||||
behaviour,behaviours,behavioural,
|
||||
analogue,analogues,
|
||||
initialise,initialised,initialises,initialising,initialisation,
|
||||
normalise,normalised,normalises,normalising,normalisation,normalisations,
|
||||
favour,favours,favoured,favouring,favourite,favourites,
|
||||
centralise,centralised,centralises,centralising,
|
||||
serialise,serialised,serialises,serialising,serialisation,
|
||||
parameterise,parameterised,parameterises,parameterising,
|
||||
parametrise,parametrised,parametrises,parametrising,
|
||||
realise,realised,realises,realising,realisation,
|
||||
optimise,optimised,optimises,optimising,optimisation,
|
||||
sanitise,sanitised,sanitises,sanitising,
|
||||
generalise,generalised,generalises,generalising,
|
||||
amortise,amortised,amortises,amortising,
|
||||
factorise,factorised,factorises,factorising,
|
||||
discretise,discretised,discretises,discretising,
|
||||
summarise,summarised,summarises,summarising,
|
||||
colour,colours,coloured,colouring,
|
||||
artefact,artefacts,
|
||||
iff,
|
||||
dof,dofs,
|
||||
browseable,
|
||||
re-use,re-uses,re-used,re-using,
|
||||
specialise,specialised,specialises,specialising,specialisation,specialisations,
|
||||
visualise,visualised,visualises,visualising,visualisation,visualisations,
|
||||
model,modeled,modelled,modelling,
|
||||
minimise,minimised,minimises,minimising,minimisation,
|
||||
maximise,maximised,maximises,maximising,maximisation,
|
||||
organise,organised,organises,organising,organisation,
|
||||
characterise,characterised,characterises,characterising,
|
||||
emphasise,emphasised,emphasises,emphasising,
|
||||
analyse,analysed,analyses,analysing,analyser,analysers,
|
||||
organise,organisation,organisational,
|
||||
parameterise,parameterisation,
|
||||
centre,centred,centres,
|
||||
catalogue,catalogues,
|
||||
maths,
|
||||
generalisation,generalisations,
|
||||
realisation,realisations,
|
||||
specialisation,specialisations,
|
||||
visualisation,visualisations,
|
||||
minimisation,maximisation,characterisation,
|
||||
groupes,fuchsiens,théorie,théorème,
|
||||
iff,
|
||||
honour,honoured,honours,honouring,thead,optimiser,optimisers,
|
||||
categorise,categorised,categorises,categorising,
|
||||
optimisation,optimisations,
|
||||
acknowledgement,acknowledgements,acknowledging,
|
||||
neighbour,neighbours,neighbouring,neighboured,
|
||||
labelled,labelling,labels,labelled,
|
||||
fulfil,fulfils,fulfilled,fulfilling,
|
||||
endcode,
|
||||
deklaration,deklarationen,
|
||||
recognise,recognised,recognises,recognising,recognisation,
|
||||
signalled,signalling,
|
||||
travelled,travelling,
|
||||
cancelled,cancelling,
|
||||
modelled,modelling
|
||||
|
||||
# Words we explicitly DO want flagged (override the default skip list).
|
||||
# Keep empty for now; add as we hit real-but-not-flagged typos.
|
||||
builtin = clear,rare,informal,usage,code,en-GB_to_en-US,names
|
||||
56
.editorconfig
Normal file
56
.editorconfig
Normal file
@@ -0,0 +1,56 @@
|
||||
# conformallab++ EditorConfig
|
||||
#
|
||||
# Honoured natively by VSCode (with the EditorConfig extension), CLion,
|
||||
# Vim, Emacs, Sublime, … Covers the basics that .clang-format /
|
||||
# .cmake-format don't catch (Markdown, Python, YAML, shell, JSON, …)
|
||||
# and acts as a cross-IDE fallback when clang-format isn't installed.
|
||||
#
|
||||
# Authoritative formatting for C++ source still comes from .clang-format;
|
||||
# this file just keeps the editor's defaults from fighting it.
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
# C++ — match .clang-format
|
||||
[*.{h,hpp,cpp,c,cc}]
|
||||
indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
# CMake — match .cmake-format.yaml
|
||||
[{CMakeLists.txt,*.cmake}]
|
||||
indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
# Python — PEP-8 default
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
# Shell — Google shell style
|
||||
[*.sh]
|
||||
indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
# YAML — community convention
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
# JSON
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
||||
# Markdown — preserve trailing spaces (used for line breaks); don't strip
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
max_line_length = off
|
||||
|
||||
# Makefiles must use tabs
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -1,24 +1,38 @@
|
||||
name: C++ Tests
|
||||
|
||||
# Trigger keywords in commit message (checked via head_commit.message):
|
||||
# /test-cgal — full CGAL test suite (277 tests, ~5 min build)
|
||||
# /quality-gates — license, codespell, shellcheck, CGAL conventions
|
||||
#
|
||||
# ⚠ /ci-all was removed: the Pi runner (3-4 GB RAM) cannot sustain
|
||||
# multiple Docker containers simultaneously. Use one keyword per commit.
|
||||
#
|
||||
# Examples:
|
||||
# git commit -m "fix: correct angle formula /test-cgal"
|
||||
# git commit -m "chore: update headers /quality-gates"
|
||||
#
|
||||
# test-fast always runs on every push — it is fast (< 5 s) and cheap.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- "claude/**"
|
||||
- "feature/**"
|
||||
- "review/**"
|
||||
pull_request:
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job 1 — test-fast
|
||||
# Pure-math tests (Clausen, ImLi₂, Hyper-ideal geometry).
|
||||
# No CGAL, no Boost. Eigen + GTest only. Runs on ALL branches.
|
||||
# No CGAL, no Boost. Eigen + GTest only. Runs on EVERY push.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
test-fast:
|
||||
runs-on: eulernest
|
||||
container:
|
||||
image: git.eulernest.eu/conformallab/ci-cpp:latest
|
||||
options: "--memory=800m --memory-swap=1200m"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -27,7 +41,11 @@ jobs:
|
||||
run: cmake -S code -B build -DCMAKE_BUILD_TYPE=Release
|
||||
|
||||
- name: Build
|
||||
run: nice -n 19 cmake --build build --target conformallab_tests -j$(nproc)
|
||||
# Serial build (-j1): the 800m-limited container OOM-kills cc1plus when
|
||||
# several Eigen+GoogleTest translation units compile in parallel
|
||||
# (`-j$(nproc)`), failing test-fast non-deterministically regardless of
|
||||
# branch content. Mirrors the Pi-protection already used by test-cgal.
|
||||
run: nice -n 19 cmake --build build --target conformallab_tests -j1
|
||||
|
||||
- name: Run tests
|
||||
run: >
|
||||
@@ -48,33 +66,39 @@ jobs:
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job 2 — test-cgal
|
||||
# Full CGAL test suite (Phase 3–7, 158 tests).
|
||||
# Runs ONLY on pull requests (not on direct pushes to dev/main).
|
||||
# Starts only after test-fast succeeds.
|
||||
#
|
||||
# Uses -DWITH_CGAL_TESTS=ON (not -DWITH_CGAL=ON) to avoid building
|
||||
# Viewer/GLFW — the CI container has no wayland-scanner.
|
||||
# Trigger: include "/test-cgal" anywhere in the commit message.
|
||||
#
|
||||
# Boost (libboost-dev) is already present in the container since the image rebuild.
|
||||
# git commit -m "fix: correct angle formula /test-cgal"
|
||||
#
|
||||
# Why keyword-triggered (not automatic on every push):
|
||||
# The Pi runner (3-4 GB RAM, swap heavily loaded) cannot sustain a
|
||||
# CGAL build on every WIP commit. Adding the keyword to a commit
|
||||
# message explicitly signals "this commit is ready for full testing".
|
||||
#
|
||||
# LOW_MEMORY_BUILD applies four RAM-saving measures so the build fits in
|
||||
# a 2000 MB container: -O0, no PCH, unity batch 1, --no-keep-memory.
|
||||
# See code/tests/cgal/CMakeLists.txt for the full explanation.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
test-cgal:
|
||||
needs: test-fast
|
||||
if: github.event_name == 'pull_request'
|
||||
if: contains(github.event.head_commit.message, '/test-cgal')
|
||||
runs-on: eulernest
|
||||
container:
|
||||
image: git.eulernest.eu/conformallab/ci-cpp:latest
|
||||
# Memory bumped from 1400m → 1600m to avoid OOM during CGAL header
|
||||
# compilation on ARM64 (CGAL + Eigen templates allocate ~700 MB per
|
||||
# cc1plus instance; -j1 leaves a small margin).
|
||||
# memory-swap == memory disables swap entirely so OOM fails fast
|
||||
# rather than thrashing on the SD card.
|
||||
options: "--memory=1600m --memory-swap=1600m"
|
||||
# 2000 MB hard limit; 1000 MB swap headroom (memory-swap = RAM + swap).
|
||||
# With LOW_MEMORY_BUILD peak per TU is ~150-200 MB → well within limit.
|
||||
options: "--memory=2000m --memory-swap=3000m"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Configure (WITH_CGAL_TESTS — no viewer, no wayland-scanner)
|
||||
run: cmake -S code -B build -DWITH_CGAL_TESTS=ON -DCMAKE_BUILD_TYPE=Release
|
||||
- name: Configure (LOW_MEMORY_BUILD — -O0, no PCH, unity batch 1)
|
||||
run: |
|
||||
cmake -S code -B build \
|
||||
-DWITH_CGAL_TESTS=ON \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCONFORMALLAB_LOW_MEMORY_BUILD=ON
|
||||
|
||||
- name: Build CGAL-Tests
|
||||
run: nice -n 19 cmake --build build --target conformallab_cgal_tests -j1
|
||||
@@ -96,3 +120,62 @@ jobs:
|
||||
passed=$(( ${total:-0} - ${failed:-0} - ${skipped:-0} ))
|
||||
echo "CGAL ▸ TOTAL ${total:-0} | PASSED $passed | FAILED ${failed:-0} | SKIPPED ${skipped:-0}"
|
||||
fi
|
||||
|
||||
- name: Verify test-count consistency (doc/api/tests.md)
|
||||
run: BUILD_DIR=build bash scripts/check-test-counts.sh
|
||||
|
||||
- name: End-to-end smoke test (scripts/try_it.sh)
|
||||
run: bash scripts/try_it.sh
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job 3 — quality-gates
|
||||
#
|
||||
# Trigger: include "/quality-gates" anywhere in the commit message.
|
||||
#
|
||||
# git commit -m "chore: update docs /quality-gates"
|
||||
#
|
||||
# Cheap (~30 s): license headers, CGAL conventions, codespell, shellcheck.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
quality-gates:
|
||||
if: contains(github.event.head_commit.message, '/quality-gates')
|
||||
runs-on: eulernest
|
||||
container:
|
||||
image: git.eulernest.eu/conformallab/ci-cpp:latest
|
||||
options: "--memory=600m --memory-swap=900m"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install codespell + shellcheck (job-local)
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
codespell shellcheck
|
||||
|
||||
- name: License headers (every C++ source carries MIT SPDX)
|
||||
run: bash scripts/quality/license-headers.sh
|
||||
|
||||
- name: CGAL conventions (6 rules over CGAL public headers)
|
||||
run: python3 scripts/quality/cgal-conventions.py
|
||||
|
||||
- name: codespell (docs + source comments + script messages)
|
||||
run: bash scripts/quality/codespell.sh
|
||||
|
||||
- name: shellcheck (scripts/**/*.sh, severity=warning, strict)
|
||||
run: bash scripts/quality/shellcheck.sh --strict
|
||||
|
||||
- name: Install lcov (coverage gate)
|
||||
run: apt-get install -y --no-install-recommends lcov
|
||||
|
||||
- name: Coverage gate (fast-test suite, ramp-up mode)
|
||||
# SKIP_COVERAGE_GATE=1: reports numbers but does not fail on threshold.
|
||||
# Remove once I5 is resolved (coverage is wired to the full CGAL suite,
|
||||
# not just the 26 fast tests). Threshold: 80% line / 70% branch / 90% func.
|
||||
run: SKIP_COVERAGE_GATE=1 bash scripts/quality/coverage.sh
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "QUALITY ▸ all gates passed."
|
||||
echo " see scripts/quality/README.md for the full catalogue"
|
||||
echo " (sanitizers, clang-tidy, coverage report in build-coverage/)"
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
name: API Docs
|
||||
|
||||
# Trigger: include "/docs" anywhere in the commit message.
|
||||
#
|
||||
# git commit -m "docs: update API examples /docs"
|
||||
#
|
||||
# Also available via workflow_dispatch for manual runs.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
- "claude/**"
|
||||
- "feature/**"
|
||||
- "review/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Doc-build — informational only
|
||||
#
|
||||
# Generates Doxygen HTML from the public headers and reports warning
|
||||
# statistics. Does NOT block merges: `continue-on-error: true` ensures
|
||||
# warnings or extraction issues never fail the CI gate. When Doxygen
|
||||
# coverage is denser (Phase 8c), this job can be promoted to a hard
|
||||
# requirement and the HTML deployed to Pages.
|
||||
#
|
||||
# Note: Gitea Actions on GHES does not support `actions/upload-artifact@v4`,
|
||||
# so HTML artifact upload is intentionally omitted. The warning summary
|
||||
# in the job log is the primary reviewer signal; reviewers who want the
|
||||
# HTML can rebuild it locally with `cmake --build build --target doc`.
|
||||
# warnings or extraction issues never fail.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
doc-build:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.head_commit.message, '/docs')
|
||||
runs-on: eulernest
|
||||
container:
|
||||
image: git.eulernest.eu/conformallab/ci-cpp:latest
|
||||
options: "--memory=800m --memory-swap=1200m"
|
||||
continue-on-error: true # never block the merge
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
130
.gitea/workflows/doxygen-pages.yml
Normal file
130
.gitea/workflows/doxygen-pages.yml
Normal file
@@ -0,0 +1,130 @@
|
||||
name: Doxygen → Codeberg Pages (manual-only)
|
||||
|
||||
# Auto-publish Doxygen HTML to https://tmoussa.codeberg.page/ConformalLabpp/
|
||||
# every time the public API or docs source changes on main.
|
||||
#
|
||||
# Pattern: mirrors mirror-to-codeberg.yml — reuses the existing
|
||||
# CODEBERG_TOKEN secret + HTTPS push. No new secret setup required.
|
||||
#
|
||||
# ─── DISABLED 2026-05-26 ───────────────────────────────────────────────────
|
||||
# The auto-publish trigger is intentionally disabled. Background: the
|
||||
# `pages` branch on codeberg went into an inconsistent state during the
|
||||
# v0.10.0 PR-merge sequence (the cleanup deleted preview branches and the
|
||||
# resulting force-pushes left the pages branch unreachable for the CI's
|
||||
# HTTPS push). Until the underlying push step is verified to work cleanly
|
||||
# from CI again, the workflow is restricted to `workflow_dispatch` only —
|
||||
# meaning: manual republication via the Gitea Actions UI, no automatic
|
||||
# overwrite of the manually-curated pages branch on every main push.
|
||||
#
|
||||
# The pages branch currently serves the v6 reviewer hub HTML
|
||||
# (`doc/reviewer/hub.html` source-of-truth) plus the Doxygen output at
|
||||
# /doxygen.html. Both are stable and survive merges as long as nothing
|
||||
# pushes to the pages branch.
|
||||
#
|
||||
# To re-enable auto-publish later: uncomment the `push:` block below, run
|
||||
# a manual dispatch first to verify, then watch a normal merge.
|
||||
#
|
||||
# on:
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# paths:
|
||||
# - "code/include/**"
|
||||
# - "Doxyfile"
|
||||
# - "scripts/doxygen-md-filter.sh"
|
||||
# - "doc/**/*.md"
|
||||
# - "README.md"
|
||||
# - "CLAUDE.md"
|
||||
# - ".gitea/workflows/doxygen-pages.yml"
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: eulernest
|
||||
container:
|
||||
image: git.eulernest.eu/conformallab/ci-cpp:latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Configure CMake (Doxygen target only — no compiler needed)
|
||||
run: cmake -S code -B build
|
||||
|
||||
- name: Build Doxygen HTML
|
||||
run: |
|
||||
cmake --build build --target doc
|
||||
test -f doc/doxygen/html/index.html
|
||||
warnings=$(wc -l < doc/doxygen/doxygen-warnings.log)
|
||||
echo "DOC ▸ Doxygen warnings: $warnings"
|
||||
if [ "$warnings" -gt 0 ]; then
|
||||
echo "::warning::Doxygen produced $warnings warning(s) — review doc/doxygen/doxygen-warnings.log"
|
||||
head -30 doc/doxygen/doxygen-warnings.log
|
||||
fi
|
||||
|
||||
- name: Enforce Doxygen coverage 100%
|
||||
# Coverage is measured against every public symbol under
|
||||
# code/include/ (the `detail::` namespaces are excluded). As of
|
||||
# the `docs/doxygen-coverage-100` PR the baseline is 100 %, so
|
||||
# the gate fires only on regressions.
|
||||
run: bash scripts/doxygen-coverage.sh --threshold 100
|
||||
|
||||
- name: Regenerate doc/api/headers.md from XML
|
||||
run: |
|
||||
python3 scripts/gen-headers-md.py
|
||||
# If the auto-generated headers.md drifted from main, note it.
|
||||
# This job runs on every main push so a drift only persists
|
||||
# for the duration of one push — the next push that lands
|
||||
# will fold the new headers.md back into main (via the
|
||||
# codeberg pages branch). For deterministic regeneration
|
||||
# within main itself, run `bash scripts/regen-docs.sh`
|
||||
# locally before pushing.
|
||||
if ! git diff --quiet -- doc/api/headers.md; then
|
||||
echo "::warning::doc/api/headers.md drifted — run scripts/regen-docs.sh locally and commit before next push"
|
||||
git --no-pager diff -- doc/api/headers.md | head -30
|
||||
fi
|
||||
|
||||
- name: Publish HTML to codeberg pages branch
|
||||
env:
|
||||
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
# Build the publish payload in a clean scratch dir so the
|
||||
# orphan branch contains only the reviewer hub + Doxygen
|
||||
# output (and a marker README), never any build/source
|
||||
# artefacts.
|
||||
publish_dir=$(mktemp -d)
|
||||
cp -r doc/doxygen/html/. "$publish_dir/"
|
||||
|
||||
# ── Reviewer hub override ─────────────────────────────────
|
||||
# If doc/reviewer/hub.html is present, install it as the
|
||||
# publish landing page and demote the auto-generated Doxygen
|
||||
# index to /doxygen.html. The hub is hand-curated and lives
|
||||
# under source control; this step keeps it visible after
|
||||
# every push to main, surviving the auto-publish cycle.
|
||||
if [ -f doc/reviewer/hub.html ]; then
|
||||
mv "$publish_dir/index.html" "$publish_dir/doxygen.html"
|
||||
cp doc/reviewer/hub.html "$publish_dir/index.html"
|
||||
echo "DOC ▸ reviewer hub installed; Doxygen index now at /doxygen.html"
|
||||
fi
|
||||
|
||||
cat > "$publish_dir/README.txt" <<EOF
|
||||
conformallab++ — Doxygen HTML API documentation + reviewer hub.
|
||||
Auto-generated by .gitea/workflows/doxygen-pages.yml from
|
||||
commit ${GITHUB_SHA:-$(git rev-parse HEAD)} on $(date -Iseconds).
|
||||
Source: https://codeberg.org/TMoussa/ConformalLabpp
|
||||
Reviewer hub: doc/reviewer/hub.html (in-repo)
|
||||
Doxygen index: /doxygen.html
|
||||
EOF
|
||||
|
||||
cd "$publish_dir"
|
||||
git init -q -b pages
|
||||
git config user.email "ci@eulernest"
|
||||
git config user.name "conformallab CI"
|
||||
git add -A
|
||||
git commit -q -m "Auto-publish: Doxygen HTML for ${GITHUB_SHA:-HEAD}"
|
||||
# Force-push: the pages branch is a publish target, history
|
||||
# is not interesting (we only ever serve the latest snapshot).
|
||||
git push -f \
|
||||
"https://TMoussa:${CODEBERG_TOKEN}@codeberg.org/TMoussa/ConformalLabpp.git" \
|
||||
pages:pages
|
||||
44
.gitea/workflows/markdown-links.yml
Normal file
44
.gitea/workflows/markdown-links.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Markdown link check
|
||||
|
||||
# Verify every internal markdown link in the repo resolves to an existing
|
||||
# file (or anchor).
|
||||
#
|
||||
# Triggers:
|
||||
# - "/links" in commit message (manual, on that exact commit)
|
||||
# - Weekly cron Mon 05:00 UTC (catches link rot without any activity)
|
||||
# - workflow_dispatch (manual run on any branch)
|
||||
#
|
||||
# git commit -m "docs: rename section /links"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "claude/**"
|
||||
- "feature/**"
|
||||
- "review/**"
|
||||
schedule:
|
||||
- cron: "0 5 * * 1" # Monday 05:00 UTC weekly link-rot check
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: |
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.head_commit.message, '/links')
|
||||
runs-on: eulernest
|
||||
container:
|
||||
image: git.eulernest.eu/conformallab/ci-cpp:latest
|
||||
options: "--memory=400m --memory-swap=600m"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ── Pure-python internal link check (no external network needed) ────
|
||||
# We use the same logic that found the 2 broken links before the
|
||||
# reviewer meeting: parse every [text](path) link, check that the
|
||||
# target file exists relative to the source file's directory. Skips
|
||||
# http(s)://, mailto:, and pure-anchor (#fragment) links.
|
||||
- name: Internal link check (all *.md files)
|
||||
run: python3 scripts/check-markdown-links.py
|
||||
185
.gitea/workflows/perf-compile-time.yml
Normal file
185
.gitea/workflows/perf-compile-time.yml
Normal file
@@ -0,0 +1,185 @@
|
||||
name: Compile-time perf bench
|
||||
|
||||
# Cross-platform compile-time benchmark. Validates the predictions
|
||||
# made in doc/architecture/compile-time.md against the eulernest CI
|
||||
# runner (Linux + g++ on ARM64).
|
||||
#
|
||||
# Specifically tests whether:
|
||||
# 1. FAST_TEST_BUILD=ON delivers the ~40 % wall-time reduction on
|
||||
# Linux + g++ that was predicted from `-ftime-trace` profiling
|
||||
# (recall: on Apple clang + Apple M1 it was net-neutral).
|
||||
# 2. ccache hit rate is in the predicted 80%+ range on a warm
|
||||
# rerun (where the macOS-local hit rate was 0 % due to
|
||||
# Apple-clang + PCH friction).
|
||||
#
|
||||
# When run:
|
||||
# * push to main (after PR #19 lands)
|
||||
# * workflow_dispatch (manual trigger for ad-hoc verification)
|
||||
#
|
||||
# NOT run on every PR — this is a perf data-collection job, not a
|
||||
# correctness gate. Pollutes the summary with timings but does not
|
||||
# block merges.
|
||||
|
||||
# ─── DISABLED auto-trigger 2026-05-26 ──────────────────────────────────────
|
||||
# Same precaution as `.gitea/workflows/doxygen-pages.yml`: while we work
|
||||
# through the pages-branch + CI-push issue surfaced during the v0.10.0
|
||||
# merge, every workflow that auto-fires on main is restricted to
|
||||
# `workflow_dispatch` only. Manual reruns from the Gitea UI continue
|
||||
# to work; transient failures no longer clutter the run history.
|
||||
#
|
||||
# To re-enable: uncomment the `push:` block below.
|
||||
#
|
||||
# on:
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# paths:
|
||||
# - "code/CMakeLists.txt"
|
||||
# - "code/tests/**/CMakeLists.txt"
|
||||
# - "code/include/**"
|
||||
# - ".gitea/workflows/perf-compile-time.yml"
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
compile-time-matrix:
|
||||
runs-on: eulernest
|
||||
container:
|
||||
image: git.eulernest.eu/conformallab/ci-cpp:latest
|
||||
options: "--memory=2400m --memory-swap=2400m"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install ccache (idempotent)
|
||||
run: |
|
||||
which ccache >/dev/null 2>&1 || apt-get install -y --no-install-recommends ccache
|
||||
|
||||
# ─── Run 1: baseline (PCH OFF, Unity OFF, ccache cleared) ──────
|
||||
- name: "Run 1: cold baseline (no PCH, no Unity, no ccache)"
|
||||
run: |
|
||||
ccache -C >/dev/null 2>&1 || true
|
||||
rm -rf build-baseline
|
||||
cmake -S code -B build-baseline -G Ninja \
|
||||
-DWITH_CGAL_TESTS=ON \
|
||||
-DCONFORMALLAB_USE_PCH=OFF \
|
||||
-DCMAKE_UNITY_BUILD=OFF \
|
||||
-DCONFORMALLAB_USE_CCACHE=OFF
|
||||
start=$(date +%s)
|
||||
nice -n 19 cmake --build build-baseline --target conformallab_cgal_tests -j1
|
||||
end=$(date +%s)
|
||||
echo "PERF baseline_wall=$((end - start)) s"
|
||||
echo "PERF_BASELINE_WALL=$((end - start))" >> $GITHUB_ENV
|
||||
|
||||
# ─── Run 2: PCH only ──────────────────────────────────────────
|
||||
- name: "Run 2: PCH only (Unity off, ccache off)"
|
||||
run: |
|
||||
ccache -C >/dev/null 2>&1 || true
|
||||
rm -rf build-pch
|
||||
cmake -S code -B build-pch -G Ninja \
|
||||
-DWITH_CGAL_TESTS=ON \
|
||||
-DCONFORMALLAB_USE_PCH=ON \
|
||||
-DCMAKE_UNITY_BUILD=OFF \
|
||||
-DCONFORMALLAB_USE_CCACHE=OFF
|
||||
start=$(date +%s)
|
||||
nice -n 19 cmake --build build-pch --target conformallab_cgal_tests -j1
|
||||
end=$(date +%s)
|
||||
echo "PERF pch_only_wall=$((end - start)) s"
|
||||
echo "PERF_PCH_WALL=$((end - start))" >> $GITHUB_ENV
|
||||
|
||||
# ─── Run 3: default (PCH + Unity Build + #6 Dense→Core) ───────
|
||||
- name: "Run 3: default config (PCH + Unity + Dense→Core)"
|
||||
run: |
|
||||
ccache -C >/dev/null 2>&1 || true
|
||||
rm -rf build-default
|
||||
cmake -S code -B build-default -G Ninja \
|
||||
-DWITH_CGAL_TESTS=ON \
|
||||
-DCONFORMALLAB_USE_CCACHE=OFF
|
||||
start=$(date +%s)
|
||||
nice -n 19 cmake --build build-default --target conformallab_cgal_tests -j1
|
||||
end=$(date +%s)
|
||||
echo "PERF default_wall=$((end - start)) s"
|
||||
echo "PERF_DEFAULT_WALL=$((end - start))" >> $GITHUB_ENV
|
||||
|
||||
# ─── Run 4: + FAST_TEST_BUILD (-O0 -g) ────────────────────────
|
||||
- name: "Run 4: default + FAST_TEST_BUILD=ON (-O0 -g for tests)"
|
||||
run: |
|
||||
ccache -C >/dev/null 2>&1 || true
|
||||
rm -rf build-fast
|
||||
cmake -S code -B build-fast -G Ninja \
|
||||
-DWITH_CGAL_TESTS=ON \
|
||||
-DCONFORMALLAB_FAST_TEST_BUILD=ON \
|
||||
-DCONFORMALLAB_USE_CCACHE=OFF
|
||||
start=$(date +%s)
|
||||
nice -n 19 cmake --build build-fast --target conformallab_cgal_tests -j1
|
||||
end=$(date +%s)
|
||||
echo "PERF fast_test_wall=$((end - start)) s"
|
||||
echo "PERF_FAST_WALL=$((end - start))" >> $GITHUB_ENV
|
||||
|
||||
# ─── Run 5: ccache hit-rate validation ─────────────────────────
|
||||
- name: "Run 5: ccache hit-rate (rebuild build-default)"
|
||||
run: |
|
||||
ccache -C >/dev/null 2>&1 || true
|
||||
ccache --zero-stats >/dev/null
|
||||
# First rebuild: populate ccache.
|
||||
rm -rf build-cc
|
||||
cmake -S code -B build-cc -G Ninja \
|
||||
-DWITH_CGAL_TESTS=ON \
|
||||
-DCONFORMALLAB_USE_CCACHE=ON
|
||||
nice -n 19 cmake --build build-cc --target conformallab_cgal_tests -j1 >/dev/null
|
||||
ccache_first=$(ccache -s 2>&1 | grep -E "^\s*Hits" | head -1 | awk '{print $2}')
|
||||
# Second rebuild: expect cache hits.
|
||||
rm -rf build-cc-warm
|
||||
cmake -S code -B build-cc-warm -G Ninja \
|
||||
-DWITH_CGAL_TESTS=ON \
|
||||
-DCONFORMALLAB_USE_CCACHE=ON
|
||||
start=$(date +%s)
|
||||
nice -n 19 cmake --build build-cc-warm --target conformallab_cgal_tests -j1
|
||||
end=$(date +%s)
|
||||
warm_wall=$((end - start))
|
||||
ccache_stats=$(ccache -s 2>&1 | grep -E "Hits|Misses" | head -4)
|
||||
echo "── ccache stats after warm rebuild ──"
|
||||
echo "$ccache_stats"
|
||||
echo "PERF ccache_warm_wall=${warm_wall} s"
|
||||
echo "PERF_CCACHE_WARM_WALL=$warm_wall" >> $GITHUB_ENV
|
||||
|
||||
# ─── Test correctness (last gate; perf data already collected) ─
|
||||
- name: Verify all configs produced working binaries
|
||||
if: always()
|
||||
run: |
|
||||
for build in build-baseline build-pch build-default build-fast; do
|
||||
if [ -d "$build" ]; then
|
||||
ctest --test-dir "$build" -R "^cgal\." --output-on-failure --timeout 120 \
|
||||
| tail -3
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── Final summary ─────────────────────────────────────────────
|
||||
- name: Compile-time perf summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " COMPILE-TIME PERF BENCH — Linux ARM64 / g++ / -j1"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
printf " %-30s %4s s\n" "Run 1: cold baseline" "${PERF_BASELINE_WALL:-?}"
|
||||
printf " %-30s %4s s\n" "Run 2: + PCH" "${PERF_PCH_WALL:-?}"
|
||||
printf " %-30s %4s s\n" "Run 3: + PCH + Unity (default)" "${PERF_DEFAULT_WALL:-?}"
|
||||
printf " %-30s %4s s\n" "Run 4: + FAST_TEST_BUILD" "${PERF_FAST_WALL:-?}"
|
||||
printf " %-30s %4s s\n" "Run 5: + ccache warm rerun" "${PERF_CCACHE_WARM_WALL:-?}"
|
||||
echo "──────────────────────────────────────────────────────"
|
||||
echo "Predictions to validate vs Apple-M1 baseline:"
|
||||
echo " ┃ FAST_TEST_BUILD: expected ~40 % faster than default"
|
||||
echo " ┃ ccache warm: expected ≤ 10 s (vs Apple's 55 s)"
|
||||
echo "──────────────────────────────────────────────────────"
|
||||
# Compute relative deltas
|
||||
if [ -n "${PERF_DEFAULT_WALL:-}" ] && [ -n "${PERF_FAST_WALL:-}" ]; then
|
||||
pct=$(awk -v d="${PERF_DEFAULT_WALL}" -v f="${PERF_FAST_WALL}" \
|
||||
'BEGIN { printf "%.0f", 100.0 * (d - f) / d }')
|
||||
echo " Δ FAST_TEST_BUILD vs default: ${pct} % wall reduction"
|
||||
fi
|
||||
if [ -n "${PERF_DEFAULT_WALL:-}" ] && [ -n "${PERF_CCACHE_WARM_WALL:-}" ]; then
|
||||
pct=$(awk -v d="${PERF_DEFAULT_WALL}" -v c="${PERF_CCACHE_WARM_WALL}" \
|
||||
'BEGIN { printf "%.0f", 100.0 * (d - c) / d }')
|
||||
echo " Δ ccache warm vs default: ${pct} % wall reduction"
|
||||
fi
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -25,8 +25,10 @@ Testing/
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/
|
||||
# Claude Code worktrees + local/session data (but track shared project config)
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
!.claude/token-hygiene.md
|
||||
|
||||
# Doxygen output
|
||||
doc/doxygen/
|
||||
|
||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -6,6 +6,94 @@ project uses [Semantic Versioning](https://semver.org).
|
||||
|
||||
---
|
||||
|
||||
## [0.10.0] — 2026-05-26
|
||||
|
||||
The **"reviewer-ready"** release. Three PRs (#17 + #18 + #19, 13
|
||||
thematic commits total) landed: a 100 %-Doxygen-covered public API,
|
||||
a 14-gate structural quality suite (4 of them required CI), a
|
||||
researcher-targeted reviewer materials package, the `output_uv_map`
|
||||
named parameter extended to four of the five DCE solvers, six new
|
||||
roadmap phases from a full Java-library scan, thirteen new Tier-1/2
|
||||
literature citations, three RESEARCH-only phases with acceptance
|
||||
criteria, and a six-mode compile-time workflow matrix.
|
||||
|
||||
### Added — reviewer materials (PR #19)
|
||||
|
||||
* `doc/reviewer/{briefing,questions,agenda,README}.md` — one-page
|
||||
reviewer briefing + seven scoped questions (Q1–Q2 research-track
|
||||
alignment; Q3–Q4 porting decisions; Q5–Q6 process; Q7 the "no"
|
||||
question) + internal meeting agenda + landing index.
|
||||
* `doc/reviewer/hub.html` — hand-curated reviewer landing page,
|
||||
in-repo so the publish URL survives every merge.
|
||||
* `code/deps/THIRD-PARTY-LICENSES.md` — per-vendored-dep SPDX with
|
||||
MIT-compatibility analysis (CGAL LGPL §3 vs §4 distinction).
|
||||
* `doc/architecture/dependencies.md` — required vs optional deps;
|
||||
standalone-verification recipe.
|
||||
|
||||
### Added — new roadmap content (PR #19)
|
||||
|
||||
* Six new phases from full Java-library scan: Phase 9d (cones),
|
||||
9d.4 (variational Möbius centring), 9e (circle-pattern layout),
|
||||
10d (Koebe circle-domain), 10e (quasi-isothermic, ~800 lines, 6
|
||||
classes), 10f (Koebe polyhedra), 10g (cyclic-symmetry quotients).
|
||||
* Three RESEARCH phases with acceptance criteria: 9d.2 (non-Euclidean
|
||||
cone extensions), 9f (polygon Laplacian on non-triangular meshes,
|
||||
no Java parent), 10c′ (Koebe polyhedron rigidity).
|
||||
* 13 new Tier-1/Tier-2 citations in `doc/math/references.md`.
|
||||
|
||||
### Added — compile-time workflow matrix (PR #19)
|
||||
|
||||
* PCH + Unity Build defaults: CGAL test wall-time **78 s → 55 s**
|
||||
(−30 %), CPU time 676 s → 167 s (−75 %).
|
||||
* Five new opt-in workflow modes (`BUILD_TESTING=OFF`,
|
||||
`CONFORMALLAB_HEADERS_CHECK`, `CONFORMALLAB_DEV_BUILD`,
|
||||
`CONFORMALLAB_FAST_TEST_BUILD`, `CONFORMALLAB_USE_CCACHE`).
|
||||
* `doc/architecture/compile-time.md` — full measurement + workflow
|
||||
matrix + macOS-vs-Linux honesty notes.
|
||||
* `.gitea/workflows/perf-compile-time.yml` — Linux CI bench.
|
||||
|
||||
### Added — output_uv_map covers 4 of 5 DCE entries (PR #19)
|
||||
|
||||
* `CGAL::discrete_inversive_distance_map` honours `output_uv_map`
|
||||
via Bowers-Stephenson edge-length reconstruction.
|
||||
* `CGAL::discrete_circle_packing_euclidean` rejects `output_uv_map`
|
||||
with a clear `std::runtime_error` (face-based DOFs, Phase 9c).
|
||||
|
||||
### Added — structural quality gates (PR #18)
|
||||
|
||||
Four scripts promoted to required CI: `license-headers.sh`,
|
||||
`cgal-conventions.py`, `codespell.sh`, `shellcheck.sh --strict`.
|
||||
Seven additional local-only gates: clang-format, cmake-format,
|
||||
cppcheck, sanitizers (ASan + UBSan), clang-tidy, multi-compiler,
|
||||
reproducible-build, CGAL-version-matrix.
|
||||
|
||||
### Added — Doxygen 100 % public-API coverage (PR #17)
|
||||
|
||||
* Doxygen coverage: **24 % → 100 %** (396/396 public symbols).
|
||||
* Fixed `EXCLUDE_PATTERNS` bug that previously silently excluded
|
||||
every `.hpp`/`.h` — pre-fix HTML had ~0 % API surface.
|
||||
* MathJax + CGAL `\cgalParam*` aliases.
|
||||
* New scripts: `scripts/doxygen-coverage.sh` (CI-gateable),
|
||||
`scripts/gen-headers-md.py` (auto-regenerates `doc/api/headers.md`).
|
||||
|
||||
### Changed
|
||||
|
||||
* Three headers `<Eigen/Dense>` → `<Eigen/Core>` (none use Eigen
|
||||
decompositions): `projective_math.hpp`,
|
||||
`hyper_ideal_visualization_utility.hpp`, `mesh_utils.hpp`.
|
||||
* `doc/api/tests.md` — CGAL suite 234 → 236 tests.
|
||||
* `code/.gitignore` — un-ignore `code/deps/THIRD-PARTY-LICENSES.md`.
|
||||
|
||||
### Numbers at release
|
||||
|
||||
* 259 / 259 tests pass, 0 skipped.
|
||||
* 100 % Doxygen coverage on public API, 0 warnings.
|
||||
* 14 / 15 quality gates green, 1 SKIP (no CGAL tarballs locally).
|
||||
* CI build wall: ~55 s on Apple M1 (−30 % vs v0.9.0).
|
||||
* 13 Tier-1 / Tier-2 literature citations integrated.
|
||||
|
||||
---
|
||||
|
||||
## [0.9.0] — 2026-05-22
|
||||
|
||||
The “DCE-complete + CGAL-surface-complete” release. Two new discrete-
|
||||
|
||||
@@ -7,8 +7,8 @@ authors:
|
||||
email: Tarik.moussa95@gmail.com
|
||||
|
||||
title: "conformallab++"
|
||||
version: 0.9.0
|
||||
date-released: 2026-05-22
|
||||
version: 0.10.0
|
||||
date-released: 2026-05-26
|
||||
url: "https://codeberg.org/TMoussa/ConformalLabpp"
|
||||
repository-code: "https://codeberg.org/TMoussa/ConformalLabpp"
|
||||
license: MIT
|
||||
|
||||
240
CLAUDE.md
240
CLAUDE.md
@@ -11,10 +11,12 @@ conformallab++ is a C++17 reimplementation of [ConformalLab](https://github.com/
|
||||
|
||||
**The long-term goal is a CGAL package** — a submission to the CGAL library that brings discrete conformal maps (hyper-ideal, spherical, Euclidean) to the CGAL ecosystem using `CGAL::Surface_mesh` as the underlying halfedge data structure, with a traits-class design compatible with arbitrary CGAL-conforming mesh types.
|
||||
|
||||
The project has three distinct phases:
|
||||
- **Phase 1–7 (done):** Direct port of the Java library algorithms to C++
|
||||
- **Phase 8–9 (planned):** CGAL package infrastructure + remaining Java features not yet ported (inversive-distance functional, analytic HyperIdeal Hessian, genus-g > 1 fundamental domain)
|
||||
- **Phase 10+ (research):** New mathematics beyond the Java original — holomorphic differentials, Siegel period matrix Ω ∈ H_g, full uniformization for genus g ≥ 2
|
||||
The project has four distinct phase blocks (updated 2026-05-22):
|
||||
- **Phase 1–7 (done, v0.7.0):** Direct port of the Java library algorithms to C++.
|
||||
- **Phase 8a MVP + 8b-Lite (done, v0.9.0):** CGAL public-API surface for all five DCE models via `<CGAL/Discrete_*.h>`. Phase 8a.2 (generic FaceGraph), 8c (manuals), 8d (CGAL-test-format), 8e (YAML pipeline) deferred on-demand.
|
||||
- **Phase 9a + 9b (done, v0.9.0):** Two new functionals (CP-Euclidean port, Inversive-Distance research), two new Newton solvers, block-FD HyperIdeal Hessian.
|
||||
- **Phase 9b-analytic + 9c (planned):** Full analytic HyperIdeal Hessian via Schläfli identity (research, see `doc/roadmap/research-track.md`); 4g-polygon fundamental domain for genus g > 1 (mixed port + research).
|
||||
- **Phase 10+ (research):** Holomorphic differentials, Siegel period matrix Ω ∈ H_g, full uniformization for genus g ≥ 2.
|
||||
|
||||
## Language
|
||||
|
||||
@@ -43,6 +45,21 @@ cmake --build build -j$(nproc)
|
||||
|
||||
`-DWITH_CGAL=ON` automatically enables `-DWITH_VIEWER=ON`, which pulls in GLFW and requires `wayland-scanner`. Never use this in headless CI.
|
||||
|
||||
### Compile-time options (v0.10.0 matrix)
|
||||
|
||||
The CGAL test build defaults to **PCH + Unity Build ON** (CGAL test wall-time 78 s → 55 s, CPU time 676 s → 167 s on Apple M1). Opt-in/opt-out flags — full measurements + macOS-vs-Linux notes in `doc/architecture/compile-time.md`:
|
||||
|
||||
| Flag | Default | Effect |
|
||||
|---|---|---|
|
||||
| `-DBUILD_TESTING=OFF` | ON | Skip the entire test subtree (headers-only consumers). |
|
||||
| `-DCONFORMALLAB_USE_PCH=OFF` | ON | Disable precompiled headers for `conformallab_cgal_tests`. |
|
||||
| `-DCMAKE_UNITY_BUILD=OFF` | ON | Disable Unity (jumbo) build. |
|
||||
| `-DCONFORMALLAB_DEV_BUILD=ON` | OFF | Dev iteration: PCH on, Unity forced off (cheaper incremental rebuilds). |
|
||||
| `-DCONFORMALLAB_FAST_TEST_BUILD=ON` | OFF | `-O0 -g` for tests (faster compile, slower run). |
|
||||
| `-DCONFORMALLAB_LOW_MEMORY_BUILD=ON` | OFF | **RAM-constrained CI** (Raspberry Pi): `-O0` (no -g), PCH off, unity batch 1, `--no-keep-memory` linker. Drops cc1plus peak from ~700 MB to ~150-200 MB per TU so the CGAL build fits in a 2 GB container. Tests run ~15× slower but all pass. Use with `-j1`. |
|
||||
| `-DCONFORMALLAB_USE_CCACHE=ON` | OFF | Route compiles through ccache. |
|
||||
| `-DCONFORMALLAB_HEADERS_CHECK=ON` | OFF | Standalone header self-containment check target. |
|
||||
|
||||
### Running a single test
|
||||
|
||||
```bash
|
||||
@@ -90,17 +107,22 @@ This replaces the Java `CoHDS` (half-edge data structure) and its intrusive `CoV
|
||||
|
||||
`CGAL_DISABLE_GMP` and `CGAL_DISABLE_MPFR` are defined for all CGAL targets — the library deliberately uses `Simple_cartesian<double>` (floating-point, no exact arithmetic) because conformal geometry does not require exact predicates.
|
||||
|
||||
### The three geometry modes
|
||||
### The five DCE models
|
||||
|
||||
Each mode has its own Maps struct that bundles all property maps, plus functional, Hessian, and Newton function:
|
||||
Each model has its own Maps struct that bundles all property maps, plus a functional, optional Hessian, Newton solver, and (since v0.9.0) a CGAL public-API entry function:
|
||||
|
||||
| Mode | Space | Maps struct | Key headers | Newton function |
|
||||
|---|---|---|---|---|
|
||||
| Euclidean | ℝ² | `EuclideanMaps` | `euclidean_functional.hpp`, `euclidean_hessian.hpp` | `newton_euclidean()` |
|
||||
| Spherical | S² | `SphericalMaps` | `spherical_functional.hpp`, `spherical_hessian.hpp` | `newton_spherical()` |
|
||||
| Hyper-ideal | H² (Poincaré disk) | `HyperIdealMaps` | `hyper_ideal_functional.hpp`, `hyper_ideal_hessian.hpp` | `newton_hyper_ideal()` |
|
||||
| Model | Space | DOFs | Maps struct | Key headers | Newton function | CGAL entry |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Euclidean | ℝ² | vertex | `EuclideanMaps` | `euclidean_functional.hpp`, `euclidean_hessian.hpp` | `newton_euclidean()` | `discrete_conformal_map_euclidean()` |
|
||||
| Spherical | S² | vertex | `SphericalMaps` | `spherical_functional.hpp`, `spherical_hessian.hpp` | `newton_spherical()` | `discrete_conformal_map_spherical()` |
|
||||
| Hyper-ideal | H² (Poincaré disk) | vertex + edge | `HyperIdealMaps` | `hyper_ideal_functional.hpp`, `hyper_ideal_hessian.hpp` (block-FD, Phase 9b) | `newton_hyper_ideal()` | `discrete_conformal_map_hyper_ideal()` |
|
||||
| CP-Euclidean (BPS 2010) | face-based circle packing | **face** | `CPEuclideanMaps` | `cp_euclidean_functional.hpp` | `newton_cp_euclidean()` | `discrete_circle_packing_euclidean()` |
|
||||
| Inversive-Distance (Luo 2004) | vertex-based circle packing | vertex | `InversiveDistanceMaps` | `inversive_distance_functional.hpp` | `newton_inversive_distance()` | `discrete_inversive_distance_map()` |
|
||||
|
||||
HyperIdeal also has edge DOFs (`e_idx[e]`); Euclidean and Spherical are vertex-DOF only. For HyperIdeal: `assign_all_dof_indices(mesh, maps)` assigns all vertex and edge DOFs automatically. For Euclidean/Spherical: pin one vertex manually (`maps.v_idx[first_vertex] = -1`) then assign sequential indices.
|
||||
DOF-assignment patterns:
|
||||
- **Vertex-only models** (Euclidean, Spherical, Inversive-Distance): pin one vertex manually (`maps.v_idx[first_vertex] = -1`) then assign sequential indices. The CGAL public entries do this automatically with the "natural-theta" trick (so calling them with no arguments returns x = 0 as the equilibrium).
|
||||
- **HyperIdeal**: `assign_all_dof_indices(mesh, maps)` assigns vertex + edge DOFs automatically.
|
||||
- **CP-Euclidean**: face-based — `assign_cp_euclidean_face_dof_indices(mesh, maps, pinned_face)` pins one face and indexes the rest.
|
||||
|
||||
### The full pipeline
|
||||
|
||||
@@ -125,18 +147,19 @@ After `compute_*_lambda0_from_mesh()` the original vertex positions are no longe
|
||||
|
||||
### Newton solver (`newton_solver.hpp`)
|
||||
|
||||
The gradient sign convention differs between modes:
|
||||
- **Euclidean/Spherical:** `G_v = Θ_v − actual_angle_sum` (target minus actual)
|
||||
- **HyperIdeal:** `G_v = actual_angle_sum − Θ_v` (actual minus target)
|
||||
Gradient sign convention differs across the five models:
|
||||
- **Euclidean / Spherical / Inversive-Distance:** `G_v = Θ_v − actual_angle_sum` (target minus actual).
|
||||
- **HyperIdeal:** `G_v = actual_angle_sum − Θ_v` (actual minus target).
|
||||
- **CP-Euclidean:** `G_f = φ_f − Σ_{h:face(h)=f} (p(θ*,Δρ) + θ*)` (face-based; see `cp_euclidean_functional.hpp` header for the full formula).
|
||||
|
||||
Hessian sign and solver:
|
||||
- **Euclidean:** H is PSD (cotangent Laplacian) → `SimplicialLDLT(H)`
|
||||
- **Spherical:** H is NSD (concave energy) → `SimplicialLDLT(−H)` (sign flip inside `newton_spherical`)
|
||||
- **HyperIdeal:** H is PSD (strictly convex) → `SimplicialLDLT(H)`
|
||||
Hessian sign and solver per model:
|
||||
- **Euclidean:** H is PSD (cotangent Laplacian) → `SimplicialLDLT(H)`.
|
||||
- **Spherical:** H is NSD (concave energy) → `SimplicialLDLT(−H)` (sign flip inside `newton_spherical`).
|
||||
- **HyperIdeal:** H is PSD (strictly convex) → `SimplicialLDLT(H)`. Phase 9b uses a **block-FD Hessian** (per-face 6×6 local block, ~96× speed-up vs full FD on V=200). Full analytic Hessian via the chain `(bᵢ, aₑ) → lᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ` is planned research — see `doc/roadmap/research-track.md` Phase 9b-analytic.
|
||||
- **CP-Euclidean:** analytic 2×2-per-edge `h_jk = sin θ / (cosh Δρ − cos θ)` (BPS 2010), strictly convex → `SimplicialLDLT(H)`.
|
||||
- **Inversive-Distance:** FD Hessian (inline in `newton_inversive_distance`). Analytic via Glickenstein 2011 §5.2 is planned research (Phase 9a.2-analytic).
|
||||
|
||||
When `SimplicialLDLT` fails (rank-deficient H — gauge mode on closed mesh without pinned vertex), the solver automatically retries with `Eigen::SparseQR` to find the minimum-norm step orthogonal to the null space. Public API: `solve_linear_system(H, rhs, &used_fallback)`.
|
||||
|
||||
The HyperIdeal Hessian is currently a **symmetric finite-difference approximation** (O(ε²), costs n extra gradient evaluations per Newton step). The analytic Hessian via the chain `(bᵢ, aₑ) → lᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ` is deferred to Phase 9b.
|
||||
When `SimplicialLDLT` fails (rank-deficient H — gauge mode on a closed mesh without pinned vertex/face), the solver automatically retries with `Eigen::SparseQR` to find the minimum-norm step orthogonal to the null space. Public API: `solve_linear_system(H, rhs, &used_fallback)`.
|
||||
|
||||
### Layout and holonomy (`layout.hpp`)
|
||||
|
||||
@@ -169,13 +192,15 @@ The Java library under `de.varylab.discreteconformal` contains these items not y
|
||||
|---|---|---|
|
||||
| `InversiveDistanceFunctional` | `inversive_distance_functional.hpp` | 9a |
|
||||
| Analytic HyperIdeal Hessian | `hyper_ideal_hessian.hpp` (replace FD) | 9b |
|
||||
| 4g-polygon boundary walk in `FundamentalDomainUtility` | `fundamental_domain.hpp` (extend) | 9c |
|
||||
| 4g-polygon boundary walk in `FundamentalDomainUtility` | `fundamental_domain.hpp` (extend) | 9c † |
|
||||
| `DiscreteHarmonicFormUtility` | Phase 10a prerequisite | 10 |
|
||||
| `DiscreteHolomorphicFormUtility` | Phase 10a | 10 |
|
||||
| `HomologyUtility`, `CanonicalBasisUtility` | Phase 10 | 10 |
|
||||
| `HomologyUtility`, `CanonicalBasisUtility` | Phase 10 † | 10 |
|
||||
|
||||
When porting a Java class, locate the original in `de.varylab.discreteconformal.*` at [github.com/varylab/conformallab](https://github.com/varylab/conformallab) and use it as the reference implementation.
|
||||
|
||||
**† High-precision requirement (Phase 9c / 10):** The Java uniformization classes (`FundamentalPolygon`, `CanonicalFormUtility`) use `RnBig`/`PnBig`/`P2Big` with `MathContext(50)` — 50 significant decimal digits. Reason: products of hyperbolic isometry generators grow exponentially, so `double` fails when verifying the group relation ∏gᵢ = Id. When porting, replicate this **locally** with `boost::multiprecision::cpp_dec_float_50` (or MPFR `mpreal`) — only inside the uniformization module, NOT globally and NOT in the Eigen solver. The core flattening (Newton/energy) stays `double` (see `conformal_mesh.hpp:45`).
|
||||
|
||||
## Test design patterns
|
||||
|
||||
### "Natural theta" — constructing a known equilibrium at x* = 0
|
||||
@@ -235,87 +260,72 @@ my_map[v] = 3.14;
|
||||
|
||||
## CI pipeline
|
||||
|
||||
Two jobs in `.gitea/workflows/cpp-tests.yml`:
|
||||
Three jobs in `.gitea/workflows/cpp-tests.yml`:
|
||||
|
||||
| Job | CMake flags | Deps | Triggers on |
|
||||
|---|---|---|---|
|
||||
| `test-fast` | *(none)* | Eigen + GTest only | all branches |
|
||||
| `test-cgal` | `-DWITH_CGAL_TESTS=ON` | + Boost | pull requests only |
|
||||
| Job | CMake flags | Deps | Triggers on | Status |
|
||||
|---|---|---|---|---|
|
||||
| `test-fast` | *(none)* | Eigen + GTest only | all branches (auto) | **active** |
|
||||
| `test-cgal` | `-DWITH_CGAL_TESTS=ON -DCONFORMALLAB_LOW_MEMORY_BUILD=ON` | + Boost | `/test-cgal` in commit message | **active** |
|
||||
| `quality-gates` | *(none)* | + codespell, shellcheck | `/quality-gates` in commit message | **active** |
|
||||
| `doc-build` | *(none)* | Doxygen | `/docs` in commit message or `workflow_dispatch` | **active** |
|
||||
| `markdown-links` | *(none)* | python3 | `/links` in commit message, weekly cron, `workflow_dispatch` | **active** |
|
||||
|
||||
Runner: `eulernest` — self-hosted Raspberry Pi, ARM64, Ubuntu 22.04. Docker image: `git.eulernest.eu/conformallab/ci-cpp:latest`. `test-cgal` needs `test-fast` to pass first (`needs: test-fast`).
|
||||
> **⚠ Pi runner limit:** use **one keyword per commit**. The Pi (3-4 GB RAM) cannot run
|
||||
> multiple Docker containers simultaneously. `/ci-all` was removed for this reason.
|
||||
> Typical workflow: one commit with `/test-cgal`, then if green a separate commit with
|
||||
> `/quality-gates`.
|
||||
|
||||
Expected results: **23 non-CGAL tests pass**, **227 CGAL tests pass, 0 skipped**.
|
||||
Runner: `eulernest` — self-hosted Raspberry Pi, ARM64, Ubuntu 22.04. Docker image: `git.eulernest.eu/conformallab/ci-cpp:latest`. `test-cgal` and `quality-gates` both need `test-fast` to pass first (`needs: test-fast`).
|
||||
|
||||
`quality-gates` runs four required structural gates: `license-headers.sh`, `cgal-conventions.py`, `codespell.sh`, `shellcheck.sh --strict`. Seven more gates (clang-format, cmake-format, cppcheck, sanitizers, clang-tidy, multi-compiler, reproducible-build) are local-only — see `scripts/quality/README.md`.
|
||||
|
||||
**`test-cgal` is comment-triggered** (2026-05-31): write `/test-cgal` as a comment on any PR to start the CGAL suite manually. Not triggered on every push — the Pi runner (3-4 GB RAM, swap heavily loaded) cannot sustain a build on every WIP commit. `LOW_MEMORY_BUILD=ON` (-O0, no PCH, unity batch 1) keeps peak cc1plus RAM at ~150-200 MB, fitting in a 2000 MB container. All 277 tests pass in ~31 s run time. The two structural sub-gates (`scripts/check-test-counts.sh`, `scripts/try_it.sh`) still run after the test step.
|
||||
|
||||
Two other workflows are also restricted to `workflow_dispatch:` only (auto-trigger disabled 2026-05-26 while the codeberg pages-branch push is being stabilised):
|
||||
- `.gitea/workflows/doxygen-pages.yml` — publishes Doxygen HTML + reviewer hub to the codeberg `pages` branch.
|
||||
- `.gitea/workflows/perf-compile-time.yml` — Linux ARM64 compile-time benchmark.
|
||||
|
||||
Expected results: `test-fast` + `quality-gates` green on every push, 0 skipped, 0 failed. The canonical counts live in `doc/api/tests.md` — do not hardcode them anywhere else (see [`doc/release-policy.md`](doc/release-policy.md)).
|
||||
|
||||
## Release state
|
||||
|
||||
Current release: **v0.7.0** (tag on `origin/dev`, PR to `main` open).
|
||||
Phase 7 is complete. Phase 7.5 (Doxygen) and Phase 8 (CGAL package) are next.
|
||||
Current release: **v0.10.0** (tag on `main`, released 2026-05-26 — the "reviewer-ready" release).
|
||||
Phases 1–9a complete, Phase 8b-Lite CGAL API surface complete (all 5 DCE models reachable via `<CGAL/Discrete_*.h>`), Phase 9b block-FD HyperIdeal Hessian shipped (~96× speed-up). v0.10.0 added: 100 % Doxygen public-API coverage (396/396 symbols, 0 warnings), a 14-gate structural quality suite (4 required in CI), the reviewer materials package (`doc/reviewer/`), `output_uv_map` for 4 of 5 DCE entries, six new roadmap phases + three RESEARCH phases, and a six-mode compile-time workflow matrix. Numbers (single source of truth = `doc/api/tests.md`): **272/272 tests pass, 0 skipped** (26 non-CGAL + 246 CGAL). Next planned milestones: Phase 9c (4g-polygon, genus g > 1) and Phase 9b-analytic (Schläfli identity). See `doc/release-policy.md` for the version-tag policy, `CHANGELOG.md` for full release notes, and `doc/roadmap/phases.md` for the phase plan.
|
||||
|
||||
## Phase 8 strategic decisions (2026-05-19)
|
||||
## Phase 8 CGAL-package decisions (frozen 2026-05-19)
|
||||
|
||||
The CGAL-package architecture was frozen on 2026-05-19. Full design:
|
||||
[`doc/api/cgal-package.md`](doc/api/cgal-package.md). Key decisions:
|
||||
Locked architecture choices — full design in [`doc/api/cgal-package.md`](doc/api/cgal-package.md), locked-vs-flexible split in `doc/architecture/locked-vs-flexible.md`:
|
||||
**MIT license** (no LGPL switch) · **Named Parameters** (`CGAL::parameters::...`) · default kernel **`Simple_cartesian<double>`** · **dual-layer wrapper** (`code/include/*.hpp` = implementation, `include/CGAL/*.h` = thin wrapper, no duplication) · target design is generic **`FaceGraph + HalfedgeGraph`** but ships Surface_mesh-only · upstream CGAL submission is pre-submission-ready, not bound (12+ month horizon). Phase 8 extensions (8a.2 generic FaceGraph, 8c manuals, 8d CGAL-format tests, 8e YAML pipeline) are deferred to on-demand.
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Submission to upstream CGAL | **Pre-submission-ready, not bound.** 12+ months horizon. |
|
||||
| License | **MIT preserved** (no LGPL switch). |
|
||||
| Mesh-type flexibility | **Generic `FaceGraph + HalfedgeGraph`** in target design; MVP starts Surface_mesh-only. |
|
||||
| Parameter style | **Named Parameters** (`CGAL::parameters::...`). |
|
||||
| Default kernel | **`Simple_cartesian<double>`** (status quo). |
|
||||
| Backward compatibility | **Dual-layer wrapper** — `code/include/*.hpp` stays as implementation, `include/CGAL/*.h` is thin wrapper. No algorithm duplication. |
|
||||
| Implementation strategy | **Hybrid MVP** — minimum Phase 8 (traits + one wrapper) first, then Phase 9 in full, then Phase 8 extensions only on concrete demand. |
|
||||
| Phase-8 MVP acceptance test | **Phase 9a (Inversive-Distance)** as the first new client of the new traits API. |
|
||||
## Port-vs-research maintenance rule
|
||||
|
||||
### Implementation sequence (committed)
|
||||
|
||||
```
|
||||
1. Phase 7.5 Doxygen + cleanup done ✅
|
||||
2. Phase 8 MVP — traits + one euclidean wrapper 3–5 days
|
||||
3. Phase 9a — Inversive-Distance against new traits 3–5 days
|
||||
4. Phase 9b — analytic HyperIdeal Hessian 1 week
|
||||
5. Phase 9c — 4g-polygon for genus g > 1 1 week
|
||||
→ port really complete, v0.9.0 release
|
||||
```
|
||||
|
||||
Phase 8 extensions (8a.2 generic FaceGraph, 8c full Doxygen manuals, 8d
|
||||
CGAL-format tests, 8e YAML pipeline) are deferred to on-demand status —
|
||||
no speculative architecture for an uncertain submission.
|
||||
|
||||
Root-level files added at v0.7.0:
|
||||
- `CITATION.cff` — machine-readable citation (Sechelmann 2016, Springborn 2020, Bobenko–Springborn 2004)
|
||||
- `CONTRIBUTING.md` — short root-level pointer to `doc/contributing.md`
|
||||
- `scripts/try_it.sh` — one-script quickstart: build → 209 tests → example run
|
||||
- CMake install target: `cmake --install build --prefix /usr/local` → headers land in `include/conformallab/`
|
||||
|
||||
## Port-vs-research maintenance rule (2026-05-21 audit)
|
||||
|
||||
Before claiming something "ports X from Java", **verify empirically**:
|
||||
Before claiming something "ports X from Java", **verify empirically** — if zero matches, it is **new research** (→ `doc/roadmap/research-track.md` with citations, **not** `doc/roadmap/java-parity.md`):
|
||||
|
||||
```bash
|
||||
find /Users/tarikmoussa/Desktop/conformallab -iname "*X*"
|
||||
grep -r "ClassName" /Users/tarikmoussa/Desktop/conformallab/src
|
||||
```
|
||||
|
||||
If zero matches, the work is **new research** — add it to
|
||||
`doc/roadmap/research-track.md` with primary literature citations,
|
||||
**not** to `doc/roadmap/java-parity.md`.
|
||||
The 2026-05-21 audit corrected four mis-labels (now fixed): `InversiveDistanceFunctional`, both HyperIdeal Hessians (FD + analytic), and the inversive-distance tutorial were all wrongly tagged "Java port" — they are research (no Java parent; Java declares `hasHessian()==false`). Details in `research-track.md`.
|
||||
|
||||
The 2026-05-21 audit found four pre-existing mis-labels:
|
||||
### Java↔C++ math-correctness audit (2026-05-29)
|
||||
|
||||
| Item | Wrong claim | Reality |
|
||||
|---|---|---|
|
||||
| `InversiveDistanceFunctional` | "Java port (Luo 2004)" | No such Java class exists |
|
||||
| HyperIdeal Hessian (FD) | "Phase 4a" | Research — Java has `hasHessian()==false` |
|
||||
| HyperIdeal Hessian (analytic) | "Phase 9b port" | Research — derivation via Schläfli 1858 |
|
||||
| Tutorial framing | "ports `InversiveDistanceFunctional.java`" | Implementation from Luo 2004 + Glickenstein 2011 |
|
||||
Full line-by-line audit of all math-critical headers against `de.varylab.discreteconformal.*` lives in **`doc/reviewer/java-port-audit.md`** (read it before re-investigating any of these). All 11 findings are resolved or noted; the four that needed code changes are now ✅ FIXED and **all CGAL tests pass** (246 after the Euclidean holonomy/τ end-to-end tests, the spherical edge-DOF closed-form oracle, and the Java golden-value oracle suites — incl. three *full-mesh* oracles driving the real `EuclideanCyclicFunctional`/`SphericalFunctional` on a shared tetrahedron, one of them an edge-DOF gradient oracle — landed on top; see `doc/api/tests.md`):
|
||||
|
||||
All four are corrected as of this commit. Future contributors must
|
||||
follow the empirical verification rule above before any new claim.
|
||||
- **Finding 3** (`spherical_functional.hpp`) — spherical edge-DOF now uses Java's *replacement* parameterization (`Λ = λ_e` when the edge is a DOF, via helper `spher_eff_lambda`); edge gradient is `α_opp⁺ + α_opp⁻ − θ_e` (dropped the extra `−(S_f⁺+S_f⁻)/2` term). Vertex-only path is bit-for-bit unchanged.
|
||||
- **Finding 4** (`spherical_hessian.hpp`) — added an always-compiled `throw std::logic_error` edge-DOF guard (mirrors Finding 2 for Euclidean).
|
||||
- **Finding 6** (`period_matrix.hpp`) — `compute_period_matrix` now calls the faithful `normalizeModulus` (matches Java oracle: `0 ≤ Re ≤ ½`, `Im ≥ 0`, `|τ| ≥ 1`); `reduce_to_fundamental_domain` retained for the canonical SL(2,ℤ) domain.
|
||||
- **Finding 9** (`inversive_distance_functional.hpp`) — degenerate-face gradient now uses limiting angles instead of skipping (mirrors Finding 1); the genuinely-non-real `l*sq <= 0` skip is kept.
|
||||
|
||||
**Java golden-value oracles (2026-05-29, P0):** five suites now pin the C++ pure-math core bit-for-bit (1e-12) against the compiled Java library (openjdk 17, real `Clausen.Л` / `HyperIdealUtility` / `DiscreteEllipticUtility.normalizeModulus`): `HyperIdealGoldenJava` (Clausen/Л/ImLi₂, ζ₁₃/₁₄/₁₅/ζ, both tetrahedron-volume formulas), `EuclideanGoldenJava` (angle formula + 2·Л energy), `SphericalGoldenJava` (law-of-cosines angles + β relations + Л energy), and `PeriodMatrix.NormalizeModulus_GoldenJava` (audit missing-test item 7, ✅ done). Oracle harnesses live in `/tmp/oracle/*.java` (recipe in the audit doc).
|
||||
|
||||
**Full-mesh oracles (2026-05-29):** `EuclideanGoldenJava.FullMeshGradientAndEnergy_Tetrahedron` and `SphericalGoldenJava.FullMeshGradientAndEnergy_Tetrahedron` drive the *real* `EuclideanCyclicFunctional` / `SphericalFunctional` on a shared tetrahedron (`/tmp/oracle/{tet.obj,EucMeshOracle.java,SphereMeshOracle.java}`) and pin both the per-vertex gradient (`Θ−Σα`) and `ΔE = E(x)−E(0)` to 1e-12 — the energy cross-checks C++'s Gauss-Legendre *path integral* against Java's *closed-form* functional (audit missing-test item 5, ✅ done). **Finding surfaced:** the spherical oracle must call Java's raw `conformalEnergyAndGradient`, NOT `evaluate()` — the latter pre-runs a 1-D Brent maximization over the global-scale gauge (`maximizeInNegativeDirection`), which C++ deliberately factors into the Newton solver's `spherical_gauge_shift` instead (both correct; different factoring).
|
||||
|
||||
**Open follow-ups (tests only, not bugs):** (a) the spherical edge-DOF *gradient* is now Java-oracle'd at the solution level (`SphericalGoldenJava.FullMeshEdgeDofGradient_Tetrahedron` — vertex + edge components vs raw `conformalEnergyAndGradient`, locking Finding 3); the only remaining edge-DOF gap is *Newton-to-convergence* with edge DOFs, blocked by the Finding-4 spherical-Hessian guard (needs an FD-Hessian or guard relaxation first) — a solver feature, not a correctness gap; (b) inversive-distance degenerate-face robustness (item 10) has no Java oracle because the functional is research with no Java parent — only a NaN-free limiting-angle regression test applies. Build/test reminder: the CGAL build dir used for this audit is **`build-cgal`** at the *repo root* (not under `code/`), target `conformallab_cgal_tests`, filter `ctest -R '^cgal\.'`.
|
||||
|
||||
## Documentation map
|
||||
|
||||
24 documents across 6 categories. Read the relevant one before reasoning from scratch
|
||||
38 documents across 7 categories. Read the relevant one before reasoning from scratch
|
||||
— do not hallucinate content that is already written down.
|
||||
|
||||
### Mathematics & theory
|
||||
@@ -340,6 +350,9 @@ follow the empirical verification rule above before any new claim.
|
||||
| Key architectural decisions and their rationale | `doc/architecture/design-decisions.md` |
|
||||
| Detailed comparison with geometry-central (CMU): overlap, adoption, scientific value | `doc/architecture/geometry-central-comparison.md` |
|
||||
| Phase 9a validation report (CP-Euclidean port + Luo-inversive-distance literature check) | `doc/architecture/phase-9a-validation.md` |
|
||||
| Compile-time measurements + six-mode workflow matrix (macOS-vs-Linux honesty notes) | `doc/architecture/compile-time.md` |
|
||||
| Required vs optional dependencies + standalone-verification recipe | `doc/architecture/dependencies.md` |
|
||||
| Which 12 architecture decisions are locked vs still flexible | `doc/architecture/locked-vs-flexible.md` |
|
||||
|
||||
### API & extension
|
||||
|
||||
@@ -349,7 +362,7 @@ follow the empirical verification rule above before any new claim.
|
||||
| Full pipeline API for all three geometries | `doc/api/pipeline.md` |
|
||||
| What does each processing unit require/provide (contracts)? | `doc/api/contracts.md` |
|
||||
| How to add a new functional / geometry mode / port from Java | `doc/api/extending.md` |
|
||||
| All 39 test suites, 227+23 tests, individual counts | `doc/api/tests.md` |
|
||||
| Per-suite breakdown and counts (single source of truth) | `doc/api/tests.md` |
|
||||
| Phase 8 CGAL package design + Declarative YAML pipeline spec | `doc/api/cgal-package.md` |
|
||||
|
||||
### Concepts & specs
|
||||
@@ -362,9 +375,12 @@ follow the empirical verification rule above before any new claim.
|
||||
|
||||
| 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
|
||||
|
||||
@@ -373,6 +389,21 @@ follow the empirical verification rule above before any new claim.
|
||||
| Build modes, single-test invocation, CLI, Docker image rebuild | `doc/getting-started.md` |
|
||||
| Step-by-step: port the Inversive Distance functional (Phase 9a template) | `doc/tutorials/add-inversive-distance.md` |
|
||||
| Language policy, test standards, release flow | `doc/contributing.md` |
|
||||
| Versioning rules + release process + single-source-of-truth list | `doc/release-policy.md` |
|
||||
|
||||
### Reviewer materials (v0.10.0)
|
||||
|
||||
| Question | Document |
|
||||
|---|---|
|
||||
| One-page reviewer briefing | `doc/reviewer/briefing.md` |
|
||||
| Seven scoped reviewer questions (Q1–Q7) | `doc/reviewer/questions.md` |
|
||||
| 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.
|
||||
|
||||
### geometry-central context
|
||||
|
||||
@@ -384,9 +415,44 @@ The shared mathematical core (Springborn 2020) means cross-validation is meaning
|
||||
Full analysis: `doc/architecture/geometry-central-comparison.md`.
|
||||
Optional adoption roadmap (GC-1/2/3): `doc/roadmap/phases.md` (Optional section).
|
||||
|
||||
## Agentic workflow patterns
|
||||
|
||||
Recommended loops when working in this repo. Prefer the cheapest gate that catches the class of error you just touched.
|
||||
|
||||
- **Add/port an algorithm**: new `.hpp` in `code/include/` → test in `code/tests/cgal/` (must include a gradient-check, see Test design patterns) → register in `code/tests/cgal/CMakeLists.txt` → build `conformallab_cgal_tests` → `ctest -R "^cgal\."`. Before claiming "ports X", run the empirical port-vs-research check above.
|
||||
- **Inner dev loop** (fast iteration): `-DCONFORMALLAB_DEV_BUILD=ON` (PCH on, Unity off) for cheap incremental rebuilds; run a single suite via `--gtest_filter`. Switch back to the default (Unity on) for a final full build.
|
||||
- **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.
|
||||
- **settings.json**: `.claude/settings.json` (committed) pre-allows the safe read/build/test/quality commands so they don't prompt, and denies destructive git on `main`. Extend the allowlist as new safe commands recur rather than re-approving each time.
|
||||
|
||||
### Token hygiene — session-cut (Tier 1, highest impact)
|
||||
|
||||
Every turn re-sends the whole conversation, so context accumulation is the largest avoidable cost.
|
||||
|
||||
- **One session per task.** After a task is done, start a fresh session (`/clear`) instead of pivoting to an unrelated task in the same thread — the old task's context is dead weight in every later turn.
|
||||
- **Compact proactively at clean breakpoints.** Run `/compact <what matters>` when a task finishes and before the next starts, rather than waiting for auto-compaction at the limit (which you don't control).
|
||||
- **Delegate read-heavy sweeps to a subagent.** An `Explore`/`Plan` subagent reads large amounts in *its* context and returns a short summary — the bulk never enters the main context. Use it for "where is X used / what depends on Y"; for a *known* path, `Read` directly (a subagent starts cold and only pays off on a large search space).
|
||||
|
||||
### Token hygiene — command discipline (Tier 2)
|
||||
|
||||
- **Scope + filter together.** Path-scope searches (`grep -rn "newton_" code/include/`, not repo-wide). Filter test output (`ctest -R "cgal.NewtonSolver" --output-on-failure`, or `--gtest_filter` for one suite) instead of dumping all 272 results.
|
||||
- **Never read raw logs into context.** Redirect to a file, then `grep`/`tail` it. With `run_in_background`, read the output file selectively rather than pulling it whole.
|
||||
- **Don't re-read a file you just edited.** `Edit` errors on stale state — the harness tracks it; a `Read`-back after a successful edit is wasted context.
|
||||
- **Reference by line number** (`newton_solver.hpp:147`) instead of re-pasting code blocks.
|
||||
|
||||
Cache discipline (Tier 3) is a user-facing guide — see [`.claude/token-hygiene.md`](.claude/token-hygiene.md). **Remind the user of the relevant Tier-3 rule when you observe the matching anti-pattern** (mid-session CLAUDE.md edits, chained sub-5-minute waits, long multi-topic sessions).
|
||||
|
||||
## Known quirks
|
||||
|
||||
- **`test-fast` also runs stubs**: `conformallab_tests` (non-CGAL) contains `GTEST_SKIP`-based stubs for functionals that need CGAL. This is intentional — those tests document what was in the Java port scope but requires the CGAL mesh type.
|
||||
- **No GTEST_SKIP stubs remain** (since v0.9.0): the three stale HDS-port stub files were removed because the CGAL test suite covers the same functionality with real tests. The pure-math `conformallab_tests` target now only contains active tests.
|
||||
- **Boost is header-only**: CGAL 6.x uses only Boost headers (`Boost.Config`, `Boost.Graph`). No compiled Boost libraries are needed. `find_package(Boost REQUIRED)` only locates the include path.
|
||||
- **`main` branch is protected** on `origin` (Gitea). Push to `dev`, then merge via pull request. Codeberg `main` can be pushed to directly.
|
||||
- **Both remotes must stay in sync**: `origin` = `git.eulernest.eu` (CI runs here), `codeberg` = `codeberg.org/TMoussa/ConformalLabpp` (public mirror). Push to both after every significant change.
|
||||
- **Both remotes must stay in sync**: `origin` = `git.eulernest.eu` (CI runs here), `codeberg` = `codeberg.org/TMoussa/ConformalLabpp` (public mirror, SSH). Push to both after every significant change.
|
||||
- **Codeberg `pages` branch is an orphan publish target** that serves the reviewer hub + Doxygen HTML at https://tmoussa.codeberg.page/ConformalLabpp/. Its source-of-truth is `doc/reviewer/hub.html` (installed as `index.html`) + a local Doxygen build (`cmake --build build --target doc`, demoted to `/doxygen.html`). Because the auto-publish workflow (`doxygen-pages.yml`) is on `workflow_dispatch:` only, the branch is **not** refreshed on normal pushes and has been accidentally lost during force-push/merge cleanups. To republish manually: build Doxygen, copy `doc/doxygen/html/.` into a scratch dir, `mv index.html doxygen.html`, copy `hub.html` → `index.html`, then `git init -b pages && git commit && git push -f codeberg pages:pages`. Codeberg pages caches for ~10 min (`Cache-Control: max-age=600`) — verify with a cache-busting `?cb=$(date +%s)` query. Protect the branch in codeberg settings to prevent deletion (leave force-push allowed so CI/manual republish still works).
|
||||
- **Both `main` branches are independent for the pages cycle**: `origin/main` is protected (PR-only); `codeberg/main` can be pushed directly.
|
||||
|
||||
48
Doxyfile
48
Doxyfile
@@ -31,9 +31,23 @@ EXCLUDE_PATTERNS = */build*/* \
|
||||
*/deps/* \
|
||||
*/.git/* \
|
||||
*/test-reports/* \
|
||||
*/* 2.hpp
|
||||
*\ 2.hpp \
|
||||
*\ 2.h
|
||||
# Research-quality LaTeX notes use raw \sinh / \cosh / \frac / \beta /
|
||||
# \cdot / \partial / \zeta macros which are valid LaTeX but unknown to
|
||||
# Doxygen. These files are intended to be read as PDF or in a LaTeX-
|
||||
# aware markdown viewer, not as Doxygen pages. Excluding them removes
|
||||
# ~500 spurious "unknown command" warnings while keeping the .md files
|
||||
# discoverable on GitHub.
|
||||
EXCLUDE = doc/math/hyperideal-hessian-derivation.md
|
||||
EXCLUDE_SYMBOLS = Eigen::* boost::* std::*
|
||||
|
||||
# Markdown filter: rewrites repo-relative links like [x](doc/api/tests.md)
|
||||
# into basename-only links [x](tests.md) so Doxygen's basename-indexed
|
||||
# \ref resolver can find them. On-disk files are untouched (GitHub keeps
|
||||
# rendering them correctly). See scripts/doxygen-md-filter.sh.
|
||||
FILTER_PATTERNS = *.md=scripts/doxygen-md-filter.sh
|
||||
|
||||
# ── Source browsing ──────────────────────────────────────────────────────────
|
||||
EXTRACT_ALL = YES
|
||||
EXTRACT_PRIVATE = NO
|
||||
@@ -64,7 +78,7 @@ EXTENSION_MAPPING = h=C++ hpp=C++
|
||||
# ── Warnings ─────────────────────────────────────────────────────────────────
|
||||
QUIET = NO
|
||||
WARNINGS = YES
|
||||
WARN_IF_UNDOCUMENTED = NO
|
||||
WARN_IF_UNDOCUMENTED = YES
|
||||
WARN_IF_DOC_ERROR = YES
|
||||
WARN_IF_INCOMPLETE_DOC = YES
|
||||
WARN_NO_PARAMDOC = NO
|
||||
@@ -74,13 +88,24 @@ WARN_LOGFILE = doc/doxygen/doxygen-warnings.log
|
||||
|
||||
# ── HTML output ──────────────────────────────────────────────────────────────
|
||||
GENERATE_HTML = YES
|
||||
|
||||
# MathJax — render LaTeX math in markdown ($...$ and $$...$$) and in
|
||||
# code-comment `\f$ ... \f$` blocks via MathJax in the generated HTML.
|
||||
# Required for the conformal-mapping math notation (\Theta, \omega, \tau,
|
||||
# \mathbb{H}, …) in doc/architecture/overall_pipeline.md and the
|
||||
# header docstrings.
|
||||
USE_MATHJAX = YES
|
||||
MATHJAX_VERSION = MathJax_3
|
||||
MATHJAX_FORMAT = HTML-CSS
|
||||
MATHJAX_RELPATH = https://cdn.jsdelivr.net/npm/mathjax@3/es5/
|
||||
HTML_OUTPUT = html
|
||||
HTML_FILE_EXTENSION = .html
|
||||
HTML_COLORSTYLE = LIGHT
|
||||
HTML_COLORSTYLE_HUE = 220
|
||||
HTML_COLORSTYLE_SAT = 100
|
||||
HTML_COLORSTYLE_GAMMA = 80
|
||||
HTML_TIMESTAMP = NO
|
||||
# HTML_TIMESTAMP was removed in Doxygen 1.10; use TIMESTAMP=NO instead.
|
||||
TIMESTAMP = NO
|
||||
HTML_DYNAMIC_SECTIONS = YES
|
||||
GENERATE_TREEVIEW = YES
|
||||
DISABLE_INDEX = NO
|
||||
@@ -94,7 +119,9 @@ SERVER_BASED_SEARCH = NO
|
||||
GENERATE_LATEX = NO
|
||||
GENERATE_RTF = NO
|
||||
GENERATE_MAN = NO
|
||||
GENERATE_XML = NO
|
||||
GENERATE_XML = YES
|
||||
XML_OUTPUT = xml
|
||||
XML_PROGRAMLISTING = NO
|
||||
GENERATE_DOCBOOK = NO
|
||||
GENERATE_AUTOGEN_DEF = NO
|
||||
GENERATE_PERLMOD = NO
|
||||
@@ -124,3 +151,16 @@ ALIASES += "concept{1}=\xrefitem concept \"Concept\" \"Concepts\"
|
||||
ALIASES += "models{1}=\xrefitem models \"Models\" \"Models\" \1"
|
||||
ALIASES += "cgalRequires{1}=\par Requirements: \n\1"
|
||||
ALIASES += "cgalParam{2}=\param \1 \2"
|
||||
# CGAL named-parameter block aliases — replicates the upstream
|
||||
# ${CGAL}/Documentation/doc/Documentation/Doxyfile_common conventions
|
||||
# so that \cgalParamNBegin{name} … \cgalParamNEnd blocks render as
|
||||
# nested HTML lists in our Doxygen output.
|
||||
ALIASES += "cgalNamedParamsBegin=<dl class=\"params\"><dt>Optional named parameters</dt><dd><table class=\"params\">"
|
||||
ALIASES += "cgalNamedParamsEnd=</table></dd></dl>"
|
||||
ALIASES += "cgalParamNBegin{1}=<tr><td class=\"paramname\"><code>\1</code></td><td>"
|
||||
ALIASES += "cgalParamNEnd=</td></tr>"
|
||||
ALIASES += "cgalParamDescription{1}=<b>Description:</b> \1<br/>"
|
||||
ALIASES += "cgalParamType{1}=<b>Type:</b> \1<br/>"
|
||||
ALIASES += "cgalParamDefault{1}=<b>Default:</b> \1<br/>"
|
||||
ALIASES += "cgalParamPrecondition{1}=<b>Precondition:</b> \1<br/>"
|
||||
ALIASES += "cgalParamExtra{1}=<i>\1</i><br/>"
|
||||
|
||||
70
README.md
70
README.md
@@ -3,6 +3,7 @@
|
||||
[](https://git.eulernest.eu/conformallab/ConformalLabpp/actions)
|
||||
[](LICENSE)
|
||||
[](https://depositonce.tu-berlin.de/items/8e2988b2-d991-45b5-aad5-9fb7988f3b2f)
|
||||
[](https://tmoussa.codeberg.page/ConformalLabpp/)
|
||||
|
||||
C++17 reimplementation of [ConformalLab](https://github.com/varylab/conformallab) —
|
||||
Stefan Sechelmann's Java research library for discrete conformal geometry (TU Berlin).
|
||||
@@ -13,7 +14,7 @@ Algorithmic foundation:
|
||||
> DOI: [10.14279/depositonce-5415](https://depositonce.tu-berlin.de/items/8e2988b2-d991-45b5-aad5-9fb7988f3b2f) · CC BY-SA 4.0 ·
|
||||
> [Java original](https://github.com/varylab/conformallab) · [sechel.de](https://sechel.de/)
|
||||
|
||||
**Status:** v0.9.0 — Phases 1–9a complete, Phase 8b-Lite CGAL API surface. Newton solvers for **five** DCE models (Euclidean / Spherical / HyperIdeal / CP-Euclidean / Inversive-Distance), priority-BFS layout in ℝ²/S²/Poincaré disk, Gauss–Bonnet, tree-cotree cut graph, Möbius holonomy, period matrix (genus 1), fundamental domain, halfedge_uv texture atlas, JSON/XML serialisation, CLI app. **227 CGAL tests + 23 non-CGAL tests, 0 skipped.**
|
||||
**Status:** v0.10.0 — Phases 1–9b complete, Phase 8b-Lite CGAL API surface. Newton solvers for **five** DCE models (Euclidean / Spherical / HyperIdeal / CP-Euclidean / Inversive-Distance), priority-BFS layout in ℝ²/S²/Poincaré disk, Gauss–Bonnet, tree-cotree cut graph, Möbius holonomy, period matrix (genus 1), fundamental domain, halfedge_uv texture atlas, JSON/XML serialisation, CLI app. 277 tests passing, 0 skipped — see [`doc/api/tests.md`](doc/api/tests.md) for the per-suite breakdown.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,13 +34,63 @@ ctest --test-dir build -R "^cgal\." --output-on-failure
|
||||
|
||||
# Full build with CLI + viewer (requires Wayland/X11 dev headers)
|
||||
cmake -S code -B build -DWITH_CGAL=ON && cmake --build build -j$(nproc)
|
||||
./bin/conformallab_core -i input.off -g euclidean -o layout.off -j result.json
|
||||
|
||||
# Conformal flattening (Θ_v = 2π target). Closed meshes pin one vertex +
|
||||
# enforce Gauss-Bonnet; open meshes pin the boundary and flatten the interior.
|
||||
./bin/conformallab_core -i code/data/off/torus_8x8.off -g euclidean -v \
|
||||
-o layout.off -j result.json
|
||||
# topology: closed, free DOFs=63, genus=1
|
||||
# Euclidean: converged=yes iter=3 |grad|_inf≈5e-15
|
||||
./bin/conformallab_core -i code/data/obj/cathead.obj -g euclidean -v -o cat.off
|
||||
# topology: open (boundary pinned), free DOFs=119
|
||||
# Euclidean: converged=yes iter=4
|
||||
|
||||
# API documentation (requires doxygen: brew/apt install doxygen)
|
||||
cmake --build build --target doc
|
||||
open doc/doxygen/html/index.html
|
||||
```
|
||||
|
||||
### Compile-time workflow modes
|
||||
|
||||
The default build (PCH + Unity Build + Dense→Core trims) takes ~47 s
|
||||
clean for the full CGAL test target. Five opt-in modes cover other
|
||||
iteration scenarios:
|
||||
|
||||
```bash
|
||||
# Configure-only, no compile. ~1 s configure, 0 s build — emits
|
||||
# compile_commands.json for IDE / clangd; skips the GTest fetch.
|
||||
cmake -S code -B build -DBUILD_TESTING=OFF
|
||||
|
||||
# Header smoke check: per-public-header isolated compile. ~12 s full,
|
||||
# ~0.1 s after touching one header. "Does my refactor still parse?"
|
||||
cmake -S code -B build -DBUILD_TESTING=OFF -DCONFORMALLAB_HEADERS_CHECK=ON
|
||||
cmake --build build --target headers_check
|
||||
|
||||
# Dev iteration: PCH on, Unity off. Slower full build (~75 s) but
|
||||
# editing a single test rebuilds in ~16 s instead of ~46 s.
|
||||
cmake -S code -B build -DWITH_CGAL_TESTS=ON -DCONFORMALLAB_DEV_BUILD=ON
|
||||
|
||||
# Fast CI tests: -O0 -g for the test executables only (library /
|
||||
# install targets keep -O3). Linux + g++ typically ~40 % faster
|
||||
# build at the cost of 5–15× slower test RUN. Neutral on macOS.
|
||||
cmake -S code -B build -DWITH_CGAL_TESTS=ON -DCONFORMALLAB_FAST_TEST_BUILD=ON
|
||||
|
||||
# Low-memory build: -O0, no PCH, unity batch 1, --no-keep-memory linker.
|
||||
# Drops cc1plus peak from ~700 MB to ~150 MB per TU. Use on Raspberry Pi
|
||||
# or any runner with ≤ 4 GB RAM. Always build with -j1.
|
||||
cmake -S code -B build -DWITH_CGAL_TESTS=ON -DCONFORMALLAB_LOW_MEMORY_BUILD=ON
|
||||
cmake --build build --target conformallab_cgal_tests -j1
|
||||
|
||||
# Pristine measurement: disable both performance levers, e.g. for
|
||||
# scripts/quality/coverage.sh that needs every TU compiled fresh.
|
||||
cmake -S code -B build -DWITH_CGAL_TESTS=ON \
|
||||
-DCONFORMALLAB_USE_PCH=OFF -DCMAKE_UNITY_BUILD=OFF
|
||||
```
|
||||
|
||||
ccache is detected automatically when present on `PATH`; disable with
|
||||
`-DCONFORMALLAB_USE_CCACHE=OFF`. Full mode matrix + measurements in
|
||||
[`doc/architecture/compile-time.md`](doc/architecture/compile-time.md).
|
||||
|
||||
---
|
||||
|
||||
## Minimal usage
|
||||
@@ -58,14 +109,12 @@ ConformalMesh mesh = load_mesh("input.off");
|
||||
EuclideanMaps maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// Assign DOFs — pin first vertex (gauge fix)
|
||||
auto vit = mesh.vertices().begin();
|
||||
maps.v_idx[*vit++] = -1;
|
||||
int idx = 0;
|
||||
for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++;
|
||||
// Assign DOFs — pin first vertex as gauge fix, index the rest 0..n-1
|
||||
auto gauge = *mesh.vertices().begin();
|
||||
int n = assign_euclidean_vertex_dof_indices(mesh, maps, gauge);
|
||||
|
||||
// Natural equilibrium target: x* = 0 by construction
|
||||
std::vector<double> x0(idx, 0.0);
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||||
auto G0 = euclidean_gradient(mesh, x0, maps);
|
||||
for (auto v : mesh.vertices())
|
||||
if (maps.v_idx[v] >= 0) maps.theta_v[v] -= G0[maps.v_idx[v]];
|
||||
@@ -81,10 +130,11 @@ Layout2D layout = euclidean_layout(mesh, res.x, maps);
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **API reference (Doxygen HTML)** — every public class, function and named-parameter helper | https://tmoussa.codeberg.page/ConformalLabpp/ |
|
||||
| **Getting started** — build modes, single-test invocation, CLI, Docker | [doc/getting-started.md](doc/getting-started.md) |
|
||||
| **Pipeline API** — all three geometries, holonomy, serialisation | [doc/api/pipeline.md](doc/api/pipeline.md) |
|
||||
| **Public headers** — all 24 headers with descriptions | [doc/api/headers.md](doc/api/headers.md) |
|
||||
| **Test suites** — 39 suites, 227+23 tests, individual counts | [doc/api/tests.md](doc/api/tests.md) |
|
||||
| **Public headers** — all public headers with descriptions | [doc/api/headers.md](doc/api/headers.md) |
|
||||
| **Test suites** — per-suite breakdown and counts (single source of truth) | [doc/api/tests.md](doc/api/tests.md) |
|
||||
| **Extending** — new functionals, geometry modes, porting from Java | [doc/api/extending.md](doc/api/extending.md) |
|
||||
| **Processing unit contracts** — preconditions / provides table | [doc/api/contracts.md](doc/api/contracts.md) |
|
||||
| **CGAL package design** — Phase 8 target, YAML pipeline | [doc/api/cgal-package.md](doc/api/cgal-package.md) |
|
||||
|
||||
1
code/.gitignore
vendored
1
code/.gitignore
vendored
@@ -13,6 +13,7 @@ deps/*
|
||||
!deps/tarballs
|
||||
!deps/single_includes/
|
||||
!deps/CMakeLists.txt
|
||||
!deps/THIRD-PARTY-LICENSES.md
|
||||
|
||||
# macOS iCloud Drive duplicates ("file 2.cpp", "file 2.hpp", …)
|
||||
* 2.*
|
||||
|
||||
@@ -38,8 +38,9 @@ if(WITH_CGAL AND NOT WITH_VIEWER)
|
||||
set(WITH_VIEWER ON CACHE BOOL "" FORCE)
|
||||
endif()
|
||||
|
||||
# Propagate Boost requirement for both CGAL modes.
|
||||
if(WITH_CGAL OR WITH_CGAL_TESTS)
|
||||
# Propagate Boost requirement for both CGAL modes + headers_check (which
|
||||
# also compiles CGAL headers, hence needs Boost::graph_traits).
|
||||
if(WITH_CGAL OR WITH_CGAL_TESTS OR CONFORMALLAB_HEADERS_CHECK)
|
||||
find_package(Boost REQUIRED)
|
||||
endif()
|
||||
|
||||
@@ -54,9 +55,80 @@ if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
# ── ccache integration (lever D) ───────────────────────────────────────────────
|
||||
#
|
||||
# Detect `ccache` on the host and prepend it to the compile + link launchers.
|
||||
# Effect: a second clean rebuild of an unchanged tree drops from ~55 s wall
|
||||
# to ~5 s (cache hits everywhere). Costs nothing when ccache is absent.
|
||||
# Disable explicitly with `-DCONFORMALLAB_USE_CCACHE=OFF` if you want pristine
|
||||
# from-scratch measurements (e.g. when re-running scripts/quality/coverage.sh).
|
||||
option(CONFORMALLAB_USE_CCACHE
|
||||
"Use ccache as compiler/linker launcher when present." ON)
|
||||
if(CONFORMALLAB_USE_CCACHE)
|
||||
find_program(CCACHE_PROGRAM ccache)
|
||||
if(CCACHE_PROGRAM)
|
||||
set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
|
||||
set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
|
||||
message(STATUS "ccache: enabled (${CCACHE_PROGRAM})")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# ── Dev-iteration build mode (lever C, opt-in) ─────────────────────────────────
|
||||
#
|
||||
# Turns off Unity Build target-wide. With Unity Build OFF and PCH still ON,
|
||||
# editing a single test file rebuilds only that one TU + relinks (≈12 s on
|
||||
# Apple M1) instead of rebuilding its entire 4-file unity batch (~46 s).
|
||||
#
|
||||
# Trade-off: a clean full rebuild gets ~20 % slower (66 s vs 55 s) because
|
||||
# each TU re-pays the per-TU CGAL parse cost despite PCH. Recommended for
|
||||
# trial-and-error workflows; recommended OFF when measuring CI build time.
|
||||
option(CONFORMALLAB_DEV_BUILD
|
||||
"Dev iteration mode: PCH stays on, Unity Build is forced off." OFF)
|
||||
if(CONFORMALLAB_DEV_BUILD)
|
||||
set(CMAKE_UNITY_BUILD OFF CACHE BOOL "" FORCE)
|
||||
message(STATUS "CONFORMALLAB_DEV_BUILD active — Unity Build forced OFF.")
|
||||
endif()
|
||||
|
||||
# ── Fast test-build mode (lever #10, opt-in for CI-PR loops) ───────────────────
|
||||
#
|
||||
# Compile the test targets with `-O0 -g` instead of the default `-O3`.
|
||||
# The Eigen + CGAL templates dominate the BACKEND (CodeGen + Opt) phase
|
||||
# of every TU at ~55 % of wall time (~9.3 s of a 17 s TU per
|
||||
# `clang -ftime-trace`). Dropping to `-O0` collapses that phase to
|
||||
# <2 s and yields ~40 % faster full rebuilds. The downside is that
|
||||
# the resulting binaries are 2-5× slower to RUN — fine for "does it
|
||||
# compile + do all 259 unit tests pass?" CI loops, NOT fine for any
|
||||
# scalability or benchmark workload.
|
||||
#
|
||||
# Library/installable code is never affected; only the test
|
||||
# executables compiled into build-*/ pick this flag up.
|
||||
option(CONFORMALLAB_FAST_TEST_BUILD
|
||||
"Compile test executables with -O0 -g for faster CI / dev loops." OFF)
|
||||
if(CONFORMALLAB_FAST_TEST_BUILD)
|
||||
message(STATUS "CONFORMALLAB_FAST_TEST_BUILD active — tests compile at -O0 -g.")
|
||||
endif()
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
|
||||
# ─── Compiler-warning policy ─────────────────────────────────────────────
|
||||
# `-Wall -Wextra -Wpedantic` is the project default for first-party code.
|
||||
# Vendored deps under code/deps/ get a separate, looser policy (handled
|
||||
# via per-target SYSTEM include marking when they are pulled in).
|
||||
#
|
||||
# `CONFORMALLAB_WARNINGS_AS_ERRORS=ON` flips on `-Werror` — used in CI's
|
||||
# promotion-track and by `scripts/quality/sanitizers.sh` to make sure no
|
||||
# new warning class slips in unannounced. Off by default so regular
|
||||
# builds on slightly older toolchains aren't broken by a new GCC's
|
||||
# added warning.
|
||||
option(CONFORMALLAB_WARNINGS_AS_ERRORS
|
||||
"Treat compiler warnings as errors (-Werror)." OFF)
|
||||
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
|
||||
if(CONFORMALLAB_WARNINGS_AS_ERRORS)
|
||||
add_compile_options(-Werror)
|
||||
message(STATUS "Warnings-as-errors mode active (-Werror).")
|
||||
endif()
|
||||
|
||||
# AddressSanitizer only in Debug (gtest_discover_tests runs the binary at
|
||||
# configure time and hangs with ASan enabled).
|
||||
if(CMAKE_BUILD_TYPE STREQUAL "Debug" AND NOT BUILD_TESTING)
|
||||
@@ -65,20 +137,29 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# ── GTest (always – tests are always built) ────────────────────────────────────
|
||||
# ── GTest (only when tests are enabled) ────────────────────────────────────────
|
||||
#
|
||||
# CMake's standard `BUILD_TESTING` option (defaults ON via include(CTest))
|
||||
# gates the entire test subtree below. Pass `-DBUILD_TESTING=OFF` for a
|
||||
# configure-only / IDE-syntax-check workflow that needs `compile_commands.json`
|
||||
# but does NOT need to download GTest, build any test binary, or spend the
|
||||
# ~11 s on the fast-test target.
|
||||
include(FetchContent)
|
||||
include(CTest)
|
||||
enable_testing()
|
||||
include(CTest) # also defines BUILD_TESTING (default ON)
|
||||
|
||||
FetchContent_Declare(
|
||||
googletest
|
||||
GIT_REPOSITORY https://github.com/google/googletest.git
|
||||
GIT_TAG v1.14.0
|
||||
)
|
||||
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||
set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_GMOCK OFF CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
if(BUILD_TESTING)
|
||||
enable_testing()
|
||||
|
||||
FetchContent_Declare(
|
||||
googletest
|
||||
GIT_REPOSITORY https://github.com/google/googletest.git
|
||||
GIT_TAG v1.14.0
|
||||
)
|
||||
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||
set(INSTALL_GTEST OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_GMOCK OFF CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
endif()
|
||||
|
||||
# ── External deps (lazy tarball extraction) ────────────────────────────────────
|
||||
add_subdirectory(deps)
|
||||
@@ -124,8 +205,67 @@ if(WITH_CGAL)
|
||||
add_subdirectory(examples)
|
||||
endif()
|
||||
|
||||
# ── Tests (always) ────────────────────────────────────────────────────────────
|
||||
add_subdirectory(tests)
|
||||
# ── Tests (gated on BUILD_TESTING) ────────────────────────────────────────────
|
||||
#
|
||||
# Default ON (CTest convention). Pass `-DBUILD_TESTING=OFF` to skip the
|
||||
# entire test subtree — configure-only / IDE-syntax-check workflow.
|
||||
if(BUILD_TESTING)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
# ── headers_check target (lever A, opt-in) ────────────────────────────────────
|
||||
#
|
||||
# Lightweight per-header smoke-compile target. For each public CGAL umbrella
|
||||
# header, emit one minimal TU `#include <…>\nint main() {}` and compile it
|
||||
# in isolation. Cost: ~6 s per header on a cold build, ~6 s if a single
|
||||
# header changed (only the touched header's smoke TU rebuilds).
|
||||
#
|
||||
# Use case: "did my Phase-N refactor break the public API surface?" without
|
||||
# waiting 55 s for the full CGAL test build. Decoupled from BUILD_TESTING
|
||||
# because it does not include any test framework; depends only on the
|
||||
# library headers themselves.
|
||||
#
|
||||
# Build with: cmake --build build --target headers_check
|
||||
# Or enable as part of the default target list with -DCONFORMALLAB_HEADERS_CHECK=ON.
|
||||
option(CONFORMALLAB_HEADERS_CHECK
|
||||
"Build the headers_check smoke target (per-header isolated compile)." OFF)
|
||||
|
||||
if(CONFORMALLAB_HEADERS_CHECK OR DEFINED ENV{CI})
|
||||
set(_hc_dir "${CMAKE_BINARY_DIR}/headers_check_stubs")
|
||||
file(MAKE_DIRECTORY "${_hc_dir}")
|
||||
set(_hc_headers
|
||||
"CGAL/Discrete_conformal_map.h"
|
||||
"CGAL/Discrete_circle_packing.h"
|
||||
"CGAL/Discrete_inversive_distance.h"
|
||||
"CGAL/Conformal_layout.h"
|
||||
"CGAL/Conformal_map_traits.h"
|
||||
"CGAL/Conformal_map/internal/parameters.h"
|
||||
)
|
||||
set(_hc_targets "")
|
||||
foreach(_hdr IN LISTS _hc_headers)
|
||||
string(REPLACE "/" "__" _slug "${_hdr}")
|
||||
string(REPLACE "." "_" _slug "${_slug}")
|
||||
set(_stub "${_hc_dir}/${_slug}.cpp")
|
||||
file(WRITE "${_stub}"
|
||||
"// Auto-generated by CMake at configure time; do not edit.
|
||||
// Smoke-compile sentinel for ${_hdr}.
|
||||
#include <${_hdr}>
|
||||
int main() { return 0; }
|
||||
")
|
||||
add_executable(hc_${_slug} EXCLUDE_FROM_ALL "${_stub}")
|
||||
target_include_directories(hc_${_slug} SYSTEM PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/deps/eigen-3.4.0
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/deps/CGAL-6.1.1/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/deps/single_includes
|
||||
${Boost_INCLUDE_DIRS})
|
||||
target_include_directories(hc_${_slug} PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include)
|
||||
target_compile_definitions(hc_${_slug} PRIVATE
|
||||
CGAL_DISABLE_GMP CGAL_DISABLE_MPFR)
|
||||
list(APPEND _hc_targets hc_${_slug})
|
||||
endforeach()
|
||||
add_custom_target(headers_check DEPENDS ${_hc_targets})
|
||||
endif()
|
||||
|
||||
# ── Install target (header-only library) ──────────────────────────────────────
|
||||
# Installs all public headers to <prefix>/include/conformallab/
|
||||
|
||||
50
code/data/off/torus_skewed_4x4.off
Normal file
50
code/data/off/torus_skewed_4x4.off
Normal file
@@ -0,0 +1,50 @@
|
||||
OFF
|
||||
16 32 0
|
||||
0.0 0.0 0.0
|
||||
1.0 0.0 0.0
|
||||
2.0 0.0 0.0
|
||||
3.0 0.0 0.0
|
||||
-0.25 1.0 0.0
|
||||
0.75 1.0 0.0
|
||||
1.75 1.0 0.0
|
||||
2.75 1.0 0.0
|
||||
-0.5 2.0 0.0
|
||||
0.5 2.0 0.0
|
||||
1.5 2.0 0.0
|
||||
2.5 2.0 0.0
|
||||
-0.75 3.0 0.0
|
||||
0.25 3.0 0.0
|
||||
1.25 3.0 0.0
|
||||
2.25 3.0 0.0
|
||||
3 0 1 5
|
||||
3 0 5 4
|
||||
3 1 2 6
|
||||
3 1 6 5
|
||||
3 2 3 7
|
||||
3 2 7 6
|
||||
3 3 0 4
|
||||
3 3 4 7
|
||||
3 4 5 9
|
||||
3 4 9 8
|
||||
3 5 6 10
|
||||
3 5 10 9
|
||||
3 6 7 11
|
||||
3 6 11 10
|
||||
3 7 4 8
|
||||
3 7 8 11
|
||||
3 8 9 13
|
||||
3 8 13 12
|
||||
3 9 10 14
|
||||
3 9 14 13
|
||||
3 10 11 15
|
||||
3 10 15 14
|
||||
3 11 8 12
|
||||
3 11 12 15
|
||||
3 12 13 1
|
||||
3 12 1 0
|
||||
3 13 14 2
|
||||
3 13 2 1
|
||||
3 14 15 3
|
||||
3 14 3 2
|
||||
3 15 12 0
|
||||
3 15 0 3
|
||||
98
code/deps/THIRD-PARTY-LICENSES.md
Normal file
98
code/deps/THIRD-PARTY-LICENSES.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Third-party licenses
|
||||
|
||||
This directory contains source code from external projects that
|
||||
conformallab++ vendors at fixed versions for build reproducibility.
|
||||
Each project is governed by its own license; this file enumerates them
|
||||
so downstream packagers, distributors, and reviewers can audit
|
||||
compatibility without crawling each upstream tarball.
|
||||
|
||||
> **Why vendored at all?** conformallab++ is header-only and ships
|
||||
> nothing it does not author except the optional CLI binary
|
||||
> (`-DWITH_CGAL=ON`). Vendoring guarantees that the CGAL / Eigen /
|
||||
> Boost API surface every contributor sees is identical, removing
|
||||
> "works on my machine because I have CGAL 6.0 not 5.6" failure modes
|
||||
> during early review. Downstream packagers replacing the vendored
|
||||
> trees with system installs is supported and is the recommended path
|
||||
> for distribution-level packaging (see `doc/architecture/dependencies.md`).
|
||||
|
||||
## conformallab++ itself
|
||||
|
||||
| Item | License | Notes |
|
||||
|---|---|---|
|
||||
| `code/include/**`, `code/src/**`, `code/tests/**`, `scripts/**`, `doc/**` | **MIT** (see `LICENSE` at repo root) | Every C++ source file carries `SPDX-License-Identifier: MIT`; CI gate `scripts/quality/license-headers.sh` enforces this. |
|
||||
|
||||
## Vendored dependencies
|
||||
|
||||
The table below lists each tree under `code/deps/`, its upstream
|
||||
license, the SPDX identifier, and any compatibility note relevant to
|
||||
shipping conformallab++ as MIT.
|
||||
|
||||
| Directory | Upstream project | Version | License (SPDX) | Compatibility with MIT distribution | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `CGAL-6.1.1/` | [CGAL](https://www.cgal.org) | 6.1.1 | **LGPL-3.0-or-later** (most headers) + **GPL-3.0-or-later** (a small subset — see CGAL's per-header `\cgal_license{...}` macro) | Header-only consumption is compatible; we ONLY include LGPL'd parts (`Surface_mesh`, `Polygon_mesh_processing`, BGL adapters, kernels). | conformallab++ does not include any of the GPL-only CGAL packages (e.g. `Triangulation_3` parts, certain mesh-3 internals). The `\cgal_license` macro is checked at compile time and would fail the build if a GPL-only header were transitively pulled in. Commercial licenses are available from GeometryFactory for users who can't accept (L)GPL. |
|
||||
| `eigen-3.4.0/` | [Eigen](https://eigen.tuxfamily.org) | 3.4.0 | **MPL-2.0** for almost everything, **LGPL-2.1-or-later** for a few legacy files (e.g. `Eigen/src/Core/util/NonMPL2.h` gates these) | MPL-2.0 is permissive enough for MIT; the LGPL files are NOT pulled in by `<Eigen/Dense>` / `<Eigen/Sparse>` (the only Eigen headers conformallab++ includes). | We define no preprocessor flag that activates the non-MPL2 code paths. The default Eigen build is pure MPL-2.0. |
|
||||
| `libigl-2.6.0/` | [libigl](https://libigl.github.io) | 2.6.0 | **MPL-2.0** | Compatible with MIT distribution. | Only the viewer subsystem under `code/src/viewer/` uses libigl, and only when `-DWITH_VIEWER=ON`. The library headers and the CGAL wrapper headers do not depend on libigl. |
|
||||
| `libigl-glad/` | [Glad](https://glad.dav1d.de/) (the generated OpenGL loader libigl ships) | bundled with libigl 2.6.0 | **MIT** (the generator's output is licensed permissively; the loader code itself is in the public domain via the original Khronos headers) | Compatible. | Built only with `-DWITH_VIEWER=ON`. |
|
||||
| `glfw-3.4/` | [GLFW](https://www.glfw.org) | 3.4 | **zlib/libpng** | Permissive; compatible with MIT. | Built only with `-DWITH_VIEWER=ON`. See `code/deps/glfw-3.4/LICENSE.md` for the verbatim text. |
|
||||
| `single_includes/json.hpp` | [nlohmann/json](https://github.com/nlohmann/json) | 3.x (header-only single-include) | **MIT** | Identical to ours. | The file itself carries the SPDX header `MIT`; see `code/deps/single_includes/json.hpp` first lines. |
|
||||
| `tarballs/` | (build-artefact cache) | — | n/a | n/a | This directory just caches the downloaded source tarballs to avoid re-downloading on every clean build. The tarballs are bit-for-bit identical to the upstream releases. |
|
||||
|
||||
## Auto-fetched (not vendored)
|
||||
|
||||
These are pulled by CMake `FetchContent` at configure time. They are
|
||||
**not** redistributed by conformallab++; the user's CMake fetches them
|
||||
during build. We list them anyway for transparency.
|
||||
|
||||
| Item | Upstream | Version | License | Fetched by |
|
||||
|---|---|---|---|---|
|
||||
| **GoogleTest** | https://github.com/google/googletest | v1.14.0 | **BSD-3-Clause** | `code/CMakeLists.txt` (test target only) |
|
||||
|
||||
## System dependencies (required at build time, not redistributed)
|
||||
|
||||
| Item | Where it lives | License | Purpose |
|
||||
|---|---|---|---|
|
||||
| **Boost** (header-only subset) | system package (`apt install libboost-dev`, etc.) | **Boost Software License 1.0** | Required by CGAL's BGL adapters (only when `WITH_CGAL=ON` or `WITH_CGAL_TESTS=ON`). |
|
||||
| **C++17 standard library** | the compiler's libstdc++ / libc++ / msvc | LGPL-3.0 with exception / Apache 2.0 with LLVM exception / MSVC redist | normal compiler runtime. |
|
||||
|
||||
## Summary for downstream packagers
|
||||
|
||||
If you are packaging conformallab++ for a distribution, the practical
|
||||
license matrix is:
|
||||
|
||||
```
|
||||
binary you ship (CLI app, -DWITH_CGAL=ON)
|
||||
├── conformallab++ (MIT)
|
||||
├── CGAL (LGPL-3.0-or-later — comply with §4 LGPL: source
|
||||
│ of CGAL must be obtainable or shipped)
|
||||
├── Eigen (MPL-2.0 — comply with §3 MPL: any modifications
|
||||
│ must be released under MPL-2.0)
|
||||
├── libigl (MPL-2.0 — same as Eigen)
|
||||
├── GLFW (zlib/libpng — acknowledgement in product docs)
|
||||
├── Glad (MIT — preserve copyright notice)
|
||||
└── Boost (headers) (BSL-1.0 — preserve copyright notice)
|
||||
|
||||
header-only consumer (just #include our headers)
|
||||
├── conformallab++ (MIT)
|
||||
├── Eigen (MPL-2.0 transitively)
|
||||
├── CGAL (LGPL-3.0-or-later transitively)
|
||||
└── Boost (headers) (BSL-1.0 transitively, only if you include any
|
||||
CGAL/* header)
|
||||
```
|
||||
|
||||
The header-only consumer typically doesn't trigger LGPL §4 obligations
|
||||
because LGPL §3 explicitly permits use of LGPL'd material as
|
||||
"templates, inline functions, macros" by an "Application" without
|
||||
imposing copyleft on the Application — which is exactly the
|
||||
header-only consumption pattern.
|
||||
|
||||
If you have specific compliance questions, the upstream license texts
|
||||
are authoritative; this file is a navigational aid.
|
||||
|
||||
## How this file is maintained
|
||||
|
||||
* Updated whenever a `code/deps/` tree is added, removed, or version-bumped.
|
||||
* Cross-referenced by `doc/architecture/dependencies.md`.
|
||||
* There is no CI gate that auto-verifies the SPDX entries against
|
||||
upstream — that would require either an SBOM tool (e.g. `syft`,
|
||||
`tern`) or a manual audit. The current policy is "review on
|
||||
dep-tree change", logged in the commit message of the bump.
|
||||
@@ -42,6 +42,22 @@ target_include_directories(example_layout SYSTEM PRIVATE ${EXAMPLE_INCLUDES})
|
||||
target_include_directories(example_layout PRIVATE ${EXAMPLE_PRIVATE_INCLUDES})
|
||||
target_compile_definitions(example_layout PRIVATE ${EXAMPLE_DEFS})
|
||||
|
||||
# ── example_flatten (primary use case: real conformal flattening) ─────────────
|
||||
# Demonstrates Θ_v = 2π → non-trivial conformal map; contrast with
|
||||
# example_euclidean which uses "natural theta" (u_v ≈ 0, testing trick).
|
||||
add_executable(example_flatten example_flatten.cpp)
|
||||
target_include_directories(example_flatten SYSTEM PRIVATE ${EXAMPLE_INCLUDES})
|
||||
target_include_directories(example_flatten PRIVATE ${EXAMPLE_PRIVATE_INCLUDES})
|
||||
target_compile_definitions(example_flatten PRIVATE ${EXAMPLE_DEFS})
|
||||
|
||||
# ── example_cgal_api (CGAL public API: one-call interface) ────────────────────
|
||||
# Demonstrates CGAL::discrete_conformal_map_euclidean from
|
||||
# <CGAL/Discrete_conformal_map.h>; contrast with example_flatten (internal API).
|
||||
add_executable(example_cgal_api example_cgal_api.cpp)
|
||||
target_include_directories(example_cgal_api SYSTEM PRIVATE ${EXAMPLE_INCLUDES})
|
||||
target_include_directories(example_cgal_api PRIVATE ${EXAMPLE_PRIVATE_INCLUDES})
|
||||
target_compile_definitions(example_cgal_api PRIVATE ${EXAMPLE_DEFS})
|
||||
|
||||
# ── example_viewer (requires WITH_VIEWER) ─────────────────────────────────────
|
||||
if(WITH_VIEWER)
|
||||
add_executable(example_viewer example_viewer.cpp)
|
||||
|
||||
171
code/examples/example_cgal_api.cpp
Normal file
171
code/examples/example_cgal_api.cpp
Normal file
@@ -0,0 +1,171 @@
|
||||
// example_cgal_api.cpp
|
||||
//
|
||||
// conformallab++ — CGAL public API example
|
||||
//
|
||||
// This example demonstrates the HIGH-LEVEL CGAL API defined in
|
||||
// <CGAL/Discrete_conformal_map.h>. It is the recommended entry point for
|
||||
// users who want a simple one-call interface without managing Maps bundles,
|
||||
// DOF assignment, or Newton solver details.
|
||||
//
|
||||
// Contrast with example_euclidean.cpp / example_flatten.cpp which use the
|
||||
// INTERNAL API (setup_euclidean_maps + newton_euclidean + euclidean_layout).
|
||||
//
|
||||
// ┌─────────────────────────────────────────────────────────────────────┐
|
||||
// │ When to use the CGAL API vs the internal API │
|
||||
// │ │
|
||||
// │ CGAL API (this file): │
|
||||
// │ • One call, sensible defaults │
|
||||
// │ • Named parameters for tuning (tolerance, cone angles, …) │
|
||||
// │ • Result type: Conformal_map_result<FT> (u_per_vertex indexed │
|
||||
// │ by raw vertex index) │
|
||||
// │ • Layout via CGAL::euclidean_layout (Conformal_layout.h) │
|
||||
// │ │
|
||||
// │ Internal API (example_flatten.cpp, example_layout.cpp): │
|
||||
// │ • Full control over every pipeline step │
|
||||
// │ • Access to holonomy, period matrix, cut graph │
|
||||
// │ • Result type: NewtonResult (x indexed by DOF index) │
|
||||
// │ • Required for closed surfaces (genus ≥ 1) │
|
||||
// └─────────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Build (requires -DWITH_CGAL=ON):
|
||||
// cmake -S code -B build -DWITH_CGAL=ON
|
||||
// cmake --build build --target example_cgal_api
|
||||
//
|
||||
// Run:
|
||||
// ./build/examples/example_cgal_api # built-in mesh
|
||||
// ./build/examples/example_cgal_api code/data/obj/cathead.obj flat.off
|
||||
|
||||
#include <CGAL/Simple_cartesian.h>
|
||||
#include <CGAL/Surface_mesh.h>
|
||||
#include <CGAL/Discrete_conformal_map.h> // CGAL public API
|
||||
#include <CGAL/Conformal_layout.h> // CGAL layout wrapper
|
||||
|
||||
// For loading meshes and saving results we still use the internal helpers.
|
||||
#include "mesh_io.hpp"
|
||||
#include "mesh_builder.hpp"
|
||||
#include "layout.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <iomanip>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
using Kernel = CGAL::Simple_cartesian<double>;
|
||||
using Mesh = CGAL::Surface_mesh<Kernel::Point_3>;
|
||||
|
||||
// ── Step 1: obtain mesh ───────────────────────────────────────────────────
|
||||
Mesh mesh;
|
||||
std::string input_path = (argc > 1) ? argv[1] : "";
|
||||
std::string output_path = (argc > 2) ? argv[2] : "/tmp/conformallab_cgal_api_out.off";
|
||||
|
||||
if (input_path.empty()) {
|
||||
std::cout << "[example_cgal_api] No input given. Using make_quad_strip().\n"
|
||||
<< " For a non-trivial result, supply a 3-D mesh:\n"
|
||||
<< " ./example_cgal_api code/data/obj/cathead.obj\n\n";
|
||||
mesh = conformallab::make_quad_strip();
|
||||
} else {
|
||||
std::cout << "[example_cgal_api] Loading mesh: " << input_path << "\n";
|
||||
try { mesh = conformallab::load_mesh(input_path); }
|
||||
catch (const std::exception& e) {
|
||||
std::cerr << "Error loading mesh: " << e.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "[example_cgal_api] Mesh: "
|
||||
<< mesh.number_of_vertices() << " vertices, "
|
||||
<< mesh.number_of_faces() << " faces.\n";
|
||||
|
||||
// ── Step 2: CGAL API call ─────────────────────────────────────────────────
|
||||
//
|
||||
// Default invocation — one function call, no explicit target angles:
|
||||
// • No vertex_curvature_map supplied → "natural theta" default:
|
||||
// Θ_v is set to the ACTUAL angle sum at x = 0. This makes x* = 0
|
||||
// the equilibrium, so Newton converges in 0 iterations with u_v = 0.
|
||||
//
|
||||
// ⚠ Natural theta is a TESTING CONVENTION, not a conformal flattening.
|
||||
// The output u_v = 0 means "no deformation" — the map is identity.
|
||||
// For real conformal flattening use:
|
||||
// • example_flatten.cpp (internal API, recommended for open meshes)
|
||||
// • Supply vertex_curvature_map(theta_map) with Θ_v = 2π for a
|
||||
// closed mesh (must satisfy Gauss-Bonnet; see below)
|
||||
//
|
||||
// The function sets up maps, computes λ°, assigns DOFs, runs Newton,
|
||||
// and returns the converged result.
|
||||
auto result = CGAL::discrete_conformal_map_euclidean(mesh);
|
||||
|
||||
std::cout << "[example_cgal_api] CGAL API result (natural theta — identity map):\n"
|
||||
<< " converged = " << std::boolalpha << result.converged << "\n"
|
||||
<< " iterations = " << result.iterations
|
||||
<< " (0 = natural theta, trivially at equilibrium)\n"
|
||||
<< " |grad|_inf = " << std::scientific << std::setprecision(2)
|
||||
<< result.gradient_norm << "\n";
|
||||
|
||||
// ── Step 3: inspect the result ────────────────────────────────────────────
|
||||
//
|
||||
// result.u_per_vertex is indexed by raw vertex index (v.idx()), NOT by
|
||||
// DOF index. Length = num_vertices(mesh). Pinned vertices have u = 0.
|
||||
//
|
||||
// This differs from the internal API (NewtonResult.x) which is indexed
|
||||
// by DOF index (v_idx[v]). The CGAL API handles the mapping internally.
|
||||
double u_min = 0.0, u_max = 0.0;
|
||||
for (auto v : mesh.vertices()) {
|
||||
double u = result.u_per_vertex[static_cast<std::size_t>(v.idx())];
|
||||
u_min = std::min(u_min, u);
|
||||
u_max = std::max(u_max, u);
|
||||
}
|
||||
std::cout << " u_v range = [" << std::fixed << std::setprecision(4)
|
||||
<< u_min << ", " << u_max << "]\n\n";
|
||||
|
||||
// Print a few per-vertex values
|
||||
std::cout << "[example_cgal_api] First 6 per-vertex scale factors u_v:\n";
|
||||
int shown = 0;
|
||||
for (auto v : mesh.vertices()) {
|
||||
if (shown++ >= 6) break;
|
||||
double u = result.u_per_vertex[static_cast<std::size_t>(v.idx())];
|
||||
std::cout << " v" << v.idx() << " u = " << std::fixed
|
||||
<< std::setprecision(6) << u << "\n";
|
||||
}
|
||||
|
||||
// ── Step 4: tuning via named parameters ───────────────────────────────────
|
||||
//
|
||||
// The CGAL API accepts named parameters for fine-grained control.
|
||||
// Example: tighter tolerance and more iterations.
|
||||
//
|
||||
// auto result2 = CGAL::discrete_conformal_map_euclidean(
|
||||
// mesh,
|
||||
// CGAL::parameters::gradient_tolerance(1e-12)
|
||||
// .max_iterations(500));
|
||||
//
|
||||
// Example: supply explicit cone angles (requires Gauss-Bonnet to hold):
|
||||
//
|
||||
// auto angle_map = mesh.add_property_map<Mesh::Vertex_index, double>(
|
||||
// "my:angles", 2.0 * M_PI).first;
|
||||
// // … set angle_map[v] for cone vertices …
|
||||
// auto result3 = CGAL::discrete_conformal_map_euclidean(
|
||||
// mesh,
|
||||
// CGAL::parameters::vertex_curvature_map(angle_map));
|
||||
//
|
||||
// See <CGAL/Discrete_conformal_map.h> for all named parameters.
|
||||
|
||||
// ── Step 5: compute layout via the CGAL layout wrapper ────────────────────
|
||||
//
|
||||
// The CGAL API does not return a layout directly; call CGAL::euclidean_layout
|
||||
// (Conformal_layout.h) with the converged DOF vector and the internal maps.
|
||||
// Note: to access the DOF vector and maps from a CGAL-API call, use the
|
||||
// result.x field (if exposed) or call the internal API directly.
|
||||
//
|
||||
// For Phase 8b-Lite, the cleanest way to get a layout after the CGAL call
|
||||
// is to use the internal API (example_flatten.cpp) — the CGAL result carries
|
||||
// u_per_vertex for inspection but the full pipeline (layout, holonomy, period
|
||||
// matrix) still requires the internal Maps bundle.
|
||||
|
||||
if (result.converged)
|
||||
std::cout << "\n[example_cgal_api] ✓ Conformal map computed successfully.\n"
|
||||
<< " For UV layout output, see example_flatten.cpp (internal API)\n"
|
||||
<< " which provides direct access to euclidean_layout().\n";
|
||||
|
||||
return result.converged ? 0 : 1;
|
||||
}
|
||||
@@ -59,20 +59,25 @@ int main(int argc, char* argv[])
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// ── Step 3: pin the first vertex (gauge fix) ──────────────────────────
|
||||
auto vit = mesh.vertices().begin();
|
||||
Vertex_index v_pinned = *vit++;
|
||||
maps.v_idx[v_pinned] = -1; // pinned: u[v_pinned] = 0 (fixed)
|
||||
int idx = 0;
|
||||
for (; vit != mesh.vertices().end(); ++vit)
|
||||
maps.v_idx[*vit] = idx++;
|
||||
const int n = idx;
|
||||
// The gauge-vertex overload pins the chosen vertex (v_idx = -1) and
|
||||
// assigns sequential indices 0..n-1 to the rest in a single call.
|
||||
Vertex_index v_pinned = *mesh.vertices().begin();
|
||||
const int n = assign_euclidean_vertex_dof_indices(mesh, maps, v_pinned);
|
||||
|
||||
std::cout << "[example_euclidean] DOFs: " << n << " (1 vertex pinned).\n";
|
||||
|
||||
// ── Step 4: natural equilibrium — set theta_v = actual angle sum at x=0 ─
|
||||
// After this step x* = 0 is the equilibrium (no deformation).
|
||||
// In a real application you would set theta_v = desired angle (e.g. 2π
|
||||
// for flat disks, or the cone angles for a cone metric).
|
||||
//
|
||||
// ⚠ TESTING CONVENTION — NOT A REAL CONFORMAL MAP
|
||||
//
|
||||
// "Natural theta" sets Θ_v = actual angle sum at x = 0, so x* = 0 is the
|
||||
// equilibrium by construction. The solver converges in 0–1 iterations and
|
||||
// u_v ≈ 0 everywhere. This is useful for testing the solver pipeline but
|
||||
// produces NO conformal deformation.
|
||||
//
|
||||
// For a REAL conformal flattening (the primary use case):
|
||||
// → see example_flatten.cpp
|
||||
// which sets Θ_v = 2π (flat interior target) and produces non-trivial u_v.
|
||||
{
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||||
auto G0 = euclidean_gradient(mesh, x0, maps);
|
||||
|
||||
206
code/examples/example_flatten.cpp
Normal file
206
code/examples/example_flatten.cpp
Normal file
@@ -0,0 +1,206 @@
|
||||
// example_flatten.cpp
|
||||
//
|
||||
// conformallab++ — Real conformal flattening example
|
||||
//
|
||||
// This example demonstrates the PRIMARY USE CASE of the library:
|
||||
// conformally flatten a 3-D surface mesh to the plane with minimal
|
||||
// angle distortion. Every interior vertex is assigned a target cone
|
||||
// angle of 2π (a regular flat vertex); the solver finds the unique
|
||||
// conformal factor u_v that realises this target.
|
||||
//
|
||||
// Contrast with the other examples (example_euclidean, example_layout)
|
||||
// which use the "natural theta" testing trick that produces x* = 0 —
|
||||
// a valid solver test but NOT a conformal flattening.
|
||||
//
|
||||
// ┌─────────────────────────────────────────────────────────────────────┐
|
||||
// │ Pipeline for conformal flattening of an OPEN mesh (disk topology) │
|
||||
// │ │
|
||||
// │ 1. Load mesh │
|
||||
// │ 2. Setup maps + compute λ° from geometry │
|
||||
// │ 3. Pin all boundary vertices (they define the boundary of the UV) │
|
||||
// │ Set Θ_v = 2π for all interior vertices (flat target) │
|
||||
// │ ← NO Gauss–Bonnet check needed for open meshes │
|
||||
// │ 4. Solve Newton │
|
||||
// │ 5. Compute planar layout — the conformal UV parameterisation │
|
||||
// │ 6. Save UV-mapped OFF + report distortion │
|
||||
// └─────────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Build (requires -DWITH_CGAL=ON):
|
||||
// cmake -S code -B build -DWITH_CGAL=ON
|
||||
// cmake --build build --target example_flatten
|
||||
//
|
||||
// Run:
|
||||
// # With a real 3-D surface mesh (open, disk topology):
|
||||
// ./build/examples/example_flatten code/data/obj/cathead.obj flat.off
|
||||
//
|
||||
// # Without arguments: uses a built-in synthetic open mesh (6 vertices)
|
||||
// ./build/examples/example_flatten
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include "mesh_builder.hpp"
|
||||
#include "mesh_io.hpp"
|
||||
#include "euclidean_functional.hpp"
|
||||
#include "gauss_bonnet.hpp"
|
||||
#include "newton_solver.hpp"
|
||||
#include "layout.hpp"
|
||||
#include "constants.hpp"
|
||||
#include <CGAL/boost/graph/iterator.h>
|
||||
#include <iostream>
|
||||
#include <iomanip>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
using namespace conformallab;
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
// ── Step 1: load or synthesise a mesh ────────────────────────────────────
|
||||
ConformalMesh mesh;
|
||||
std::string input_path = (argc > 1) ? argv[1] : "";
|
||||
std::string output_path = (argc > 2) ? argv[2] : "/tmp/conformallab_flatten_out.off";
|
||||
|
||||
if (input_path.empty()) {
|
||||
// Fallback: load cathead.obj from the standard data location if
|
||||
// it exists alongside the executable; otherwise use a synthetic mesh.
|
||||
// For a meaningful non-trivial flattening, supply a real 3-D mesh:
|
||||
// ./example_flatten code/data/obj/cathead.obj
|
||||
std::cout << "[example_flatten] No input given. Using make_quad_strip() "
|
||||
"(flat synthetic mesh — u_v will be near-zero).\n"
|
||||
<< " For a non-trivial flattening, provide a 3-D mesh:\n"
|
||||
<< " ./example_flatten code/data/obj/cathead.obj\n\n";
|
||||
mesh = make_quad_strip();
|
||||
} else {
|
||||
std::cout << "[example_flatten] Loading mesh: " << input_path << "\n";
|
||||
try { mesh = load_mesh(input_path); }
|
||||
catch (const std::exception& e) {
|
||||
std::cerr << "Error loading mesh: " << e.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const int V = static_cast<int>(mesh.number_of_vertices());
|
||||
const int F = static_cast<int>(mesh.number_of_faces());
|
||||
std::cout << "[example_flatten] Mesh: " << V << " vertices, " << F << " faces\n";
|
||||
|
||||
// ── Step 2: setup maps + compute initial edge lengths ─────────────────────
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// ── Step 3: DOF assignment — pin boundary, free interior ──────────────────
|
||||
//
|
||||
// For an OPEN mesh (disk topology):
|
||||
// • Boundary vertices are pinned (v_idx = -1): they define the
|
||||
// boundary of the UV domain and are not optimised.
|
||||
// • Interior vertices get a DOF (v_idx ≥ 0) and target Θ_v = 2π.
|
||||
// This means "make every interior point look like a flat plane vertex".
|
||||
//
|
||||
// For a CLOSED mesh (e.g. a sphere or torus), use the Euclidean pipeline
|
||||
// on a cut-open mesh (see example_layout.cpp + cut_graph.hpp), or use the
|
||||
// spherical / hyper-ideal functional instead.
|
||||
//
|
||||
// ⚠ This is the KEY difference from example_euclidean.cpp:
|
||||
// There, "natural theta" sets Θ_v = actual angle sum → x* = 0 (trivial).
|
||||
// Here, Θ_v = 2π → the solver finds the REAL conformal map.
|
||||
int idx = 0;
|
||||
int n_boundary = 0, n_interior = 0;
|
||||
|
||||
for (auto v : mesh.vertices()) {
|
||||
// CGAL: a vertex is on the boundary iff it has an incident border halfedge.
|
||||
bool is_bnd = false;
|
||||
for (auto h : CGAL::halfedges_around_target(v, mesh))
|
||||
if (mesh.is_border(h)) { is_bnd = true; break; }
|
||||
|
||||
if (is_bnd) {
|
||||
maps.v_idx[v] = -1; // pinned — boundary defines the UV border
|
||||
++n_boundary;
|
||||
} else {
|
||||
maps.theta_v[v] = TWO_PI; // flat interior target — the actual goal
|
||||
maps.v_idx[v] = idx++;
|
||||
++n_interior;
|
||||
}
|
||||
}
|
||||
|
||||
std::cout << "[example_flatten] Boundary vertices (pinned): " << n_boundary
|
||||
<< " Interior (free DOFs): " << n_interior << "\n";
|
||||
|
||||
if (n_interior == 0) {
|
||||
std::cerr << "[example_flatten] No interior vertices — mesh has no free DOFs.\n"
|
||||
<< " Use a mesh with interior vertices (e.g. cathead.obj).\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
// For open meshes the Gauss–Bonnet identity holds in a different form and
|
||||
// does NOT need to be checked before calling newton_euclidean. The solver
|
||||
// converges as long as at least one interior vertex exists.
|
||||
// (For CLOSED meshes: call enforce_gauss_bonnet(mesh, maps) here.)
|
||||
|
||||
// ── Step 4: Newton ────────────────────────────────────────────────────────
|
||||
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
|
||||
|
||||
std::cout << "[example_flatten] Running Newton (tol = 1e-9)…\n";
|
||||
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-9);
|
||||
|
||||
if (res.converged)
|
||||
std::cout << "[example_flatten] Converged in " << res.iterations
|
||||
<< " iterations ||G||_inf = " << std::scientific
|
||||
<< std::setprecision(2) << res.grad_inf_norm << "\n";
|
||||
else
|
||||
std::cout << "[example_flatten] WARNING: did not converge after "
|
||||
<< res.iterations << " iterations ||G||_inf = "
|
||||
<< res.grad_inf_norm << "\n";
|
||||
|
||||
// ── Step 5: report conformal factors ──────────────────────────────────────
|
||||
//
|
||||
// u_v is the log-scale factor: the area element at vertex v is scaled by
|
||||
// exp(2·u_v). For a non-trivial mesh the values are non-zero.
|
||||
double u_min = 0.0, u_max = 0.0;
|
||||
for (auto v : mesh.vertices()) {
|
||||
int iv = maps.v_idx[v];
|
||||
if (iv >= 0) {
|
||||
double u = res.x[static_cast<std::size_t>(iv)];
|
||||
u_min = std::min(u_min, u);
|
||||
u_max = std::max(u_max, u);
|
||||
}
|
||||
}
|
||||
std::cout << "[example_flatten] Conformal factors u_v: "
|
||||
<< "min = " << std::fixed << std::setprecision(4) << u_min
|
||||
<< " max = " << u_max << "\n";
|
||||
|
||||
if (std::abs(u_max - u_min) < 1e-6)
|
||||
std::cout << "[example_flatten] Note: u_v ≈ 0 everywhere — the mesh is "
|
||||
"already flat in the\n"
|
||||
" Euclidean sense (e.g. a planar mesh). Use a 3-D surface "
|
||||
"for non-trivial output.\n";
|
||||
else
|
||||
std::cout << "[example_flatten] Non-trivial u_v range = "
|
||||
<< (u_max - u_min) << " — real conformal deformation computed.\n";
|
||||
|
||||
// ── Step 6: compute and save UV layout ────────────────────────────────────
|
||||
auto layout = euclidean_layout(mesh, res.x, maps);
|
||||
|
||||
if (layout.success) {
|
||||
// Find the bounding box of the UV coords
|
||||
double xmin = 1e30, xmax = -1e30, ymin = 1e30, ymax = -1e30;
|
||||
for (auto v : mesh.vertices()) {
|
||||
if (static_cast<std::size_t>(v.idx()) < layout.uv.size()) {
|
||||
const auto& p = layout.uv[static_cast<std::size_t>(v.idx())];
|
||||
xmin = std::min(xmin, p.x()); xmax = std::max(xmax, p.x());
|
||||
ymin = std::min(ymin, p.y()); ymax = std::max(ymax, p.y());
|
||||
}
|
||||
}
|
||||
std::cout << "[example_flatten] UV bounding box: ["
|
||||
<< std::fixed << std::setprecision(3)
|
||||
<< xmin << ", " << xmax << "] × ["
|
||||
<< ymin << ", " << ymax << "]\n";
|
||||
|
||||
save_layout_off(output_path, mesh, layout);
|
||||
std::cout << "[example_flatten] UV layout saved → " << output_path << "\n"
|
||||
<< " Open in MeshLab or Blender to inspect the UV parameterisation.\n";
|
||||
} else {
|
||||
std::cerr << "[example_flatten] Layout failed.\n";
|
||||
}
|
||||
|
||||
return (res.converged && layout.success) ? 0 : 1;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ int main(int argc, char* argv[])
|
||||
|
||||
// ── Step 2: set up functional maps ────────────────────────────────────
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
std::cout << "[example_hyper_ideal] DOFs: " << n
|
||||
<< " (" << mesh.number_of_vertices() << " vertex + "
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
using namespace conformallab;
|
||||
|
||||
// ── Helper: natural target angles so x* = 0 is the equilibrium ───────────────
|
||||
//
|
||||
// ⚠ TESTING CONVENTION — NOT A REAL CONFORMAL MAP
|
||||
// Natural theta sets Θ_v = actual angle sum at x=0, making x* = 0 trivially
|
||||
// the equilibrium (u_v ≈ 0, no deformation). For real conformal flattening,
|
||||
// see example_flatten.cpp which uses Θ_v = 2π (flat interior target).
|
||||
static void set_natural_theta(ConformalMesh& mesh, EuclideanMaps& maps, int n)
|
||||
{
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||||
@@ -49,14 +54,13 @@ static void set_natural_theta(ConformalMesh& mesh, EuclideanMaps& maps, int n)
|
||||
}
|
||||
|
||||
// ── Helper: pin vertex 0, assign DOF indices 0..n-1 to the rest ──────────────
|
||||
// Uses the gauge-vertex overload introduced in external-audit Finding-D:
|
||||
// assign_euclidean_vertex_dof_indices(mesh, maps, gauge) pins the chosen
|
||||
// vertex and assigns sequential indices in a single pass.
|
||||
static int pin_first(ConformalMesh& mesh, EuclideanMaps& maps)
|
||||
{
|
||||
auto vit = mesh.vertices().begin();
|
||||
maps.v_idx[*vit++] = -1; // pinned
|
||||
int idx = 0;
|
||||
for (; vit != mesh.vertices().end(); ++vit)
|
||||
maps.v_idx[*vit] = idx++;
|
||||
return idx;
|
||||
return assign_euclidean_vertex_dof_indices(
|
||||
mesh, maps, *mesh.vertices().begin());
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -124,7 +128,9 @@ int main(int argc, char* argv[])
|
||||
if (layout.success) {
|
||||
std::cout << "UV coordinates:\n";
|
||||
for (auto v : mesh.vertices()) {
|
||||
auto& p = layout.uv[v.idx()];
|
||||
// layout.uv is indexed by v.idx() (raw integer vertex index).
|
||||
// Valid as long as no vertices were removed/compacted after loading.
|
||||
auto& p = layout.uv[static_cast<std::size_t>(v.idx())];
|
||||
std::cout << " v" << v.idx()
|
||||
<< ": (" << std::fixed << std::setprecision(6)
|
||||
<< p.x() << ", " << p.y() << ")\n";
|
||||
|
||||
47
code/include/CGAL/Conformal_map/doxygen_groups.h
Normal file
47
code/include/CGAL/Conformal_map/doxygen_groups.h
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
/*! \file CGAL/Conformal_map/doxygen_groups.h
|
||||
\brief Doxygen `\defgroup` registrations for the Conformal_map package.
|
||||
*/
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// This header contains only Doxygen \defgroup commands. It is included
|
||||
// nowhere in the build; its sole purpose is to register the package's
|
||||
// Doxygen group hierarchy so that `@ingroup Pkg...` references in the
|
||||
// other public headers resolve cleanly.
|
||||
|
||||
#ifndef CGAL_CONFORMAL_MAP_DOXYGEN_GROUPS_H
|
||||
#define CGAL_CONFORMAL_MAP_DOXYGEN_GROUPS_H
|
||||
|
||||
/*!
|
||||
\defgroup PkgConformalMap CGAL Discrete Conformal Map package
|
||||
\brief Discrete conformal maps on triangulated surfaces — five DCE models
|
||||
(Euclidean, Spherical, Hyper-Ideal, Circle-Packing Euclidean,
|
||||
Inversive-Distance).
|
||||
|
||||
This package provides the C++ implementation of the variational discrete
|
||||
conformal equivalence solvers from Springborn 2020, Bobenko/Pinkall/Springborn
|
||||
2010, and Luo 2004, together with a CGAL-style named-parameter API.
|
||||
|
||||
See `doc/api/cgal-package.md` for the full design rationale.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\defgroup PkgConformalMapRef Reference manual
|
||||
\ingroup PkgConformalMap
|
||||
\brief Public C++ API: entry functions, traits, layout helpers.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\defgroup PkgConformalMapConcepts Concepts
|
||||
\ingroup PkgConformalMap
|
||||
\brief C++ concepts and traits classes consumed by the entry functions.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\defgroup PkgConformalMapNamedParameters Named function parameters
|
||||
\ingroup PkgConformalMap
|
||||
\brief Package-specific named-parameter helpers in `CGAL::parameters::*`,
|
||||
plus the pipe-operator chaining convention.
|
||||
*/
|
||||
|
||||
#endif // CGAL_CONFORMAL_MAP_DOXYGEN_GROUPS_H
|
||||
91
code/include/CGAL/Conformal_map/doxygen_namespaces.h
Normal file
91
code/include/CGAL/Conformal_map/doxygen_namespaces.h
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
/*! \file CGAL/Conformal_map/doxygen_namespaces.h
|
||||
\brief Doxygen `\namespace` documentation blocks for the project namespaces.
|
||||
*/
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Doxygen namespace documentation only — no declarations. Centralised
|
||||
// here so that each namespace gets a single, consistent description in
|
||||
// the generated HTML, regardless of which header is parsed first.
|
||||
|
||||
#ifndef CGAL_CONFORMAL_MAP_DOXYGEN_NAMESPACES_H
|
||||
#define CGAL_CONFORMAL_MAP_DOXYGEN_NAMESPACES_H
|
||||
|
||||
/*!
|
||||
\namespace CGAL
|
||||
\brief Root namespace of the CGAL library; conformallab++ adds its
|
||||
public entry points (`discrete_conformal_map_*`, `Conformal_map_traits`,
|
||||
…) directly into this namespace, matching CGAL package conventions.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace CGAL::Conformal_map
|
||||
\brief Implementation-detail namespace for the Discrete Conformal Map
|
||||
package. Users normally do not need to enter this namespace; all
|
||||
public entry points are re-exported into `CGAL::`.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace CGAL::Conformal_map::internal_np
|
||||
\brief Tag types backing the package-local named-function parameters
|
||||
(`vertex_curvature_map_t`, `gradient_tolerance_t`, `output_uv_map_t`, …).
|
||||
Users invoke them via the helpers in `CGAL::parameters::*`.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace CGAL::parameters
|
||||
\brief CGAL named-function-parameter helpers — both upstream CGAL's and
|
||||
the conformallab++ package extensions (`vertex_curvature_map(...)`,
|
||||
`gradient_tolerance(...)`, `output_uv_map(...)`, `normalise_layout(...)`).
|
||||
Also home of the pipe-operator chaining convention; see
|
||||
`doc/tutorials/add-output-uv-map.md` §3.4.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace conformallab
|
||||
\brief Core math/algorithm namespace of conformallab++. Holds the five
|
||||
DCE functionals (Euclidean / Spherical / HyperIdeal / CP-Euclidean /
|
||||
Inversive-Distance), the Newton solver, layout helpers, mesh-property
|
||||
typedefs, and serialisation utilities. Lives under
|
||||
`code/include/*.hpp` and is consumed both by the standalone CLI and
|
||||
by the thin CGAL wrappers under `CGAL::`.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace conformallab::detail
|
||||
\brief Implementation-private helpers for the `conformallab` namespace.
|
||||
Not part of the stable public API.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace conformallab::cp_detail
|
||||
\brief Implementation-private helpers for the Circle-Packing Euclidean
|
||||
functional (see `cp_euclidean_functional.hpp`).
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace conformallab::id_detail
|
||||
\brief Implementation-private helpers for the Inversive-Distance
|
||||
functional (see `inversive_distance_functional.hpp`).
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace conformallab::detail_xml
|
||||
\brief Implementation-private XML helpers for the (de)serialisation
|
||||
layer (see `serialization.hpp`).
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace mesh_utils
|
||||
\brief Small, opinion-free mesh utilities (loaders, validators,
|
||||
property-map registration) used by both the standalone tools and the
|
||||
CGAL wrappers.
|
||||
*/
|
||||
|
||||
/*!
|
||||
\namespace viewer_utils
|
||||
\brief libigl-based interactive viewer helpers; built only when
|
||||
`WITH_VIEWER=ON`. Not part of the headless / CGAL public surface.
|
||||
*/
|
||||
|
||||
#endif // CGAL_CONFORMAL_MAP_DOXYGEN_NAMESPACES_H
|
||||
@@ -55,6 +55,23 @@ enum max_iterations_t { max_iterations };
|
||||
/// Default: first vertex is pinned, all others are variable.
|
||||
enum fixed_vertex_map_t { fixed_vertex_map };
|
||||
|
||||
// ─── Layout output (Phase 8b-Lite extension) ────────────────────────────────
|
||||
/// Property-map: vertex_descriptor → 2-D / 3-D coordinate. If provided,
|
||||
/// the entry function calls the appropriate `*_layout()` after Newton
|
||||
/// convergence and writes the per-vertex coordinates into this map:
|
||||
/// - Euclidean / Hyper-ideal / Inversive-Distance: `Point_2` (UV in ℝ²)
|
||||
/// - Spherical: `Point_3` (point on S² ⊂ ℝ³)
|
||||
/// If the parameter is absent, no layout step is performed. Callers
|
||||
/// who need finer control should run `*_layout()` directly on
|
||||
/// `result.x` plus the maps from `setup_*_maps()`.
|
||||
enum output_uv_map_t { output_uv_map };
|
||||
|
||||
/// Boolean flag: if `true`, apply the canonical post-layout
|
||||
/// normalisation (`normalise_euclidean` PCA centroid + axis,
|
||||
/// `normalise_spherical` Rodrigues to north pole, …) before writing
|
||||
/// into `output_uv_map`. Default: `false`.
|
||||
enum normalise_layout_t { normalise_layout };
|
||||
|
||||
} // namespace internal_np
|
||||
} // namespace Conformal_map
|
||||
|
||||
@@ -118,10 +135,103 @@ auto fixed_vertex_map(const PropertyMap& pmap)
|
||||
>(pmap);
|
||||
}
|
||||
|
||||
/// `output_uv_map(pmap)` — write the per-vertex layout coordinates
|
||||
/// into `pmap` after Newton converges.
|
||||
///
|
||||
/// Type: model of `WritablePropertyMap` with key = `vertex_descriptor`
|
||||
/// and value either `Point_2` (Euclidean / Hyper-ideal / Inversive-
|
||||
/// Distance entries) or `Point_3` (Spherical entry).
|
||||
///
|
||||
/// Implementation: the entry function runs the appropriate
|
||||
/// `*_layout()` from `code/include/layout.hpp` after Newton, then
|
||||
/// writes one coordinate per vertex into `pmap`. If omitted, no
|
||||
/// layout is performed.
|
||||
template <typename PropertyMap>
|
||||
auto output_uv_map(const PropertyMap& pmap)
|
||||
{
|
||||
return CGAL::Named_function_parameters<
|
||||
PropertyMap,
|
||||
Conformal_map::internal_np::output_uv_map_t,
|
||||
CGAL::internal_np::No_property
|
||||
>(pmap);
|
||||
}
|
||||
|
||||
/// `normalise_layout(flag)` — apply the canonical post-layout
|
||||
/// normalisation (PCA centroid for Euclidean; north-pole alignment
|
||||
/// for Spherical; Möbius centring for Hyper-ideal). Default: `false`.
|
||||
/// Only meaningful in combination with `output_uv_map`.
|
||||
inline auto normalise_layout(bool flag)
|
||||
{
|
||||
return CGAL::Named_function_parameters<
|
||||
bool,
|
||||
Conformal_map::internal_np::normalise_layout_t,
|
||||
CGAL::internal_np::No_property
|
||||
>(flag);
|
||||
}
|
||||
|
||||
/// \}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Pipe-operator chaining for the Discrete_conformal_map package
|
||||
//
|
||||
// CGAL's standard chaining syntax `a.b(...).c(...)` requires modifying the
|
||||
// CGAL upstream `parameters_interface.h` file, which we deliberately treat
|
||||
// as a read-only vendored dependency. Instead, conformallab++ provides a
|
||||
// pipe-operator overload that achieves the same effect from
|
||||
// left-to-right composition:
|
||||
//
|
||||
// auto p = CGAL::parameters::gradient_tolerance(1e-12)
|
||||
// | CGAL::parameters::max_iterations(500)
|
||||
// | CGAL::parameters::output_uv_map(uv);
|
||||
// CGAL::discrete_conformal_map_euclidean(mesh, p);
|
||||
//
|
||||
// Semantics: `a | b` reads as "first apply a, then b". The result is a
|
||||
// Named_function_parameters chain identical to what `.b()` chained onto
|
||||
// `a` would have produced, so the resulting object is accepted by every
|
||||
// entry function in the package.
|
||||
//
|
||||
// Implementation note: this operator is intentionally placed in the
|
||||
// CGAL::parameters namespace so it is found by ADL when the operands are
|
||||
// `Named_function_parameters` objects produced by the helpers above. We
|
||||
// constrain it to no-base NPs only (i.e. the operands are fresh
|
||||
// single-parameter packs) to avoid colliding with any future CGAL
|
||||
// operator on the same type.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Close the PkgConformalMapNamedParameters group block that was opened
|
||||
// above the helper functions (see \addtogroup at the top of this section).
|
||||
/// \}
|
||||
|
||||
} // namespace parameters
|
||||
|
||||
/// Pipe-operator chaining for package-local named parameters.
|
||||
///
|
||||
/// `a | b` combines `a` and `b` into a single `Named_function_parameters`
|
||||
/// chain. The right-hand side `b` must be a fresh single-parameter pack
|
||||
/// (i.e. its Base is `No_property`) — typically the direct return value
|
||||
/// of one of the helper functions in `CGAL::parameters::*`. The
|
||||
/// left-hand side can be any chain length.
|
||||
///
|
||||
/// Lives in `namespace CGAL` (not `CGAL::parameters`) so ADL finds it
|
||||
/// when the operands are `CGAL::Named_function_parameters<...>` values.
|
||||
///
|
||||
/// Use as a workaround for the missing `a.b().c()` chaining syntax
|
||||
/// while CGAL upstream does not yet expose a per-package extension
|
||||
/// point for member-function chainers.
|
||||
template <typename T_a, typename Tag_a, typename Base_a,
|
||||
typename T_b, typename Tag_b>
|
||||
auto operator|(const CGAL::Named_function_parameters<T_a, Tag_a, Base_a>& a,
|
||||
const CGAL::Named_function_parameters<T_b, Tag_b, CGAL::internal_np::No_property>& b)
|
||||
{
|
||||
// Re-build b as if it had been chained on top of a.
|
||||
using LHS_NP = CGAL::Named_function_parameters<T_a, Tag_a, Base_a>;
|
||||
using Combined = CGAL::Named_function_parameters<T_b, Tag_b, LHS_NP>;
|
||||
// Read b's value (Named_params_impl::v is the stored value).
|
||||
using Impl_b = CGAL::internal_np::Named_params_impl<T_b, Tag_b, CGAL::internal_np::No_property>;
|
||||
const auto& v_b = static_cast<const Impl_b&>(b).v;
|
||||
return Combined(v_b, a);
|
||||
}
|
||||
|
||||
} // namespace CGAL
|
||||
|
||||
#endif // CGAL_CONFORMAL_MAP_INTERNAL_PARAMETERS_H
|
||||
|
||||
@@ -80,10 +80,13 @@ namespace CGAL {
|
||||
// Default_conformal_map_traits — primary template (undefined)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// The undefined primary template forces specialisation per mesh type.
|
||||
// MVP provides only the `Surface_mesh` specialisation below; further
|
||||
// mesh types (Polyhedron_3, OpenMesh, pmp) are deferred to Phase 8a.2.
|
||||
|
||||
/*!
|
||||
\ingroup PkgConformalMapConcepts
|
||||
\brief Primary `ConformalMapTraits` template — undefined, must be
|
||||
specialised per mesh type. The MVP only ships the `Surface_mesh`
|
||||
specialisation below; further mesh types (Polyhedron_3, OpenMesh,
|
||||
pmp) are deferred to Phase 8a.2.
|
||||
*/
|
||||
template <typename TriangleMesh,
|
||||
typename Kernel_ = CGAL::Simple_cartesian<double>>
|
||||
struct Default_conformal_map_traits;
|
||||
@@ -110,20 +113,33 @@ selected automatically when `TriangleMesh = CGAL::Surface_mesh<...>`.
|
||||
template <typename K>
|
||||
struct Default_conformal_map_traits<CGAL::Surface_mesh<typename K::Point_3>, K>
|
||||
{
|
||||
/// The CGAL kernel parameter; defaults to `Simple_cartesian<double>`.
|
||||
using Kernel = K;
|
||||
/// Field type used for all scalar conformal-map data (lengths, λ, Θ, …).
|
||||
using FT = typename K::FT;
|
||||
/// 3-D point type used for vertex coordinates.
|
||||
using Point_3 = typename K::Point_3;
|
||||
/// The triangle-mesh type this specialisation targets.
|
||||
using Triangle_mesh = CGAL::Surface_mesh<Point_3>;
|
||||
|
||||
/// Boost-graph vertex descriptor for `Triangle_mesh`.
|
||||
using Vertex_descriptor = typename boost::graph_traits<Triangle_mesh>::vertex_descriptor;
|
||||
/// Boost-graph half-edge descriptor for `Triangle_mesh`.
|
||||
using Halfedge_descriptor = typename boost::graph_traits<Triangle_mesh>::halfedge_descriptor;
|
||||
/// Boost-graph edge descriptor for `Triangle_mesh`.
|
||||
using Edge_descriptor = typename boost::graph_traits<Triangle_mesh>::edge_descriptor;
|
||||
/// Boost-graph face descriptor for `Triangle_mesh`.
|
||||
using Face_descriptor = typename boost::graph_traits<Triangle_mesh>::face_descriptor;
|
||||
|
||||
// Property-map types — match the names used by setup_euclidean_maps().
|
||||
|
||||
/// Property map vertex → `Point_3` (the mesh's geometric embedding).
|
||||
using Vertex_point_map = typename Triangle_mesh::template Property_map<Vertex_descriptor, Point_3>;
|
||||
/// Property map vertex → target cone angle Θᵥ in radians (legacy name `ev:theta`).
|
||||
using Theta_pmap = typename Triangle_mesh::template Property_map<Vertex_descriptor, FT>;
|
||||
/// Property map vertex → contiguous integer index (legacy name `ev:idx`).
|
||||
using Vertex_index_pmap = typename Triangle_mesh::template Property_map<Vertex_descriptor, int>;
|
||||
/// Property map edge → log of original edge length λ⁰ (legacy name `ee:lam0`).
|
||||
using Lambda0_pmap = typename Triangle_mesh::template Property_map<Edge_descriptor, FT>;
|
||||
|
||||
// ─── Property-map accessors ───────────────────────────────────────────
|
||||
@@ -133,10 +149,12 @@ struct Default_conformal_map_traits<CGAL::Surface_mesh<typename K::Point_3>, K>
|
||||
// calling either `setup_euclidean_maps(m)` first or the accessor first
|
||||
// is equivalent.
|
||||
|
||||
/// Return the built-in vertex-point map of `m` (the geometric embedding).
|
||||
static Vertex_point_map vertex_points(Triangle_mesh& m) {
|
||||
return m.points();
|
||||
}
|
||||
|
||||
/// Return (or create with default 2π) the target-angle property map.
|
||||
static Theta_pmap theta_map(Triangle_mesh& m) {
|
||||
auto [pm, created] = m.template add_property_map<Vertex_descriptor, FT>(
|
||||
"ev:theta", FT(2.0 * 3.141592653589793238));
|
||||
@@ -144,6 +162,7 @@ struct Default_conformal_map_traits<CGAL::Surface_mesh<typename K::Point_3>, K>
|
||||
return pm;
|
||||
}
|
||||
|
||||
/// Return (or create with default −1) the vertex-index property map.
|
||||
static Vertex_index_pmap vertex_index_map(Triangle_mesh& m) {
|
||||
auto [pm, created] = m.template add_property_map<Vertex_descriptor, int>(
|
||||
"ev:idx", -1);
|
||||
@@ -151,6 +170,7 @@ struct Default_conformal_map_traits<CGAL::Surface_mesh<typename K::Point_3>, K>
|
||||
return pm;
|
||||
}
|
||||
|
||||
/// Return (or create with default 0) the λ⁰ (initial log-length) property map.
|
||||
static Lambda0_pmap lambda0_map(Triangle_mesh& m) {
|
||||
auto [pm, created] = m.template add_property_map<Edge_descriptor, FT>(
|
||||
"ee:lam0", FT(0));
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
\ingroup PkgConformalMapRef
|
||||
|
||||
User-facing entry for the **face-based** circle-packing functional of
|
||||
Bobenko-Pinkall-Springborn 2010. See `cp_euclidean_functional.hpp`
|
||||
Bobenko-Pinkall-Springborn 2015. See `cp_euclidean_functional.hpp`
|
||||
for the underlying algorithm and `doc/architecture/phase-9a-validation.md`
|
||||
for the line-by-line mapping to the Java original
|
||||
`CPEuclideanFunctional.java`.
|
||||
@@ -34,30 +34,57 @@ class (Strategy C of the Phase 8b architecture audit).
|
||||
#include "../cp_euclidean_functional.hpp"
|
||||
#include "../newton_solver.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
namespace CGAL {
|
||||
|
||||
// ── Default traits for CP-Euclidean ───────────────────────────────────────────
|
||||
|
||||
/*!
|
||||
\ingroup PkgConformalMapConcepts
|
||||
\brief Traits class for `discrete_circle_packing_euclidean()` —
|
||||
declares the kernel, mesh and property-map types used by the
|
||||
BPS-2015 face-based circle-packing functional.
|
||||
|
||||
Primary template; specialise it for non-`Surface_mesh` triangle meshes.
|
||||
*/
|
||||
template <typename TriangleMesh,
|
||||
typename Kernel_ = CGAL::Simple_cartesian<double>>
|
||||
struct Default_cp_euclidean_traits;
|
||||
|
||||
/*!
|
||||
\ingroup PkgConformalMapConcepts
|
||||
\brief Specialisation for `CGAL::Surface_mesh<P>`; the only one shipped
|
||||
in Phase 8b-Lite.
|
||||
*/
|
||||
template <typename K>
|
||||
struct Default_cp_euclidean_traits<CGAL::Surface_mesh<typename K::Point_3>, K>
|
||||
{
|
||||
/// CGAL kernel parameter (defaults to `Simple_cartesian<double>`).
|
||||
using Kernel = K;
|
||||
/// Scalar field type used for all CP-Euclidean DOFs (`ρ_f`, `θ_e`, `φ_f`).
|
||||
using FT = typename K::FT;
|
||||
/// 3-D point type (vertex coordinates).
|
||||
using Point_3 = typename K::Point_3;
|
||||
/// Triangle-mesh type this specialisation targets.
|
||||
using Triangle_mesh = CGAL::Surface_mesh<Point_3>;
|
||||
|
||||
/// Boost-graph vertex descriptor for `Triangle_mesh`.
|
||||
using Vertex_descriptor = typename boost::graph_traits<Triangle_mesh>::vertex_descriptor;
|
||||
/// Boost-graph half-edge descriptor for `Triangle_mesh`.
|
||||
using Halfedge_descriptor = typename boost::graph_traits<Triangle_mesh>::halfedge_descriptor;
|
||||
/// Boost-graph edge descriptor for `Triangle_mesh`.
|
||||
using Edge_descriptor = typename boost::graph_traits<Triangle_mesh>::edge_descriptor;
|
||||
/// Boost-graph face descriptor for `Triangle_mesh`.
|
||||
using Face_descriptor = typename boost::graph_traits<Triangle_mesh>::face_descriptor;
|
||||
|
||||
// CP-Euclidean property maps — note the *face* DOF index map.
|
||||
|
||||
/// Property map face → contiguous integer DOF index (legacy `cf:idx`).
|
||||
using Face_index_pmap = typename Triangle_mesh::template Property_map<Face_descriptor, int>;
|
||||
/// Property map edge → intersection angle θₑ (legacy `ce:theta`).
|
||||
using Theta_e_pmap = typename Triangle_mesh::template Property_map<Edge_descriptor, FT>;
|
||||
/// Property map face → target angle sum φ_f (legacy `cf:phi`).
|
||||
using Phi_f_pmap = typename Triangle_mesh::template Property_map<Face_descriptor, FT>;
|
||||
};
|
||||
|
||||
@@ -69,15 +96,29 @@ 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
|
||||
{
|
||||
/// Face DOFs `ρ_f = log R_f` (length = num_faces(mesh); pinned face = 0).
|
||||
std::vector<FT> rho_per_face;
|
||||
|
||||
/// Newton iterations actually performed (≤ `max_iterations`).
|
||||
int iterations = 0;
|
||||
/// Final infinity-norm of the gradient (Newton stopping criterion).
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
@@ -85,7 +126,7 @@ struct Circle_packing_result
|
||||
/*!
|
||||
\ingroup PkgConformalMapRef
|
||||
|
||||
Compute the BPS-2010 face-based circle-packing of `mesh`.
|
||||
Compute the BPS-2015 face-based circle-packing of `mesh`.
|
||||
|
||||
\tparam TriangleMesh A `CGAL::Surface_mesh<P>`.
|
||||
\tparam NamedParameters Optional CGAL named-parameter pack.
|
||||
@@ -102,7 +143,7 @@ Compute the BPS-2010 face-based circle-packing of `mesh`.
|
||||
\returns A `Circle_packing_result<FT>` with `ρ_f` per face.
|
||||
|
||||
\pre `mesh` is a triangle mesh.
|
||||
\pre `φ_f` and `θ_e` satisfy the BPS-2010 admissibility conditions
|
||||
\pre `φ_f` and `θ_e` satisfy the BPS-2015 admissibility conditions
|
||||
(Σ_f φ_f = 2π·χ + Σ_e (π − θ_e), see paper §6).
|
||||
*/
|
||||
template <typename TriangleMesh,
|
||||
@@ -158,6 +199,48 @@ 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) ────────────────────────────
|
||||
//
|
||||
// The CP-Euclidean functional carries one DOF per *face* (the log of the
|
||||
// face-circle radius `ρ_f = log R_f`), not per vertex. A faithful
|
||||
// layout therefore produces a circle packing in ℝ² — each face f is
|
||||
// mapped to a circle of radius `R_f` at some centre `c_f`, with
|
||||
// adjacent circles meeting at the prescribed intersection angle `θ_e`.
|
||||
// That is a per-face output, not the per-vertex Point_2 that
|
||||
// `output_uv_map` is typed for.
|
||||
//
|
||||
// For Phase 8b-Lite we deliberately don't fake it. If the caller
|
||||
// supplies `output_uv_map(pmap)` we throw `std::runtime_error` with a
|
||||
// clear pointer to Phase 9c (BPS-2015 §6 face-based circle-packing
|
||||
// layout, ~150 lines, on the porting roadmap). Failing loudly is
|
||||
// better than silently writing zeros.
|
||||
//
|
||||
// Users who want a UV-like coordinate today can:
|
||||
// 1. Solve a Euclidean DCE on the same mesh (vertex DOFs),
|
||||
// 2. Use `discrete_inversive_distance_map(... output_uv_map(pmap))`,
|
||||
// 3. Or compute face-centre positions by hand from `result.rho_per_face`
|
||||
// + the per-edge `θ_e` values, plus a priority-BFS of their own.
|
||||
{
|
||||
auto uv_param = parameters::get_parameter(
|
||||
np, Conformal_map::internal_np::output_uv_map);
|
||||
constexpr bool has_uv = !std::is_same_v<
|
||||
decltype(uv_param), internal_np::Param_not_found>;
|
||||
if constexpr (has_uv) {
|
||||
throw std::runtime_error(
|
||||
"CGAL::discrete_circle_packing_euclidean: the "
|
||||
"`output_uv_map(...)` named parameter is not yet supported "
|
||||
"for face-based CP-Euclidean. The faithful output is a "
|
||||
"circle packing in the plane (per-face), not per-vertex "
|
||||
"UVs. Tracked as Phase 9c; "
|
||||
"see doc/architecture/locked-vs-flexible.md and "
|
||||
"doc/tutorials/add-output-uv-map.md.");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,17 @@
|
||||
\file CGAL/Discrete_conformal_map.h
|
||||
\ingroup PkgConformalMapRef
|
||||
|
||||
User-facing entry point for the Discrete_conformal_map package.
|
||||
User-facing entry points for the Discrete_conformal_map package (Phase 8b-Lite).
|
||||
|
||||
This header provides a single function — `discrete_conformal_map_euclidean`
|
||||
— that computes a Euclidean discrete-conformal flattening of an open or
|
||||
closed triangle mesh. Spherical and hyperbolic variants are scheduled
|
||||
for Phase 8b.2 once the Euclidean pattern is validated by Phase 9a
|
||||
(Inversive-Distance functional).
|
||||
This header provides three functions covering all three DCE geometries:
|
||||
|
||||
- `CGAL::discrete_conformal_map_euclidean` — flat conformal map (ℝ²), open or closed mesh
|
||||
- `CGAL::discrete_conformal_map_spherical` — spherical uniformisation (S²), genus-0 mesh
|
||||
- `CGAL::discrete_conformal_map_hyper_ideal` — hyperbolic conformal map (H²), genus ≥ 1
|
||||
|
||||
For circle-packing models see the companion headers:
|
||||
- `<CGAL/Discrete_circle_packing.h>` — `discrete_circle_packing_euclidean` (CP-Euclidean)
|
||||
- `<CGAL/Discrete_inversive_distance.h>` — `discrete_inversive_distance_map` (Luo 2004)
|
||||
|
||||
\section Example Simplest usage
|
||||
|
||||
@@ -59,6 +63,7 @@ auto result = CGAL::discrete_conformal_map_euclidean(
|
||||
|
||||
// Existing implementation headers (Layer 1 — unchanged).
|
||||
#include "../euclidean_functional.hpp"
|
||||
#include "../layout.hpp"
|
||||
#include "../spherical_functional.hpp"
|
||||
#include "../hyper_ideal_functional.hpp"
|
||||
#include "../gauss_bonnet.hpp"
|
||||
@@ -73,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
|
||||
|
||||
@@ -95,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);
|
||||
};
|
||||
|
||||
|
||||
@@ -267,6 +285,36 @@ 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) ──────────────────
|
||||
//
|
||||
// If the caller supplied `output_uv_map(pmap)`, run the priority-BFS
|
||||
// trilateration on the converged x and write per-vertex `Point_2`
|
||||
// coordinates into `pmap`. Optional `normalise_layout(true)` applies
|
||||
// the canonical PCA centroid + major-axis normalisation.
|
||||
auto uv_param = parameters::get_parameter(
|
||||
np, Conformal_map::internal_np::output_uv_map);
|
||||
constexpr bool has_uv = !std::is_same_v<
|
||||
decltype(uv_param), internal_np::Param_not_found>;
|
||||
if constexpr (has_uv) {
|
||||
if (nr.converged) {
|
||||
auto layout = ::conformallab::euclidean_layout(mesh, nr.x, maps);
|
||||
|
||||
const bool do_norm = parameters::choose_parameter(
|
||||
parameters::get_parameter(np, Conformal_map::internal_np::normalise_layout),
|
||||
false);
|
||||
if (do_norm) ::conformallab::normalise_euclidean(layout);
|
||||
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& uv = layout.uv[v.idx()];
|
||||
put(uv_param, v,
|
||||
typename Traits::Kernel::Point_2(uv.x(), uv.y()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -317,7 +365,7 @@ auto discrete_conformal_map_spherical(
|
||||
Conformal_map_result<FT> result;
|
||||
|
||||
auto maps = ::conformallab::setup_spherical_maps(mesh);
|
||||
::conformallab::compute_lambda0_from_mesh(mesh, maps);
|
||||
::conformallab::compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
auto theta_param = parameters::get_parameter(
|
||||
np, Conformal_map::internal_np::vertex_curvature_map);
|
||||
@@ -378,6 +426,30 @@ 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(
|
||||
np, Conformal_map::internal_np::output_uv_map);
|
||||
constexpr bool has_uv = !std::is_same_v<
|
||||
decltype(uv_param), internal_np::Param_not_found>;
|
||||
if constexpr (has_uv) {
|
||||
if (nr.converged) {
|
||||
auto layout = ::conformallab::spherical_layout(mesh, nr.x, maps);
|
||||
const bool do_norm = parameters::choose_parameter(
|
||||
parameters::get_parameter(np, Conformal_map::internal_np::normalise_layout),
|
||||
false);
|
||||
if (do_norm) ::conformallab::normalise_spherical(layout);
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = layout.pos[v.idx()];
|
||||
put(uv_param, v,
|
||||
typename Traits::Kernel::Point_3(p.x(), p.y(), p.z()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -400,9 +472,20 @@ struct Hyper_ideal_map_result
|
||||
/// Edge DOFs `a_e` (length = num_edges(mesh); pinned edges = 0).
|
||||
std::vector<FT> a_per_edge;
|
||||
|
||||
/// Newton iterations actually performed (≤ `max_iterations`).
|
||||
int iterations = 0;
|
||||
/// Final infinity-norm of the gradient (Newton stopping criterion).
|
||||
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);
|
||||
};
|
||||
|
||||
/*!
|
||||
@@ -447,7 +530,7 @@ auto discrete_conformal_map_hyper_ideal(
|
||||
maps.theta_v[v] = get(theta_param, v);
|
||||
}
|
||||
|
||||
const int n = ::conformallab::assign_all_dof_indices(mesh, maps);
|
||||
const int n = ::conformallab::assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
const FT tol = parameters::choose_parameter(
|
||||
parameters::get_parameter(np, Conformal_map::internal_np::gradient_tolerance),
|
||||
@@ -482,6 +565,31 @@ 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(
|
||||
np, Conformal_map::internal_np::output_uv_map);
|
||||
constexpr bool has_uv = !std::is_same_v<
|
||||
decltype(uv_param), internal_np::Param_not_found>;
|
||||
if constexpr (has_uv) {
|
||||
if (nr.converged) {
|
||||
auto layout = ::conformallab::hyper_ideal_layout(mesh, nr.x, maps);
|
||||
const bool do_norm = parameters::choose_parameter(
|
||||
parameters::get_parameter(np, Conformal_map::internal_np::normalise_layout),
|
||||
false);
|
||||
if (do_norm) ::conformallab::normalise_hyperbolic(layout);
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& uv = layout.uv[v.idx()];
|
||||
put(uv_param, v,
|
||||
typename Traits::Kernel::Point_2(uv.x(), uv.y()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(void)n; // already-counted by maps; silence unused-var warnings if any
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
\ingroup PkgConformalMapRef
|
||||
|
||||
User-facing entry for the **vertex-based** inversive-distance circle-
|
||||
packing functional of Luo (2004), with the Bowers-Stephenson (2004)
|
||||
packing functional of Luo 2004, with the Bowers-Stephenson (2004)
|
||||
initialisation. See `inversive_distance_functional.hpp` for the
|
||||
underlying algorithm and `doc/roadmap/research-track.md` (item 9a.2)
|
||||
for the research-track classification — this functional has **no Java
|
||||
@@ -47,25 +47,49 @@ namespace CGAL {
|
||||
|
||||
// ── Default traits for Inversive-Distance ────────────────────────────────────
|
||||
|
||||
/*!
|
||||
\ingroup PkgConformalMapConcepts
|
||||
\brief Traits class for `discrete_inversive_distance_map()` — declares
|
||||
the kernel, mesh and property-map types used by Luo's 2004 vertex-based
|
||||
inversive-distance circle packing.
|
||||
|
||||
Primary template; specialise it for non-`Surface_mesh` triangle meshes.
|
||||
*/
|
||||
template <typename TriangleMesh,
|
||||
typename Kernel_ = CGAL::Simple_cartesian<double>>
|
||||
struct Default_inversive_distance_traits;
|
||||
|
||||
/*!
|
||||
\ingroup PkgConformalMapConcepts
|
||||
\brief Specialisation for `CGAL::Surface_mesh<P>`; the only one shipped
|
||||
in Phase 8b-Lite.
|
||||
*/
|
||||
template <typename K>
|
||||
struct Default_inversive_distance_traits<CGAL::Surface_mesh<typename K::Point_3>, K>
|
||||
{
|
||||
/// CGAL kernel parameter (defaults to `Simple_cartesian<double>`).
|
||||
using Kernel = K;
|
||||
/// Scalar field type used for all inversive-distance DOFs.
|
||||
using FT = typename K::FT;
|
||||
/// 3-D point type (vertex coordinates).
|
||||
using Point_3 = typename K::Point_3;
|
||||
/// Triangle-mesh type this specialisation targets.
|
||||
using Triangle_mesh = CGAL::Surface_mesh<Point_3>;
|
||||
|
||||
/// Boost-graph vertex descriptor for `Triangle_mesh`.
|
||||
using Vertex_descriptor = typename boost::graph_traits<Triangle_mesh>::vertex_descriptor;
|
||||
/// Boost-graph edge descriptor for `Triangle_mesh`.
|
||||
using Edge_descriptor = typename boost::graph_traits<Triangle_mesh>::edge_descriptor;
|
||||
|
||||
// Inversive-distance specific property maps.
|
||||
|
||||
/// Property map vertex → contiguous integer DOF index (legacy `iv:idx`).
|
||||
using Vertex_index_pmap = typename Triangle_mesh::template Property_map<Vertex_descriptor, int>;
|
||||
/// Property map vertex → target cone angle Θᵥ in radians (legacy `iv:theta`).
|
||||
using Theta_v_pmap = typename Triangle_mesh::template Property_map<Vertex_descriptor, FT>;
|
||||
/// Property map vertex → initial radius r⁰ᵥ (legacy `iv:r0`).
|
||||
using R0_pmap = typename Triangle_mesh::template Property_map<Vertex_descriptor, FT>;
|
||||
/// Property map edge → inversive distance Iᵢⱼ (legacy `ie:I`).
|
||||
using I_e_pmap = typename Triangle_mesh::template Property_map<Edge_descriptor, FT>;
|
||||
};
|
||||
|
||||
@@ -74,7 +98,7 @@ struct Default_inversive_distance_traits<CGAL::Surface_mesh<typename K::Point_3>
|
||||
/*!
|
||||
\ingroup PkgConformalMapRef
|
||||
|
||||
Compute the Luo-2004 vertex-based inversive-distance circle packing of `mesh`.
|
||||
Compute the Luo 2004 vertex-based inversive-distance circle packing of `mesh`.
|
||||
|
||||
The per-edge constant `I_ij` is computed once at the start from the input
|
||||
3-D geometry via the Bowers-Stephenson identity
|
||||
@@ -183,6 +207,57 @@ 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) ─────────────────────
|
||||
//
|
||||
// If the caller supplied `output_uv_map(pmap)`, lay out the converged
|
||||
// packing in ℝ² and write per-vertex `Point_2` coordinates into `pmap`.
|
||||
//
|
||||
// Method: the converged Inversive-Distance radii `r_i = exp(u_i)`
|
||||
// together with the fixed per-edge `I_ij` constants determine effective
|
||||
// Euclidean edge lengths via the Bowers-Stephenson identity
|
||||
// ℓᵢⱼ² = rᵢ² + rⱼ² + 2·Iᵢⱼ·rᵢ·rⱼ
|
||||
// so we can populate a temporary `EuclideanMaps` whose `lambda0` carries
|
||||
// `log(ℓᵢⱼ²)` per edge and then reuse `euclidean_layout(mesh, 0, eucl)`
|
||||
// — the existing priority-BFS trilateration on the resulting triangle
|
||||
// metric. All vertex/edge DOF indices stay at −1 (pinned), so the empty
|
||||
// DOF vector `0` produces lengths driven purely by `lambda0`.
|
||||
auto uv_param = parameters::get_parameter(
|
||||
np, Conformal_map::internal_np::output_uv_map);
|
||||
constexpr bool has_uv = !std::is_same_v<
|
||||
decltype(uv_param), internal_np::Param_not_found>;
|
||||
if constexpr (has_uv) {
|
||||
if (nr.converged) {
|
||||
auto eucl = ::conformallab::setup_euclidean_maps(mesh);
|
||||
for (auto e : mesh.edges()) {
|
||||
auto h = mesh.halfedge(e);
|
||||
const double u_i = result.u_per_vertex[mesh.source(h).idx()];
|
||||
const double u_j = result.u_per_vertex[mesh.target(h).idx()];
|
||||
const double I = maps.I_e[e];
|
||||
const double l2 = ::conformallab::id_detail::edge_length_squared(u_i, u_j, I);
|
||||
eucl.lambda0[e] = (l2 > 0.0) ? std::log(l2) : -30.0;
|
||||
}
|
||||
// Empty DOF vector: every vertex is pinned (idx=-1), so the
|
||||
// layout depends purely on the lambda0 we just computed.
|
||||
std::vector<double> zero;
|
||||
auto layout = ::conformallab::euclidean_layout(mesh, zero, eucl);
|
||||
|
||||
const bool do_norm = parameters::choose_parameter(
|
||||
parameters::get_parameter(np, Conformal_map::internal_np::normalise_layout),
|
||||
false);
|
||||
if (do_norm) ::conformallab::normalise_euclidean(layout);
|
||||
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& uv = layout.uv[v.idx()];
|
||||
put(uv_param, v,
|
||||
typename Traits::Kernel::Point_2(uv.x(), uv.y()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
// Clausen integral, Lobachevsky function, and Im(Li2).
|
||||
// Ported from de.varylab.discreteconformal.functional.Clausen (Java).
|
||||
@@ -27,6 +30,12 @@ inline double csevl(double x, const double* cs, int n) noexcept {
|
||||
|
||||
// Count Chebyshev terms needed so truncation error <= eta.
|
||||
// Corresponds to Java Clausen.inits().
|
||||
//
|
||||
// Note on return value: the loop decrements n one extra time after the
|
||||
// stopping condition is met, so the returned value is one less than the
|
||||
// last index checked. Callers pass the result directly to csevl() as
|
||||
// the term count, which evaluates terms [0, n-1] — this is intentional
|
||||
// and matches the Java Clausen.inits() / csevl() contract exactly.
|
||||
inline int inits(const double* series, int n, double eta) noexcept {
|
||||
double err = 0.0;
|
||||
while (err <= eta && n-- != 0) {
|
||||
@@ -128,9 +137,9 @@ inline int ncl5pi6() noexcept {
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// Clausen's integral Cl2(x) = -integral_0^x log|2 sin(t/2)| dt.
|
||||
// High-precision Chebyshev implementation.
|
||||
// Corresponds to Java Clausen.clausen2().
|
||||
/// Clausen's integral `Cl₂(x) = −∫₀ˣ log|2 sin(t/2)| dt`,
|
||||
/// computed via a high-precision Chebyshev expansion.
|
||||
/// Same as Java `Clausen.clausen2()`.
|
||||
inline double clausen2(double x) noexcept {
|
||||
using namespace detail;
|
||||
constexpr double pi = 3.14159265358979323846264338328;
|
||||
@@ -154,8 +163,8 @@ inline double clausen2(double x) noexcept {
|
||||
return rh ? -f : f;
|
||||
}
|
||||
|
||||
// Milnor's Lobachevsky function Л(x) = Cl2(2x)/2.
|
||||
// Corresponds to Java Clausen.Л().
|
||||
/// Milnor's Lobachevsky function `Л(x) = Cl₂(2x) / 2`.
|
||||
/// Same as Java `Clausen.Л()`.
|
||||
inline double Lobachevsky(double x) noexcept {
|
||||
constexpr double pi = 3.14159265358979323846264338328;
|
||||
x = std::fmod(x, pi);
|
||||
@@ -163,8 +172,8 @@ inline double Lobachevsky(double x) noexcept {
|
||||
return clausen2(2.0 * x) / 2.0;
|
||||
}
|
||||
|
||||
// Imaginary part of the dilogarithm Im(Li2(z)).
|
||||
// Corresponds to Java Clausen.ImLi2().
|
||||
/// Imaginary part of the dilogarithm `Im(Li₂(z))` for complex `z`.
|
||||
/// Same as Java `Clausen.ImLi2()`.
|
||||
inline double ImLi2(std::complex<double> z) noexcept {
|
||||
auto a = std::log(1.0 - std::conj(z)); // log(1 - conj(z))
|
||||
auto b = std::log(1.0 - z); // log(1 - z)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// conformal_mesh.hpp
|
||||
//
|
||||
// Central mesh type for the discrete conformal mapping algorithms.
|
||||
@@ -34,6 +37,7 @@
|
||||
|
||||
#include <CGAL/Simple_cartesian.h>
|
||||
#include <CGAL/Surface_mesh.h>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace conformallab {
|
||||
@@ -41,30 +45,42 @@ namespace conformallab {
|
||||
// ── Kernel ──────────────────────────────────────────────────────────────────
|
||||
// Simple double-precision Cartesian. Conformal mapping algorithms never
|
||||
// need exact arithmetic — they operate on floating-point lengths and angles.
|
||||
|
||||
/// CGAL kernel used by all conformallab algorithms (double precision).
|
||||
using Kernel = CGAL::Simple_cartesian<double>;
|
||||
/// 3-D point type (vertex coordinates).
|
||||
using Point3 = Kernel::Point_3;
|
||||
/// 2-D point type (UV-domain layout coordinates).
|
||||
using Point2 = Kernel::Point_2;
|
||||
|
||||
// ── Mesh type ────────────────────────────────────────────────────────────────
|
||||
/// Triangle mesh carrying all conformal-map data as property maps.
|
||||
using ConformalMesh = CGAL::Surface_mesh<Point3>;
|
||||
|
||||
// ── Index/descriptor aliases (CGAL 6.x naming) ───────────────────────────────
|
||||
/// Vertex descriptor of `ConformalMesh`.
|
||||
using Vertex_index = ConformalMesh::Vertex_index;
|
||||
/// Half-edge descriptor of `ConformalMesh`.
|
||||
using Halfedge_index = ConformalMesh::Halfedge_index;
|
||||
/// Edge descriptor of `ConformalMesh`.
|
||||
using Edge_index = ConformalMesh::Edge_index;
|
||||
/// Face descriptor of `ConformalMesh`.
|
||||
using Face_index = ConformalMesh::Face_index;
|
||||
|
||||
// ── Geometry type constant (replaces Java CoFace.type enum) ─────────────────
|
||||
/// Discrete geometry type that a face/mesh is interpreted in.
|
||||
/// Replaces the original Java `CoFace.type` enum.
|
||||
enum class GeometryType : int {
|
||||
Euclidean = 0,
|
||||
Hyperbolic = 1,
|
||||
Spherical = 2
|
||||
Euclidean = 0, ///< Flat metric (ℝ²).
|
||||
Hyperbolic = 1, ///< Hyperbolic metric (ℍ²).
|
||||
Spherical = 2 ///< Spherical metric (S²).
|
||||
};
|
||||
|
||||
// ── Standard property-map bundles ────────────────────────────────────────────
|
||||
|
||||
// Add the vertex properties used by all conformal-map functionals.
|
||||
// Returns {lambda, theta, idx}.
|
||||
/// Register and return the vertex-side property maps used by all five
|
||||
/// DCE functionals: `v:lambda` (log conformal factor), `v:theta` (target
|
||||
/// cone angle), `v:idx` (contiguous integer index).
|
||||
inline auto add_vertex_properties(ConformalMesh& mesh)
|
||||
{
|
||||
auto [lambda, ok1] = mesh.add_property_map<Vertex_index, double>("v:lambda", 0.0);
|
||||
@@ -74,7 +90,8 @@ inline auto add_vertex_properties(ConformalMesh& mesh)
|
||||
return std::make_tuple(lambda, theta, idx);
|
||||
}
|
||||
|
||||
// Add the edge intersection-angle property used by the hyperbolic functional.
|
||||
/// Register and return the edge intersection-angle property `e:alpha`
|
||||
/// (used by the hyper-ideal functional).
|
||||
inline auto add_edge_properties(ConformalMesh& mesh)
|
||||
{
|
||||
auto [alpha, ok] = mesh.add_property_map<Edge_index, double>("e:alpha", 0.0);
|
||||
@@ -82,7 +99,7 @@ inline auto add_edge_properties(ConformalMesh& mesh)
|
||||
return alpha;
|
||||
}
|
||||
|
||||
// Add the face geometry-type property.
|
||||
/// Register and return the per-face geometry-type property `f:type`.
|
||||
inline auto add_face_properties(ConformalMesh& mesh)
|
||||
{
|
||||
auto [ftype, ok] = mesh.add_property_map<Face_index, int>(
|
||||
@@ -91,4 +108,23 @@ inline auto add_face_properties(ConformalMesh& mesh)
|
||||
return ftype;
|
||||
}
|
||||
|
||||
// ── Half-edge index helper ────────────────────────────────────────────────────
|
||||
//
|
||||
// Convert a Halfedge_index to std::size_t for use as a vector subscript.
|
||||
// Each functional previously duplicated this one-liner under a name like
|
||||
// eucl_hidx / spher_hidx / hidx. Centralised here (MINOR-4 fix).
|
||||
//
|
||||
// Implementation: the double-cast uint32_t → size_t is intentional. CGAL 6.x
|
||||
// Surface_mesh stores half-edge indices internally as 32-bit unsigned integers.
|
||||
// Casting directly to size_t on a 64-bit system would produce the same result
|
||||
// because CGAL index types use non-negative values, but the explicit intermediate
|
||||
// cast documents the assumption and silences spurious sign-conversion warnings.
|
||||
|
||||
/// Convert a `Halfedge_index` to `std::size_t` for vector subscript use.
|
||||
/// Replaces the duplicated `eucl_hidx`, `spher_hidx`, `hidx` helpers.
|
||||
inline std::size_t halfedge_to_index(Halfedge_index h) noexcept
|
||||
{
|
||||
return static_cast<std::size_t>(static_cast<std::uint32_t>(h));
|
||||
}
|
||||
|
||||
} // namespace conformallab
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// constants.hpp
|
||||
//
|
||||
// Single source of truth for mathematical constants used throughout
|
||||
@@ -14,4 +17,28 @@ constexpr double PI = 3.14159265358979323846264338328;
|
||||
/// 2π (full turn).
|
||||
constexpr double TWO_PI = 2.0 * PI;
|
||||
|
||||
// ── Numerical thresholds and bounds (N4/N6 audit) ──────────────────────────
|
||||
|
||||
/// Edge length floor for log-scale functionals (euclidean_functional, spherical_functional).
|
||||
/// exp(-30.0) ≈ 3e-7: edges below this length are treated as degenerate/zero.
|
||||
/// Rationale: avoids log(tiny) overflow and enforces a minimum edge scale.
|
||||
constexpr double LOG_EDGE_LENGTH_FLOOR = -30.0;
|
||||
|
||||
/// Hyper-ideal scale factor lower bound (hyper_ideal_functional:179-181).
|
||||
/// If b (scale) becomes negative (infeasible), clamp to 0.01 to keep geometry valid.
|
||||
/// Rationale: ensures b > 0 maintains the Lorentzian model's causal structure.
|
||||
constexpr double HYPER_IDEAL_SCALE_FLOOR = 0.01;
|
||||
|
||||
/// Sharpness β of the optional C¹ smooth-barrier scale floor (N3 audit).
|
||||
/// Only used when the hyper-ideal clamp mode is `SmoothBarrier`; larger β
|
||||
/// makes the softplus transition tighter around `HYPER_IDEAL_SCALE_FLOOR`
|
||||
/// (β·floor ≈ 1, so β ≈ 100 keeps the barrier ≈ identity for b ≳ 0.05 while
|
||||
/// staying C¹ across b = 0). See `clamp_hyper_ideal_scale`.
|
||||
constexpr double HYPER_IDEAL_SCALE_SHARPNESS = 100.0;
|
||||
|
||||
/// Domain guard for asin in spherical_l (spherical_geometry:34).
|
||||
/// Clamps exp(λ/2) to slightly below 1.0 so asin stays in [−π/2, π/2].
|
||||
/// Rationale: asin(x) is undefined for |x| > 1; this prevents IEEE Inf/NaN.
|
||||
constexpr double ASIN_DOMAIN_GUARD = 1.0 - 1e-15;
|
||||
|
||||
} // namespace conformallab
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// cp_euclidean_functional.hpp
|
||||
//
|
||||
// Phase 9a.1 — Circle-Packing Euclidean functional (CP-Euclidean).
|
||||
@@ -16,7 +19,7 @@
|
||||
// │ The variable is ρ_f = log R_f. │
|
||||
// │ Adjacent face-circles intersect at a prescribed angle θ_e per edge. │
|
||||
// │ │
|
||||
// │ This is the FACE-DUAL of the classical vertex-based Luo (2004) │
|
||||
// │ This is the FACE-DUAL of the classical vertex-based Luo 2004 │
|
||||
// │ inversive-distance circle packing implemented in │
|
||||
// │ inversive_distance_functional.hpp (Phase 9a.2). The relation │
|
||||
// │ I_ij = cos θ_e │
|
||||
@@ -30,7 +33,7 @@
|
||||
// │ θ_e per edge intersection angle of the two face-circles │
|
||||
// │ φ_f per face target sum of corner-angles inside the face │
|
||||
// │ │
|
||||
// │ Energy (BPS-2010 §6) │
|
||||
// │ Energy (BPS-2015 §6) │
|
||||
// │ E(ρ) = Σ_f φ_f · ρ_f │
|
||||
// │ + Σ_{(h,f=face(h)): │
|
||||
// │ [ if opposite face exists ] │
|
||||
@@ -49,7 +52,7 @@
|
||||
// │ Per interior hf: −(p + θ*) added to G[face(h)] │
|
||||
// │ Per boundary hf: −2 θ* added to G[face(h)] │
|
||||
// │ │
|
||||
// │ Hessian (analytic, BPS-2010 eq. 6.8; Java getHessian lines 127-166) │
|
||||
// │ Hessian (analytic, BPS-2015 eq. 6.8; Java getHessian lines 127-166) │
|
||||
// │ Per interior undirected edge e (connecting faces j and k): │
|
||||
// │ h_jk = sin θ / (cosh Δρ − cos θ) │
|
||||
// │ H[j,j] += h_jk, H[k,k] += h_jk, H[j,k] −= h_jk, H[k,j] −= h_jk │
|
||||
@@ -77,21 +80,42 @@ namespace conformallab {
|
||||
|
||||
// ── Property-map type aliases ────────────────────────────────────────────────
|
||||
|
||||
/// Property map face → `int` for the CP-Euclidean functional.
|
||||
using CPFMapI = ConformalMesh::Property_map<Face_index, int>;
|
||||
/// Property map face → `double` for the CP-Euclidean functional.
|
||||
using CPFMapD = ConformalMesh::Property_map<Face_index, double>;
|
||||
/// Property map edge → `double` for the CP-Euclidean functional.
|
||||
using CPEMapD = ConformalMesh::Property_map<Edge_index, double>;
|
||||
|
||||
// ── Persistent map bundle ─────────────────────────────────────────────────────
|
||||
|
||||
/// Bundle of the three property maps consumed by the CP-Euclidean
|
||||
/// (Bobenko-Pinkall-Springborn 2015) circle-packing functional.
|
||||
struct CPEuclideanMaps {
|
||||
CPFMapI f_idx; ///< DOF index per face (−1 = pinned)
|
||||
CPEMapD theta_e; ///< intersection angle per edge (default π/2 = orthogonal)
|
||||
CPFMapD phi_f; ///< target face-angle sum (default 2π)
|
||||
};
|
||||
|
||||
// Create the property maps with sensible defaults.
|
||||
// θ_e = π/2 produces an orthogonal circle packing (Koebe-Andreev-Thurston).
|
||||
// φ_f = 2π is the natural target for a flat triangle.
|
||||
/// Attach the three CP-Euclidean property maps to `mesh` with default
|
||||
/// values and return their handles.
|
||||
///
|
||||
/// Defaults:
|
||||
/// * `theta_e[e] = π/2` for every edge — orthogonal circle packing
|
||||
/// (Koebe-Andreev-Thurston).
|
||||
/// * `phi_f[f] = 2π` for every face — flat target.
|
||||
/// * `f_idx[f] = -1` for every face — all faces pinned initially;
|
||||
/// call `assign_cp_euclidean_face_dof_indices()` next to assign
|
||||
/// DOF indices to all faces except one gauge-pinned face.
|
||||
///
|
||||
/// The maps are named with the `"cf:"` / `"ce:"` prefixes
|
||||
/// (cf = circle-packing-face, ce = circle-packing-edge) so they do
|
||||
/// not collide with the Euclidean / Spherical / HyperIdeal maps.
|
||||
///
|
||||
/// \param mesh Input mesh. Modified in place: three property maps are
|
||||
/// attached if not already present, otherwise the existing
|
||||
/// maps are returned unchanged (CGAL property-map idempotence).
|
||||
/// \returns A bundle of all three property maps for caller use.
|
||||
inline CPEuclideanMaps setup_cp_euclidean_maps(ConformalMesh& mesh)
|
||||
{
|
||||
CPEuclideanMaps m;
|
||||
@@ -101,8 +125,18 @@ inline CPEuclideanMaps setup_cp_euclidean_maps(ConformalMesh& mesh)
|
||||
return m;
|
||||
}
|
||||
|
||||
// Assign DOF indices 0..n-1 to all faces except `pinned`, which gets −1.
|
||||
// Mirrors Java CPEuclideanFunctional's convention "skip face index 0".
|
||||
/// Assign sequential DOF indices `0..n-1` to all faces except `pinned`,
|
||||
/// which receives the sentinel `-1` (gauge-fixed face, `ρ_pinned = 0`).
|
||||
///
|
||||
/// Mirrors the Java CPEuclideanFunctional's "skip face index 0"
|
||||
/// convention from `evaluateEnergyAndGradient` (lines 184-185 of
|
||||
/// CPEuclideanFunctional.java). The C++ port exposes the choice of
|
||||
/// pinned face explicitly rather than hard-coding it.
|
||||
///
|
||||
/// \param mesh The mesh. Read for face iteration only; not modified.
|
||||
/// \param m Map bundle whose `f_idx` is overwritten.
|
||||
/// \param pinned The face whose DOF is fixed at zero (the gauge).
|
||||
/// \returns The number of free DOFs assigned (`num_faces(mesh) - 1`).
|
||||
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
||||
CPEuclideanMaps& m,
|
||||
Face_index pinned)
|
||||
@@ -115,7 +149,9 @@ inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Convenience: pin the first face in iteration order.
|
||||
/// Convenience overload: pin the **first** face in `mesh.faces()` order.
|
||||
/// Use this when any face works as the gauge (typically true for
|
||||
/// closed mesh experiments).
|
||||
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
||||
CPEuclideanMaps& m)
|
||||
{
|
||||
@@ -124,6 +160,8 @@ inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
|
||||
return assign_cp_euclidean_face_dof_indices(mesh, m, *it);
|
||||
}
|
||||
|
||||
/// Count the free DOFs (faces with `f_idx >= 0`).
|
||||
/// Equivalent to `num_faces(mesh) - <number of pinned faces>`.
|
||||
inline int cp_euclidean_dimension(const ConformalMesh& mesh,
|
||||
const CPEuclideanMaps& m)
|
||||
{
|
||||
@@ -154,11 +192,8 @@ inline double dof_val(int idx, const std::vector<double>& x) noexcept
|
||||
|
||||
} // namespace cp_detail
|
||||
|
||||
// ── Energy ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors evaluateEnergyAndGradient in the Java code (lines 170-240) for the
|
||||
// energy accumulation only. The gradient is computed in a dedicated function
|
||||
// below for clarity.
|
||||
/// CP-Euclidean energy value at DOF vector `x` (ρ per face).
|
||||
/// Mirrors `evaluateEnergyAndGradient()` in the Java original (lines 170-240).
|
||||
inline double cp_euclidean_energy(const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const CPEuclideanMaps& m)
|
||||
@@ -203,10 +238,8 @@ inline double cp_euclidean_energy(const ConformalMesh& mesh,
|
||||
return E;
|
||||
}
|
||||
|
||||
// ── Gradient ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// ∂E/∂ρ_f = φ_f − Σ_{h: face(h)=f, !is_border(h)} (p + θ*)
|
||||
// OR (boundary): 2 θ*
|
||||
/// CP-Euclidean gradient `∂E/∂ρ_f` (per face DOF). Interior term
|
||||
/// `−(p + θ*)`, boundary term `−2 θ*`; see `setup_cp_euclidean_maps`.
|
||||
inline std::vector<double> cp_euclidean_gradient(const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const CPEuclideanMaps& m)
|
||||
@@ -250,12 +283,10 @@ inline std::vector<double> cp_euclidean_gradient(const ConformalMesh& mes
|
||||
return G;
|
||||
}
|
||||
|
||||
// ── Hessian (analytic) ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Per interior undirected edge e with adjacent faces (j, k):
|
||||
// h_jk = sin θ / (cosh(Δρ) − cos θ)
|
||||
// Diagonal contributions on both endpoints; off-diagonal block is −h_jk.
|
||||
// Pinned faces are skipped (their DOF index is −1 ⇒ excluded from the matrix).
|
||||
/// Analytic CP-Euclidean Hessian, sparse. Per interior edge `(j,k)`
|
||||
/// the contribution is `h_jk = sin θ / (cosh(Δρ) − cos θ)`, added to
|
||||
/// diagonals `H_jj`, `H_kk` and subtracted off-diagonals `H_jk = H_kj`.
|
||||
/// Pinned faces are excluded (DOF index −1).
|
||||
inline Eigen::SparseMatrix<double> cp_euclidean_hessian(const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const CPEuclideanMaps& m)
|
||||
@@ -293,19 +324,19 @@ inline Eigen::SparseMatrix<double> cp_euclidean_hessian(const ConformalMesh&
|
||||
return H;
|
||||
}
|
||||
|
||||
// ── Finite-difference gradient check ─────────────────────────────────────────
|
||||
//
|
||||
// Mirrors the Java FunctionalTest pattern:
|
||||
// For each DOF i, compare analytic G[i] to (E(x+ε·e_i) − E(x−ε·e_i)) / (2ε).
|
||||
// Default tolerance 1e-6 with ε = 1e-5 leaves ~3 digits of margin for sane meshes.
|
||||
/// FD gradient check for the CP-Euclidean functional (central differences).
|
||||
/// Uses the same **relative** error criterion as every other gradient check in
|
||||
/// this library: `|analytic − fd| / max(1, |analytic|) < tol`.
|
||||
/// Default `eps = 1e-5`, `tol = 1e-4` (matches Java `FunctionalTest`).
|
||||
inline bool gradient_check_cp_euclidean(const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const CPEuclideanMaps& m,
|
||||
double eps = 1e-5,
|
||||
double tol = 1e-6)
|
||||
double tol = 1e-4)
|
||||
{
|
||||
auto G = cp_euclidean_gradient(mesh, x, m);
|
||||
const std::size_t n = G.size();
|
||||
bool ok = true;
|
||||
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
std::vector<double> xp = x, xm = x;
|
||||
@@ -313,30 +344,33 @@ inline bool gradient_check_cp_euclidean(const ConformalMesh& mesh,
|
||||
xm[i] -= eps;
|
||||
const double Ep = cp_euclidean_energy(mesh, xp, m);
|
||||
const double Em = cp_euclidean_energy(mesh, xm, m);
|
||||
const double fd = (Ep - Em) / (2.0 * eps);
|
||||
if (std::abs(G[i] - fd) > tol) {
|
||||
const double fd = (Ep - Em) / (2.0 * eps);
|
||||
const double err = std::abs(G[i] - fd);
|
||||
const double scale = std::max(1.0, std::abs(G[i]));
|
||||
if (err / scale > tol) {
|
||||
std::cerr << "[cp-euclidean] FD gradient mismatch at DOF " << i
|
||||
<< ": analytic=" << G[i]
|
||||
<< " FD=" << fd
|
||||
<< " diff=" << (G[i] - fd) << "\n";
|
||||
return false;
|
||||
<< " rel-err=" << (err / scale) << "\n";
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ── Finite-difference Hessian check ──────────────────────────────────────────
|
||||
//
|
||||
// Verifies analytic H against ( G(x+ε·e_i) − G(x−ε·e_i) ) / (2ε) column-wise.
|
||||
// Symmetry is implicit in the analytic form; we check both off-diagonal entries.
|
||||
/// FD Hessian check for the CP-Euclidean functional. Verifies analytic
|
||||
/// `H` column-by-column against `(G(x+εe_j) − G(x−εe_j)) / (2ε)`.
|
||||
/// Uses the same **relative** error criterion as `hessian_check_euclidean`:
|
||||
/// `|analytic − fd| / max(1, |analytic|) < tol`.
|
||||
inline bool hessian_check_cp_euclidean(const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const CPEuclideanMaps& m,
|
||||
double eps = 1e-5,
|
||||
double tol = 1e-5)
|
||||
double tol = 1e-4)
|
||||
{
|
||||
const auto H = cp_euclidean_hessian(mesh, x, m);
|
||||
const int n = static_cast<int>(H.rows());
|
||||
bool ok = true;
|
||||
|
||||
for (int j = 0; j < n; ++j) {
|
||||
std::vector<double> xp = x, xm = x;
|
||||
@@ -346,19 +380,21 @@ inline bool hessian_check_cp_euclidean(const ConformalMesh& mesh,
|
||||
auto Gm = cp_euclidean_gradient(mesh, xm, m);
|
||||
|
||||
for (int i = 0; i < n; ++i) {
|
||||
double fd = (Gp[static_cast<std::size_t>(i)] - Gm[static_cast<std::size_t>(i)])
|
||||
/ (2.0 * eps);
|
||||
double an = H.coeff(i, j);
|
||||
if (std::abs(an - fd) > tol) {
|
||||
double fd = (Gp[static_cast<std::size_t>(i)] - Gm[static_cast<std::size_t>(i)])
|
||||
/ (2.0 * eps);
|
||||
double an = H.coeff(i, j);
|
||||
double err = std::abs(an - fd);
|
||||
double scale = std::max(1.0, std::abs(an));
|
||||
if (err / scale > tol) {
|
||||
std::cerr << "[cp-euclidean] FD Hessian mismatch at ("
|
||||
<< i << "," << j << "): analytic=" << an
|
||||
<< " FD=" << fd
|
||||
<< " diff=" << (an - fd) << "\n";
|
||||
return false;
|
||||
<< " rel-err=" << (err / scale) << "\n";
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace conformallab
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// cut_graph.hpp
|
||||
//
|
||||
// Phase 6 — Tree-cotree algorithm for computing a cut graph of a triangulated
|
||||
@@ -36,6 +39,8 @@ namespace conformallab {
|
||||
// CutGraph
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cut-graph result of the tree-cotree algorithm: the set of `2g` edges
|
||||
/// whose removal turns a closed genus-`g` surface into a topological disk.
|
||||
struct CutGraph {
|
||||
/// cut_edge_flags[e.idx()] = true ↔ this edge is a cut edge.
|
||||
/// Size = mesh.number_of_edges().
|
||||
@@ -44,27 +49,36 @@ struct CutGraph {
|
||||
/// Indices of the 2g cut edges in order (size = 2g).
|
||||
std::vector<std::size_t> cut_edge_indices;
|
||||
|
||||
/// dual_tree_edge_flags[e.idx()] = true ↔ edge `e` is a dual-spanning-tree
|
||||
/// edge (its dual is in T*). Size = mesh.number_of_edges().
|
||||
/// Crossing only these edges develops the surface onto a topological disk
|
||||
/// (the fundamental polygon), so that the `2g` cut edges become genuine
|
||||
/// boundary identifications carrying the holonomy generators.
|
||||
std::vector<bool> dual_tree_edge_flags;
|
||||
|
||||
/// Genus of the surface (0 for topological spheres and open patches).
|
||||
int genus = 0;
|
||||
|
||||
/// `true` iff edge `e` is a cut edge of this graph.
|
||||
bool is_cut(Edge_index e) const
|
||||
{
|
||||
return static_cast<std::size_t>(e.idx()) < cut_edge_flags.size()
|
||||
&& cut_edge_flags[static_cast<std::size_t>(e.idx())];
|
||||
}
|
||||
|
||||
/// `true` iff edge `e` is a dual-spanning-tree edge (crossable when
|
||||
/// developing the surface onto a disk).
|
||||
bool is_dual_tree(Edge_index e) const
|
||||
{
|
||||
return static_cast<std::size_t>(e.idx()) < dual_tree_edge_flags.size()
|
||||
&& dual_tree_edge_flags[static_cast<std::size_t>(e.idx())];
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// compute_cut_graph
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Implements the standard tree-cotree algorithm (Erickson–Whittlesey 2005):
|
||||
//
|
||||
// Step 1: BFS primal spanning tree T (V−1 primal tree edges).
|
||||
// Step 2: BFS dual spanning tree T* (F−1 dual/primal edges, avoiding
|
||||
// edges whose primal crosses T).
|
||||
// Step 3: cut edges = primal edges neither in T nor "used" by T*.
|
||||
|
||||
/// Compute the cut graph of `mesh` via the standard tree-cotree
|
||||
/// algorithm (Erickson–Whittlesey 2005): primal BFS spanning tree T,
|
||||
/// dual BFS spanning tree T* avoiding T-primals, then the `2g` cut
|
||||
/// edges are those in neither T nor T*.
|
||||
inline CutGraph compute_cut_graph(const ConformalMesh& mesh)
|
||||
{
|
||||
const std::size_t nv = mesh.number_of_vertices();
|
||||
@@ -146,6 +160,11 @@ inline CutGraph compute_cut_graph(const ConformalMesh& mesh)
|
||||
cg.cut_edge_indices.push_back(idx);
|
||||
}
|
||||
|
||||
// Expose the dual spanning tree T*: developing across only these edges
|
||||
// unfolds the surface onto a disk, making the cut edges the boundary
|
||||
// identifications that carry the holonomy generators.
|
||||
cg.dual_tree_edge_flags = std::move(dual_tree_edge);
|
||||
|
||||
return cg;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
// Ported from de.varylab.discreteconformal.util.DiscreteEllipticUtility (Java).
|
||||
// Only the pure-math subset (no HDS required).
|
||||
@@ -17,7 +20,9 @@ namespace conformallab {
|
||||
// 3. Re-flip: Re < 0 → Re = -Re
|
||||
// 4. S-invert: |tau| < 1 → tau = 1/tau
|
||||
//
|
||||
// Corresponds to Java DiscreteEllipticUtility.normalizeModulus(Complex).
|
||||
/// Normalise a complex modulus `τ` into the standard fundamental
|
||||
/// domain of an elliptic curve (`|τ| ≥ 1`, `0 ≤ Re τ ≤ ½`, `Im τ ≥ 0`).
|
||||
/// Same as Java `DiscreteEllipticUtility.normalizeModulus(Complex)`.
|
||||
inline std::complex<double> normalizeModulus(std::complex<double> tau) {
|
||||
int maxIter = 100;
|
||||
while (--maxIter > 0) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// euclidean_functional.hpp
|
||||
//
|
||||
// Energy and gradient of the Euclidean discrete conformal functional
|
||||
@@ -38,6 +41,7 @@
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include "constants.hpp"
|
||||
#include "gauss_legendre.hpp"
|
||||
#include "euclidean_geometry.hpp"
|
||||
#include <CGAL/boost/graph/iterator.h>
|
||||
#include <vector>
|
||||
@@ -48,13 +52,18 @@ namespace conformallab {
|
||||
|
||||
// ── Property-map type aliases ─────────────────────────────────────────────────
|
||||
|
||||
/// Property map vertex → `double` for the Euclidean functional.
|
||||
using EuclVMapD = ConformalMesh::Property_map<Vertex_index, double>;
|
||||
/// Property map vertex → `int` for the Euclidean functional.
|
||||
using EuclVMapI = ConformalMesh::Property_map<Vertex_index, int>;
|
||||
/// Property map edge → `double` for the Euclidean functional.
|
||||
using EuclEMapD = ConformalMesh::Property_map<Edge_index, double>;
|
||||
/// Property map edge → `int` for the Euclidean functional.
|
||||
using EuclEMapI = ConformalMesh::Property_map<Edge_index, int>;
|
||||
|
||||
// ── Persistent map bundle ─────────────────────────────────────────────────────
|
||||
|
||||
/// Bundle of the five property maps consumed by the Euclidean functional.
|
||||
struct EuclideanMaps {
|
||||
EuclVMapI v_idx; ///< DOF index per vertex (-1 = pinned / u_v = 0)
|
||||
EuclEMapI e_idx; ///< DOF index per edge (-1 = no edge DOF)
|
||||
@@ -63,8 +72,17 @@ struct EuclideanMaps {
|
||||
EuclEMapD lambda0; ///< base log-length λ°_e (default 0.0)
|
||||
};
|
||||
|
||||
// Create and attach property maps with sensible defaults.
|
||||
// theta_v = 2π (flat vertex), phi_e = π (interior edge, flat surface).
|
||||
/// Attach the five Euclidean property maps to `mesh` with sensible
|
||||
/// defaults and return their handles.
|
||||
///
|
||||
/// Defaults:
|
||||
/// * `v_idx[v] = -1` (every vertex pinned; user must reassign before solving)
|
||||
/// * `e_idx[e] = -1` (no edge DOFs by default; use `assign_euclidean_all_dof_indices` for cyclic functional)
|
||||
/// * `theta_v[v] = 2π` (flat interior vertex target)
|
||||
/// * `phi_e[e] = π` (interior edge turn angle target — flat surface)
|
||||
/// * `lambda0[e] = 0` (placeholder; call `compute_euclidean_lambda0_from_mesh` next)
|
||||
///
|
||||
/// Map name prefix: `"ev:"` (vertex) and `"ee:"` (edge).
|
||||
inline EuclideanMaps setup_euclidean_maps(ConformalMesh& mesh)
|
||||
{
|
||||
EuclideanMaps m;
|
||||
@@ -76,7 +94,16 @@ inline EuclideanMaps setup_euclidean_maps(ConformalMesh& mesh)
|
||||
return m;
|
||||
}
|
||||
|
||||
// Assign DOF indices 0..n-1 for all vertices only (no edge DOFs).
|
||||
/// Assign sequential DOF indices `0..n-1` to all vertices.
|
||||
///
|
||||
/// **Note:** this overload assigns indices to ALL vertices unconditionally.
|
||||
/// Any `v_idx` set before the call is overwritten. To pin a gauge vertex,
|
||||
/// either use the two-argument overload below, or set `m.v_idx[v] = -1`
|
||||
/// **after** this call. Pinning before this call has no effect.
|
||||
///
|
||||
/// For closed meshes one gauge vertex should be pinned to remove the
|
||||
/// rotational null mode (the Newton solver's SparseQR fallback handles an
|
||||
/// unpinned closed mesh automatically, but at higher cost).
|
||||
inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
|
||||
{
|
||||
int idx = 0;
|
||||
@@ -84,7 +111,24 @@ inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMap
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Assign DOF indices for all vertices AND all edges.
|
||||
/// Assign sequential DOF indices to all vertices, pinning `gauge`
|
||||
/// (`m.v_idx[gauge] = -1`). Use this overload on closed meshes to fix
|
||||
/// the rotational gauge mode in a single call.
|
||||
///
|
||||
/// \returns The number of free DOFs assigned (`num_vertices − 1`).
|
||||
inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMaps& m,
|
||||
Vertex_index gauge)
|
||||
{
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices())
|
||||
m.v_idx[v] = (v == gauge) ? -1 : idx++;
|
||||
return idx;
|
||||
}
|
||||
|
||||
/// Assign DOF indices for all vertices AND all edges (vertex-DOFs first,
|
||||
/// then edge-DOFs). Use this overload for the "cyclic" formulation that
|
||||
/// includes per-edge log-length DOFs (`λ_e`) on top of per-vertex scale
|
||||
/// factors (`u_v`).
|
||||
inline int assign_euclidean_all_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
|
||||
{
|
||||
int idx = 0;
|
||||
@@ -93,7 +137,7 @@ inline int assign_euclidean_all_dof_indices(ConformalMesh& mesh, EuclideanMaps&
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Count variable DOFs (vertices + edges).
|
||||
/// Count the free DOFs (vertices + edges with index `≥ 0`).
|
||||
inline int euclidean_dimension(const ConformalMesh& mesh, const EuclideanMaps& m)
|
||||
{
|
||||
int dim = 0;
|
||||
@@ -102,10 +146,9 @@ inline int euclidean_dimension(const ConformalMesh& mesh, const EuclideanMaps& m
|
||||
return dim;
|
||||
}
|
||||
|
||||
// Set lambda0 from mesh vertex positions (Euclidean):
|
||||
// λ°_e = 2·log(|p_i − p_j|) (natural log of Euclidean edge length squared)
|
||||
//
|
||||
// This gives exp(Λ̃_ij / 2) = l_ij at x=0.
|
||||
/// Set `lambda0` from mesh vertex positions:
|
||||
/// `λ°_e = 2·log(|p_i − p_j|)` (natural log of Euclidean edge length²).
|
||||
/// This gives `exp(Λ̃_ij / 2) = l_ij` at `x = 0`.
|
||||
inline void compute_euclidean_lambda0_from_mesh(ConformalMesh& mesh, EuclideanMaps& m)
|
||||
{
|
||||
for (auto e : mesh.edges()) {
|
||||
@@ -119,31 +162,28 @@ inline void compute_euclidean_lambda0_from_mesh(ConformalMesh& mesh, EuclideanMa
|
||||
if (len > 1e-15)
|
||||
m.lambda0[e] = 2.0 * std::log(len);
|
||||
else
|
||||
m.lambda0[e] = -30.0; // degenerate edge
|
||||
m.lambda0[e] = LOG_EDGE_LENGTH_FLOOR; // degenerate edge
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Read DOF value from `x` for index `idx`; return 0 if pinned (idx < 0).
|
||||
static inline double eucl_dof_val(int idx, const std::vector<double>& x)
|
||||
{
|
||||
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
|
||||
}
|
||||
|
||||
static inline std::size_t eucl_hidx(Halfedge_index h)
|
||||
{
|
||||
return static_cast<std::size_t>(static_cast<std::uint32_t>(h));
|
||||
}
|
||||
// halfedge_to_index is defined in conformal_mesh.hpp (included above).
|
||||
// The old local alias eucl_hidx is retained as a thin wrapper for now so
|
||||
// call-sites below do not need touching; a follow-up can remove it.
|
||||
static inline std::size_t eucl_hidx(Halfedge_index h) { return halfedge_to_index(h); }
|
||||
|
||||
// ── Gradient ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// G_v = Θ_v − Σ_{faces adj. v} α_v(face)
|
||||
// G_e = α_opp(face⁺) + α_opp(face⁻) − φ_e
|
||||
//
|
||||
// Corner-angle storage (h_alpha):
|
||||
// h_alpha[h] = corner angle OPPOSITE to the edge of halfedge h in its face.
|
||||
// h_alpha[h0] = α3, h_alpha[h1] = α1, h_alpha[h2] = α2
|
||||
// (same convention as SphericalFunctional)
|
||||
/// Compute the Euclidean-functional gradient G(x):
|
||||
/// * `G_v = Θ_v − Σ_faces α_v(face)`
|
||||
/// * `G_e = α_opp(face⁺) + α_opp(face⁻) − φ_e`
|
||||
///
|
||||
/// Same half-edge corner-angle storage convention as `spherical_gradient`.
|
||||
inline std::vector<double> euclidean_gradient(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
@@ -180,7 +220,11 @@ inline std::vector<double> euclidean_gradient(
|
||||
double lam31 = m.lambda0[e31] + u3 + u1 + eucl_dof_val(m.e_idx[e31], x);
|
||||
|
||||
auto fa = euclidean_angles(lam12, lam23, lam31);
|
||||
if (!fa.valid) continue; // degenerate triangle: contributes 0
|
||||
// NOTE: do NOT skip degenerate faces. euclidean_angles() returns the
|
||||
// limiting angles (one corner = π, others = 0) when the triangle
|
||||
// inequality is violated; using them is exactly what the Java reference
|
||||
// does and is required for the BPS energy to be the convex C¹ extension
|
||||
// onto the infeasible region (otherwise Newton can stall at a flip).
|
||||
|
||||
// h_alpha[h] = corner angle OPPOSITE to h's edge:
|
||||
// h0 (edge v1v2) → opposite corner at v3 → α3
|
||||
@@ -221,28 +265,15 @@ inline std::vector<double> euclidean_gradient(
|
||||
return G;
|
||||
}
|
||||
|
||||
// ── Energy via Gauss-Legendre path integral ───────────────────────────────────
|
||||
//
|
||||
// E(x) = ∫₀¹ ⟨G(tx), x⟩ dt (10-point GL quadrature, same as SphericalFunctional)
|
||||
/// Euclidean energy `E(x) = ∫₀¹ ⟨G(t·x), x⟩ dt`, evaluated with
|
||||
/// 10-point Gauss-Legendre quadrature (same as the Spherical functional).
|
||||
inline double euclidean_energy(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const EuclideanMaps& m)
|
||||
{
|
||||
static const double gl_s[10] = {
|
||||
-0.9739065285171717, -0.8650633666889845,
|
||||
-0.6794095682990244, -0.4333953941292472,
|
||||
-0.1488743389816312, 0.1488743389816312,
|
||||
0.4333953941292472, 0.6794095682990244,
|
||||
0.8650633666889845, 0.9739065285171717
|
||||
};
|
||||
static const double gl_w[10] = {
|
||||
0.0666713443086881, 0.1494513491505806,
|
||||
0.2190863625159820, 0.2692667193099963,
|
||||
0.2955242247147529, 0.2955242247147529,
|
||||
0.2692667193099963, 0.2190863625159820,
|
||||
0.1494513491505806, 0.0666713443086881
|
||||
};
|
||||
const double* gl_s = gl10_nodes();
|
||||
const double* gl_w = gl10_weights();
|
||||
|
||||
const std::size_t n = x.size();
|
||||
double E = 0.0;
|
||||
@@ -265,11 +296,14 @@ inline double euclidean_energy(
|
||||
|
||||
// ── Full evaluation (energy + gradient) ──────────────────────────────────────
|
||||
|
||||
/// Output of `evaluate_euclidean()` — energy plus optional gradient.
|
||||
struct EuclideanResult {
|
||||
double energy = 0.0;
|
||||
std::vector<double> gradient;
|
||||
double energy = 0.0; ///< Functional value at input DOFs.
|
||||
std::vector<double> gradient; ///< Gradient ∇E (empty if not requested).
|
||||
};
|
||||
|
||||
/// Evaluate the Euclidean functional at DOFs `x`. Returns energy and
|
||||
/// gradient (toggle via `need_energy` / `need_gradient`).
|
||||
inline EuclideanResult evaluate_euclidean(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
@@ -285,10 +319,8 @@ inline EuclideanResult evaluate_euclidean(
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Finite-difference gradient check ─────────────────────────────────────────
|
||||
//
|
||||
// Tests |G[i] − (E(x+εeᵢ) − E(x−εeᵢ))/(2ε)| / max(1,|G[i]|) < tol
|
||||
// for all variable DOFs.
|
||||
/// Finite-difference gradient check for the Euclidean functional
|
||||
/// (central differences). Defaults `eps = 1e-5`, `tol = 1e-4`.
|
||||
inline bool gradient_check_euclidean(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x0,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// euclidean_geometry.hpp
|
||||
//
|
||||
// Corner-angle formula for Euclidean triangles in the discrete conformal
|
||||
@@ -25,23 +28,22 @@
|
||||
// rescales all three sides by the same factor, leaving angles unchanged but
|
||||
// keeping the arguments of exp in a safe numerical range.
|
||||
|
||||
#include "constants.hpp"
|
||||
#include <cmath>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
/// Interior corner angles of a Euclidean triangle.
|
||||
struct EuclideanFaceAngles {
|
||||
double alpha1; ///< corner angle at v1 (opposite l23)
|
||||
double alpha2; ///< corner angle at v2 (opposite l31)
|
||||
double alpha3; ///< corner angle at v3 (opposite l12)
|
||||
bool valid;
|
||||
double alpha1; ///< Corner angle at v₁ (opposite l₂₃).
|
||||
double alpha2; ///< Corner angle at v₂ (opposite l₃₁).
|
||||
double alpha3; ///< Corner angle at v₃ (opposite l₁₂).
|
||||
bool valid; ///< `false` when the triangle is degenerate.
|
||||
};
|
||||
|
||||
// ── From side lengths ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Given three Euclidean side lengths l12, l23, l31 > 0 satisfying the triangle
|
||||
// inequality, compute the corner angles.
|
||||
//
|
||||
// Returns valid=false if the triangle inequality is violated (any t-value ≤ 0).
|
||||
/// Compute the corner angles of a Euclidean triangle from its three
|
||||
/// side lengths. Returns `valid = false` when the triangle inequality
|
||||
/// is violated.
|
||||
inline EuclideanFaceAngles euclidean_angles_from_lengths(
|
||||
double l12, double l23, double l31)
|
||||
{
|
||||
@@ -49,8 +51,16 @@ inline EuclideanFaceAngles euclidean_angles_from_lengths(
|
||||
const double t23 = +l12 - l23 + l31; // 2*(s − l23)
|
||||
const double t31 = +l12 + l23 - l31; // 2*(s − l31)
|
||||
|
||||
if (t12 <= 0.0 || t23 <= 0.0 || t31 <= 0.0)
|
||||
return {0.0, 0.0, 0.0, false};
|
||||
// Degenerate (triangle inequality violated): return the *limiting* angles
|
||||
// of the flat-out triangle — the corner opposite the over-long edge is π,
|
||||
// the other two are 0. This is the convex C¹ extension of the BPS energy
|
||||
// onto the infeasible region and is what the Java reference
|
||||
// (EuclideanCyclicFunctional.triangleEnergyAndAlphas) does. `valid` stays
|
||||
// false so the cotangent Hessian still skips this face. At most one t can
|
||||
// be ≤ 0 (t12+t23 = 2·l31 > 0, etc.), so the order of these checks is moot.
|
||||
if (t23 <= 0.0) return {PI, 0.0, 0.0, false}; // l23 too long → α₁ = π
|
||||
if (t31 <= 0.0) return {0.0, PI, 0.0, false}; // l31 too long → α₂ = π
|
||||
if (t12 <= 0.0) return {0.0, 0.0, PI, false}; // l12 too long → α₃ = π
|
||||
|
||||
const double l123 = l12 + l23 + l31;
|
||||
const double denom2 = t12 * t23 * t31 * l123; // = (4·Area)²
|
||||
@@ -70,14 +80,9 @@ inline EuclideanFaceAngles euclidean_angles_from_lengths(
|
||||
};
|
||||
}
|
||||
|
||||
// ── From effective log-lengths Λ̃ ─────────────────────────────────────────────
|
||||
//
|
||||
// Converts to side lengths l_ij = exp(Λ̃_ij / 2), applying the centering
|
||||
// trick for numerical safety, then delegates to euclidean_angles_from_lengths.
|
||||
//
|
||||
// The centering constant μ = (Λ̃12 + Λ̃23 + Λ̃31) / 6 ensures
|
||||
// l12 · l23 · l31 = 1 (geometric mean = 1)
|
||||
// which keeps all l values near 1 and prevents float overflow for large |Λ̃|.
|
||||
/// Compute the corner angles of a Euclidean triangle from its three
|
||||
/// effective log-lengths `Λ̃ᵢⱼ`. Internally centres lengths so that
|
||||
/// `l₁₂·l₂₃·l₃₁ = 1` to avoid float overflow for large `|Λ̃|`.
|
||||
inline EuclideanFaceAngles euclidean_angles(
|
||||
double lam12, double lam23, double lam31)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// euclidean_hessian.hpp
|
||||
//
|
||||
// Analytical Hessian of the Euclidean discrete conformal energy —
|
||||
@@ -14,18 +17,22 @@
|
||||
// │ side lengths lij = exp(Λ̃ij/2): │
|
||||
// │ │
|
||||
// │ t12 = −l12+l23+l31, t23 = l12−l23+l31, t31 = l12+l23−l31 │
|
||||
// │ denom2 = 2·sqrt(t12·t23·t31·l123) = 8·Area │
|
||||
// │ l123 = l12+l23+l31, denom2 = 8·Area (Area via Kahan's stable │
|
||||
// │ side-length formula — see euclidean_cot_weights)│
|
||||
// │ │
|
||||
// │ cot_k = (t_adj1·l123 − t_adj2·t_opp) / denom2 │
|
||||
// │ = cotangent of the angle αk at vertex k │
|
||||
// │ Cotangent at vertex k (opposite t_opp, adjacent t_a and t_b): │
|
||||
// │ cot_k = (t_opp · l123 − t_a · t_b) / denom2 │
|
||||
// │ │
|
||||
// │ Hessian contributions per face: │
|
||||
// │ H[vi, vi] += cot_vj + cot_vk (diagonal, both non-opp angles) │
|
||||
// │ H[vi, vj] -= cot_vk (off-diagonal, for variable vi,vj│
|
||||
// │ Assignment (k ↔ opposite edge ↔ opposite t-value): │
|
||||
// │ cot1 (v1, opp l23): t_opp=t23, t_a=t12, t_b=t31 │
|
||||
// │ cot2 (v2, opp l31): t_opp=t31, t_a=t12, t_b=t23 │
|
||||
// │ cot3 (v3, opp l12): t_opp=t12, t_a=t23, t_b=t31 │
|
||||
// │ │
|
||||
// │ This is exactly the cotangent-Laplace operator from Pinkall–Polthier. │
|
||||
// │ Hessian contributions per face (Pinkall–Polthier ½ factor): │
|
||||
// │ H[vi, vi] += (cot_vj + cot_vk) / 2 (diagonal) │
|
||||
// │ H[vi, vj] −= cot_vk / 2 (off-diagonal, variable pairs) │
|
||||
// │ │
|
||||
// │ Pinned vertices (v_idx = −1) contribute to diagonal of neighbours but │
|
||||
// │ Pinned vertices (v_idx = −1) contribute to diagonal of neighbours but │
|
||||
// │ do not create a column/row in H themselves. │
|
||||
// └──────────────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
@@ -40,7 +47,10 @@
|
||||
#include "euclidean_functional.hpp"
|
||||
#include <Eigen/Sparse>
|
||||
#include <vector>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
@@ -49,11 +59,25 @@ namespace conformallab {
|
||||
// Given three Euclidean SIDE LENGTHS l12, l23, l31 (already exp(Λ̃/2)),
|
||||
// return the three cotangent weights (cot1, cot2, cot3).
|
||||
//
|
||||
// cot_k = (t_adj·l123 − t_opp·t_other) / (8·Area)
|
||||
// cot_k = (t_opp · l123 − t_a · t_b) / (8·Area)
|
||||
//
|
||||
// where t_opp is the t-value of the edge OPPOSITE vertex k, and t_a, t_b are
|
||||
// the t-values of the two edges ADJACENT to vertex k. See the box comment at
|
||||
// the top of this file for the explicit assignment of t_opp/t_a/t_b per vertex.
|
||||
//
|
||||
// Returns {0,0,0} for degenerate faces (triangle inequality violated or Area=0).
|
||||
struct EuclCotWeights { double cot1, cot2, cot3; bool valid; };
|
||||
/// Three Euclidean cotangent weights `(cot1, cot2, cot3)` for the
|
||||
/// vertices opposite to edges (l₂₃, l₃₁, l₁₂) of a triangle, plus a
|
||||
/// `valid` flag that is `false` when the triangle is degenerate.
|
||||
struct EuclCotWeights {
|
||||
double cot1; ///< Cotangent at vertex 1 (opposite to l₂₃).
|
||||
double cot2; ///< Cotangent at vertex 2 (opposite to l₃₁).
|
||||
double cot3; ///< Cotangent at vertex 3 (opposite to l₁₂).
|
||||
bool valid;///< `false` when the triangle is degenerate (triangle inequality violated or area = 0).
|
||||
};
|
||||
|
||||
/// Compute the three Euclidean cotangent weights from edge lengths.
|
||||
/// Returns `{0,0,0,false}` for degenerate triangles.
|
||||
inline EuclCotWeights euclidean_cot_weights(double l12, double l23, double l31)
|
||||
{
|
||||
const double t12 = -l12 + l23 + l31;
|
||||
@@ -63,16 +87,31 @@ inline EuclCotWeights euclidean_cot_weights(double l12, double l23, double l31)
|
||||
if (t12 <= 0.0 || t23 <= 0.0 || t31 <= 0.0)
|
||||
return {0.0, 0.0, 0.0, false};
|
||||
|
||||
const double l123 = l12 + l23 + l31;
|
||||
const double denom2_sq = t12 * t23 * t31 * l123;
|
||||
if (denom2_sq <= 0.0) return {0.0, 0.0, 0.0, false};
|
||||
const double l123 = l12 + l23 + l31;
|
||||
|
||||
// denom2 = 2·sqrt(t12·t23·t31·l123) = 8·Area
|
||||
const double denom2 = 2.0 * std::sqrt(denom2_sq);
|
||||
// Triangle area via Kahan's numerically stable formula. Sort the side
|
||||
// lengths a ≥ b ≥ c and evaluate with the cancellation-avoiding grouping
|
||||
// Area = ¼·√[ (a+(b+c))·(c−(a−b))·(c+(a−b))·(a+(b−c)) ].
|
||||
// This replaces the naive 2·√(t12·t23·t31·l123): that product of
|
||||
// near-equal-length differences loses precision for needle/cap triangles,
|
||||
// where it would feed large relative error straight into the cotangent
|
||||
// weights and the linear solve. The result denom2 = 8·Area is identical
|
||||
// up to rounding for well-shaped triangles, but accurate for slivers.
|
||||
double a = l12, b = l23, c = l31;
|
||||
if (a < b) std::swap(a, b);
|
||||
if (a < c) std::swap(a, c);
|
||||
if (b < c) std::swap(b, c); // now a ≥ b ≥ c
|
||||
|
||||
// cot at v1 (opposite l23): adjacent t-values are t12 and t31.
|
||||
// cot at v2 (opposite l31): adjacent t-values are t12 and t23.
|
||||
// cot at v3 (opposite l12): adjacent t-values are t23 and t31.
|
||||
const double kahan = (a + (b + c)) * (c - (a - b))
|
||||
* (c + (a - b)) * (a + (b - c));
|
||||
if (kahan <= 0.0) return {0.0, 0.0, 0.0, false};
|
||||
|
||||
const double denom2 = 8.0 * (0.25 * std::sqrt(kahan)); // = 8·Area
|
||||
|
||||
// Formula: cot_k = (t_opp · l123 − t_a · t_b) / denom2
|
||||
// cot1: t_opp=t23, t_a=t12, t_b=t31 (v1 opposite l23)
|
||||
// cot2: t_opp=t31, t_a=t12, t_b=t23 (v2 opposite l31)
|
||||
// cot3: t_opp=t12, t_a=t23, t_b=t31 (v3 opposite l12)
|
||||
return {
|
||||
(t23 * l123 - t31 * t12) / denom2, // cot1
|
||||
(t31 * l123 - t12 * t23) / denom2, // cot2
|
||||
@@ -81,15 +120,9 @@ inline EuclCotWeights euclidean_cot_weights(double l12, double l23, double l31)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Analytical Hessian (cotangent Laplacian) ──────────────────────────────────
|
||||
//
|
||||
// Returns the n×n sparse Hessian matrix H where n = euclidean_dimension(mesh, m).
|
||||
//
|
||||
// Only vertex DOFs are supported. Edge DOFs (m.e_idx[e] >= 0) produce
|
||||
// additional mixed-derivative entries that are not yet implemented; this
|
||||
// function asserts they are absent.
|
||||
//
|
||||
// x – current DOF vector (used to compute effective log-lengths Λ̃ij).
|
||||
/// Analytical Euclidean Hessian (cotangent Laplacian), sparse.
|
||||
/// Only vertex DOFs are supported — the function asserts that no edge
|
||||
/// DOF is variable. `x` is used to compute effective log-lengths Λ̃ᵢⱼ.
|
||||
inline Eigen::SparseMatrix<double> euclidean_hessian(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
@@ -97,6 +130,18 @@ inline Eigen::SparseMatrix<double> euclidean_hessian(
|
||||
{
|
||||
const int n = euclidean_dimension(mesh, m);
|
||||
|
||||
// Only the vertex block of the cyclic Hessian is implemented here. If any
|
||||
// edge DOF is variable (assign_euclidean_all_dof_indices), the edge-edge and
|
||||
// vertex-edge blocks present in the Java reference (conformalHessian) are
|
||||
// missing, which would leave singular zero rows/cols. Fail loudly instead
|
||||
// of silently returning a rank-deficient matrix (matches the doc contract).
|
||||
for (auto e : mesh.edges()) {
|
||||
if (m.e_idx[e] >= 0)
|
||||
throw std::logic_error(
|
||||
"euclidean_hessian: edge DOFs are not supported "
|
||||
"(only the vertex-block cotangent Laplacian is implemented)");
|
||||
}
|
||||
|
||||
// Collect triplets (row, col, value) — setFromTriplets sums duplicates.
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(static_cast<std::size_t>(n) * 7); // rough estimate
|
||||
@@ -167,12 +212,236 @@ inline Eigen::SparseMatrix<double> euclidean_hessian(
|
||||
return H;
|
||||
}
|
||||
|
||||
// ── Block-FD Hessian (cyclic: vertex + edge DOFs) ────────────────────────────
|
||||
//
|
||||
// The analytic `euclidean_hessian` above covers only the vertex-block cotangent
|
||||
// Laplacian. The *cyclic* functional adds edge DOFs λ_e, giving vertex-edge and
|
||||
// edge-edge Hessian blocks. We obtain the full Hessian by per-face block FD,
|
||||
// mirroring `hyper_ideal_hessian_block_fd`.
|
||||
//
|
||||
// Locality lemma: the gradient decomposes by face,
|
||||
// G_v = Θ_v − Σ_{f∋v} α_v(f) (vertex output = −α_v)
|
||||
// G_e = Σ_{f∋e} α_opp(f) − φ_e (edge output = +α_opp)
|
||||
// and α at face f depends ONLY on f's 6 local DOFs (u₁,u₂,u₃,λ₁₂,λ₂₃,λ₃₁), so
|
||||
// ∂G_x/∂y = Σ_{f: x,y ∈ local(f)} ∂(output)/∂y at f.
|
||||
// Accumulating per-face 6×6 blocks therefore reproduces the full Hessian and is
|
||||
// consistent with `euclidean_gradient` by construction (it is its FD).
|
||||
|
||||
/// Per-face contributions to the cyclic gradient, in slot order
|
||||
/// (v₁, v₂, v₃, e₁₂, e₂₃, e₃₁). Vertex slots carry −α (since G_v = Θ−Σα),
|
||||
/// edge slots carry +α_opp (since G_e = Σα_opp − φ).
|
||||
inline std::array<double, 6> eucl_face_local_outputs(
|
||||
double u1, double u2, double u3,
|
||||
double d12, double d23, double d31,
|
||||
double lam0_12, double lam0_23, double lam0_31)
|
||||
{
|
||||
const double lam12 = lam0_12 + u1 + u2 + d12;
|
||||
const double lam23 = lam0_23 + u2 + u3 + d23;
|
||||
const double lam31 = lam0_31 + u3 + u1 + d31;
|
||||
const auto fa = euclidean_angles(lam12, lam23, lam31);
|
||||
// edge slot e12 ↔ opposite corner α3, e23 ↔ α1, e31 ↔ α2 (see gradient Pass 3).
|
||||
return { -fa.alpha1, -fa.alpha2, -fa.alpha3,
|
||||
+fa.alpha3, +fa.alpha1, +fa.alpha2 };
|
||||
}
|
||||
|
||||
/// Block-FD Euclidean cyclic Hessian (vertex + edge DOFs), sparse.
|
||||
/// Supports both vertex-only and cyclic DOF layouts; cost `F·12` face-angle
|
||||
/// evaluations. Consistent with `euclidean_gradient` to `O(ε²)`.
|
||||
inline Eigen::SparseMatrix<double> euclidean_hessian_block_fd(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const EuclideanMaps& m,
|
||||
double eps = 1e-5)
|
||||
{
|
||||
const int n = euclidean_dimension(mesh, m);
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(static_cast<std::size_t>(36) * mesh.number_of_faces());
|
||||
|
||||
for (auto f : mesh.faces()) {
|
||||
Halfedge_index h0 = mesh.halfedge(f);
|
||||
Halfedge_index h1 = mesh.next(h0);
|
||||
Halfedge_index h2 = mesh.next(h1);
|
||||
|
||||
Vertex_index v1 = mesh.source(h0);
|
||||
Vertex_index v2 = mesh.source(h1);
|
||||
Vertex_index v3 = mesh.source(h2);
|
||||
Edge_index e12 = mesh.edge(h0);
|
||||
Edge_index e23 = mesh.edge(h1);
|
||||
Edge_index e31 = mesh.edge(h2);
|
||||
|
||||
const int idx[6] = {
|
||||
m.v_idx[v1], m.v_idx[v2], m.v_idx[v3],
|
||||
m.e_idx[e12], m.e_idx[e23], m.e_idx[e31]
|
||||
};
|
||||
const double lam0_12 = m.lambda0[e12];
|
||||
const double lam0_23 = m.lambda0[e23];
|
||||
const double lam0_31 = m.lambda0[e31];
|
||||
|
||||
const double vals[6] = {
|
||||
eucl_dof_val(idx[0], x), eucl_dof_val(idx[1], x), eucl_dof_val(idx[2], x),
|
||||
eucl_dof_val(idx[3], x), eucl_dof_val(idx[4], x), eucl_dof_val(idx[5], x)
|
||||
};
|
||||
|
||||
for (int j = 0; j < 6; ++j) {
|
||||
if (idx[j] < 0) continue; // pinned: no column
|
||||
|
||||
double vp[6], vm[6];
|
||||
for (int k = 0; k < 6; ++k) { vp[k] = vm[k] = vals[k]; }
|
||||
vp[j] += eps;
|
||||
vm[j] -= eps;
|
||||
|
||||
auto Op = eucl_face_local_outputs(
|
||||
vp[0], vp[1], vp[2], vp[3], vp[4], vp[5], lam0_12, lam0_23, lam0_31);
|
||||
auto Om = eucl_face_local_outputs(
|
||||
vm[0], vm[1], vm[2], vm[3], vm[4], vm[5], lam0_12, lam0_23, lam0_31);
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
if (idx[i] < 0) continue;
|
||||
const double val = (Op[static_cast<std::size_t>(i)]
|
||||
- Om[static_cast<std::size_t>(i)]) / (2.0 * eps);
|
||||
if (std::abs(val) > 1e-15)
|
||||
trips.emplace_back(idx[i], idx[j], val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Eigen::SparseMatrix<double> H(n, n);
|
||||
H.setFromTriplets(trips.begin(), trips.end());
|
||||
return H;
|
||||
}
|
||||
|
||||
/// Symmetrised block-FD Euclidean cyclic Hessian: `(H + Hᵀ)/2`.
|
||||
inline Eigen::SparseMatrix<double> euclidean_hessian_block_fd_sym(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const EuclideanMaps& m,
|
||||
double eps = 1e-5)
|
||||
{
|
||||
auto H = euclidean_hessian_block_fd(mesh, x, m, eps);
|
||||
Eigen::SparseMatrix<double> Ht = H.transpose();
|
||||
return (H + Ht) * 0.5;
|
||||
}
|
||||
|
||||
// ── Analytic cyclic Hessian (vertex + edge DOFs) ─────────────────────────────
|
||||
//
|
||||
// Closed-form Jacobian of the cyclic gradient. With s_i = effective log-length
|
||||
// (Λ̃) of the edge OPPOSITE vertex i (s = 2·ln ℓ), the Euclidean corner-angle
|
||||
// derivatives are (derived from the law of cosines; Σ_j ∂α_i/∂s_j = 0):
|
||||
//
|
||||
// ∂α_i/∂s_i = ℓ_i² / (4A)
|
||||
// ∂α_i/∂s_j = ½ cot α_i − ℓ_j² / (4A) (j ≠ i)
|
||||
//
|
||||
// where A is the triangle area (4A = denom2/2, denom2 = 8A from the cot helper).
|
||||
// Chaining through s₁ = Λ̃(e₂₃), s₂ = Λ̃(e₃₁), s₃ = Λ̃(e₁₂) and
|
||||
// Λ̃_e = λ⁰_e + u_i + u_j + λ_e gives the per-face 6×6 block over the local
|
||||
// DOFs (u₁,u₂,u₃,λ₁₂,λ₂₃,λ₃₁); per-face outputs carry the gradient signs
|
||||
// (−α vertex, +α_opp edge), so the assembled matrix equals ∂G/∂x exactly.
|
||||
// This is the analytic counterpart of `euclidean_hessian_block_fd` (no FD).
|
||||
|
||||
/// Analytic Euclidean cyclic Hessian (vertex + edge DOFs), sparse.
|
||||
/// Closed-form (no finite differences); matches `euclidean_hessian_block_fd`
|
||||
/// to round-off and the analytic vertex-only Laplacian on vertex-only layouts.
|
||||
inline Eigen::SparseMatrix<double> euclidean_hessian_analytic(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const EuclideanMaps& m)
|
||||
{
|
||||
const int n = euclidean_dimension(mesh, m);
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(static_cast<std::size_t>(36) * mesh.number_of_faces());
|
||||
|
||||
for (auto f : mesh.faces()) {
|
||||
Halfedge_index h0 = mesh.halfedge(f);
|
||||
Halfedge_index h1 = mesh.next(h0);
|
||||
Halfedge_index h2 = mesh.next(h1);
|
||||
|
||||
Vertex_index v1 = mesh.source(h0);
|
||||
Vertex_index v2 = mesh.source(h1);
|
||||
Vertex_index v3 = mesh.source(h2);
|
||||
Edge_index e12 = mesh.edge(h0);
|
||||
Edge_index e23 = mesh.edge(h1);
|
||||
Edge_index e31 = mesh.edge(h2);
|
||||
|
||||
const double u1 = eucl_dof_val(m.v_idx[v1], x);
|
||||
const double u2 = eucl_dof_val(m.v_idx[v2], x);
|
||||
const double u3 = eucl_dof_val(m.v_idx[v3], x);
|
||||
|
||||
const double lam12 = m.lambda0[e12] + u1 + u2 + eucl_dof_val(m.e_idx[e12], x); // s3
|
||||
const double lam23 = m.lambda0[e23] + u2 + u3 + eucl_dof_val(m.e_idx[e23], x); // s1
|
||||
const double lam31 = m.lambda0[e31] + u3 + u1 + eucl_dof_val(m.e_idx[e31], x); // s2
|
||||
|
||||
// Centered side lengths (scale-invariant for ℓ²/4A and cot).
|
||||
const double mu = (lam12 + lam23 + lam31) / 6.0;
|
||||
const double l12 = std::exp((lam12 - 2.0 * mu) * 0.5);
|
||||
const double l23 = std::exp((lam23 - 2.0 * mu) * 0.5);
|
||||
const double l31 = std::exp((lam31 - 2.0 * mu) * 0.5);
|
||||
|
||||
const double t12 = -l12 + l23 + l31;
|
||||
const double t23 = l12 - l23 + l31;
|
||||
const double t31 = l12 + l23 - l31;
|
||||
if (t12 <= 0.0 || t23 <= 0.0 || t31 <= 0.0) continue;
|
||||
const double l123 = l12 + l23 + l31;
|
||||
const double denom2_sq = t12 * t23 * t31 * l123;
|
||||
if (denom2_sq <= 0.0) continue;
|
||||
const double denom2 = 2.0 * std::sqrt(denom2_sq); // = 8A
|
||||
const double fourA = denom2 * 0.5; // = 4A
|
||||
|
||||
auto cw = euclidean_cot_weights(l12, l23, l31);
|
||||
if (!cw.valid) continue;
|
||||
const double cot[3] = { cw.cot1, cw.cot2, cw.cot3 }; // at v1, v2, v3
|
||||
|
||||
// ℓ_i² = squared length of edge opposite vertex i:
|
||||
// opp(v1)=e23=l23, opp(v2)=e31=l31, opp(v3)=e12=l12
|
||||
const double Lsq[3] = { l23 * l23, l31 * l31, l12 * l12 };
|
||||
|
||||
// dA[i][j] = ∂α_i/∂s_j (i,j ∈ {0,1,2}; s_j opposite v_{j+1})
|
||||
double dA[3][3];
|
||||
for (int i = 0; i < 3; ++i)
|
||||
for (int j = 0; j < 3; ++j)
|
||||
dA[i][j] = (i == j) ? (Lsq[i] / fourA)
|
||||
: (0.5 * cot[i] - Lsq[j] / fourA);
|
||||
|
||||
// ∂α_i/∂(local dof), dof order (u1,u2,u3, λ12,λ23,λ31).
|
||||
// u1 ↔ s2,s3 ; u2 ↔ s1,s3 ; u3 ↔ s1,s2 ; λ12 ↔ s3 ; λ23 ↔ s1 ; λ31 ↔ s2.
|
||||
double g[3][6];
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
g[i][0] = dA[i][1] + dA[i][2]; // u1
|
||||
g[i][1] = dA[i][0] + dA[i][2]; // u2
|
||||
g[i][2] = dA[i][0] + dA[i][1]; // u3
|
||||
g[i][3] = dA[i][2]; // λ12 (s3)
|
||||
g[i][4] = dA[i][0]; // λ23 (s1)
|
||||
g[i][5] = dA[i][1]; // λ31 (s2)
|
||||
}
|
||||
|
||||
// Output slot → (angle index, sign): (−α1,−α2,−α3,+α3,+α1,+α2).
|
||||
const int out_a[6] = { 0, 1, 2, 2, 0, 1 };
|
||||
const double out_sg[6] = { -1.0, -1.0, -1.0, +1.0, +1.0, +1.0 };
|
||||
|
||||
const int idx[6] = {
|
||||
m.v_idx[v1], m.v_idx[v2], m.v_idx[v3],
|
||||
m.e_idx[e12], m.e_idx[e23], m.e_idx[e31]
|
||||
};
|
||||
|
||||
for (int oi = 0; oi < 6; ++oi) {
|
||||
if (idx[oi] < 0) continue;
|
||||
for (int dj = 0; dj < 6; ++dj) {
|
||||
if (idx[dj] < 0) continue;
|
||||
const double val = out_sg[oi] * g[out_a[oi]][dj];
|
||||
if (std::abs(val) > 1e-15)
|
||||
trips.emplace_back(idx[oi], idx[dj], val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Eigen::SparseMatrix<double> H(n, n);
|
||||
H.setFromTriplets(trips.begin(), trips.end());
|
||||
return H;
|
||||
}
|
||||
|
||||
// ── Finite-difference Hessian check ──────────────────────────────────────────
|
||||
//
|
||||
// Compares the analytical Hessian column-by-column against
|
||||
// H_fd[:, j] = (G(x + ε·eⱼ) − G(x − ε·eⱼ)) / (2ε).
|
||||
//
|
||||
// Returns true if max relative error < tol for every entry.
|
||||
/// FD Hessian check for the Euclidean functional. Compares analytic
|
||||
/// `H` column-by-column to `(G(x+εeⱼ) − G(x−εeⱼ)) / (2ε)`; returns
|
||||
/// `true` iff max relative error is below `tol`.
|
||||
inline bool hessian_check_euclidean(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x0,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// fundamental_domain.hpp
|
||||
//
|
||||
// Phase 7 — Fundamental domain polygon for closed surfaces.
|
||||
@@ -43,6 +46,9 @@ namespace conformallab {
|
||||
// FundamentalDomain
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fundamental polygon of a closed surface obtained by cutting along a
|
||||
/// `CutGraph`: corner vertices, paired-edge identifications and holonomy
|
||||
/// generators. For genus-1 the polygon is a parallelogram with 4 corners.
|
||||
struct FundamentalDomain {
|
||||
/// Polygon corners in order (CCW). Size = 4 for genus-1.
|
||||
std::vector<Eigen::Vector2d> vertices;
|
||||
@@ -56,6 +62,7 @@ struct FundamentalDomain {
|
||||
/// For genus-1: generators[0] = ω_1, generators[1] = ω_2.
|
||||
std::vector<Eigen::Vector2d> generators;
|
||||
|
||||
/// `true` iff the polygon has at least 3 vertices.
|
||||
bool is_valid() const { return vertices.size() >= 3; }
|
||||
};
|
||||
|
||||
@@ -75,6 +82,8 @@ struct FundamentalDomain {
|
||||
// bottom (v0→v1) ≡ top (v3→v2) by ω_2
|
||||
// left (v3→v0) ≡ right (v2→v1) by ω_1 (reversed convention)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/// Build the parallelogram fundamental domain from genus-1 Euclidean
|
||||
/// holonomy data (`hol.translations[0] = ω₁`, `hol.translations[1] = ω₂`).
|
||||
inline FundamentalDomain compute_fundamental_domain_genus1(
|
||||
const HolonomyData& hol)
|
||||
{
|
||||
@@ -148,6 +157,8 @@ inline FundamentalDomain compute_fundamental_domain_genus1(
|
||||
// this is intentionally deferred and NOT implemented here.
|
||||
// See period_matrix.hpp for the genus-1 case (τ = ω_2/ω_1 ∈ ℍ).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/// Dispatcher: for genus 1 returns `compute_fundamental_domain_genus1`,
|
||||
/// for higher genus returns an empty domain (4g-polygon not yet implemented).
|
||||
inline FundamentalDomain compute_fundamental_domain(
|
||||
const HolonomyData& hol)
|
||||
{
|
||||
@@ -165,6 +176,8 @@ inline FundamentalDomain compute_fundamental_domain(
|
||||
// return a translated copy of the layout shifted by m·ω_1 + n·ω_2.
|
||||
// Useful for visualising the tiled universal cover.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/// Return a translated copy of `layout` shifted by `m·ω₁ + n·ω₂`.
|
||||
/// Useful for visualising the tiled universal cover.
|
||||
inline Layout2D tiling_copy(const Layout2D& layout,
|
||||
const Eigen::Vector2d& w1,
|
||||
const Eigen::Vector2d& w2,
|
||||
@@ -183,6 +196,8 @@ inline Layout2D tiling_copy(const Layout2D& layout,
|
||||
// Returns a vector of tiling copies for (m, n) with |m| ≤ m_max, |n| ≤ n_max.
|
||||
// The result includes the original (m=0, n=0) at index (m_max)(2*n_max+1)+n_max.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/// Build a tiling neighbourhood: all `(m, n)` with `|m| ≤ m_max`,
|
||||
/// `|n| ≤ n_max`. The original tile `(0, 0)` is included.
|
||||
inline std::vector<Layout2D> tiling_neighbourhood(
|
||||
const Layout2D& layout,
|
||||
const HolonomyData& hol,
|
||||
|
||||
@@ -1,26 +1,51 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// gauss_bonnet.hpp
|
||||
//
|
||||
// Phase 6 — Gauss–Bonnet consistency check for prescribed target angles.
|
||||
//
|
||||
// Before calling newton_*() with custom target angles, verify that
|
||||
// the angle defect sum matches the topology:
|
||||
// the angle defect sum matches the topology.
|
||||
//
|
||||
// Σ_v (2π − Θ_v) = 2π · χ(M) (Euclidean / flat)
|
||||
// Σ_v (2π − Θ_v) > 0 (spherical, χ > 0)
|
||||
// Σ_v (2π − Θ_v) < 0 (hyperbolic, χ < 0)
|
||||
// ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
// │ Geometry Identity to satisfy │
|
||||
// │ ───────────────────────────────────────────────────────────────────── │
|
||||
// │ Euclidean/flat Σ_v (2π − Θ_v) = 2π · χ(M) (exact equality) │
|
||||
// │ Spherical Σ_v (2π − Θ_v) > 0 (sufficient, χ > 0) │
|
||||
// │ │
|
||||
// │ HyperIdeal — NOT SUPPORTED by this header. │
|
||||
// │ The correct hyperbolic Gauss–Bonnet identity is │
|
||||
// │ Σ_v (2π − Θ_v) − Area(M) = 2π · χ(M) │
|
||||
// │ which differs from the Euclidean identity by the Area(M) > 0 term. │
|
||||
// │ Computing Area(M) from the HyperIdeal DOFs is non-trivial. │
|
||||
// │ gauss_bonnet_sum(mesh, HyperIdealMaps) and │
|
||||
// │ enforce_gauss_bonnet(mesh, HyperIdealMaps) are therefore DELETED. │
|
||||
// │ Do NOT call check_gauss_bonnet before newton_hyper_ideal — │
|
||||
// │ it is not needed; the HyperIdeal energy is strictly convex so Newton │
|
||||
// │ converges without a pre-check. │
|
||||
// └─────────────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// If this fails, no conformal factor can realise the target angles and
|
||||
// Newton will silently fail to converge.
|
||||
// If the Euclidean/Spherical check fails, no conformal factor can realise
|
||||
// the target angles and Newton will silently fail to converge.
|
||||
//
|
||||
// PRECONDITION — closed meshes only. Every function here sums (2π − Θ_v)
|
||||
// over ALL vertices. On a mesh with boundary the boundary vertices carry a
|
||||
// (π − Θ_v) term instead, so the identity Σ(2π−Θ_v) = 2π·χ does NOT hold and
|
||||
// `check_gauss_bonnet` will (correctly) throw. For open meshes pin the
|
||||
// boundary directly and skip the Gauss–Bonnet check (see the CLI's
|
||||
// flattening path).
|
||||
//
|
||||
// API:
|
||||
// int euler_characteristic(mesh)
|
||||
// int genus(mesh)
|
||||
// double gauss_bonnet_sum(mesh, maps) — Σ(2π − Θ_v)
|
||||
// double gauss_bonnet_sum(mesh, EuclideanMaps/SphericalMaps) — Σ(2π − Θ_v)
|
||||
// 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 Δ
|
||||
// (HyperIdealMaps overloads are deleted — see box above)
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include "euclidean_functional.hpp"
|
||||
@@ -56,6 +81,7 @@ inline int genus(const ConformalMesh& mesh)
|
||||
|
||||
// ── Left-hand side Σ(2π − Θ_v) ─────────────────────────────────────────────
|
||||
|
||||
/// Sum `Σ_v (2π − Θ_v)` for a raw vertex → angle property map.
|
||||
inline double gauss_bonnet_sum(
|
||||
const ConformalMesh& mesh,
|
||||
const ConformalMesh::Property_map<Vertex_index, double>& theta)
|
||||
@@ -66,15 +92,27 @@ inline double gauss_bonnet_sum(
|
||||
return s;
|
||||
}
|
||||
|
||||
inline double gauss_bonnet_sum(const ConformalMesh& m, const EuclideanMaps& mp)
|
||||
/// `gauss_bonnet_sum` for the Euclidean-functional property bundle.
|
||||
inline double gauss_bonnet_sum(const ConformalMesh& m, const EuclideanMaps& mp)
|
||||
{ return gauss_bonnet_sum(m, mp.theta_v); }
|
||||
inline double gauss_bonnet_sum(const ConformalMesh& m, const SphericalMaps& mp)
|
||||
{ return gauss_bonnet_sum(m, mp.theta_v); }
|
||||
inline double gauss_bonnet_sum(const ConformalMesh& m, const HyperIdealMaps& mp)
|
||||
/// `gauss_bonnet_sum` for the Spherical-functional property bundle.
|
||||
inline double gauss_bonnet_sum(const ConformalMesh& m, const SphericalMaps& mp)
|
||||
{ return gauss_bonnet_sum(m, mp.theta_v); }
|
||||
|
||||
// gauss_bonnet_sum for HyperIdealMaps is intentionally DELETED.
|
||||
// The correct hyperbolic Gauss–Bonnet identity is
|
||||
// Σ(2π−Θ_v) − Area(M) = 2π·χ(M)
|
||||
// not the Euclidean form Σ(2π−Θ_v) = 2π·χ(M). Providing this overload
|
||||
// would silently skip the Area term, making check_gauss_bonnet always
|
||||
// fail for valid hyperbolic targets (e.g. a genus-2 mesh with Θ_v=2π
|
||||
// gives Σ(2π−Θ_v)=0 but 2π·χ=−4π → deficit=4π ≠ 0 every time).
|
||||
// Use newton_hyper_ideal directly — no pre-check is needed because the
|
||||
// HyperIdeal energy is strictly convex (Springborn 2020 Theorem 1.3).
|
||||
inline double gauss_bonnet_sum(const ConformalMesh&, const HyperIdealMaps&) = delete;
|
||||
|
||||
// ── Right-hand side 2π · χ(M) ───────────────────────────────────────────────
|
||||
|
||||
/// Right-hand side of Gauss-Bonnet: `2π · χ(M)`.
|
||||
inline double gauss_bonnet_rhs(const ConformalMesh& mesh)
|
||||
{
|
||||
return TWO_PI * static_cast<double>(euler_characteristic(mesh));
|
||||
@@ -82,14 +120,15 @@ inline double gauss_bonnet_rhs(const ConformalMesh& mesh)
|
||||
|
||||
// ── Deficit: lhs − rhs (0 = Gauss–Bonnet satisfied) ─────────────────────────
|
||||
|
||||
/// Gauss-Bonnet deficit `lhs − rhs`; zero iff the identity is satisfied.
|
||||
template <typename Maps>
|
||||
inline double gauss_bonnet_deficit(const ConformalMesh& mesh, const Maps& maps)
|
||||
{
|
||||
return gauss_bonnet_sum(mesh, maps) - gauss_bonnet_rhs(mesh);
|
||||
}
|
||||
|
||||
// ── check_gauss_bonnet — throws std::runtime_error if |deficit| > tol ─────────
|
||||
|
||||
/// Throws `std::runtime_error` if `|lhs − 2π·χ| > tol`.
|
||||
/// Overload accepting a precomputed `lhs`.
|
||||
inline void check_gauss_bonnet(const ConformalMesh& mesh,
|
||||
double lhs,
|
||||
double tol = 1e-8)
|
||||
@@ -108,6 +147,7 @@ inline void check_gauss_bonnet(const ConformalMesh& mesh,
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws `std::runtime_error` if Gauss-Bonnet is violated by more than `tol`.
|
||||
template <typename Maps>
|
||||
inline void check_gauss_bonnet(const ConformalMesh& mesh,
|
||||
const Maps& maps,
|
||||
@@ -118,11 +158,14 @@ inline void check_gauss_bonnet(const ConformalMesh& mesh,
|
||||
|
||||
// ── enforce_gauss_bonnet — adjust θ_v by uniform Δ ───────────────────────────
|
||||
//
|
||||
// Adds δ = (rhs − lhs) / V to every θ_v so that Gauss–Bonnet holds exactly.
|
||||
// Adds δ = (lhs − rhs) / V to every θ_v so that Gauss–Bonnet holds exactly.
|
||||
// After this call, check_gauss_bonnet() will not throw (up to floating-point).
|
||||
// Only modifies free vertices (v_idx[v] >= 0 for EuclideanMaps / SphericalMaps;
|
||||
// always all vertices for the raw property-map overload).
|
||||
// Modifies ALL vertices' θ_v (no v_idx filtering) — the shift is a property
|
||||
// of the target angles, independent of which vertices are free DOFs.
|
||||
|
||||
/// 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(
|
||||
ConformalMesh& mesh,
|
||||
ConformalMesh::Property_map<Vertex_index, double>& theta)
|
||||
@@ -136,10 +179,19 @@ inline void enforce_gauss_bonnet(
|
||||
theta[v] += delta;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
template <typename Maps>
|
||||
inline void enforce_gauss_bonnet(ConformalMesh& mesh, Maps& maps)
|
||||
{
|
||||
enforce_gauss_bonnet(mesh, maps.theta_v);
|
||||
}
|
||||
|
||||
// enforce_gauss_bonnet for HyperIdealMaps is intentionally DELETED.
|
||||
// The Euclidean identity Σ(2π−Θ_v)=2π·χ is not the correct pre-condition
|
||||
// for HyperIdeal. Calling this function would silently shift Θ_v to
|
||||
// satisfy the wrong identity, producing incorrect target angles.
|
||||
inline void enforce_gauss_bonnet(ConformalMesh&, HyperIdealMaps&) = delete;
|
||||
|
||||
} // namespace conformallab
|
||||
|
||||
49
code/include/gauss_legendre.hpp
Normal file
49
code/include/gauss_legendre.hpp
Normal file
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// gauss_legendre.hpp
|
||||
//
|
||||
// 10-point Gauss-Legendre quadrature nodes and weights on [-1,1].
|
||||
//
|
||||
// Previously duplicated verbatim in:
|
||||
// euclidean_functional.hpp, spherical_functional.hpp,
|
||||
// inversive_distance_functional.hpp (MINOR-3 fix)
|
||||
//
|
||||
// Usage (integration over [0,1] via change of variables t=(1+s)/2, w=w_GL/2):
|
||||
//
|
||||
// const auto* s = conformallab::gl10_nodes();
|
||||
// const auto* w = conformallab::gl10_weights();
|
||||
// for (int k = 0; k < 10; ++k) {
|
||||
// double t = (1.0 + s[k]) * 0.5;
|
||||
// double wt = w[k] * 0.5;
|
||||
// E += wt * dot(G(t*x), x);
|
||||
// }
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
/// 10-point Gauss-Legendre nodes on [−1, 1].
|
||||
inline const double* gl10_nodes() noexcept {
|
||||
static constexpr double s[10] = {
|
||||
-0.9739065285171717, -0.8650633666889845,
|
||||
-0.6794095682990244, -0.4333953941292472,
|
||||
-0.1488743389816312, 0.1488743389816312,
|
||||
0.4333953941292472, 0.6794095682990244,
|
||||
0.8650633666889845, 0.9739065285171717
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
/// 10-point Gauss-Legendre weights on [−1, 1].
|
||||
inline const double* gl10_weights() noexcept {
|
||||
static constexpr double w[10] = {
|
||||
0.0666713443086881, 0.1494513491505806,
|
||||
0.2190863625159820, 0.2692667193099963,
|
||||
0.2955242247147529, 0.2955242247147529,
|
||||
0.2692667193099963, 0.2190863625159820,
|
||||
0.1494513491505806, 0.0666713443086881
|
||||
};
|
||||
return w;
|
||||
}
|
||||
|
||||
} // namespace conformallab
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// hyper_ideal_functional.hpp
|
||||
//
|
||||
// Energy and gradient of the hyper-ideal discrete conformal map functional
|
||||
@@ -22,15 +25,17 @@
|
||||
// ─────
|
||||
// auto mesh = make_tetrahedron();
|
||||
// auto maps = setup_hyper_ideal_maps(mesh);
|
||||
// int n = assign_all_dof_indices(mesh, maps); // all variable
|
||||
// int n = assign_hyper_ideal_all_dof_indices(mesh, maps); // all variable
|
||||
// std::vector<double> x(n, 1.0);
|
||||
// auto res = evaluate_hyper_ideal(mesh, x, maps);
|
||||
// // res.energy, res.gradient
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include "constants.hpp"
|
||||
#include "hyper_ideal_geometry.hpp"
|
||||
#include "hyper_ideal_utility.hpp"
|
||||
#include <CGAL/boost/graph/iterator.h>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
@@ -39,22 +44,39 @@ namespace conformallab {
|
||||
|
||||
// ── Property-map type aliases ─────────────────────────────────────────────────
|
||||
|
||||
/// Property map vertex → `double` (HyperIdeal scalar-per-vertex data).
|
||||
using VMapD = ConformalMesh::Property_map<Vertex_index, double>;
|
||||
/// Property map vertex → `int` (HyperIdeal DOF indices).
|
||||
using VMapI = ConformalMesh::Property_map<Vertex_index, int>;
|
||||
/// Property map edge → `double` (HyperIdeal scalar-per-edge data).
|
||||
using EMapD = ConformalMesh::Property_map<Edge_index, double>;
|
||||
/// Property map edge → `int` (HyperIdeal DOF indices).
|
||||
using EMapI = ConformalMesh::Property_map<Edge_index, int>;
|
||||
|
||||
// ── Persistent map bundle ─────────────────────────────────────────────────────
|
||||
|
||||
/// Bundle of the four property maps consumed by the HyperIdeal functional.
|
||||
struct HyperIdealMaps {
|
||||
VMapI v_idx; // DOF index per vertex (-1 = pinned / ideal point)
|
||||
EMapI e_idx; // DOF index per edge (-1 = fixed)
|
||||
VMapD theta_v; // target cone angle Θ_v (parameter, not variable)
|
||||
EMapD theta_e; // target intersection angle θ_e
|
||||
VMapI v_idx; ///< DOF index per vertex (−1 = pinned / ideal point).
|
||||
EMapI e_idx; ///< DOF index per edge (−1 = fixed).
|
||||
VMapD theta_v; ///< Target cone angle Θᵥ (parameter, not variable).
|
||||
EMapD theta_e; ///< Target intersection angle θₑ.
|
||||
};
|
||||
|
||||
// Add all needed persistent property maps and return handles.
|
||||
// Defaults: theta_v = 2π (regular cone), theta_e = π (orthogonal circles).
|
||||
/// Attach the four HyperIdeal property maps to `mesh` and return their
|
||||
/// handles.
|
||||
///
|
||||
/// Defaults:
|
||||
/// * `v_idx[v] = -1` (ideal vertex — i.e. the corresponding `b_v` is fixed at 0)
|
||||
/// * `e_idx[e] = -1` (edge DOF fixed at 0)
|
||||
/// * `theta_v[v] = 2π` (regular cone target)
|
||||
/// * `theta_e[e] = π` (orthogonal-circle target)
|
||||
///
|
||||
/// The map prefix `"v:"` / `"e:"` is intentionally generic for the
|
||||
/// HyperIdeal functional — it is the canonical / Phase 3b model.
|
||||
/// Other functionals use distinct prefixes (`"ev:"` Euclidean, `"sv:"`
|
||||
/// Spherical, `"cf:"`/`"ce:"` CP-Euclidean, `"iv:"`/`"ie:"`
|
||||
/// Inversive-Distance) so all models can coexist on the same mesh.
|
||||
inline HyperIdealMaps setup_hyper_ideal_maps(ConformalMesh& mesh)
|
||||
{
|
||||
HyperIdealMaps m;
|
||||
@@ -65,7 +87,7 @@ inline HyperIdealMaps setup_hyper_ideal_maps(ConformalMesh& mesh)
|
||||
return m;
|
||||
}
|
||||
|
||||
// Count variable DOFs: #variable_vertices + #variable_edges.
|
||||
/// Count free DOFs: `#variable_vertices + #variable_edges`.
|
||||
inline int hyper_ideal_dimension(const ConformalMesh& mesh, const HyperIdealMaps& m)
|
||||
{
|
||||
int dim = 0;
|
||||
@@ -74,9 +96,16 @@ inline int hyper_ideal_dimension(const ConformalMesh& mesh, const HyperIdealMaps
|
||||
return dim;
|
||||
}
|
||||
|
||||
// Assign DOF indices 0..n-1: vertices first, then edges.
|
||||
// All vertices and edges become variable. Returns total DOF count.
|
||||
inline int assign_all_dof_indices(ConformalMesh& mesh, HyperIdealMaps& m)
|
||||
/// Make every vertex hyper-ideal and every edge variable, assigning
|
||||
/// sequential DOF indices `0..n-1` (vertices first, edges after).
|
||||
///
|
||||
/// This is the standard initialisation for the Springborn-2020
|
||||
/// hyper-ideal functional — gauge fixing is **not** needed because
|
||||
/// the energy is strictly convex on the full DOF space (no
|
||||
/// rotational mode for an all-hyper-ideal configuration).
|
||||
///
|
||||
/// \returns total DOF count = `num_vertices(mesh) + num_edges(mesh)`.
|
||||
inline int assign_hyper_ideal_all_dof_indices(ConformalMesh& mesh, HyperIdealMaps& m)
|
||||
{
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices()) m.v_idx[v] = idx++;
|
||||
@@ -84,26 +113,30 @@ inline int assign_all_dof_indices(ConformalMesh& mesh, HyperIdealMaps& m)
|
||||
return idx;
|
||||
}
|
||||
|
||||
/// \deprecated Use `assign_hyper_ideal_all_dof_indices` (API-naming audit A1).
|
||||
[[deprecated("renamed to assign_hyper_ideal_all_dof_indices")]]
|
||||
inline int assign_all_dof_indices(ConformalMesh& mesh, HyperIdealMaps& m)
|
||||
{ return assign_hyper_ideal_all_dof_indices(mesh, m); }
|
||||
|
||||
// ── Evaluation result ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Output of `evaluate_hyper_ideal()` — the energy value and (optionally)
|
||||
/// its gradient evaluated at the current DOF vector.
|
||||
struct HyperIdealResult {
|
||||
double energy = 0.0;
|
||||
std::vector<double> gradient; // empty when gradient was not requested
|
||||
double energy = 0.0; ///< Functional value at the input DOFs.
|
||||
std::vector<double> gradient; ///< Gradient ∇E; empty when not requested.
|
||||
};
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// Get the DOF value from x, or 0.0 if pinned.
|
||||
/// Read the DOF value from `x` for index `idx`; return 0 if pinned (idx < 0).
|
||||
static inline double dof_val(int idx, const std::vector<double>& x)
|
||||
{
|
||||
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
|
||||
}
|
||||
|
||||
// Convert a CGAL halfedge index to a plain std::size_t (for vector indexing).
|
||||
static inline std::size_t hidx(Halfedge_index h)
|
||||
{
|
||||
return static_cast<std::size_t>(static_cast<std::uint32_t>(h));
|
||||
}
|
||||
// halfedge_to_index is defined in conformal_mesh.hpp.
|
||||
static inline std::size_t hidx(Halfedge_index h) { return halfedge_to_index(h); }
|
||||
|
||||
// ── Pure-math face-angle kernel ──────────────────────────────────────────────
|
||||
//
|
||||
@@ -125,23 +158,83 @@ static inline std::size_t hidx(Halfedge_index h)
|
||||
// HyperIdealFunctional.java's defensive behaviour (lines 122-127 of the
|
||||
// Java original); this keeps the FD perturbation regime well-defined.
|
||||
|
||||
struct FaceAngleOutputs {
|
||||
double beta1, beta2, beta3; ///< interior angles at v₁,v₂,v₃
|
||||
double alpha12, alpha23, alpha31; ///< dihedral angles at e₁₂,e₂₃,e₃₁
|
||||
// ── Scale-floor clamp mode (N3 audit) ─────────────────────────────────────────
|
||||
//
|
||||
// The vertex scale b must stay positive for the hyper-ideal geometry to be
|
||||
// valid. Two ways to enforce that are offered, selectable per call:
|
||||
//
|
||||
// • HardJava (DEFAULT) — the original `b < 0 → HYPER_IDEAL_SCALE_FLOOR` snap.
|
||||
// Bit-for-bit faithful to HyperIdealFunctional.java, so the Java
|
||||
// golden-oracle parity tests hold exactly. It is only C⁰ at b = 0: a
|
||||
// Newton step that crosses the feasibility boundary sees a kink, which can
|
||||
// stall convergence (numerical-stability audit N3). This is the
|
||||
// production default precisely to preserve parity.
|
||||
//
|
||||
// • SmoothBarrier — a C¹ softplus floor
|
||||
// b ↦ floor + softplus_β(b − floor), softplus_β(x) = log(1+e^{βx})/β
|
||||
// which is ≈ b for b well above the floor (to machine precision once
|
||||
// β·(b−floor) ≳ 35) and decays smoothly to `floor` as b → −∞, with a
|
||||
// continuous derivative everywhere. This removes the N3 kink and is the
|
||||
// mathematically clean choice, but it perturbs values near the boundary
|
||||
// and therefore deviates from the Java oracle — opt in when robustness of
|
||||
// a boundary-crossing solve matters more than strict parity.
|
||||
//
|
||||
// Both modes share the same floor (`HYPER_IDEAL_SCALE_FLOOR`); SmoothBarrier
|
||||
// additionally uses `HYPER_IDEAL_SCALE_SHARPNESS` (β). The `a < 0 → 0` edge
|
||||
// clamp is unaffected (a = 0 is a genuine geometric floor, not flagged by N3).
|
||||
enum class HyperIdealScaleClamp {
|
||||
HardJava, ///< b<0 → floor. C⁰, Java-parity-faithful (default).
|
||||
SmoothBarrier ///< C¹ softplus floor; clean but deviates from Java near b=0.
|
||||
};
|
||||
|
||||
/// Apply the vertex-scale floor to `b` under the chosen clamp `mode`.
|
||||
/// `HardJava` reproduces the original snap; `SmoothBarrier` is the C¹
|
||||
/// softplus floor (see `HyperIdealScaleClamp`). `variable` mirrors the
|
||||
/// call-site guard (only clamp DOFs that are actually free / variable).
|
||||
inline double clamp_hyper_ideal_scale(double b, bool variable,
|
||||
HyperIdealScaleClamp mode) noexcept
|
||||
{
|
||||
if (!variable) return b;
|
||||
if (mode == HyperIdealScaleClamp::HardJava)
|
||||
return b < 0.0 ? HYPER_IDEAL_SCALE_FLOOR : b;
|
||||
|
||||
// SmoothBarrier: floor + softplus_β(b − floor), evaluated stably.
|
||||
const double beta = HYPER_IDEAL_SCALE_SHARPNESS;
|
||||
const double x = beta * (b - HYPER_IDEAL_SCALE_FLOOR);
|
||||
// softplus_β(x) = log1p(e^{βx})/β, with the standard large-x guard
|
||||
// (for x ≳ 35, log1p(e^x) == x to double precision) to avoid overflow.
|
||||
const double softplus = (x > 35.0) ? x : std::log1p(std::exp(x));
|
||||
return HYPER_IDEAL_SCALE_FLOOR + softplus / beta;
|
||||
}
|
||||
|
||||
/// Six per-face angle outputs computed from local DOFs (see
|
||||
/// `face_angles_from_local_dofs`). Used by the block-FD Hessian.
|
||||
struct FaceAngleOutputs {
|
||||
double beta1; ///< Interior angle at v₁.
|
||||
double beta2; ///< Interior angle at v₂.
|
||||
double beta3; ///< Interior angle at v₃.
|
||||
double alpha12; ///< Dihedral angle at edge e₁₂.
|
||||
double alpha23; ///< Dihedral angle at edge e₂₃.
|
||||
double alpha31; ///< Dihedral angle at edge e₃₁.
|
||||
};
|
||||
|
||||
/// Pure-math 6→6 kernel: given the six local DOFs (b₁,b₂,b₃,a₁₂,a₂₃,a₃₁)
|
||||
/// of one face plus the per-vertex variability flags, return the six
|
||||
/// HyperIdeal angle outputs. No mesh, no property maps — used by the
|
||||
/// per-face block-FD Hessian in `hyper_ideal_hessian.hpp`.
|
||||
inline FaceAngleOutputs face_angles_from_local_dofs(
|
||||
double b1, double b2, double b3,
|
||||
double a12, double a23, double a31,
|
||||
bool v1b, bool v2b, bool v3b)
|
||||
bool v1b, bool v2b, bool v3b,
|
||||
HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava)
|
||||
{
|
||||
// Same defensive clamps as compute_face_angles.
|
||||
if (v1b && v2b && a12 < 0.0) a12 = 0.0;
|
||||
if (v2b && v3b && a23 < 0.0) a23 = 0.0;
|
||||
if (v3b && v1b && a31 < 0.0) a31 = 0.0;
|
||||
if (v1b && b1 < 0.0) b1 = 0.01;
|
||||
if (v2b && b2 < 0.0) b2 = 0.01;
|
||||
if (v3b && b3 < 0.0) b3 = 0.01;
|
||||
b1 = clamp_hyper_ideal_scale(b1, v1b, clamp);
|
||||
b2 = clamp_hyper_ideal_scale(b2, v2b, clamp);
|
||||
b3 = clamp_hyper_ideal_scale(b3, v3b, clamp);
|
||||
|
||||
double l12 = lij(b1, b2, a12, v1b, v2b);
|
||||
double l23 = lij(b2, b3, a23, v2b, v3b);
|
||||
@@ -177,19 +270,35 @@ inline FaceAngleOutputs face_angles_from_local_dofs(
|
||||
|
||||
// ── Per-face angle kernel ─────────────────────────────────────────────────────
|
||||
|
||||
/// Per-face angle bundle returned by `compute_face_angles()`. Carries
|
||||
/// the six output angles plus the six input DOFs (so the energy and
|
||||
/// gradient kernels can reuse them without re-reading the mesh).
|
||||
struct FaceAngles {
|
||||
double alpha12, alpha23, alpha31; // dihedral angles at each edge
|
||||
double beta1, beta2, beta3; // interior angles at each vertex
|
||||
double a12, a23, a31; // edge DOF values (used in energy)
|
||||
double b1, b2, b3; // vertex DOF values
|
||||
bool v1b, v2b, v3b; // whether each vertex is variable
|
||||
double alpha12; ///< Dihedral angle at edge e₁₂.
|
||||
double alpha23; ///< Dihedral angle at edge e₂₃.
|
||||
double alpha31; ///< Dihedral angle at edge e₃₁.
|
||||
double beta1; ///< Interior angle at vertex v₁.
|
||||
double beta2; ///< Interior angle at vertex v₂.
|
||||
double beta3; ///< Interior angle at vertex v₃.
|
||||
double a12; ///< Edge DOF value at e₁₂.
|
||||
double a23; ///< Edge DOF value at e₂₃.
|
||||
double a31; ///< Edge DOF value at e₃₁.
|
||||
double b1; ///< Vertex DOF value at v₁.
|
||||
double b2; ///< Vertex DOF value at v₂.
|
||||
double b3; ///< Vertex DOF value at v₃.
|
||||
bool v1b; ///< `true` iff vertex v₁ is variable (not pinned).
|
||||
bool v2b; ///< `true` iff vertex v₂ is variable.
|
||||
bool v3b; ///< `true` iff vertex v₃ is variable.
|
||||
};
|
||||
|
||||
/// Compute the six per-face angles (+ remember the input DOFs) for face
|
||||
/// `f` of `mesh`, given the current DOF vector `x` and DOF-index maps.
|
||||
static FaceAngles compute_face_angles(
|
||||
const ConformalMesh& mesh,
|
||||
Face_index f,
|
||||
const std::vector<double>& x,
|
||||
const HyperIdealMaps& m)
|
||||
const HyperIdealMaps& m,
|
||||
HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava)
|
||||
{
|
||||
Halfedge_index h0 = mesh.halfedge(f);
|
||||
Halfedge_index h1 = mesh.next(h0);
|
||||
@@ -219,9 +328,9 @@ static FaceAngles compute_face_angles(
|
||||
if (fa.v1b && fa.v2b && fa.a12 < 0.0) fa.a12 = 0.0;
|
||||
if (fa.v2b && fa.v3b && fa.a23 < 0.0) fa.a23 = 0.0;
|
||||
if (fa.v3b && fa.v1b && fa.a31 < 0.0) fa.a31 = 0.0;
|
||||
if (fa.v1b && fa.b1 < 0.0) fa.b1 = 0.01;
|
||||
if (fa.v2b && fa.b2 < 0.0) fa.b2 = 0.01;
|
||||
if (fa.v3b && fa.b3 < 0.0) fa.b3 = 0.01;
|
||||
fa.b1 = clamp_hyper_ideal_scale(fa.b1, fa.v1b, clamp);
|
||||
fa.b2 = clamp_hyper_ideal_scale(fa.b2, fa.v2b, clamp);
|
||||
fa.b3 = clamp_hyper_ideal_scale(fa.b3, fa.v3b, clamp);
|
||||
|
||||
double l12 = lij(fa.b1, fa.b2, fa.a12, fa.v1b, fa.v2b);
|
||||
double l23 = lij(fa.b2, fa.b3, fa.a23, fa.v2b, fa.v3b);
|
||||
@@ -262,26 +371,55 @@ static FaceAngles compute_face_angles(
|
||||
return fa;
|
||||
}
|
||||
|
||||
// Per-face energy contribution U(f) (before subtracting θ·a and Θ·b terms).
|
||||
/// Per-face energy contribution U(f) before subtracting the θ·a and Θ·b terms.
|
||||
///
|
||||
/// Supported configurations (faithful port of HyperIdealFunctional.java):
|
||||
/// * All three vertices hyper-ideal (v?b = true) → Meyerhoff/Ushijima volume
|
||||
/// * Exactly one vertex ideal (v?b = false, other two true) → Kolpakov-Mednykh volume
|
||||
///
|
||||
/// NOT supported — faces with two or three ideal vertices. The Java reference
|
||||
/// (HyperIdealFunctional.java lines 222-231) uses an if/else-if chain that
|
||||
/// silently applies the one-ideal-vertex formula to the first ideal vertex it
|
||||
/// finds, ignoring additional ideal vertices. That is mathematically wrong for
|
||||
/// two-ideal or three-ideal faces. Rather than silently computing a wrong result,
|
||||
/// this C++ port throws immediately so the caller can diagnose the problem.
|
||||
/// The correct volume formulas for semi-ideal and fully-ideal faces are not
|
||||
/// implemented in the Java reference and would require new research.
|
||||
static double face_energy(const FaceAngles& fa)
|
||||
{
|
||||
// Guard: reject configurations with 2 or 3 ideal vertices in one face.
|
||||
const int ideal_count = (!fa.v1b ? 1 : 0)
|
||||
+ (!fa.v2b ? 1 : 0)
|
||||
+ (!fa.v3b ? 1 : 0);
|
||||
if (ideal_count >= 2)
|
||||
throw std::logic_error(
|
||||
"face_energy: faces with 2 or 3 ideal (pinned) vertices are not "
|
||||
"supported. Only 0-ideal (all hyper-ideal) and 1-ideal faces are "
|
||||
"implemented, matching the Java HyperIdealFunctional reference. "
|
||||
"Check your v_idx assignments: at most one vertex per face may be "
|
||||
"pinned (v_idx = -1).");
|
||||
|
||||
double aa = fa.a12*fa.alpha12 + fa.a23*fa.alpha23 + fa.a31*fa.alpha31;
|
||||
double bb = fa.b1 *fa.beta1 + fa.b2 *fa.beta2 + fa.b3 *fa.beta3;
|
||||
|
||||
double V = 0.0;
|
||||
if (fa.v1b && fa.v2b && fa.v3b) {
|
||||
// All three vertices are hyper-ideal.
|
||||
V = calculateTetrahedronVolume(
|
||||
fa.beta1, fa.beta2, fa.beta3,
|
||||
fa.alpha23, fa.alpha31, fa.alpha12);
|
||||
} else if (!fa.v1b) {
|
||||
// Exactly v1 is ideal (ideal_count == 1 guaranteed by guard above).
|
||||
V = calculateTetrahedronVolumeWithIdealVertexAtGamma(
|
||||
fa.beta1, fa.alpha31, fa.alpha12,
|
||||
fa.alpha23, fa.beta2, fa.beta3);
|
||||
} else if (!fa.v2b) {
|
||||
// Exactly v2 is ideal.
|
||||
V = calculateTetrahedronVolumeWithIdealVertexAtGamma(
|
||||
fa.beta2, fa.alpha12, fa.alpha23,
|
||||
fa.alpha31, fa.beta3, fa.beta1);
|
||||
} else { // !v3b
|
||||
} else {
|
||||
// Exactly v3 is ideal (!v3b, guaranteed by ideal_count == 1).
|
||||
V = calculateTetrahedronVolumeWithIdealVertexAtGamma(
|
||||
fa.beta3, fa.alpha23, fa.alpha31,
|
||||
fa.alpha12, fa.beta1, fa.beta2);
|
||||
@@ -291,12 +429,21 @@ static double face_energy(const FaceAngles& fa)
|
||||
|
||||
// ── Full evaluation ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Evaluate the HyperIdeal functional at DOF vector `x`. Returns the
|
||||
/// energy value and (optionally) the gradient in a `HyperIdealResult`.
|
||||
///
|
||||
/// \param mesh Triangle mesh carrying the DOF-index property maps.
|
||||
/// \param x Current DOF vector (length = `hyper_ideal_dimension(...)`).
|
||||
/// \param m Property-map bundle from `setup_hyper_ideal_maps(...)`.
|
||||
/// \param need_energy If `true`, fill `result.energy` (default: `true`).
|
||||
/// \param need_gradient If `true`, fill `result.gradient` (default: `true`).
|
||||
inline HyperIdealResult evaluate_hyper_ideal(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const HyperIdealMaps& m,
|
||||
bool need_energy = true,
|
||||
bool need_gradient = true)
|
||||
bool need_gradient = true,
|
||||
HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava)
|
||||
{
|
||||
HyperIdealResult res;
|
||||
|
||||
@@ -312,7 +459,7 @@ inline HyperIdealResult evaluate_hyper_ideal(
|
||||
Halfedge_index h1 = mesh.next(h0);
|
||||
Halfedge_index h2 = mesh.next(h1);
|
||||
|
||||
FaceAngles fa = compute_face_angles(mesh, f, x, m);
|
||||
FaceAngles fa = compute_face_angles(mesh, f, x, m, clamp);
|
||||
|
||||
// Store computed angles into temporary arrays.
|
||||
// h_alpha[h] = α for the edge of h in this face.
|
||||
@@ -374,11 +521,11 @@ inline HyperIdealResult evaluate_hyper_ideal(
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Finite-difference gradient check ─────────────────────────────────────────
|
||||
//
|
||||
// Returns true if |G[i] − fd[i]| / max(1, |G[i]|) < tol for all DOFs.
|
||||
// eps = step size, tol = tolerance (same defaults as Java FunctionalTest).
|
||||
inline bool gradient_check(
|
||||
/// Finite-difference gradient check (central differences).
|
||||
///
|
||||
/// Returns `true` iff `|G[i] − fd[i]| / max(1, |G[i]|) < tol` for every
|
||||
/// DOF. Defaults `eps = 1e-5`, `tol = 1e-4` match the Java `FunctionalTest`.
|
||||
inline bool gradient_check_hyper_ideal(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x0,
|
||||
const HyperIdealMaps& m,
|
||||
@@ -411,4 +558,14 @@ inline bool gradient_check(
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// \deprecated Use `gradient_check_hyper_ideal` (API-naming audit A3).
|
||||
[[deprecated("renamed to gradient_check_hyper_ideal")]]
|
||||
inline bool gradient_check(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x0,
|
||||
const HyperIdealMaps& m,
|
||||
double eps = 1E-5,
|
||||
double tol = 1E-4)
|
||||
{ return gradient_check_hyper_ideal(mesh, x0, m, eps, tol); }
|
||||
|
||||
} // namespace conformallab
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// hyper_ideal_geometry.hpp
|
||||
//
|
||||
// Pure-math building blocks for the hyper-ideal discrete conformal map.
|
||||
@@ -22,9 +25,9 @@ namespace conformallab {
|
||||
|
||||
// ── Length functions ─────────────────────────────────────────────────────────
|
||||
|
||||
// ζ(x,y,z) — interior angle in a hyperbolic triangle with edge lengths
|
||||
// x, y, z, opposite to the side of length z.
|
||||
// Ports HyperIdealUtility.ζ(x, y, z).
|
||||
/// `ζ(x,y,z)` — interior angle (in radians) in a hyperbolic triangle
|
||||
/// with edge lengths `x`, `y`, `z`, opposite to the side of length `z`.
|
||||
/// Ports `HyperIdealUtility.ζ(x, y, z)`.
|
||||
inline double zeta(double x, double y, double z)
|
||||
{
|
||||
double cx = std::cosh(x), cy = std::cosh(y), cz = std::cosh(z);
|
||||
@@ -34,8 +37,8 @@ inline double zeta(double x, double y, double z)
|
||||
return std::acos(nbd);
|
||||
}
|
||||
|
||||
// ζ₁₃(x,y,z) — third edge length in a right-angled hyperbolic hexagon.
|
||||
// Ports HyperIdealUtility.ζ_13(x, y, z).
|
||||
/// `ζ₁₃(x,y,z)` — third edge length in a right-angled hyperbolic hexagon.
|
||||
/// Ports `HyperIdealUtility.ζ_13(x, y, z)`.
|
||||
inline double zeta13(double x, double y, double z)
|
||||
{
|
||||
double cx = std::cosh(x), cy = std::cosh(y), cz = std::cosh(z);
|
||||
@@ -43,16 +46,16 @@ inline double zeta13(double x, double y, double z)
|
||||
return std::acosh((cx*cy + cz) / (sx*sy));
|
||||
}
|
||||
|
||||
// ζ₁₄(x,y) — edge length in a hyperbolic pentagon with one ideal vertex.
|
||||
// Ports HyperIdealUtility.ζ_14(x, y).
|
||||
/// `ζ₁₄(x,y)` — edge length in a hyperbolic pentagon with one ideal vertex.
|
||||
/// Ports `HyperIdealUtility.ζ_14(x, y)`.
|
||||
inline double zeta14(double x, double y)
|
||||
{
|
||||
double cy = std::cosh(y), sy = std::sinh(y);
|
||||
return std::acosh((std::exp(x) + cy) / sy);
|
||||
}
|
||||
|
||||
// ζ₁₅(x) — length in a hyperbolic quadrilateral with two ideal vertices.
|
||||
// Ports HyperIdealUtility.ζ_15(x).
|
||||
/// `ζ₁₅(x)` — length in a hyperbolic quadrilateral with two ideal vertices.
|
||||
/// Ports `HyperIdealUtility.ζ_15(x)`.
|
||||
inline double zeta15(double x)
|
||||
{
|
||||
return 2.0 * std::asinh(std::exp(x / 2.0));
|
||||
@@ -60,12 +63,11 @@ inline double zeta15(double x)
|
||||
|
||||
// ── Effective edge length ─────────────────────────────────────────────────────
|
||||
|
||||
// l_ij: effective hyperbolic length of edge ij.
|
||||
// b_i, b_j – vertex log scale factors (used only if vertex is hyper-ideal)
|
||||
// a_ij – edge intersection-angle variable
|
||||
// vi_var – true if vertex i is hyper-ideal (has a DOF b_i)
|
||||
// vj_var – true if vertex j is hyper-ideal
|
||||
// Ports HyperIdealFunctional.lij().
|
||||
/// `l_ij`: effective hyperbolic length of edge ij.
|
||||
/// * `bi`, `bj` — vertex log scale factors (used only when vertex is hyper-ideal).
|
||||
/// * `aij` — edge intersection-angle variable.
|
||||
/// * `vi_var` / `vj_var` — `true` iff the corresponding vertex is hyper-ideal.
|
||||
/// Ports `HyperIdealFunctional.lij()`.
|
||||
inline double lij(double bi, double bj, double aij, bool vi_var, bool vj_var)
|
||||
{
|
||||
if (vi_var && vj_var) return zeta13(bi, bj, aij);
|
||||
@@ -76,8 +78,8 @@ inline double lij(double bi, double bj, double aij, bool vi_var, bool vj_var)
|
||||
|
||||
// ── Auxiliary angle functions ─────────────────────────────────────────────────
|
||||
|
||||
// σᵢ(aᵢⱼ, aₖᵢ, aⱼₖ, vj_var, vk_var) — intermediate half-length at vertex i.
|
||||
// Ports HyperIdealFunctional.σi().
|
||||
/// `σᵢ(aᵢⱼ, aₖᵢ, aⱼₖ, vj_var, vk_var)` — intermediate half-length at vertex i.
|
||||
/// Ports `HyperIdealFunctional.σi()`.
|
||||
inline double sigma_i(double aij, double aki, double ajk, bool vj_var, bool vk_var)
|
||||
{
|
||||
if (vj_var && vk_var) return zeta13(aij, aki, ajk);
|
||||
@@ -86,24 +88,24 @@ inline double sigma_i(double aij, double aki, double ajk, bool vj_var, bool vk_v
|
||||
return zeta15(ajk - aij - aki);
|
||||
}
|
||||
|
||||
// σᵢⱼ(aᵢⱼ, bᵢ, bⱼ, vj_var) — intermediate half-length for edge ij from vertex i.
|
||||
// Ports HyperIdealFunctional.σij().
|
||||
/// `σᵢⱼ(aᵢⱼ, bᵢ, bⱼ, vj_var)` — intermediate half-length for edge ij from vertex i.
|
||||
/// Ports `HyperIdealFunctional.σij()`.
|
||||
inline double sigma_ij(double aij, double bi, double bj, bool vj_var)
|
||||
{
|
||||
if (vj_var) return zeta13(aij, bi, bj);
|
||||
return zeta14(-aij, bi);
|
||||
}
|
||||
|
||||
// α_ij: computed dihedral angle at edge ij in the face with vertices i, j, k.
|
||||
//
|
||||
// Arguments (cyclic role assignment):
|
||||
// aij, ajk, aki – edge variables
|
||||
// bi, bj, bk – vertex variables
|
||||
// βi, βj, βk – interior angles of the auxiliary hyperbolic triangle
|
||||
// vi_var, vj_var, vk_var – which vertices are hyper-ideal
|
||||
//
|
||||
// Ports HyperIdealFunctional.αij() (the private helper).
|
||||
// Note: the vk_var case recurses once (never more than one level deep).
|
||||
/// `α_ij`: computed dihedral angle at edge ij in the face with vertices i, j, k.
|
||||
///
|
||||
/// Arguments (cyclic role assignment):
|
||||
/// * `aij, ajk, aki` — edge variables.
|
||||
/// * `bi, bj, bk` — vertex variables.
|
||||
/// * `beta_i, beta_j, beta_k` — interior angles of the auxiliary hyperbolic triangle.
|
||||
/// * `vi_var, vj_var, vk_var` — which vertices are hyper-ideal.
|
||||
///
|
||||
/// Ports `HyperIdealFunctional.αij()` (the private helper).
|
||||
/// Note: the `vk_var` case recurses once (never more than one level deep).
|
||||
inline double alpha_ij(
|
||||
double aij, double ajk, double aki,
|
||||
double bi, double bj, double bk,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// hyper_ideal_hessian.hpp
|
||||
//
|
||||
// Phase 4a — Hessian of the hyper-ideal discrete conformal functional.
|
||||
@@ -54,18 +57,15 @@
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
// ── Full finite-difference Hessian (baseline, Phase 4a) ──────────────────────
|
||||
//
|
||||
// Returns the n×n sparse Hessian, where n = hyper_ideal_dimension(mesh, m).
|
||||
// eps: finite-difference step size (default 1e-5 gives ~1e-10 relative error).
|
||||
//
|
||||
// Cost: n × full-gradient evaluations ≈ O(n·F). Use for small meshes or
|
||||
// as a correctness reference for the block-FD variant below.
|
||||
/// Full finite-difference HyperIdeal Hessian (baseline, Phase 4a).
|
||||
/// Cost: `n` full-gradient evaluations ≈ `O(n·F)`. Use for small
|
||||
/// meshes or as a correctness reference for the block-FD variant.
|
||||
inline Eigen::SparseMatrix<double> hyper_ideal_hessian(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const HyperIdealMaps& m,
|
||||
double eps = 1e-5)
|
||||
double eps = 1e-5,
|
||||
HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava)
|
||||
{
|
||||
const int n = hyper_ideal_dimension(mesh, m);
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
@@ -78,8 +78,8 @@ inline Eigen::SparseMatrix<double> hyper_ideal_hessian(
|
||||
xp[sj] = x[sj] + eps;
|
||||
xm[sj] = x[sj] - eps;
|
||||
|
||||
auto Gp = evaluate_hyper_ideal(mesh, xp, m, /*energy=*/false).gradient;
|
||||
auto Gm = evaluate_hyper_ideal(mesh, xm, m, /*energy=*/false).gradient;
|
||||
auto Gp = evaluate_hyper_ideal(mesh, xp, m, /*energy=*/false, /*grad=*/true, clamp).gradient;
|
||||
auto Gm = evaluate_hyper_ideal(mesh, xm, m, /*energy=*/false, /*grad=*/true, clamp).gradient;
|
||||
|
||||
xp[sj] = xm[sj] = x[sj]; // restore
|
||||
|
||||
@@ -96,17 +96,16 @@ inline Eigen::SparseMatrix<double> hyper_ideal_hessian(
|
||||
return H;
|
||||
}
|
||||
|
||||
// ── Symmetrised Hessian (full-FD variant) ────────────────────────────────────
|
||||
//
|
||||
// The FD Hessian is symmetric in exact arithmetic; floating-point rounding
|
||||
// can introduce tiny asymmetries. This helper returns (H + Hᵀ)/2.
|
||||
/// Symmetrised full-FD HyperIdeal Hessian: returns `(H + Hᵀ) / 2` to
|
||||
/// scrub the tiny asymmetries introduced by floating-point rounding.
|
||||
inline Eigen::SparseMatrix<double> hyper_ideal_hessian_sym(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const HyperIdealMaps& m,
|
||||
double eps = 1e-5)
|
||||
double eps = 1e-5,
|
||||
HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava)
|
||||
{
|
||||
auto H = hyper_ideal_hessian(mesh, x, m, eps);
|
||||
auto H = hyper_ideal_hessian(mesh, x, m, eps, clamp);
|
||||
Eigen::SparseMatrix<double> Ht = H.transpose();
|
||||
return (H + Ht) * 0.5;
|
||||
}
|
||||
@@ -137,11 +136,16 @@ inline Eigen::SparseMatrix<double> hyper_ideal_hessian_sym(
|
||||
// vs full-FD ≈ 99,200 → ~33× speed-up.
|
||||
// On brezel.obj (F=13824, n≈14000): 165 888 face evaluations
|
||||
// vs full-FD ≈ 193 M → ~1166× speed-up.
|
||||
/// Per-face block-FD HyperIdeal Hessian (Phase 9b). Uses the locality
|
||||
/// lemma `∂G_x/∂y = Σ_{f: x,y ∈ local(f)} ∂(β or α)/∂y` to perturb only
|
||||
/// the 6 face-local DOFs at a time, giving an `F·12` face-evaluation
|
||||
/// budget vs `n·F` for full-FD (~96× speed-up on brezel.obj).
|
||||
inline Eigen::SparseMatrix<double> hyper_ideal_hessian_block_fd(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const HyperIdealMaps& m,
|
||||
double eps = 1e-5)
|
||||
double eps = 1e-5,
|
||||
HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava)
|
||||
{
|
||||
const int n = hyper_ideal_dimension(mesh, m);
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
@@ -186,9 +190,9 @@ inline Eigen::SparseMatrix<double> hyper_ideal_hessian_block_fd(
|
||||
vm[j] -= eps;
|
||||
|
||||
auto Op = face_angles_from_local_dofs(
|
||||
vp[0], vp[1], vp[2], vp[3], vp[4], vp[5], v1b, v2b, v3b);
|
||||
vp[0], vp[1], vp[2], vp[3], vp[4], vp[5], v1b, v2b, v3b, clamp);
|
||||
auto Om = face_angles_from_local_dofs(
|
||||
vm[0], vm[1], vm[2], vm[3], vm[4], vm[5], v1b, v2b, v3b);
|
||||
vm[0], vm[1], vm[2], vm[3], vm[4], vm[5], v1b, v2b, v3b, clamp);
|
||||
|
||||
const double Gp[6] = {
|
||||
Op.beta1, Op.beta2, Op.beta3,
|
||||
@@ -213,18 +217,17 @@ inline Eigen::SparseMatrix<double> hyper_ideal_hessian_block_fd(
|
||||
return H;
|
||||
}
|
||||
|
||||
// ── Symmetrised block-FD Hessian ─────────────────────────────────────────────
|
||||
//
|
||||
// The per-face block is symmetric to within FD rounding, but the
|
||||
// accumulation may amplify tiny asymmetries. This helper returns
|
||||
// (H + Hᵀ)/2, identical in spirit to `hyper_ideal_hessian_sym`.
|
||||
/// Symmetrised block-FD HyperIdeal Hessian: returns `(H + Hᵀ) / 2` of
|
||||
/// `hyper_ideal_hessian_block_fd(...)` for downstream solvers that
|
||||
/// require strict symmetry.
|
||||
inline Eigen::SparseMatrix<double> hyper_ideal_hessian_block_fd_sym(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const HyperIdealMaps& m,
|
||||
double eps = 1e-5)
|
||||
double eps = 1e-5,
|
||||
HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava)
|
||||
{
|
||||
auto H = hyper_ideal_hessian_block_fd(mesh, x, m, eps);
|
||||
auto H = hyper_ideal_hessian_block_fd(mesh, x, m, eps, clamp);
|
||||
Eigen::SparseMatrix<double> Ht = H.transpose();
|
||||
return (H + Ht) * 0.5;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
// Hyperbolic tetrahedron volume formulas.
|
||||
// Ported from de.varylab.discreteconformal.functional.HyperIdealUtility (Java).
|
||||
@@ -12,9 +15,9 @@
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
// Volume of a generalized hyperbolic tetrahedron with dihedral angles A..F.
|
||||
// Formula: Meyerhoff / Ushijima (Springer 2006).
|
||||
// Corresponds to Java HyperIdealUtility.calculateTetrahedronVolume().
|
||||
/// Volume of a generalized hyperbolic tetrahedron with dihedral
|
||||
/// angles `A,…,F` via the Meyerhoff / Ushijima 2006 formula.
|
||||
/// Same as Java `HyperIdealUtility.calculateTetrahedronVolume()`.
|
||||
inline double calculateTetrahedronVolume(double A, double B, double C,
|
||||
double D, double E, double F) {
|
||||
// PI from constants.hpp (conformallab::PI)
|
||||
@@ -73,11 +76,9 @@ inline double calculateTetrahedronVolume(double A, double B, double C,
|
||||
return (U(z1) - U(z2)) / 2.0;
|
||||
}
|
||||
|
||||
// Volume of a hyperideal tetrahedron with one ideal vertex (at gamma).
|
||||
// Dihedral angles at the ideal vertex: gamma1, gamma2, gamma3.
|
||||
// Dihedral angles at opposite edges: alpha23, alpha31, alpha12.
|
||||
// Formula: Kolpakov–Mednykh (arxiv math/0603097).
|
||||
// Corresponds to Java HyperIdealUtility.calculateTetrahedronVolumeWithIdealVertexAtGamma().
|
||||
/// Volume of a hyperideal tetrahedron with one ideal vertex at γ via
|
||||
/// the Kolpakov-Mednykh formula (arxiv math/0603097). Same as Java
|
||||
/// `HyperIdealUtility.calculateTetrahedronVolumeWithIdealVertexAtGamma()`.
|
||||
inline double calculateTetrahedronVolumeWithIdealVertexAtGamma(
|
||||
double gamma1, double gamma2, double gamma3,
|
||||
double alpha23, double alpha31, double alpha12)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Port of the static helper
|
||||
// HyperIdealVisualizationPlugin.getEuclideanCircleFromHyperbolic()
|
||||
// from de.varylab.discreteconformal.plugin.
|
||||
@@ -26,15 +29,14 @@
|
||||
// After translation and projection to the Poincaré disk their circumcircle
|
||||
// equals the image of the original hyperbolic circle.
|
||||
|
||||
#include <Eigen/Dense>
|
||||
#include <Eigen/Core> // downgraded from <Eigen/Dense>: this header only
|
||||
// uses Matrix/Vector primitives, no decompositions.
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Circumcenter of three 2-D points
|
||||
// ---------------------------------------------------------------------------
|
||||
/// Circumcenter of three 2-D points (`a`, `b`, `c`) in the Euclidean plane.
|
||||
inline Eigen::Vector2d circumcenter2d(
|
||||
const Eigen::Vector2d& a,
|
||||
const Eigen::Vector2d& b,
|
||||
@@ -54,10 +56,8 @@ inline Eigen::Vector2d circumcenter2d(
|
||||
return {ux, uy};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4×4 Lorentz boost: maps the hyperboloid origin e₄=(0,0,0,1) to `center`.
|
||||
// `center` must lie on the hyperboloid: center[3]² - ‖center.head<3>()‖² = 1.
|
||||
// ---------------------------------------------------------------------------
|
||||
/// 4×4 Lorentz boost: maps the hyperboloid origin `e₄ = (0,0,0,1)` to
|
||||
/// `center`. Precondition: `center` lies on the hyperboloid.
|
||||
inline Eigen::Matrix4d hyperboloidTranslation(const Eigen::Vector4d& center)
|
||||
{
|
||||
Eigen::Vector3d p = center.head<3>();
|
||||
@@ -73,10 +73,8 @@ inline Eigen::Matrix4d hyperboloidTranslation(const Eigen::Vector4d& center)
|
||||
return T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project a hyperboloid point to the Poincaré disk (jReality convention:
|
||||
// add 1 to the w-coordinate, then dehomogenize the spatial part).
|
||||
// ---------------------------------------------------------------------------
|
||||
/// Project a hyperboloid point `x` onto the Poincaré disk (jReality
|
||||
/// convention: add 1 to the w-coordinate, then dehomogenise spatial part).
|
||||
inline Eigen::Vector2d toPoincareDisk(const Eigen::Vector4d& x)
|
||||
{
|
||||
double w = x(3) + 1.0;
|
||||
@@ -97,6 +95,10 @@ inline Eigen::Vector2d toPoincareDisk(const Eigen::Vector4d& x)
|
||||
//
|
||||
// Port of HyperIdealVisualizationPlugin.getEuclideanCircleFromHyperbolic()
|
||||
// ---------------------------------------------------------------------------
|
||||
/// Convert a hyperbolic circle (`center` on the hyperboloid, hyperbolic
|
||||
/// `radius`) to the corresponding Euclidean circle in the Poincaré disk;
|
||||
/// returns `{cx, cy, r}`. Port of `HyperIdealVisualizationPlugin
|
||||
/// .getEuclideanCircleFromHyperbolic()`.
|
||||
inline std::array<double,3> getEuclideanCircleFromHyperbolic(
|
||||
const Eigen::Vector4d& center, double radius)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// inversive_distance_functional.hpp
|
||||
//
|
||||
// Phase 9a.2 — Inversive-distance circle-packing functional (Luo 2004).
|
||||
@@ -65,6 +68,7 @@
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include "constants.hpp"
|
||||
#include "gauss_legendre.hpp"
|
||||
#include "euclidean_geometry.hpp" // euclidean_angles(λ12, λ23, λ31)
|
||||
#include <CGAL/boost/graph/iterator.h>
|
||||
#include <vector>
|
||||
@@ -76,12 +80,17 @@ namespace conformallab {
|
||||
|
||||
// ── Property-map type aliases ────────────────────────────────────────────────
|
||||
|
||||
/// Property map vertex → `int` for the Inversive-Distance functional.
|
||||
using IDVMapI = ConformalMesh::Property_map<Vertex_index, int>;
|
||||
/// Property map vertex → `double` for the Inversive-Distance functional.
|
||||
using IDVMapD = ConformalMesh::Property_map<Vertex_index, double>;
|
||||
/// Property map edge → `double` for the Inversive-Distance functional.
|
||||
using IDEMapD = ConformalMesh::Property_map<Edge_index, double>;
|
||||
|
||||
// ── Persistent map bundle ─────────────────────────────────────────────────────
|
||||
|
||||
/// Bundle of the four property maps consumed by the Inversive-Distance
|
||||
/// circle-packing functional (Luo 2004 / Bowers-Stephenson 2004).
|
||||
struct InversiveDistanceMaps {
|
||||
IDVMapI v_idx; ///< DOF index per vertex (−1 = pinned / u_v = 0)
|
||||
IDVMapD theta_v; ///< target cone angle Θ_v (default 2π)
|
||||
@@ -89,7 +98,19 @@ struct InversiveDistanceMaps {
|
||||
IDEMapD I_e; ///< inversive distance I_ij (per edge, constant)
|
||||
};
|
||||
|
||||
// Create the property maps with sensible defaults.
|
||||
/// Attach the four inversive-distance property maps to `mesh` and
|
||||
/// return their handles.
|
||||
///
|
||||
/// Defaults are intentionally trivial — every real use of this
|
||||
/// functional must call `compute_inversive_distance_init_from_mesh()`
|
||||
/// next to populate `r0` and `I_e` from the input geometry.
|
||||
/// * `v_idx[v] = -1` (all vertices pinned initially)
|
||||
/// * `theta_v[v] = 2π` (regular interior vertex)
|
||||
/// * `r0[v] = 1.0` (placeholder)
|
||||
/// * `I_e[e] = 1.0` (tangential default — overwritten by init step)
|
||||
///
|
||||
/// The maps use the `"iv:"` / `"ie:"` prefix so they do not collide
|
||||
/// with the Euclidean / Spherical / HyperIdeal / CP-Euclidean maps.
|
||||
inline InversiveDistanceMaps setup_inversive_distance_maps(ConformalMesh& mesh)
|
||||
{
|
||||
InversiveDistanceMaps m;
|
||||
@@ -100,8 +121,15 @@ inline InversiveDistanceMaps setup_inversive_distance_maps(ConformalMesh& mesh)
|
||||
return m;
|
||||
}
|
||||
|
||||
// Assign sequential DOF indices to all vertices (no gauge pinning here —
|
||||
// the caller should set one v_idx to −1 before assigning).
|
||||
/// Assign sequential DOF indices `0..n-1` to every vertex.
|
||||
///
|
||||
/// **Note:** this overload assigns indices to ALL vertices unconditionally.
|
||||
/// Any `v_idx` set before the call is overwritten. To pin a gauge vertex,
|
||||
/// either use the two-argument overload below, or set `m.v_idx[v] = -1`
|
||||
/// **after** this call. Pinning before this call has no effect.
|
||||
///
|
||||
/// For a closed mesh, exactly one pin is required to remove the
|
||||
/// global rotational mode.
|
||||
inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& mesh,
|
||||
InversiveDistanceMaps& m)
|
||||
{
|
||||
@@ -110,7 +138,22 @@ inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& m
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Count free DOFs.
|
||||
/// Assign sequential DOF indices to all vertices, pinning `gauge`
|
||||
/// (`m.v_idx[gauge] = -1`). Use this overload on closed meshes to fix
|
||||
/// the rotational gauge mode in a single call.
|
||||
///
|
||||
/// \returns The number of free DOFs assigned (`num_vertices − 1`).
|
||||
inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& mesh,
|
||||
InversiveDistanceMaps& m,
|
||||
Vertex_index gauge)
|
||||
{
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices())
|
||||
m.v_idx[v] = (v == gauge) ? -1 : idx++;
|
||||
return idx;
|
||||
}
|
||||
|
||||
/// Count the free DOFs (vertices with `v_idx >= 0`).
|
||||
inline int inversive_distance_dimension(const ConformalMesh& mesh,
|
||||
const InversiveDistanceMaps& m)
|
||||
{
|
||||
@@ -119,18 +162,28 @@ inline int inversive_distance_dimension(const ConformalMesh& mesh,
|
||||
return dim;
|
||||
}
|
||||
|
||||
// ── Initialisation from initial mesh geometry ────────────────────────────────
|
||||
//
|
||||
// Two-phase init mirroring "compute_lambda0" for euclidean_functional:
|
||||
// 1. Choose r_i^(0). Simplest heuristic: r_i = (1/3)·(min adjacent ℓ).
|
||||
// Other choices (max ℓ, mean ℓ, length-of-shortest-vertex-cycle) are
|
||||
// possible; the user can override `m.r0[v]` between setup and init.
|
||||
// 2. Compute I_ij = ( ℓ² − r_i² − r_j² ) / ( 2 r_i r_j ) for each edge.
|
||||
//
|
||||
// Note: a valid inversive-distance packing requires I_ij > −1 on every edge,
|
||||
// and the triangle inequality must hold on every face under the resulting ℓ.
|
||||
// The choice r_i = ⅓·min(ℓ_e adj v) keeps I_ij safely positive for most
|
||||
// real meshes.
|
||||
/// Two-phase initialisation from initial mesh geometry. Mirrors the
|
||||
/// role of `compute_lambda0_from_mesh` in the Euclidean functional, but
|
||||
/// adapted to Luo's vertex-based radius parametrisation.
|
||||
///
|
||||
/// **Phase 1.** Pick a positive radius per vertex:
|
||||
/// \code
|
||||
/// r_i^(0) = (1/3) · min{ℓ_e : e adjacent to v_i}
|
||||
/// \endcode
|
||||
/// This is a heuristic — the user may override `m.r0[v]` for any
|
||||
/// vertex between `setup_inversive_distance_maps()` and this call.
|
||||
///
|
||||
/// **Phase 2.** Compute the per-edge inversive distance via the
|
||||
/// Bowers-Stephenson 2004 identity:
|
||||
/// \code
|
||||
/// I_ij = ( ℓ_ij² − r_i² − r_j² ) / ( 2 r_i r_j )
|
||||
/// \endcode
|
||||
///
|
||||
/// \pre Every edge has positive 3-D length.
|
||||
/// \pre Radii produced in Phase 1 are positive (degenerate isolated
|
||||
/// vertices fall back to `r_i = 1`).
|
||||
/// \post Every `I_e[e] > -1` for a valid packing. The chosen
|
||||
/// Phase-1 heuristic keeps `I_e > 0` for most real meshes.
|
||||
inline void compute_inversive_distance_init_from_mesh(ConformalMesh& mesh,
|
||||
InversiveDistanceMaps& m)
|
||||
{
|
||||
@@ -177,10 +230,8 @@ inline double dof_val(int idx, const std::vector<double>& x) noexcept
|
||||
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
|
||||
}
|
||||
|
||||
inline std::size_t hidx(Halfedge_index h) noexcept
|
||||
{
|
||||
return static_cast<std::size_t>(static_cast<std::uint32_t>(h));
|
||||
}
|
||||
// halfedge_to_index is defined in conformal_mesh.hpp.
|
||||
inline std::size_t hidx(Halfedge_index h) noexcept { return halfedge_to_index(h); }
|
||||
|
||||
// Inversive-distance edge length squared: ℓ² = exp(2u_i) + exp(2u_j) + 2 I r_i r_j
|
||||
// where r_i = exp(u_i), so: ℓ² = r_i² + r_j² + 2 I r_i r_j.
|
||||
@@ -195,12 +246,8 @@ inline double edge_length_squared(double u_i, double u_j, double I_ij) noexcept
|
||||
|
||||
} // namespace id_detail
|
||||
|
||||
// ── Gradient ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// G_v = Θ_v − Σ_{f ∋ v} α_v(f)
|
||||
//
|
||||
// Halfedge convention (identical to euclidean_functional.hpp):
|
||||
// h_alpha[h] = corner angle OPPOSITE to the edge of halfedge h in its face.
|
||||
/// Inversive-Distance gradient `G_v = Θ_v − Σ_faces α_v(face)`. Same
|
||||
/// half-edge corner-angle storage convention as `euclidean_gradient`.
|
||||
inline std::vector<double> inversive_distance_gradient(
|
||||
const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
@@ -235,11 +282,18 @@ inline std::vector<double> inversive_distance_gradient(
|
||||
double l23sq = id_detail::edge_length_squared(u2, u3, m.I_e[e23]);
|
||||
double l31sq = id_detail::edge_length_squared(u3, u1, m.I_e[e31]);
|
||||
|
||||
// A non-real circle configuration (ℓ² ≤ 0) has no limiting angle — skip it.
|
||||
if (l12sq <= 0 || l23sq <= 0 || l31sq <= 0) continue;
|
||||
|
||||
// euclidean_angles expects 2·log(ℓ) per edge — feed log(ℓ²).
|
||||
// For a triangle-inequality-violating face euclidean_angles returns the
|
||||
// *limiting* angles (π opposite the over-long edge, 0/0 otherwise) with
|
||||
// valid=false. We deliberately do NOT skip on !fa.valid: using those
|
||||
// limiting angles is the convex C¹ extension onto the infeasible region,
|
||||
// so Newton can pass through a flip instead of stalling (Finding 9 —
|
||||
// mirrors the Euclidean/Spherical fix in Finding 1). The angles come
|
||||
// from genuine Euclidean side lengths, so the extension is geometric.
|
||||
auto fa = euclidean_angles(std::log(l12sq), std::log(l23sq), std::log(l31sq));
|
||||
if (!fa.valid) continue;
|
||||
|
||||
h_alpha[id_detail::hidx(h0)] = fa.alpha3;
|
||||
h_alpha[id_detail::hidx(h1)] = fa.alpha1;
|
||||
@@ -261,28 +315,52 @@ inline std::vector<double> inversive_distance_gradient(
|
||||
return G;
|
||||
}
|
||||
|
||||
// ── Energy via Gauss-Legendre path integral ─────────────────────────────────
|
||||
//
|
||||
// E(u) = ∫₀¹ ⟨G(tu), u⟩ dt (10-point GL, identical constants to euclidean_functional)
|
||||
/// Per-face contribution to the Inversive-Distance gradient, as a pure
|
||||
/// 3→3 kernel of the three local vertex DOFs `(u1,u2,u3)` and the three
|
||||
/// edge inversive distances `(I12,I23,I31)`. No mesh, no property maps —
|
||||
/// used by the per-face block-FD Hessian in `inversive_distance_hessian.hpp`.
|
||||
///
|
||||
/// Returns the three values this face *adds* to the global gradient at
|
||||
/// `(v1,v2,v3)`. Since `G_v = Θ_v − Σ_faces α_v`, the per-face contribution
|
||||
/// is the **negative** corner angles `(−α₁,−α₂,−α₃)`. A non-real circle
|
||||
/// configuration (any `ℓ² ≤ 0`) contributes nothing — exactly mirroring the
|
||||
/// face-skip (`continue`) in `inversive_distance_gradient`, so the block-FD
|
||||
/// Hessian and the full-FD Hessian see the same per-face support.
|
||||
struct IDFaceGradContribs {
|
||||
double g1; ///< contribution to G at v₁ (= −α₁)
|
||||
double g2; ///< contribution to G at v₂ (= −α₂)
|
||||
double g3; ///< contribution to G at v₃ (= −α₃)
|
||||
};
|
||||
|
||||
inline IDFaceGradContribs inversive_distance_face_grad_contribs(
|
||||
double u1, double u2, double u3,
|
||||
double I12, double I23, double I31)
|
||||
{
|
||||
double l12sq = id_detail::edge_length_squared(u1, u2, I12);
|
||||
double l23sq = id_detail::edge_length_squared(u2, u3, I23);
|
||||
double l31sq = id_detail::edge_length_squared(u3, u1, I31);
|
||||
|
||||
// Same face-skip as inversive_distance_gradient: a non-real circle
|
||||
// configuration has no limiting angle, so the face contributes 0.
|
||||
if (l12sq <= 0.0 || l23sq <= 0.0 || l31sq <= 0.0)
|
||||
return {0.0, 0.0, 0.0};
|
||||
|
||||
// euclidean_angles deliberately keeps its limiting (valid=false) angles
|
||||
// here — the convex C¹ extension — matching the gradient's choice not to
|
||||
// skip on !fa.valid. Corner at v_k = fa.alpha_k (see gradient Pass-2 trace).
|
||||
auto fa = euclidean_angles(std::log(l12sq), std::log(l23sq), std::log(l31sq));
|
||||
return {-fa.alpha1, -fa.alpha2, -fa.alpha3};
|
||||
}
|
||||
|
||||
/// Inversive-Distance energy `E(u) = ∫₀¹ ⟨G(t·u), u⟩ dt`, evaluated
|
||||
/// with 10-point Gauss-Legendre (constants shared with `euclidean_energy`).
|
||||
inline double inversive_distance_energy(
|
||||
const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const InversiveDistanceMaps& m)
|
||||
{
|
||||
static const double gl_s[10] = {
|
||||
-0.9739065285171717, -0.8650633666889845,
|
||||
-0.6794095682990244, -0.4333953941292472,
|
||||
-0.1488743389816312, 0.1488743389816312,
|
||||
0.4333953941292472, 0.6794095682990244,
|
||||
0.8650633666889845, 0.9739065285171717
|
||||
};
|
||||
static const double gl_w[10] = {
|
||||
0.0666713443086881, 0.1494513491505806,
|
||||
0.2190863625159820, 0.2692667193099963,
|
||||
0.2955242247147529, 0.2955242247147529,
|
||||
0.2692667193099963, 0.2190863625159820,
|
||||
0.1494513491505806, 0.0666713443086881
|
||||
};
|
||||
const double* gl_s = gl10_nodes();
|
||||
const double* gl_w = gl10_weights();
|
||||
|
||||
const std::size_t n = x.size();
|
||||
double E = 0.0;
|
||||
@@ -299,36 +377,42 @@ inline double inversive_distance_energy(
|
||||
return E;
|
||||
}
|
||||
|
||||
// ── Finite-difference gradient check ─────────────────────────────────────────
|
||||
/// FD gradient check for the Inversive-Distance functional (central diff).
|
||||
/// Uses the same **relative** error criterion as every other gradient check:
|
||||
/// `|analytic − fd| / max(1, |analytic|) < tol`.
|
||||
inline bool gradient_check_inversive_distance(
|
||||
const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const InversiveDistanceMaps& m,
|
||||
double eps = 1e-5,
|
||||
double tol = 1e-6)
|
||||
double tol = 1e-4)
|
||||
{
|
||||
auto G = inversive_distance_gradient(mesh, x, m);
|
||||
const std::size_t n = G.size();
|
||||
bool ok = true;
|
||||
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
std::vector<double> xp = x, xm = x;
|
||||
xp[i] += eps;
|
||||
xm[i] -= eps;
|
||||
double Ep = inversive_distance_energy(mesh, xp, m);
|
||||
double Em = inversive_distance_energy(mesh, xm, m);
|
||||
double fd = (Ep - Em) / (2.0 * eps);
|
||||
if (std::abs(G[i] - fd) > tol) {
|
||||
double Ep = inversive_distance_energy(mesh, xp, m);
|
||||
double Em = inversive_distance_energy(mesh, xm, m);
|
||||
double fd = (Ep - Em) / (2.0 * eps);
|
||||
double err = std::abs(G[i] - fd);
|
||||
double scale = std::max(1.0, std::abs(G[i]));
|
||||
if (err / scale > tol) {
|
||||
std::cerr << "[inversive-distance] FD gradient mismatch at DOF " << i
|
||||
<< ": analytic=" << G[i]
|
||||
<< " FD=" << fd
|
||||
<< " diff=" << (G[i] - fd) << "\n";
|
||||
return false;
|
||||
<< " rel-err=" << (err / scale) << "\n";
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ── Newton equilibrium check: gradient vanishes at converged u ──────────────
|
||||
/// Newton equilibrium check: returns `true` iff the gradient at `x`
|
||||
/// is below `tol` in infinity norm (Σ adj-face angles equal Θ_v).
|
||||
inline bool is_inversive_distance_equilibrium(
|
||||
const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
|
||||
187
code/include/inversive_distance_hessian.hpp
Normal file
187
code/include/inversive_distance_hessian.hpp
Normal file
@@ -0,0 +1,187 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// inversive_distance_hessian.hpp
|
||||
//
|
||||
// Phase 9a.2 — Hessian of the inversive-distance circle-packing functional
|
||||
// (Luo 2004 / Bowers-Stephenson 2004).
|
||||
//
|
||||
// ┌──────────────────────────────────────────────────────────────────────────┐
|
||||
// │ Implementation strategy │
|
||||
// │ │
|
||||
// │ TWO finite-difference Hessian implementations are provided here, │
|
||||
// │ mirroring the HyperIdeal pair in `hyper_ideal_hessian.hpp`: │
|
||||
// │ │
|
||||
// │ 1. `inversive_distance_hessian` — full finite-difference baseline. │
|
||||
// │ Cost ≈ n × (cost of a full gradient evaluation) = O(n · F). │
|
||||
// │ Kept as the cross-validation reference for the block-FD variant. │
|
||||
// │ │
|
||||
// │ 2. `inversive_distance_hessian_block_fd` — per-face block-FD. │
|
||||
// │ Each face contributes to the gradient through exactly 3 vertex │
|
||||
// │ DOFs (u₁,u₂,u₃); we FD the 3×3 local Jacobian of that face's │
|
||||
// │ gradient contribution and scatter it. Cost ≈ F × 6 face-angle │
|
||||
// │ evaluations = O(F). Speed-up factor ≈ n/6 over full-FD. │
|
||||
// │ │
|
||||
// │ Why the block-FD is correct (locality lemma): │
|
||||
// │ G_v = Θ_v − Σ_{f ∋ v} α_v(f), and α_v(f) depends ONLY on the 3 │
|
||||
// │ vertex DOFs of face f. Hence ∂G_x/∂y = Σ_{f: x,y ∈ {v1,v2,v3}(f)} │
|
||||
// │ ∂(−α_x)/∂y at f, so accumulating per-face 3×3 blocks reproduces the │
|
||||
// │ full Hessian (identical to O(ε²)). │
|
||||
// │ │
|
||||
// │ An analytic Hessian via Glickenstein 2011 eq. (4.6) is tracked in │
|
||||
// │ `doc/roadmap/research-track.md` as Phase 9a.2-analytic; it would take │
|
||||
// │ the cost from O(F)·(FD constant) to a single O(F) analytic pass. │
|
||||
// └──────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
#include "inversive_distance_functional.hpp"
|
||||
#include <Eigen/Sparse>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
/// Full finite-difference Inversive-Distance Hessian (baseline).
|
||||
/// Cost: `n` full-gradient evaluations ≈ `O(n·F)`. Use for small meshes
|
||||
/// or as a correctness reference for the block-FD variant.
|
||||
inline Eigen::SparseMatrix<double> inversive_distance_hessian(
|
||||
const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const InversiveDistanceMaps& m,
|
||||
double eps = 1e-5)
|
||||
{
|
||||
const int n = inversive_distance_dimension(mesh, m);
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(static_cast<std::size_t>(n) * 16);
|
||||
|
||||
std::vector<double> xp = x, xm = x;
|
||||
for (int j = 0; j < n; ++j) {
|
||||
const std::size_t sj = static_cast<std::size_t>(j);
|
||||
xp[sj] = x[sj] + eps;
|
||||
xm[sj] = x[sj] - eps;
|
||||
|
||||
auto Gp = inversive_distance_gradient(mesh, xp, m);
|
||||
auto Gm = inversive_distance_gradient(mesh, xm, m);
|
||||
|
||||
xp[sj] = xm[sj] = x[sj]; // restore
|
||||
|
||||
for (int i = 0; i < n; ++i) {
|
||||
double val = (Gp[static_cast<std::size_t>(i)]
|
||||
- Gm[static_cast<std::size_t>(i)]) / (2.0 * eps);
|
||||
if (std::abs(val) > 1e-15)
|
||||
trips.emplace_back(i, j, val);
|
||||
}
|
||||
}
|
||||
|
||||
Eigen::SparseMatrix<double> H(n, n);
|
||||
H.setFromTriplets(trips.begin(), trips.end());
|
||||
return H;
|
||||
}
|
||||
|
||||
/// Symmetrised full-FD Inversive-Distance Hessian: `(H + Hᵀ)/2`.
|
||||
inline Eigen::SparseMatrix<double> inversive_distance_hessian_sym(
|
||||
const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const InversiveDistanceMaps& m,
|
||||
double eps = 1e-5)
|
||||
{
|
||||
auto H = inversive_distance_hessian(mesh, x, m, eps);
|
||||
Eigen::SparseMatrix<double> Ht = H.transpose();
|
||||
return (H + Ht) * 0.5;
|
||||
}
|
||||
|
||||
// ── Block-FD Hessian ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// The 3 local DOFs of a face f are (u_{v1}, u_{v2}, u_{v3}). For each free
|
||||
// local DOF we recompute the face's gradient contribution (−α₁,−α₂,−α₃) at
|
||||
// x ± ε along that axis and read off the 3×3 Jacobian. The result scatters
|
||||
// into the global Hessian via the DOF-index lookup.
|
||||
//
|
||||
// Cost: F × 6 face-angle evaluations (3 DOFs × 2 directions) vs n×F for
|
||||
// full-FD — a speed-up of ≈ n/6, i.e. ~hundreds× on large closed meshes.
|
||||
/// Per-face block-FD Inversive-Distance Hessian. Uses the locality lemma
|
||||
/// `∂G_x/∂y = Σ_{f: x,y ∈ {v1,v2,v3}(f)} ∂(−α_x)/∂y` to perturb only the 3
|
||||
/// face-local DOFs at a time, giving an `F·6` face-evaluation budget vs `n·F`
|
||||
/// for full-FD. Mathematically equivalent to `inversive_distance_hessian`
|
||||
/// up to O(ε²) FD rounding.
|
||||
inline Eigen::SparseMatrix<double> inversive_distance_hessian_block_fd(
|
||||
const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const InversiveDistanceMaps& m,
|
||||
double eps = 1e-5)
|
||||
{
|
||||
const int n = inversive_distance_dimension(mesh, m);
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(9 * mesh.number_of_faces());
|
||||
|
||||
for (auto f : mesh.faces()) {
|
||||
Halfedge_index h0 = mesh.halfedge(f);
|
||||
Halfedge_index h1 = mesh.next(h0);
|
||||
Halfedge_index h2 = mesh.next(h1);
|
||||
|
||||
Vertex_index v1 = mesh.source(h0);
|
||||
Vertex_index v2 = mesh.source(h1);
|
||||
Vertex_index v3 = mesh.source(h2);
|
||||
Edge_index e12 = mesh.edge(h0);
|
||||
Edge_index e23 = mesh.edge(h1);
|
||||
Edge_index e31 = mesh.edge(h2);
|
||||
|
||||
// Local DOF indices: (u1, u2, u3). Pinned slots = -1.
|
||||
const int idx[3] = { m.v_idx[v1], m.v_idx[v2], m.v_idx[v3] };
|
||||
|
||||
const double I12 = m.I_e[e12];
|
||||
const double I23 = m.I_e[e23];
|
||||
const double I31 = m.I_e[e31];
|
||||
|
||||
// Local DOF values (0 for pinned).
|
||||
const double vals[3] = {
|
||||
id_detail::dof_val(idx[0], x),
|
||||
id_detail::dof_val(idx[1], x),
|
||||
id_detail::dof_val(idx[2], x)
|
||||
};
|
||||
|
||||
for (int j = 0; j < 3; ++j) {
|
||||
if (idx[j] < 0) continue; // never perturb a pinned DOF
|
||||
|
||||
double vp[3], vm[3];
|
||||
for (int k = 0; k < 3; ++k) { vp[k] = vm[k] = vals[k]; }
|
||||
vp[j] += eps;
|
||||
vm[j] -= eps;
|
||||
|
||||
auto Cp = inversive_distance_face_grad_contribs(
|
||||
vp[0], vp[1], vp[2], I12, I23, I31);
|
||||
auto Cm = inversive_distance_face_grad_contribs(
|
||||
vm[0], vm[1], vm[2], I12, I23, I31);
|
||||
|
||||
const double Gp[3] = { Cp.g1, Cp.g2, Cp.g3 };
|
||||
const double Gm[3] = { Cm.g1, Cm.g2, Cm.g3 };
|
||||
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if (idx[i] < 0) continue; // pinned: contributes nothing
|
||||
const double val = (Gp[i] - Gm[i]) / (2.0 * eps);
|
||||
if (std::abs(val) > 1e-15)
|
||||
trips.emplace_back(idx[i], idx[j], val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Eigen::SparseMatrix<double> H(n, n);
|
||||
H.setFromTriplets(trips.begin(), trips.end());
|
||||
return H;
|
||||
}
|
||||
|
||||
/// Symmetrised block-FD Inversive-Distance Hessian: `(H + Hᵀ)/2` of
|
||||
/// `inversive_distance_hessian_block_fd(...)` for solvers requiring strict
|
||||
/// symmetry.
|
||||
inline Eigen::SparseMatrix<double> inversive_distance_hessian_block_fd_sym(
|
||||
const ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const InversiveDistanceMaps& m,
|
||||
double eps = 1e-5)
|
||||
{
|
||||
auto H = inversive_distance_hessian_block_fd(mesh, x, m, eps);
|
||||
Eigen::SparseMatrix<double> Ht = H.transpose();
|
||||
return (H + Ht) * 0.5;
|
||||
}
|
||||
|
||||
} // namespace conformallab
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// layout.hpp
|
||||
//
|
||||
// Phase 5/6/7 — Layout / embedding: DOF vector → vertex coordinates in the
|
||||
@@ -75,28 +78,38 @@ namespace conformallab {
|
||||
// For hyperbolic holonomy the map is an orientation-preserving isometry of
|
||||
// the Poincaré disk (SU(1,1) element).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/// Möbius transformation `T(z) = (a·z + b) / (c·z + d)` of the Riemann
|
||||
/// sphere; restricted to SU(1,1) for hyperbolic holonomy on the
|
||||
/// Poincaré disk.
|
||||
struct MobiusMap {
|
||||
/// Complex scalar used for all entries.
|
||||
using C = std::complex<double>;
|
||||
C a{1.0, 0.0};
|
||||
C b{0.0, 0.0};
|
||||
C c{0.0, 0.0};
|
||||
C d{1.0, 0.0};
|
||||
C a{1.0, 0.0}; ///< Top-left coefficient.
|
||||
C b{0.0, 0.0}; ///< Top-right coefficient.
|
||||
C c{0.0, 0.0}; ///< Bottom-left coefficient.
|
||||
C d{1.0, 0.0}; ///< Bottom-right coefficient.
|
||||
|
||||
/// Apply the transformation to a complex point.
|
||||
C apply(C z) const { return (a * z + b) / (c * z + d); }
|
||||
|
||||
/// Apply the transformation to a 2-D real point (interpreted as `x + iy`).
|
||||
Eigen::Vector2d apply(const Eigen::Vector2d& p) const {
|
||||
C w = apply(C(p.x(), p.y()));
|
||||
return Eigen::Vector2d(w.real(), w.imag());
|
||||
}
|
||||
|
||||
/// Identity map.
|
||||
static MobiusMap identity() { return {C(1), C(0), C(0), C(1)}; }
|
||||
|
||||
/// Inverse map.
|
||||
MobiusMap inverse() const { return {d, -b, -c, a}; }
|
||||
/// Composition `(*this) ∘ T`, i.e. apply `T` first then `*this`.
|
||||
MobiusMap compose(const MobiusMap& T) const {
|
||||
return { a*T.a + b*T.c, a*T.b + b*T.d,
|
||||
c*T.a + d*T.c, c*T.b + d*T.d };
|
||||
}
|
||||
|
||||
/// `true` iff the map is the identity up to tolerance `tol`.
|
||||
bool is_identity(double tol = 1e-9) const {
|
||||
if (std::abs(d) < 1e-14) return false;
|
||||
C a_ = a/d, b_ = b/d, c_ = c/d;
|
||||
@@ -121,29 +134,53 @@ struct MobiusMap {
|
||||
|
||||
// ── Result types ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Result of a 2-D layout (`euclidean_layout`, `hyper_ideal_layout`):
|
||||
/// per-vertex UV coordinates plus a per-half-edge UV atlas for seamed
|
||||
/// textures.
|
||||
struct Layout2D {
|
||||
/// uv[v.idx()] — primary 2-D position (first / shallowest-BFS-depth visit).
|
||||
/// Primary 2-D position per vertex (first / shallowest-BFS-depth visit).
|
||||
///
|
||||
/// **Indexing:** `uv[v.idx()]` — indexed by the raw integer vertex index.
|
||||
/// **Length:** `mesh.number_of_vertices()`.
|
||||
///
|
||||
/// \pre No vertices have been removed from `mesh` after loading (i.e.
|
||||
/// `mesh.is_valid()` and no compaction was performed). On a fresh
|
||||
/// `Surface_mesh` loaded from file, `v.idx()` is always in
|
||||
/// `[0, number_of_vertices())` and contiguous. If vertices were
|
||||
/// deleted and `mesh.collect_garbage()` was called, re-run the
|
||||
/// layout — indices will have shifted.
|
||||
///
|
||||
/// Access pattern:
|
||||
/// ```cpp
|
||||
/// for (auto v : mesh.vertices())
|
||||
/// Eigen::Vector2d uv_v = layout.uv[v.idx()];
|
||||
/// ```
|
||||
std::vector<Eigen::Vector2d> uv;
|
||||
|
||||
/// halfedge_uv[h.idx()] — UV of source(h) as seen from face(h).
|
||||
/// UV of `source(h)` as seen from `face(h)`, indexed by `h.idx()`.
|
||||
///
|
||||
/// For interior (non-seam) halfedges: equals uv[source(h).idx()].
|
||||
/// For seam halfedges: carries the UV from the virtual unfolding across
|
||||
/// the cut (i.e. the trilaterated position that was NOT used as the
|
||||
/// primary uv). This gives each face its own copy of a seam vertex,
|
||||
/// enabling a proper GPU texture atlas without vertex duplication.
|
||||
/// **Indexing:** `halfedge_uv[h.idx()]` — raw integer halfedge index.
|
||||
/// **Length:** `mesh.number_of_halfedges()`. Same no-compaction precondition
|
||||
/// as `uv` (see above).
|
||||
///
|
||||
/// Size = mesh.number_of_halfedges(). Border halfedges = (0,0).
|
||||
/// For interior (non-seam) halfedges: equals `uv[source(h).idx()]`.
|
||||
/// For seam halfedges: carries the UV from the virtual unfolding across the
|
||||
/// cut — the trilaterated position that was NOT used as the primary `uv`.
|
||||
/// This gives each face its own copy of a seam vertex, enabling a proper
|
||||
/// per-halfedge GPU texture atlas without vertex duplication.
|
||||
///
|
||||
/// Border halfedges (outer face) hold `(0, 0)`.
|
||||
std::vector<Eigen::Vector2d> halfedge_uv;
|
||||
|
||||
bool success = false;
|
||||
bool has_seam = false; ///< true when a vertex was reached via two paths
|
||||
bool success = false; ///< `true` iff the BFS placed every vertex.
|
||||
bool has_seam = false; ///< `true` when a vertex was reached via two paths.
|
||||
};
|
||||
|
||||
/// Result of a 3-D layout (`spherical_layout`): per-vertex positions on S².
|
||||
struct Layout3D {
|
||||
std::vector<Eigen::Vector3d> pos;
|
||||
bool success = false;
|
||||
bool has_seam = false;
|
||||
std::vector<Eigen::Vector3d> pos; ///< Per-vertex spherical positions.
|
||||
bool success = false; ///< `true` iff the BFS placed every vertex.
|
||||
bool has_seam = false; ///< `true` when a vertex was reached via two paths.
|
||||
};
|
||||
|
||||
/// Per-cut-edge holonomy.
|
||||
@@ -156,9 +193,17 @@ struct Layout3D {
|
||||
/// trilaterated virtual position obtained by continuing the unfolding across
|
||||
/// the cut.
|
||||
struct HolonomyData {
|
||||
std::vector<Eigen::Vector2d> translations; ///< Euclidean / spherical
|
||||
std::vector<MobiusMap> mobius_maps; ///< hyperbolic (Phase 7)
|
||||
std::vector<std::size_t> cut_edge_indices;
|
||||
std::vector<Eigen::Vector2d> translations; ///< Euclidean / spherical translation per cut edge.
|
||||
std::vector<MobiusMap> mobius_maps; ///< Hyperbolic Möbius isometry per cut edge (Phase 7).
|
||||
std::vector<std::size_t> cut_edge_indices; ///< Index (in the cut-graph edge list) of each holonomy entry.
|
||||
|
||||
/// Residual rotation |arg(a)| (radians) of the Euclidean deck isometry
|
||||
/// z ↦ a·z + b per cut edge. Zero for a perfectly flat cone metric;
|
||||
/// a non-zero value signals under-convergence or a genuine cone-angle
|
||||
/// defect, in which case `translations[i]` (the isometry's translation
|
||||
/// part b) is only an approximate lattice generator. Empty for the
|
||||
/// hyperbolic (Möbius) path.
|
||||
std::vector<double> residual_rotation;
|
||||
};
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
@@ -343,7 +388,8 @@ inline void center_poincare_disk_weighted(
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ── Vertex Voronoi area weights ───────────────────────────────────────────────
|
||||
/// Compute per-vertex area weights (sum of 1/3 of each adjacent triangle area).
|
||||
/// Used by area-weighted layout normalisation routines.
|
||||
inline std::vector<double> compute_vertex_area_weights(const ConformalMesh& mesh)
|
||||
{
|
||||
std::vector<double> w(mesh.number_of_vertices(), 0.0);
|
||||
@@ -357,6 +403,8 @@ inline std::vector<double> compute_vertex_area_weights(const ConformalMesh& mesh
|
||||
|
||||
// ── Layout normalisation ──────────────────────────────────────────────────────
|
||||
|
||||
/// Euclidean canonical normalisation: translate centroid to the origin
|
||||
/// and rotate the principal axis of the UV cloud onto the x-axis.
|
||||
inline void normalise_euclidean(Layout2D& layout)
|
||||
{
|
||||
if (!layout.success || layout.uv.empty()) return;
|
||||
@@ -388,6 +436,8 @@ inline void normalise_hyperbolic(Layout2D& layout, const ConformalMesh& mesh)
|
||||
// halfedge_uv follows the same Möbius map
|
||||
detail::center_poincare_disk(layout.halfedge_uv);
|
||||
}
|
||||
/// Hyperbolic canonical normalisation, mesh-free fallback: uniform
|
||||
/// (unweighted) iterative Möbius centring of the Poincaré disk.
|
||||
inline void normalise_hyperbolic(Layout2D& layout) // fallback without mesh
|
||||
{
|
||||
if (!layout.success || layout.uv.empty()) return;
|
||||
@@ -395,6 +445,9 @@ inline void normalise_hyperbolic(Layout2D& layout) // fallback without mesh
|
||||
detail::center_poincare_disk(layout.halfedge_uv);
|
||||
}
|
||||
|
||||
/// Spherical canonical normalisation: rotate the layout so that the
|
||||
/// per-vertex centroid (projected back to S²) coincides with the north
|
||||
/// pole (Rodrigues rotation).
|
||||
inline void normalise_spherical(Layout3D& layout)
|
||||
{
|
||||
if (!layout.success || layout.pos.empty()) return;
|
||||
@@ -449,6 +502,142 @@ inline void set_root_huv_2d(
|
||||
huv[static_cast<std::size_t>(hf.idx())] = uv[static_cast<std::size_t>(mesh.source(hf).idx())];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Euclidean holonomy via the developing map (genus-g closed surfaces).
|
||||
//
|
||||
// The per-vertex layout produced by euclidean_layout() places every face in a
|
||||
// SINGLE consistent global frame (each vertex is placed once). In that frame
|
||||
// the holonomy is identically trivial — it is exactly the obstruction to such a
|
||||
// single frame existing on the uncut surface. To recover it we develop every
|
||||
// face INDEPENDENTLY along a spanning tree of the dual graph that does not cross
|
||||
// any cut edge, storing each face's own copy of its three corner positions
|
||||
// (`hpos[h]` = global position of source(h) as developed inside face(h)).
|
||||
//
|
||||
// Two faces adjacent across a cut edge are NOT tree-adjacent, so they are
|
||||
// developed via different tree branches and place the shared edge at two
|
||||
// different locations. The rigid motion identifying those two copies is the
|
||||
// deck transformation of the generator loop (cut edge + tree path) — i.e. the
|
||||
// holonomy. For a flat cone metric (Θ ≡ 2π) the linear part is trivial, so the
|
||||
// holonomy is the pure translation that identifies the two developments of the
|
||||
// shared edge. We recover it as a full rigid motion z ↦ a·z + b fitted to the
|
||||
// correspondence (Bs,Bt) ↦ (As,At): the shared edge endpoints S,T as developed
|
||||
// in face B map to the same endpoints as developed in face A. Because both
|
||||
// faces use the same edge length, |a| = 1 and the fit is an exact
|
||||
// orientation-preserving isometry. Its translation part b is the lattice
|
||||
// generator ω consumed by compute_period_matrix(); the rotation magnitude
|
||||
// |arg(a)| is returned as a convergence diagnostic. For a perfectly flat cone
|
||||
// metric (Θ ≡ 2π) a = 1 and b reduces to the midpoint displacement midA − midB
|
||||
// (the previous formula), so converged flat tori are bit-for-bit unchanged.
|
||||
struct EuclideanHolonomyResult {
|
||||
std::vector<Eigen::Vector2d> translations; ///< ω_i = translation part b
|
||||
std::vector<double> residual_rotation; ///< |arg(a_i)| per cut edge
|
||||
};
|
||||
|
||||
template <typename EdgeLenFn>
|
||||
inline EuclideanHolonomyResult euclidean_holonomy(
|
||||
const ConformalMesh& mesh,
|
||||
const CutGraph& cut,
|
||||
EdgeLenFn&& edge_len)
|
||||
{
|
||||
using C = std::complex<double>;
|
||||
const std::size_t nh = mesh.number_of_halfedges();
|
||||
const std::size_t nf = mesh.number_of_faces();
|
||||
|
||||
std::vector<C> hpos(nh, C(0.0, 0.0)); // pos of source(h) inside face(h)
|
||||
std::vector<bool> face_done(nf, false);
|
||||
|
||||
auto place_root = [&](Face_index f) {
|
||||
Halfedge_index h0 = mesh.halfedge(f);
|
||||
Halfedge_index h1 = mesh.next(h0), h2 = mesh.next(h1);
|
||||
double lAB = edge_len(h0), lBC = edge_len(h1), lCA = edge_len(h2);
|
||||
Eigen::Vector2d A(0.0, 0.0), B(lAB, 0.0);
|
||||
Eigen::Vector2d Cc = trilaterate_2d(A, B, lCA, lBC); // apex = source(h2)
|
||||
hpos[static_cast<std::size_t>(h0.idx())] = C(A.x(), A.y());
|
||||
hpos[static_cast<std::size_t>(h1.idx())] = C(B.x(), B.y());
|
||||
hpos[static_cast<std::size_t>(h2.idx())] = C(Cc.x(), Cc.y());
|
||||
face_done[static_cast<std::size_t>(f.idx())] = true;
|
||||
};
|
||||
|
||||
auto develop = [&](Face_index root) {
|
||||
place_root(root);
|
||||
std::queue<Halfedge_index> q; // halfedges pointing INTO unplaced faces
|
||||
auto enqueue = [&](Face_index f) {
|
||||
for (auto hf : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
|
||||
Halfedge_index ho = mesh.opposite(hf);
|
||||
if (mesh.is_border(ho)) continue;
|
||||
// Develop across the dual spanning tree T* ONLY. Crossing any
|
||||
// other edge (a cut/generator edge OR a primal-tree edge) would
|
||||
// over-connect the development: the surface minus the 2g cut
|
||||
// edges is still non-simply-connected, so the immersion would
|
||||
// wrap around and place the two copies of a generator edge on
|
||||
// top of each other (zero/garbage holonomy). Crossing only T*
|
||||
// unfolds the surface onto a disk (the fundamental polygon).
|
||||
if (!cut.is_dual_tree(mesh.edge(hf))) continue;
|
||||
Face_index fa = mesh.face(ho);
|
||||
if (!face_done[static_cast<std::size_t>(fa.idx())]) q.push(ho);
|
||||
}
|
||||
};
|
||||
enqueue(root);
|
||||
while (!q.empty()) {
|
||||
Halfedge_index h = q.front(); q.pop();
|
||||
Face_index f = mesh.face(h);
|
||||
if (face_done[static_cast<std::size_t>(f.idx())]) continue;
|
||||
|
||||
Halfedge_index ho = mesh.opposite(h);
|
||||
// Shared edge endpoints, taken from the PARENT face's development:
|
||||
// source(h) = target(ho) → parent pos hpos[next(ho)]
|
||||
// target(h) = source(ho) → parent pos hpos[ho]
|
||||
C ps = hpos[static_cast<std::size_t>(mesh.next(ho).idx())];
|
||||
C pt = hpos[static_cast<std::size_t>(ho.idx())];
|
||||
Eigen::Vector2d A(ps.real(), ps.imag()), B(pt.real(), pt.imag());
|
||||
Eigen::Vector2d apex = trilaterate_2d(
|
||||
A, B, edge_len(mesh.prev(h)), edge_len(mesh.next(h)));
|
||||
|
||||
hpos[static_cast<std::size_t>(h.idx())] = ps;
|
||||
hpos[static_cast<std::size_t>(mesh.next(h).idx())] = pt;
|
||||
hpos[static_cast<std::size_t>(mesh.prev(h).idx())] = C(apex.x(), apex.y());
|
||||
face_done[static_cast<std::size_t>(f.idx())] = true;
|
||||
enqueue(f);
|
||||
}
|
||||
};
|
||||
|
||||
develop(best_root_face(mesh));
|
||||
for (auto f : mesh.faces())
|
||||
if (!face_done[static_cast<std::size_t>(f.idx())]) develop(f);
|
||||
|
||||
// ── Holonomy isometry per cut edge ─────────────────────────────────────────
|
||||
EuclideanHolonomyResult out;
|
||||
out.translations.reserve(cut.cut_edge_indices.size());
|
||||
out.residual_rotation.reserve(cut.cut_edge_indices.size());
|
||||
for (std::size_t ce : cut.cut_edge_indices) {
|
||||
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce));
|
||||
Halfedge_index h = mesh.halfedge(e);
|
||||
Halfedge_index ho = mesh.opposite(h);
|
||||
if (mesh.is_border(h) || mesh.is_border(ho)
|
||||
|| !face_done[static_cast<std::size_t>(mesh.face(h).idx())]
|
||||
|| !face_done[static_cast<std::size_t>(mesh.face(ho).idx())]) {
|
||||
out.translations.push_back(Eigen::Vector2d::Zero());
|
||||
out.residual_rotation.push_back(0.0);
|
||||
continue;
|
||||
}
|
||||
// Face A = face(h) places edge e as halfedge h: S=source(h), T=target(h)
|
||||
C As = hpos[static_cast<std::size_t>(h.idx())];
|
||||
C At = hpos[static_cast<std::size_t>(mesh.next(h).idx())];
|
||||
// Face B = face(ho) places the same edge as halfedge ho: source(ho)=T,
|
||||
// target(ho)=S → S=pos of source(next(ho)), T=pos of source(ho)
|
||||
C Bt = hpos[static_cast<std::size_t>(ho.idx())];
|
||||
C Bs = hpos[static_cast<std::size_t>(mesh.next(ho).idx())];
|
||||
// Fit the deck isometry g(z) = a·z + b with (Bs,Bt) ↦ (As,At).
|
||||
// |a| = 1 exactly (shared edge length); b is the translation part.
|
||||
C edgeB = Bt - Bs;
|
||||
C a = (std::abs(edgeB) > 1e-300) ? (At - As) / edgeB : C(1.0, 0.0);
|
||||
C b = As - a * Bs;
|
||||
out.translations.push_back(Eigen::Vector2d(b.real(), b.imag()));
|
||||
out.residual_rotation.push_back(std::abs(std::arg(a)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ── Euclidean layout ──────────────────────────────────────────────────────────
|
||||
@@ -567,25 +756,32 @@ inline Layout2D euclidean_layout(
|
||||
result.success = true;
|
||||
|
||||
// ── Holonomy ──────────────────────────────────────────────────────────────
|
||||
// The single-frame BFS layout above puts every face in one consistent frame,
|
||||
// so the holonomy read off it is identically trivial. Instead develop each
|
||||
// face independently along a dual spanning tree that never crosses a cut edge
|
||||
// (detail::euclidean_holonomy): the displacement of each cut edge between the
|
||||
// two faces that share it is the lattice generator ω_i.
|
||||
if (cut && holonomy) {
|
||||
holonomy->cut_edge_indices = cut->cut_edge_indices;
|
||||
holonomy->translations.clear();
|
||||
holonomy->translations.reserve(cut->cut_edge_indices.size());
|
||||
holonomy->mobius_maps.clear();
|
||||
auto eh = detail::euclidean_holonomy(mesh, *cut, edge_len);
|
||||
holonomy->translations = std::move(eh.translations);
|
||||
holonomy->residual_rotation = std::move(eh.residual_rotation);
|
||||
|
||||
// Preserve the per-cut-edge seam UV in halfedge_uv (texture atlas), as
|
||||
// before, so HalfedgeUV-based tests still see the seam-crossing layout.
|
||||
for (std::size_t ce_idx : cut->cut_edge_indices) {
|
||||
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce_idx));
|
||||
Halfedge_index h = mesh.halfedge(e);
|
||||
Halfedge_index ho = mesh.opposite(h);
|
||||
Halfedge_index hx = mesh.is_border(ho) ? h : ho;
|
||||
if (mesh.is_border(hx)) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
|
||||
if (mesh.is_border(hx)) continue;
|
||||
Vertex_index vs = mesh.source(hx), vt = mesh.target(hx), vn = mesh.target(mesh.next(hx));
|
||||
if (!vertex_placed[vn.idx()]) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
|
||||
if (!vertex_placed[vn.idx()]) continue;
|
||||
Eigen::Vector2d p_tri = detail::trilaterate_2d(
|
||||
result.uv[vs.idx()], result.uv[vt.idx()],
|
||||
edge_len(mesh.prev(hx)), edge_len(mesh.next(hx)));
|
||||
// Store seam UV for the cut-crossing halfedges
|
||||
detail::set_face_huv_2d(result.halfedge_uv, mesh, hx, result.uv, p_tri);
|
||||
holonomy->translations.push_back(p_tri - result.uv[vn.idx()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +878,15 @@ inline Layout3D spherical_layout(
|
||||
for (auto f : mesh.faces()) if (!face_placed[f.idx()]) place_component(f);
|
||||
result.success = true;
|
||||
|
||||
// KNOWN LIMITATION (latent — every current caller passes holonomy == nullptr).
|
||||
// This block extracts holonomy from a single full-surface development (the BFS
|
||||
// above crosses every non-cut edge), then reads off an apex trilateration on one
|
||||
// side of each seam. That is the same flawed pattern that produced garbage τ for
|
||||
// the Euclidean path; the correct approach is detail::euclidean_holonomy, which
|
||||
// develops across only the dual spanning tree (is_dual_tree) and measures the
|
||||
// shared-edge displacement between two independent developments. Until a
|
||||
// detail::spherical_holonomy mirror exists (Phase 9c/10, see research-track.md),
|
||||
// these spherical translations are not trustworthy for genus g ≥ 1.
|
||||
if (cut && holonomy) {
|
||||
holonomy->cut_edge_indices = cut->cut_edge_indices;
|
||||
holonomy->translations.clear();
|
||||
@@ -813,6 +1018,16 @@ inline Layout2D hyper_ideal_layout(
|
||||
result.success = true;
|
||||
|
||||
// ── Möbius-map holonomy ───────────────────────────────────────────────────
|
||||
// KNOWN LIMITATION (latent — every current caller passes holonomy == nullptr).
|
||||
// Like the spherical block above, this reads the Möbius deck transformation from
|
||||
// a single full-surface development (BFS crosses all non-cut edges) and one-sided
|
||||
// apex trilateration — the same flawed pattern fixed for the Euclidean path by
|
||||
// detail::euclidean_holonomy (develop across the dual tree only, measure seam
|
||||
// displacement between two independent developments). A faithful
|
||||
// detail::hyperbolic_holonomy is Phase 9c/10 work and additionally requires
|
||||
// cpp_dec_float_50: products of these generators grow exponentially, so verifying
|
||||
// the group relation ∏gᵢ = Id overflows double (see CLAUDE.md, research-track.md).
|
||||
// Until then these mobius_maps are NOT correct for genus g ≥ 2 uniformization.
|
||||
if (cut && holonomy) {
|
||||
holonomy->cut_edge_indices = cut->cut_edge_indices;
|
||||
holonomy->translations.clear();
|
||||
@@ -852,6 +1067,8 @@ inline Layout2D hyper_ideal_layout(
|
||||
|
||||
// ── Convenience: save layout as OFF ──────────────────────────────────────────
|
||||
|
||||
/// Write a 2-D layout to disk in OFF format with z = 0. Convenience
|
||||
/// helper for quickly inspecting the UV result in any OFF viewer.
|
||||
inline void save_layout_off(
|
||||
const std::string& path, ConformalMesh& mesh, const Layout2D& layout)
|
||||
{
|
||||
@@ -865,6 +1082,7 @@ inline void save_layout_off(
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a 3-D (spherical) layout to disk in OFF format.
|
||||
inline void save_layout_off(
|
||||
const std::string& path, ConformalMesh& mesh, const Layout3D& layout)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
// 4x4 mapping matrix from corresponding point pairs.
|
||||
// Ported from de.varylab.discreteconformal.math.MatrixUtility (Java).
|
||||
@@ -7,12 +10,10 @@
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
// Find the 4×4 matrix R that maps source points to target points.
|
||||
// Each row of `from` / `to` is a homogeneous 4-vector (one point per row).
|
||||
// Post-condition: R * from.row(i).T == to.row(i).T for all i.
|
||||
//
|
||||
// Implementation: R = to^T * (from^T)^{-1}
|
||||
// Corresponds to Java MatrixUtility.makeMappingMatrix().
|
||||
/// Find the 4×4 matrix `R` that maps each row of `from` (homogeneous
|
||||
/// 4-vector) to the corresponding row of `to`: `R · fromᵀ = toᵀ`.
|
||||
/// Computed as `R = toᵀ · (fromᵀ)⁻¹`. Same as Java
|
||||
/// `MatrixUtility.makeMappingMatrix()`.
|
||||
inline Eigen::Matrix4d makeMappingMatrix(const Eigen::Matrix4d& from,
|
||||
const Eigen::Matrix4d& to) {
|
||||
return to.transpose() * from.transpose().inverse();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// mesh_builder.hpp
|
||||
//
|
||||
// Factory functions that build simple reference meshes for testing and examples.
|
||||
@@ -22,7 +25,7 @@ namespace conformallab {
|
||||
// | \
|
||||
// v0 ─ v1
|
||||
//
|
||||
// Returns a mesh with 1 face, 3 vertices, 3 edges.
|
||||
/// Build a single right-angle triangle in the xy-plane (1 face, 3 vertices, 3 edges).
|
||||
// The triangle lies in the xy-plane with a right angle at v0.
|
||||
inline ConformalMesh make_triangle(
|
||||
double x0=0, double y0=0,
|
||||
@@ -39,7 +42,7 @@ inline ConformalMesh make_triangle(
|
||||
|
||||
// ── Regular tetrahedron ──────────────────────────────────────────────────────
|
||||
//
|
||||
// 4 vertices, 4 faces, 6 edges.
|
||||
/// Build a regular tetrahedron (4 vertices, 4 faces, 6 edges; sphere topology).
|
||||
// Euler characteristic: V - E + F = 4 - 6 + 4 = 2 (sphere topology).
|
||||
// Used to test closed-surface traversal.
|
||||
inline ConformalMesh make_tetrahedron()
|
||||
@@ -67,8 +70,8 @@ inline ConformalMesh make_tetrahedron()
|
||||
// | \ |
|
||||
// v0 ─ v1
|
||||
//
|
||||
// 4 vertices, 2 faces, 5 edges (1 interior edge v1–v2 shared by both faces).
|
||||
// Useful for testing edge-interior vs edge-boundary distinction.
|
||||
/// Build a two-triangle strip (4 vertices, 2 faces, 5 edges; 1 interior edge).
|
||||
/// Useful for testing interior- vs boundary-edge distinction.
|
||||
inline ConformalMesh make_quad_strip()
|
||||
{
|
||||
ConformalMesh mesh;
|
||||
@@ -85,8 +88,8 @@ inline ConformalMesh make_quad_strip()
|
||||
|
||||
// ── Regular flat polygon fan ─────────────────────────────────────────────────
|
||||
//
|
||||
// n triangles sharing a central vertex; forms a disk topology (boundary).
|
||||
// Used to verify valence-n vertex traversal.
|
||||
/// Build a regular flat polygon fan: `n` triangles sharing a central
|
||||
/// vertex, with rim vertices on the unit circle (disk topology).
|
||||
inline ConformalMesh make_fan(int n)
|
||||
{
|
||||
CGAL_precondition(n >= 3);
|
||||
@@ -109,10 +112,9 @@ inline ConformalMesh make_fan(int n)
|
||||
|
||||
// ── Spherical tetrahedron (vertices on the unit sphere) ───────────────────────
|
||||
//
|
||||
// The four vertices of a regular tetrahedron projected onto the unit sphere.
|
||||
// Starting from (±1,±1,±1), dividing by √3 gives unit-length positions.
|
||||
// All edge lengths equal arccos(−1/3) ≈ 1.9106 radians.
|
||||
// Used for SphericalFunctional tests (all four faces are valid spherical triangles).
|
||||
/// Build a regular tetrahedron with vertices on the unit sphere.
|
||||
/// All edge lengths equal `arccos(−1/3) ≈ 1.9106 rad`; used by the
|
||||
/// SphericalFunctional tests.
|
||||
inline ConformalMesh make_spherical_tetrahedron()
|
||||
{
|
||||
ConformalMesh mesh;
|
||||
@@ -133,10 +135,9 @@ inline ConformalMesh make_spherical_tetrahedron()
|
||||
|
||||
// ── Octahedron face triangle (vertices on the unit sphere) ────────────────────
|
||||
//
|
||||
// One face of a regular octahedron: the triangle (1,0,0)→(0,1,0)→(0,0,1).
|
||||
// All edge lengths equal arccos(0) = π/2.
|
||||
// The corner angles are all π/2 (right-angled spherical triangle).
|
||||
// base log-length: λ° = 2·log(sin(π/4)) = 2·log(1/√2) = −log(2) ≈ −0.6931.
|
||||
/// Build one face of a regular octahedron `(1,0,0)→(0,1,0)→(0,0,1)`:
|
||||
/// a right-angled spherical triangle with edge length `π/2` and base
|
||||
/// log-length `λ° = −log 2 ≈ −0.6931`.
|
||||
inline ConformalMesh make_octahedron_face()
|
||||
{
|
||||
ConformalMesh mesh;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// mesh_io.hpp
|
||||
//
|
||||
// Phase 4b — CGAL::IO wrappers for ConformalMesh.
|
||||
@@ -23,39 +26,54 @@
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include <CGAL/IO/polygon_mesh_io.h>
|
||||
#include <CGAL/boost/graph/helpers.h>
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
#include <cmath>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
// ── Read ──────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Reads a polygon mesh from file into `mesh` (clears any existing content).
|
||||
// Returns true on success, false on failure.
|
||||
/// Read a polygon mesh from `filename` into `mesh` (clears existing content).
|
||||
/// Returns `true` on success, `false` on failure.
|
||||
inline bool read_mesh(const std::string& filename, ConformalMesh& mesh)
|
||||
{
|
||||
mesh.clear();
|
||||
return CGAL::IO::read_polygon_mesh(filename, mesh);
|
||||
}
|
||||
|
||||
// ── Write ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Writes `mesh` to `filename`. Returns true on success.
|
||||
/// Write `mesh` to `filename`. Returns `true` on success.
|
||||
inline bool write_mesh(const std::string& filename, const ConformalMesh& mesh)
|
||||
{
|
||||
return CGAL::IO::write_polygon_mesh(filename, mesh);
|
||||
}
|
||||
|
||||
// ── Convenience: throwing wrappers ────────────────────────────────────────────
|
||||
|
||||
/// Throwing wrapper around `read_mesh`: returns the mesh by value
|
||||
/// or throws `std::runtime_error` on read failure.
|
||||
///
|
||||
/// Also enforces that the mesh is triangulated, because every functional
|
||||
/// in this library assumes triangle faces — a quad/polygon mesh would be
|
||||
/// read in silently and then mis-handled by the angle/length formulas.
|
||||
/// Fail loudly here at the I/O boundary instead.
|
||||
inline ConformalMesh load_mesh(const std::string& filename)
|
||||
{
|
||||
ConformalMesh mesh;
|
||||
if (!read_mesh(filename, mesh))
|
||||
throw std::runtime_error("conformallab: failed to read mesh from " + filename);
|
||||
if (!CGAL::is_triangle_mesh(mesh))
|
||||
throw std::runtime_error(
|
||||
"conformallab: mesh from " + filename +
|
||||
" is not triangulated (functionals require triangle faces)");
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = mesh.point(v);
|
||||
if (!std::isfinite(p.x()) || !std::isfinite(p.y()) || !std::isfinite(p.z()))
|
||||
throw std::runtime_error(
|
||||
"conformallab: non-finite vertex coordinate in " + filename);
|
||||
}
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/// Throwing wrapper around `write_mesh`; throws `std::runtime_error` on
|
||||
/// write failure.
|
||||
inline void save_mesh(const std::string& filename, const ConformalMesh& mesh)
|
||||
{
|
||||
if (!write_mesh(filename, mesh))
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// mesh_utils.hpp
|
||||
//
|
||||
// Conversions between CGAL::Surface_mesh and Eigen matrices. Used
|
||||
// primarily by the viewer / example programs to bridge to libigl, which
|
||||
// expects (V, F) matrix pairs rather than a halfedge data structure.
|
||||
//
|
||||
// All functions are templated on the kernel so the same code works
|
||||
// with `Simple_cartesian<double>` (production) and with any CGAL
|
||||
// `Kernel_d::Point_3` (test scaffolding).
|
||||
|
||||
#include <CGAL/Surface_mesh.h>
|
||||
#include <Eigen/Dense>
|
||||
#include <Eigen/Core> // downgraded from <Eigen/Dense>: this header only
|
||||
// uses Matrix/Vector primitives, no decompositions.
|
||||
#include <CGAL/Polygon_mesh_processing/triangulate_faces.h>
|
||||
|
||||
|
||||
namespace mesh_utils {
|
||||
|
||||
/// Copy `mesh` into an Eigen `(V, F)` pair (libigl convention).
|
||||
///
|
||||
/// **Side effect:** `mesh` is triangulated in place via
|
||||
/// `CGAL::Polygon_mesh_processing::triangulate_faces` so the output
|
||||
/// `F` is guaranteed to be a 3-column matrix. If `mesh` is already a
|
||||
/// triangle mesh this is a no-op.
|
||||
///
|
||||
/// \param mesh Input surface mesh. **Modified in place** if any face
|
||||
/// has more than 3 vertices.
|
||||
/// \param V Output: `(num_vertices, 3)` matrix of vertex positions.
|
||||
/// \param F Output: `(num_faces, 3)` matrix of vertex indices per
|
||||
/// face (rows are individual triangles).
|
||||
template <typename Kernel>
|
||||
void cgal_to_eigen(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh,
|
||||
Eigen::MatrixXd& V, Eigen::MatrixXi& F) {
|
||||
|
||||
|
||||
CGAL::Polygon_mesh_processing::triangulate_faces(mesh);
|
||||
|
||||
V.resize(mesh.num_vertices(), 3);
|
||||
@@ -30,13 +57,38 @@ void cgal_to_eigen(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh,
|
||||
face_idx++;
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick interactive visualisation via libigl + GLFW.
|
||||
///
|
||||
/// **Requires** `WITH_VIEWER=ON` at CMake time (which is implied by
|
||||
/// `WITH_CGAL=ON`). Blocks until the viewer window is closed.
|
||||
/// Not suitable for CI / headless contexts.
|
||||
///
|
||||
/// Typical use:
|
||||
/// \code{.cpp}
|
||||
/// Eigen::MatrixXd V; Eigen::MatrixXi F;
|
||||
/// mesh_utils::cgal_to_eigen<Kernel>(mesh, V, F);
|
||||
/// mesh_utils::simple_visualize_mesh<Kernel>(V, F);
|
||||
/// \endcode
|
||||
template <typename Kernel>
|
||||
void simple_visualize_mesh(Eigen::MatrixXd& V, Eigen::MatrixXi& F) {
|
||||
igl::opengl::glfw::Viewer viewer;
|
||||
viewer.data().set_mesh(V, F);
|
||||
viewer.launch();
|
||||
}
|
||||
// Zero-copy map for V (optional)
|
||||
|
||||
/// Zero-copy `Eigen::Map` view of `mesh`'s vertex positions.
|
||||
///
|
||||
/// Returns a row-major `(N, 3)` `Eigen::Map` that aliases the
|
||||
/// `mesh.points()` storage directly — no allocation, O(1).
|
||||
///
|
||||
/// **Lifetime warning:** the returned `Map` references memory owned by
|
||||
/// `mesh`. Adding or removing vertices may invalidate the underlying
|
||||
/// storage; use the `Map` only as long as `mesh` is structurally stable.
|
||||
///
|
||||
/// This is the read-write counterpart to `cgal_to_eigen` for cases
|
||||
/// where the caller wants to *modify* vertex positions through Eigen
|
||||
/// (e.g. apply a Möbius transformation) without an intermediate copy.
|
||||
template <typename Kernel>
|
||||
Eigen::Map<Eigen::Matrix<double, Eigen::Dynamic, 3, Eigen::RowMajor>>
|
||||
get_vertex_map(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// newton_solver.hpp
|
||||
//
|
||||
// Phase 4a — Newton solver for all three discrete conformal functionals.
|
||||
@@ -33,6 +36,7 @@
|
||||
#include "hyper_ideal_hessian.hpp"
|
||||
#include "cp_euclidean_functional.hpp"
|
||||
#include "inversive_distance_functional.hpp"
|
||||
#include "inversive_distance_hessian.hpp"
|
||||
#include <Eigen/SparseCholesky>
|
||||
#include <Eigen/SparseQR>
|
||||
#include <Eigen/OrderingMethods>
|
||||
@@ -44,11 +48,42 @@ namespace conformallab {
|
||||
|
||||
// ── Result ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Why a Newton solve terminated (I1 audit). Distinguishes the three
|
||||
/// non-convergent exits that `converged == false` previously conflated.
|
||||
enum class NewtonStatus {
|
||||
Converged, ///< `grad_inf_norm < tol` — success.
|
||||
MaxIterations, ///< hit `max_iter` without reaching `tol`.
|
||||
LinearSolverFailed, ///< both LDLT and SparseQR failed on H·Δx = −G.
|
||||
LineSearchStalled ///< the line search could not reduce the residual.
|
||||
};
|
||||
|
||||
/// Human-readable name for a `NewtonStatus` (for logs / test messages).
|
||||
inline const char* to_string(NewtonStatus s) noexcept
|
||||
{
|
||||
switch (s) {
|
||||
case NewtonStatus::Converged: return "Converged";
|
||||
case NewtonStatus::MaxIterations: return "MaxIterations";
|
||||
case NewtonStatus::LinearSolverFailed: return "LinearSolverFailed";
|
||||
case NewtonStatus::LineSearchStalled: return "LineSearchStalled";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/// Result of a Newton solve — converged DOF vector + diagnostics.
|
||||
struct NewtonResult {
|
||||
std::vector<double> x; ///< DOF vector at termination
|
||||
int iterations; ///< Newton steps taken
|
||||
double grad_inf_norm;///< max |G_i| at termination
|
||||
bool converged; ///< true iff grad_inf_norm < tol
|
||||
std::vector<double> x; ///< DOF vector at termination.
|
||||
int iterations; ///< Newton steps actually completed.
|
||||
double grad_inf_norm;///< max |Gᵢ| at termination.
|
||||
bool converged; ///< `true` iff `status == Converged`.
|
||||
|
||||
// I1: why it stopped (more informative than the `converged` bool alone).
|
||||
NewtonStatus status = NewtonStatus::MaxIterations;
|
||||
|
||||
// N7: linear-algebra conditioning diagnostics.
|
||||
bool sparse_qr_fallback_used = false; ///< any iteration fell back to SparseQR.
|
||||
double min_ldlt_pivot = 0.0; ///< smallest |Dᵢᵢ| of the last LDLT
|
||||
///< factorisation (≈ near-singularity
|
||||
///< proxy); 0 if LDLT never succeeded.
|
||||
};
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
@@ -96,46 +131,282 @@ inline Eigen::VectorXd solve_with_fallback(
|
||||
//
|
||||
// fallback_used – if non-null, set to true iff SparseQR was invoked
|
||||
// Returns Eigen::VectorXd::Zero(rhs.size()) if both solvers fail.
|
||||
/// Solve `A·x = rhs` with the same SimplicialLDLT → SparseQR fallback
|
||||
/// strategy used inside all three Newton solvers. If `fallback_used`
|
||||
/// is non-null, it is set to `true` iff the SparseQR fallback ran.
|
||||
/// If `ok` is non-null, it is set to `true` iff at least one solver succeeded.
|
||||
/// Returns `Eigen::VectorXd::Zero(rhs.size())` if both solvers fail.
|
||||
inline Eigen::VectorXd solve_linear_system(
|
||||
const Eigen::SparseMatrix<double>& A,
|
||||
const Eigen::VectorXd& rhs,
|
||||
bool* fallback_used = nullptr)
|
||||
bool* fallback_used = nullptr,
|
||||
bool* ok = nullptr)
|
||||
{
|
||||
bool ok = false;
|
||||
return detail::solve_with_fallback(A, rhs, ok, fallback_used);
|
||||
bool ok_local = false;
|
||||
auto x = detail::solve_with_fallback(A, rhs, ok_local, fallback_used);
|
||||
if (ok) *ok = ok_local;
|
||||
return x;
|
||||
}
|
||||
|
||||
namespace detail { // re-open for the remaining helpers
|
||||
|
||||
// Backtracking line search: find the largest α in {1, 0.5, 0.25, …} such that
|
||||
// ||G(x + α·Δx)||₂ < ||G(x)||₂. Returns the accepted step (α may stay 1).
|
||||
// Globalised line search for the Newton system G(x) = 0.
|
||||
//
|
||||
// Merit function f(x) = ½‖G(x)‖²₂. Driving f to its minimum drives the
|
||||
// residual G to zero; because the merit only depends on ‖G‖ it is sign-agnostic
|
||||
// and works identically for the convex Euclidean / HyperIdeal / CP / InvDist
|
||||
// energies and the concave Spherical energy.
|
||||
//
|
||||
// Phase 1 — Newton direction `dx` (satisfies H·dx = −G, so the merit slope
|
||||
// ∇f·dx = GᵀH·dx = −‖G‖² < 0 — always a descent direction).
|
||||
// Backtrack α ∈ {1, ½, ¼, …} until the Armijo sufficient-decrease
|
||||
// condition holds:
|
||||
// ‖G(x + α·dx)‖² ≤ (1 − 2·c1·α)·‖G(x)‖²
|
||||
//
|
||||
// Phase 2 — if Phase 1 exhausts its halvings, fall back to the steepest-
|
||||
// descent direction of the merit, `d_sd = −H·G` (slope
|
||||
// ∇f·d_sd = −‖H·G‖² ≤ 0 regardless of the definiteness of H), with
|
||||
// the analogous Armijo test:
|
||||
// ‖G(x + α·d_sd)‖² ≤ ‖G(x)‖² − 2·c1·α·‖d_sd‖²
|
||||
//
|
||||
// If neither phase satisfies Armijo, return the best (smallest-residual) point
|
||||
// visited. If nothing beat ‖G(x)‖, return x unchanged and set *improved=false,
|
||||
// so the caller can stop cleanly instead of taking the old divergent full step.
|
||||
//
|
||||
// B2 (api-performance audit): the gradient computed at the accepted trial point
|
||||
// is normally discarded (only its norm is kept), forcing the next Newton
|
||||
// iteration to recompute G(x) from scratch. When `accepted_grad` is non-null it
|
||||
// receives the gradient vector at the returned point, so the caller can reuse it
|
||||
// as the next iteration's convergence gradient. It is only written when the
|
||||
// returned point differs from `x` (i.e. `*improved == true`); on a pure stall
|
||||
// (`*improved == false`) the caller must recompute G(x) itself.
|
||||
template <typename GradFn>
|
||||
inline std::vector<double> line_search(
|
||||
const std::vector<double>& x,
|
||||
const Eigen::VectorXd& dx,
|
||||
const Eigen::VectorXd& d_sd,
|
||||
double norm0,
|
||||
GradFn&& grad_fn,
|
||||
int max_halvings = 20)
|
||||
bool* improved = nullptr,
|
||||
std::vector<double>* accepted_grad = nullptr,
|
||||
int max_halvings = 20,
|
||||
double c1 = 1e-4)
|
||||
{
|
||||
const int n = static_cast<int>(x.size());
|
||||
double alpha = 1.0;
|
||||
std::vector<double> xnew(static_cast<std::size_t>(n));
|
||||
const int n = static_cast<int>(x.size());
|
||||
const double norm0_sq = norm0 * norm0;
|
||||
|
||||
for (int ls = 0; ls < max_halvings; ++ls) {
|
||||
std::vector<double> xnew(static_cast<std::size_t>(n));
|
||||
std::vector<double> best_x = x;
|
||||
double best_norm = norm0;
|
||||
|
||||
std::vector<double> trial_grad; // gradient at the most recent trial point
|
||||
std::vector<double> best_grad; // gradient at best_x (B2)
|
||||
|
||||
// Evaluate ‖G(x + α·dir)‖₂, leaving the trial point in `xnew` and the
|
||||
// gradient there in `trial_grad`.
|
||||
auto eval = [&](const Eigen::VectorXd& dir, double alpha) -> double {
|
||||
for (int i = 0; i < n; ++i)
|
||||
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)]
|
||||
+ alpha * dx[i];
|
||||
auto Gnew = grad_fn(xnew);
|
||||
double norm_new = 0.0;
|
||||
for (double v : Gnew) norm_new += v * v;
|
||||
norm_new = std::sqrt(norm_new);
|
||||
if (norm_new < norm0) return xnew;
|
||||
xnew[static_cast<std::size_t>(i)] =
|
||||
x[static_cast<std::size_t>(i)] + alpha * dir[i];
|
||||
trial_grad = grad_fn(xnew);
|
||||
double s = 0.0;
|
||||
for (double v : trial_grad) s += v * v;
|
||||
return std::sqrt(s);
|
||||
};
|
||||
|
||||
// ── Phase 1: Newton direction, Armijo backtracking ────────────────────────
|
||||
double alpha = 1.0;
|
||||
for (int ls = 0; ls < max_halvings; ++ls) {
|
||||
double norm_new = eval(dx, alpha);
|
||||
if (norm_new < best_norm) { best_norm = norm_new; best_x = xnew; best_grad = trial_grad; }
|
||||
if (norm_new * norm_new <= (1.0 - 2.0 * c1 * alpha) * norm0_sq) {
|
||||
if (improved) *improved = true;
|
||||
if (accepted_grad) *accepted_grad = trial_grad;
|
||||
return xnew;
|
||||
}
|
||||
alpha *= 0.5;
|
||||
}
|
||||
// No improvement found — return best attempt (full step)
|
||||
for (int i = 0; i < n; ++i)
|
||||
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)] + dx[i];
|
||||
return xnew;
|
||||
|
||||
// ── Phase 2: steepest-descent fallback (−H·G), Armijo backtracking ────────
|
||||
const double dsd_sq = d_sd.squaredNorm();
|
||||
if (dsd_sq > 0.0) {
|
||||
alpha = 1.0;
|
||||
for (int ls = 0; ls < max_halvings; ++ls) {
|
||||
double thresh_sq = norm0_sq - 2.0 * c1 * alpha * dsd_sq;
|
||||
double norm_new = eval(d_sd, alpha);
|
||||
if (norm_new < best_norm) { best_norm = norm_new; best_x = xnew; best_grad = trial_grad; }
|
||||
if (thresh_sq >= 0.0 && norm_new * norm_new <= thresh_sq) {
|
||||
if (improved) *improved = true;
|
||||
if (accepted_grad) *accepted_grad = trial_grad;
|
||||
return xnew;
|
||||
}
|
||||
alpha *= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Both phases failed Armijo — never take the divergent full step. ───────
|
||||
// Return the best point seen; if none improved, stay put and signal stall.
|
||||
const bool any_improvement = (best_norm < norm0);
|
||||
if (improved) *improved = any_improvement;
|
||||
if (accepted_grad && any_improvement) *accepted_grad = best_grad;
|
||||
return best_x;
|
||||
}
|
||||
|
||||
// ── Cached linear solver (B4) ─────────────────────────────────────────────────
|
||||
//
|
||||
// solve_with_fallback rebuilds a fresh SimplicialLDLT every call, re-running the
|
||||
// symbolic analysis (fill-reducing reordering / COLAMD) each Newton iteration —
|
||||
// even though the Hessian sparsity pattern is iteration-invariant for a fixed
|
||||
// mesh + DOF layout. This object keeps one persistent LDLT and runs
|
||||
// `analyzePattern` once, then only `factorize` per iteration.
|
||||
//
|
||||
// FD-built Hessians (hyper-ideal, inversive-distance) prune near-zero triplets,
|
||||
// so their pattern can shift slightly between iterations; we detect that via a
|
||||
// change in `nonZeros()` and re-analyze, which keeps the cache correct and
|
||||
// self-healing. The SparseQR fallback for singular/rank-deficient systems is
|
||||
// preserved verbatim (built fresh on the rare path). The fallback semantics are
|
||||
// identical to `solve_with_fallback`, so behaviour is unchanged.
|
||||
struct NewtonLinearSolver {
|
||||
Eigen::SimplicialLDLT<Eigen::SparseMatrix<double>> ldlt;
|
||||
bool analyzed = false;
|
||||
Eigen::Index last_nnz = -1;
|
||||
bool fallback_used = false; ///< true iff the last solve used SparseQR
|
||||
double last_min_pivot = 0.0; ///< N7: smallest |Dᵢᵢ| of the last
|
||||
///< successful LDLT (0 if it fell back)
|
||||
|
||||
Eigen::VectorXd solve(const Eigen::SparseMatrix<double>& A,
|
||||
const Eigen::VectorXd& rhs,
|
||||
bool& ok)
|
||||
{
|
||||
fallback_used = false;
|
||||
last_min_pivot = 0.0;
|
||||
if (!analyzed || A.nonZeros() != last_nnz) {
|
||||
ldlt.analyzePattern(A);
|
||||
analyzed = true;
|
||||
last_nnz = A.nonZeros();
|
||||
}
|
||||
ldlt.factorize(A);
|
||||
if (ldlt.info() == Eigen::Success) {
|
||||
Eigen::VectorXd x = ldlt.solve(rhs);
|
||||
if (ldlt.info() == Eigen::Success) {
|
||||
ok = true;
|
||||
// N7: smallest |Dᵢᵢ| in the LDLᵀ diagonal — a cheap
|
||||
// near-singularity proxy (small ⇒ ill-conditioned step).
|
||||
const auto D = ldlt.vectorD();
|
||||
last_min_pivot = (D.size() > 0) ? D.cwiseAbs().minCoeff() : 0.0;
|
||||
return x;
|
||||
}
|
||||
}
|
||||
// Fallback: SparseQR — handles singular/rank-deficient A (gauge modes).
|
||||
fallback_used = true;
|
||||
Eigen::SparseQR<Eigen::SparseMatrix<double>, Eigen::COLAMDOrdering<int>> qr(A);
|
||||
if (qr.info() == Eigen::Success) {
|
||||
Eigen::VectorXd x = qr.solve(rhs);
|
||||
if (qr.info() == Eigen::Success) { ok = true; return x; }
|
||||
}
|
||||
ok = false;
|
||||
return Eigen::VectorXd::Zero(rhs.size());
|
||||
}
|
||||
};
|
||||
|
||||
// ── Unified Newton core (H2) ──────────────────────────────────────────────────
|
||||
//
|
||||
// All five DCE Newton solvers (Euclidean, Spherical, HyperIdeal, CP-Euclidean,
|
||||
// Inversive-Distance) ran a near-identical loop that differed only in (a) the
|
||||
// gradient function, (b) the Hessian function, and (c) the spherical sign quirk
|
||||
// (the concave spherical energy factorises −H). This template captures that one
|
||||
// loop so a fix lands once instead of five times.
|
||||
//
|
||||
// `grad(x) -> std::vector<double>` : the energy gradient G(x).
|
||||
// `hess(x) -> Eigen::SparseMatrix<double>`: the TRUE Hessian H(x) (un-negated).
|
||||
// `concave` : if true, solve (−H)·Δx = G (spherical);
|
||||
// otherwise H·Δx = −G. Both are the same
|
||||
// Newton system; `concave` only selects
|
||||
// the PSD matrix actually factorised.
|
||||
//
|
||||
// Folds in B2 (reuse the line-search gradient as the next convergence gradient),
|
||||
// B4 (cached symbolic factorization), and B5 (no dead solver variable).
|
||||
template <typename GradFn, typename HessFn>
|
||||
inline NewtonResult newton_core(
|
||||
std::vector<double> x,
|
||||
GradFn&& grad,
|
||||
HessFn&& hess,
|
||||
bool concave,
|
||||
double tol,
|
||||
int max_iter)
|
||||
{
|
||||
const int n = static_cast<int>(x.size());
|
||||
|
||||
NewtonResult res;
|
||||
res.converged = false;
|
||||
res.iterations = 0;
|
||||
res.grad_inf_norm = 0.0;
|
||||
|
||||
NewtonLinearSolver solver;
|
||||
|
||||
// B2: gradient carried across the loop; the first one is the only "extra"
|
||||
// evaluation — every later iteration reuses the line-search gradient.
|
||||
std::vector<double> G_std = grad(x);
|
||||
|
||||
for (int iter = 0; iter < max_iter; ++iter) {
|
||||
Eigen::Map<const Eigen::VectorXd> G(G_std.data(), n);
|
||||
|
||||
const double inf_norm = G.cwiseAbs().maxCoeff();
|
||||
if (inf_norm < tol) {
|
||||
res.converged = true;
|
||||
res.status = NewtonStatus::Converged; // I1
|
||||
res.grad_inf_norm = inf_norm;
|
||||
res.iterations = iter;
|
||||
res.x = x;
|
||||
return res;
|
||||
}
|
||||
|
||||
Eigen::SparseMatrix<double> H = hess(x);
|
||||
|
||||
// Newton system: factor the PSD matrix (H for convex, −H for concave).
|
||||
Eigen::SparseMatrix<double> A = concave ? Eigen::SparseMatrix<double>(-H) : H;
|
||||
Eigen::VectorXd rhs = concave ? Eigen::VectorXd(G) : Eigen::VectorXd(-G);
|
||||
|
||||
bool ok = false;
|
||||
Eigen::VectorXd dx = solver.solve(A, rhs, ok);
|
||||
// N7: accumulate conditioning diagnostics from this solve.
|
||||
res.sparse_qr_fallback_used = res.sparse_qr_fallback_used || solver.fallback_used;
|
||||
res.min_ldlt_pivot = solver.last_min_pivot;
|
||||
if (!ok) { // I1/H1: linear solver failed this step
|
||||
res.iterations = iter; // (iter steps actually completed)
|
||||
res.status = NewtonStatus::LinearSolverFailed;
|
||||
break;
|
||||
}
|
||||
|
||||
// Globalised line search (Armijo + steepest-descent fallback).
|
||||
// d_sd = −(H·G) is the merit-function steepest descent for f = ½‖G‖²,
|
||||
// using the TRUE (un-negated) Hessian for both signs.
|
||||
const double norm0 = G.norm();
|
||||
Eigen::VectorXd d_sd = -(H * G);
|
||||
bool improved = true;
|
||||
std::vector<double> G_next;
|
||||
x = detail::line_search(x, dx, d_sd, norm0, grad, &improved, &G_next);
|
||||
if (!improved) { // I1/H1: line search stalled — stop cleanly
|
||||
res.iterations = iter;
|
||||
res.status = NewtonStatus::LineSearchStalled;
|
||||
break;
|
||||
}
|
||||
|
||||
G_std = std::move(G_next); // B2: reuse for the next convergence check
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
|
||||
// If we fell out of the loop without converging or breaking, it was max_iter
|
||||
// (the default `res.status` is already MaxIterations; a break has set its own).
|
||||
|
||||
// Final gradient norm (reached only on max-iter / break — recomputed to be
|
||||
// correct after a stalled or failed step).
|
||||
auto G_final = grad(x);
|
||||
double inf_final = 0.0;
|
||||
for (double v : G_final) inf_final = std::max(inf_final, std::abs(v));
|
||||
res.grad_inf_norm = inf_final;
|
||||
res.x = x;
|
||||
return res;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
@@ -171,53 +442,21 @@ inline NewtonResult newton_euclidean(
|
||||
double tol = 1e-8,
|
||||
int max_iter = 200)
|
||||
{
|
||||
std::vector<double> x = x0;
|
||||
const int n = static_cast<int>(x.size());
|
||||
// Layout is loop-invariant — decide the Hessian variant once (B3): cyclic
|
||||
// layout (edge DOFs present) → analytic cyclic Hessian, which covers the
|
||||
// vertex-edge / edge-edge blocks the vertex-only cotangent Laplacian omits.
|
||||
bool has_edge_dof = false;
|
||||
for (auto e : mesh.edges())
|
||||
if (m.e_idx[e] >= 0) { has_edge_dof = true; break; }
|
||||
|
||||
NewtonResult res;
|
||||
res.converged = false;
|
||||
res.iterations = 0;
|
||||
res.grad_inf_norm = 0.0;
|
||||
|
||||
Eigen::SimplicialLDLT<Eigen::SparseMatrix<double>> solver;
|
||||
|
||||
for (int iter = 0; iter < max_iter; ++iter) {
|
||||
// ── Gradient ──────────────────────────────────────────────────────────
|
||||
auto G_std = euclidean_gradient(mesh, x, m);
|
||||
Eigen::Map<const Eigen::VectorXd> G(G_std.data(), n);
|
||||
|
||||
double inf_norm = G.cwiseAbs().maxCoeff();
|
||||
if (inf_norm < tol) {
|
||||
res.converged = true;
|
||||
res.grad_inf_norm = inf_norm;
|
||||
res.iterations = iter;
|
||||
res.x = x;
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Hessian + solve H·Δx = −G (SparseQR fallback for singular H) ──
|
||||
auto H = euclidean_hessian(mesh, x, m);
|
||||
bool ok = false;
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
// ── Backtracking line search ──────────────────────────────────────────
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return euclidean_gradient(mesh, xnew, m);
|
||||
});
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
|
||||
// Report final gradient norm
|
||||
auto G_final = euclidean_gradient(mesh, x, m);
|
||||
double inf_final = 0.0;
|
||||
for (double v : G_final) inf_final = std::max(inf_final, std::abs(v));
|
||||
res.grad_inf_norm = inf_final;
|
||||
res.x = x;
|
||||
return res;
|
||||
return detail::newton_core(
|
||||
std::move(x0),
|
||||
[&](const std::vector<double>& xc) { return euclidean_gradient(mesh, xc, m); },
|
||||
[&](const std::vector<double>& xc) {
|
||||
return has_edge_dof ? euclidean_hessian_analytic(mesh, xc, m)
|
||||
: euclidean_hessian(mesh, xc, m);
|
||||
},
|
||||
/*concave=*/false, tol, max_iter);
|
||||
}
|
||||
|
||||
// ── Spherical Newton solver ───────────────────────────────────────────────────
|
||||
@@ -250,51 +489,14 @@ inline NewtonResult newton_spherical(
|
||||
double tol = 1e-8,
|
||||
int max_iter = 200)
|
||||
{
|
||||
std::vector<double> x = x0;
|
||||
const int n = static_cast<int>(x.size());
|
||||
|
||||
NewtonResult res;
|
||||
res.converged = false;
|
||||
res.iterations = 0;
|
||||
res.grad_inf_norm = 0.0;
|
||||
|
||||
for (int iter = 0; iter < max_iter; ++iter) {
|
||||
// ── Gradient ──────────────────────────────────────────────────────────
|
||||
auto G_std = spherical_gradient(mesh, x, m);
|
||||
Eigen::Map<const Eigen::VectorXd> G(G_std.data(), n);
|
||||
|
||||
double inf_norm = G.cwiseAbs().maxCoeff();
|
||||
if (inf_norm < tol) {
|
||||
res.converged = true;
|
||||
res.grad_inf_norm = inf_norm;
|
||||
res.iterations = iter;
|
||||
res.x = x;
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Hessian: negate to get PSD; solve (−H)·Δx = G ──────────────────
|
||||
auto H = spherical_hessian(mesh, x, m);
|
||||
auto negH = Eigen::SparseMatrix<double>(-H);
|
||||
bool ok = false;
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(negH, G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
// ── Backtracking line search ──────────────────────────────────────────
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return spherical_gradient(mesh, xnew, m);
|
||||
});
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
|
||||
auto G_final = spherical_gradient(mesh, x, m);
|
||||
double inf_final = 0.0;
|
||||
for (double v : G_final) inf_final = std::max(inf_final, std::abs(v));
|
||||
res.grad_inf_norm = inf_final;
|
||||
res.x = x;
|
||||
return res;
|
||||
// Concave energy: the Hessian H is NSD, so newton_core factors −H (PSD) and
|
||||
// solves (−H)·Δx = G — the same Newton system, just the sign that keeps LDLT
|
||||
// well-defined. The merit steepest-descent inside still uses the true H.
|
||||
return detail::newton_core(
|
||||
std::move(x0),
|
||||
[&](const std::vector<double>& xc) { return spherical_gradient(mesh, xc, m); },
|
||||
[&](const std::vector<double>& xc) { return spherical_hessian(mesh, xc, m); },
|
||||
/*concave=*/true, tol, max_iter);
|
||||
}
|
||||
|
||||
// ── HyperIdeal Newton solver ──────────────────────────────────────────────────
|
||||
@@ -308,7 +510,7 @@ inline NewtonResult newton_spherical(
|
||||
///
|
||||
/// DOF layout: first V_free entries are vertex variables b_v (hyper-ideal radii),
|
||||
/// followed by E entries for edge variables a_e (intersection angles).
|
||||
/// Use assign_all_dof_indices(mesh, maps) to set v_idx and e_idx automatically —
|
||||
/// Use assign_hyper_ideal_all_dof_indices(mesh, maps) to set v_idx and e_idx automatically —
|
||||
/// no vertex needs to be pinned.
|
||||
///
|
||||
/// \param mesh Input triangulated surface, genus g ≥ 1.
|
||||
@@ -319,6 +521,12 @@ inline NewtonResult newton_spherical(
|
||||
/// \param max_iter Maximum Newton iterations. Default: 200.
|
||||
/// \param hess_eps Finite-difference step for Hessian approximation. Default: 1e-5.
|
||||
/// (Phase 9b will replace this with an analytic Hessian.)
|
||||
/// \param clamp Vertex-scale floor mode (N3 audit). Default `HardJava`
|
||||
/// reproduces the Java oracle's hard `b<0 → floor` snap
|
||||
/// (C⁰, parity-faithful). `SmoothBarrier` uses the C¹
|
||||
/// softplus floor — smoother near the feasibility boundary
|
||||
/// but deviates from the Java golden values. See
|
||||
/// `HyperIdealScaleClamp` in hyper_ideal_functional.hpp.
|
||||
/// \return NewtonResult{x*, iterations, grad_inf_norm, converged}.
|
||||
///
|
||||
/// \see Springborn (2020), Theorem 1.3 for the strict convexity proof.
|
||||
@@ -329,52 +537,20 @@ inline NewtonResult newton_hyper_ideal(
|
||||
const HyperIdealMaps& m,
|
||||
double tol = 1e-8,
|
||||
int max_iter = 200,
|
||||
double hess_eps = 1e-5)
|
||||
double hess_eps = 1e-5,
|
||||
HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava)
|
||||
{
|
||||
std::vector<double> x = x0;
|
||||
const int n = static_cast<int>(x.size());
|
||||
|
||||
NewtonResult res;
|
||||
res.converged = false;
|
||||
res.iterations = 0;
|
||||
res.grad_inf_norm = 0.0;
|
||||
|
||||
for (int iter = 0; iter < max_iter; ++iter) {
|
||||
// ── Gradient ──────────────────────────────────────────────────────────
|
||||
auto G_std = evaluate_hyper_ideal(mesh, x, m, /*energy=*/false).gradient;
|
||||
Eigen::Map<const Eigen::VectorXd> G(G_std.data(), n);
|
||||
|
||||
double inf_norm = G.cwiseAbs().maxCoeff();
|
||||
if (inf_norm < tol) {
|
||||
res.converged = true;
|
||||
res.grad_inf_norm = inf_norm;
|
||||
res.iterations = iter;
|
||||
res.x = x;
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Hessian (numerical FD) + solve H·Δx = −G ─────────────────────────
|
||||
auto H = hyper_ideal_hessian_sym(mesh, x, m, hess_eps);
|
||||
bool ok = false;
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
// ── Backtracking line search ──────────────────────────────────────────
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return evaluate_hyper_ideal(mesh, xnew, m, false).gradient;
|
||||
});
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
|
||||
auto G_final = evaluate_hyper_ideal(mesh, x, m, false).gradient;
|
||||
double inf_final = 0.0;
|
||||
for (double v : G_final) inf_final = std::max(inf_final, std::abs(v));
|
||||
res.grad_inf_norm = inf_final;
|
||||
res.x = x;
|
||||
return res;
|
||||
// block-FD Hessian (~33–1166× faster than full-FD); `clamp` selects the
|
||||
// vertex-scale floor mode (N3). Convex energy → factor H directly.
|
||||
return detail::newton_core(
|
||||
std::move(x0),
|
||||
[&](const std::vector<double>& xc) {
|
||||
return evaluate_hyper_ideal(mesh, xc, m, /*energy=*/false, /*grad=*/true, clamp).gradient;
|
||||
},
|
||||
[&](const std::vector<double>& xc) {
|
||||
return hyper_ideal_hessian_block_fd_sym(mesh, xc, m, hess_eps, clamp);
|
||||
},
|
||||
/*concave=*/false, tol, max_iter);
|
||||
}
|
||||
|
||||
// ── CP-Euclidean Newton solver (Phase 9a.1) ───────────────────────────────────
|
||||
@@ -382,7 +558,7 @@ inline NewtonResult newton_hyper_ideal(
|
||||
/// Solve the CP-Euclidean circle-packing problem: find ρ ∈ ℝ^F such that the
|
||||
/// per-face angle sums match φ_f at every free face.
|
||||
///
|
||||
/// The CP-Euclidean energy (Bobenko-Pinkall-Springborn 2010 §6) is strictly
|
||||
/// The CP-Euclidean energy (Bobenko-Pinkall-Springborn 2015 §6) is strictly
|
||||
/// convex on its open domain of validity, so the Hessian H is PSD and the
|
||||
/// solution is unique up to the gauge mode pinned by `f_idx == −1`.
|
||||
/// `cp_euclidean_hessian` provides the analytic 2×2-per-edge formula
|
||||
@@ -400,7 +576,7 @@ inline NewtonResult newton_hyper_ideal(
|
||||
/// \note Unlike the Euclidean solver, the CP-Euclidean Hessian is exact
|
||||
/// (analytic), so the SparseQR fallback only triggers in genuine
|
||||
/// gauge-singular situations (no pinned face).
|
||||
/// \see doc/architecture/phase-9a-validation.md §1 for the BPS-2010 mapping.
|
||||
/// \see doc/architecture/phase-9a-validation.md §1 for the BPS-2015 mapping.
|
||||
inline NewtonResult newton_cp_euclidean(
|
||||
ConformalMesh& mesh,
|
||||
std::vector<double> x0,
|
||||
@@ -408,47 +584,12 @@ inline NewtonResult newton_cp_euclidean(
|
||||
double tol = 1e-8,
|
||||
int max_iter = 200)
|
||||
{
|
||||
std::vector<double> x = x0;
|
||||
const int n = static_cast<int>(x.size());
|
||||
|
||||
NewtonResult res;
|
||||
res.converged = false;
|
||||
res.iterations = 0;
|
||||
res.grad_inf_norm = 0.0;
|
||||
|
||||
for (int iter = 0; iter < max_iter; ++iter) {
|
||||
auto G_std = cp_euclidean_gradient(mesh, x, m);
|
||||
Eigen::Map<const Eigen::VectorXd> G(G_std.data(), n);
|
||||
|
||||
double inf_norm = G.cwiseAbs().maxCoeff();
|
||||
if (inf_norm < tol) {
|
||||
res.converged = true;
|
||||
res.grad_inf_norm = inf_norm;
|
||||
res.iterations = iter;
|
||||
res.x = x;
|
||||
return res;
|
||||
}
|
||||
|
||||
auto H = cp_euclidean_hessian(mesh, x, m);
|
||||
bool ok = false;
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return cp_euclidean_gradient(mesh, xnew, m);
|
||||
});
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
|
||||
auto G_final = cp_euclidean_gradient(mesh, x, m);
|
||||
double inf_final = 0.0;
|
||||
for (double v : G_final) inf_final = std::max(inf_final, std::abs(v));
|
||||
res.grad_inf_norm = inf_final;
|
||||
res.x = x;
|
||||
return res;
|
||||
// Convex energy with an exact analytic Hessian (BPS-2015 2×2-per-edge).
|
||||
return detail::newton_core(
|
||||
std::move(x0),
|
||||
[&](const std::vector<double>& xc) { return cp_euclidean_gradient(mesh, xc, m); },
|
||||
[&](const std::vector<double>& xc) { return cp_euclidean_hessian(mesh, xc, m); },
|
||||
/*concave=*/false, tol, max_iter);
|
||||
}
|
||||
|
||||
// ── Inversive-Distance Newton solver (Phase 9a.2) ─────────────────────────────
|
||||
@@ -460,10 +601,11 @@ inline NewtonResult newton_cp_euclidean(
|
||||
/// domain where every triangle satisfies the inequalities. Luo's 1-form is
|
||||
/// closed there, so the path-integral energy is well-defined.
|
||||
///
|
||||
/// MVP implementation: the Hessian is computed by **finite differences** of
|
||||
/// the analytic gradient (same pattern as the Phase 4a HyperIdeal solver).
|
||||
/// An analytic Hessian via Glickenstein 2011 eq. (4.6) is tracked in
|
||||
/// `doc/roadmap/research-track.md` as Phase 9a.2-analytic.
|
||||
/// The Hessian is computed by **per-face block finite differences**
|
||||
/// (`inversive_distance_hessian_block_fd_sym`, same pattern as the HyperIdeal
|
||||
/// solver after Phase 9b) — O(F) face evaluations instead of the O(n·F) of the
|
||||
/// full-FD baseline. An analytic Hessian via Glickenstein 2011 eq. (4.6) is
|
||||
/// tracked in `doc/roadmap/research-track.md` as Phase 9a.2-analytic.
|
||||
///
|
||||
/// \param mesh Input triangle mesh.
|
||||
/// \param x0 Initial DOF vector (length = number of free vertices).
|
||||
@@ -486,78 +628,14 @@ inline NewtonResult newton_inversive_distance(
|
||||
int max_iter = 200,
|
||||
double hess_eps = 1e-5)
|
||||
{
|
||||
std::vector<double> x = x0;
|
||||
const int n = static_cast<int>(x.size());
|
||||
|
||||
NewtonResult res;
|
||||
res.converged = false;
|
||||
res.iterations = 0;
|
||||
res.grad_inf_norm = 0.0;
|
||||
|
||||
// Local FD Hessian builder — n × (cost of gradient eval).
|
||||
auto build_hessian = [&](const std::vector<double>& xc) -> Eigen::SparseMatrix<double> {
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(static_cast<std::size_t>(n) * 16); // sparse heuristic
|
||||
|
||||
std::vector<double> xp = xc, xm = xc;
|
||||
for (int j = 0; j < n; ++j) {
|
||||
const std::size_t sj = static_cast<std::size_t>(j);
|
||||
xp[sj] = xc[sj] + hess_eps;
|
||||
xm[sj] = xc[sj] - hess_eps;
|
||||
|
||||
auto Gp = inversive_distance_gradient(mesh, xp, m);
|
||||
auto Gm = inversive_distance_gradient(mesh, xm, m);
|
||||
|
||||
xp[sj] = xm[sj] = xc[sj]; // restore
|
||||
|
||||
for (int i = 0; i < n; ++i) {
|
||||
double val = (Gp[static_cast<std::size_t>(i)]
|
||||
- Gm[static_cast<std::size_t>(i)])
|
||||
/ (2.0 * hess_eps);
|
||||
if (std::abs(val) > 1e-15)
|
||||
trips.emplace_back(i, j, val);
|
||||
}
|
||||
}
|
||||
Eigen::SparseMatrix<double> H(n, n);
|
||||
H.setFromTriplets(trips.begin(), trips.end());
|
||||
// Symmetrise — FD rounding may introduce tiny asymmetries.
|
||||
Eigen::SparseMatrix<double> Ht = H.transpose();
|
||||
return (H + Ht) * 0.5;
|
||||
};
|
||||
|
||||
for (int iter = 0; iter < max_iter; ++iter) {
|
||||
auto G_std = inversive_distance_gradient(mesh, x, m);
|
||||
Eigen::Map<const Eigen::VectorXd> G(G_std.data(), n);
|
||||
|
||||
double inf_norm = G.cwiseAbs().maxCoeff();
|
||||
if (inf_norm < tol) {
|
||||
res.converged = true;
|
||||
res.grad_inf_norm = inf_norm;
|
||||
res.iterations = iter;
|
||||
res.x = x;
|
||||
return res;
|
||||
}
|
||||
|
||||
auto H = build_hessian(x);
|
||||
bool ok = false;
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return inversive_distance_gradient(mesh, xnew, m);
|
||||
});
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
|
||||
auto G_final = inversive_distance_gradient(mesh, x, m);
|
||||
double inf_final = 0.0;
|
||||
for (double v : G_final) inf_final = std::max(inf_final, std::abs(v));
|
||||
res.grad_inf_norm = inf_final;
|
||||
res.x = x;
|
||||
return res;
|
||||
// Convex energy; block-FD Hessian (~n/6× faster than full-FD).
|
||||
return detail::newton_core(
|
||||
std::move(x0),
|
||||
[&](const std::vector<double>& xc) { return inversive_distance_gradient(mesh, xc, m); },
|
||||
[&](const std::vector<double>& xc) {
|
||||
return inversive_distance_hessian_block_fd_sym(mesh, xc, m, hess_eps);
|
||||
},
|
||||
/*concave=*/false, tol, max_iter);
|
||||
}
|
||||
|
||||
} // namespace conformallab
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
// 2-D projective geometry utilities for the Euclidean signature.
|
||||
// Ported from de.jreality.math.P2 and de.varylab.discreteconformal.math.P2Big.
|
||||
@@ -13,9 +16,9 @@ namespace conformallab {
|
||||
|
||||
// ── Point / line duality ──────────────────────────────────────────────────────
|
||||
|
||||
// Intersection of two lines l1, l2 (or line through two points p1, p2)
|
||||
// via the cross product. Works for any P2 element.
|
||||
// Corresponds to Java P2.pointFromLines / P2.lineFromPoints.
|
||||
/// Cross-product point–line duality in P²: returns the intersection
|
||||
/// of two lines (or the line through two points). Same as Java
|
||||
/// `P2.pointFromLines` / `P2.lineFromPoints`.
|
||||
inline Eigen::Vector3d pointFromLines(const Eigen::Vector3d& l1,
|
||||
const Eigen::Vector3d& l2) {
|
||||
return l1.cross(l2);
|
||||
@@ -23,11 +26,9 @@ inline Eigen::Vector3d pointFromLines(const Eigen::Vector3d& l1,
|
||||
|
||||
// ── Euclidean perpendicular bisector ─────────────────────────────────────────
|
||||
|
||||
// Returns the homogeneous line coordinates (a, b, c) of the perpendicular
|
||||
// bisector of the segment [p, q] in the Euclidean plane.
|
||||
// Coordinates: ax + by + c = 0 (after dehomogenizing p and q).
|
||||
//
|
||||
// Corresponds to Java P2.perpendicularBisector(p, q, Pn.EUCLIDEAN).
|
||||
/// Homogeneous line coordinates `(a, b, c)` of the perpendicular
|
||||
/// bisector of `[p, q]` in the Euclidean plane (`ax + by + c = 0`).
|
||||
/// Same as Java `P2.perpendicularBisector(p, q, Pn.EUCLIDEAN)`.
|
||||
inline Eigen::Vector3d perpendicularBisectorEuclidean(const Eigen::Vector3d& p_h,
|
||||
const Eigen::Vector3d& q_h) {
|
||||
// Dehomogenize
|
||||
@@ -46,8 +47,7 @@ inline Eigen::Vector3d perpendicularBisectorEuclidean(const Eigen::Vector3d& p_h
|
||||
return {d(0), d(1), c};
|
||||
}
|
||||
|
||||
// ── Euclidean distance between two P2 homogeneous points ─────────────────────
|
||||
|
||||
/// Euclidean distance between two P² homogeneous points (dehomogenises both).
|
||||
inline double euclideanDistanceP2(const Eigen::Vector3d& p_h,
|
||||
const Eigen::Vector3d& q_h) {
|
||||
Eigen::Vector2d p = p_h.head<2>() / p_h(2);
|
||||
@@ -57,11 +57,9 @@ inline double euclideanDistanceP2(const Eigen::Vector3d& p_h,
|
||||
|
||||
// ── Direct Euclidean isometry from two point-frames ──────────────────────────
|
||||
|
||||
// Build the 3×3 projective matrix that represents the coordinate frame
|
||||
// anchored at p0 with p1 defining the positive x-direction.
|
||||
// Euclidean case: columns are [dehom(p0), unit_dir(p0→p1), perp_dir].
|
||||
//
|
||||
// Template parameter S allows float / double / long double.
|
||||
/// Build the 3×3 projective frame matrix anchored at `p0` with `p1`
|
||||
/// defining the positive x-direction (Euclidean case). Columns:
|
||||
/// `[dehom(p0), unit_dir(p0→p1), perp_dir]`.
|
||||
template <typename S>
|
||||
Eigen::Matrix<S, 3, 3> makeFrameMatrix(Eigen::Matrix<S, 3, 1> p0_h,
|
||||
Eigen::Matrix<S, 3, 1> p1_h) {
|
||||
@@ -84,11 +82,9 @@ Eigen::Matrix<S, 3, 3> makeFrameMatrix(Eigen::Matrix<S, 3, 1> p0_h,
|
||||
return M;
|
||||
}
|
||||
|
||||
// Find the 3×3 Euclidean isometry (as a projective matrix) that maps
|
||||
// the frame (s1, s2) to the frame (t1, t2).
|
||||
//
|
||||
// Corresponds to Java P2.makeDirectIsometryFromFrames(s1, s2, t1, t2, Pn.EUCLIDEAN)
|
||||
// and P2Big.makeDirectIsometryFromFrames(...) (the BigDecimal / high-precision variant).
|
||||
/// 3×3 Euclidean isometry (as a projective matrix) that maps the
|
||||
/// frame `(s1, s2)` to the frame `(t1, t2)`. Same as Java
|
||||
/// `P2.makeDirectIsometryFromFrames(..., Pn.EUCLIDEAN)`.
|
||||
template <typename S>
|
||||
Eigen::Matrix<S, 3, 3> makeDirectIsometryFromFramesEuclidean(
|
||||
Eigen::Matrix<S, 3, 1> s1, Eigen::Matrix<S, 3, 1> s2,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// period_matrix.hpp
|
||||
//
|
||||
// Phase 7 — Period matrix for closed surfaces with Euclidean (flat) metric.
|
||||
@@ -38,6 +41,7 @@
|
||||
// std::complex<double> reduce_to_fundamental_domain(τ) — apply SL(2,ℤ)
|
||||
|
||||
#include "layout.hpp"
|
||||
#include "discrete_elliptic_utility.hpp" // normalizeModulus (Java-faithful)
|
||||
#include <complex>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
@@ -50,6 +54,8 @@ namespace conformallab {
|
||||
// PeriodData
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Period-matrix data for a genus-g closed surface. For genus 1 the
|
||||
/// conformal type is fully captured by `τ = ω₂ / ω₁ ∈ ℍ`.
|
||||
struct PeriodData {
|
||||
/// Lattice generators as complex numbers (one per cut edge).
|
||||
/// omega[i] = translations[i].x() + i·translations[i].y()
|
||||
@@ -63,6 +69,7 @@ struct PeriodData {
|
||||
/// True if τ has been reduced to the standard fundamental domain.
|
||||
bool in_fundamental_domain = false;
|
||||
|
||||
/// Genus of the surface = `|omega| / 2`.
|
||||
int genus() const { return static_cast<int>(omega.size()) / 2; }
|
||||
};
|
||||
|
||||
@@ -74,6 +81,9 @@ struct PeriodData {
|
||||
//
|
||||
// Returns the reduced τ. Throws if Im(τ) ≤ 0 (not in upper half-plane).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/// Reduce `τ ∈ ℍ` to the standard SL(2,ℤ) fundamental domain
|
||||
/// `F = { τ ∈ ℍ : |τ| ≥ 1, −½ ≤ Re τ < ½ }` via the generators
|
||||
/// `S: τ↦−1/τ` and `T: τ↦τ+1`. Throws if `Im τ ≤ 0`.
|
||||
inline std::complex<double> reduce_to_fundamental_domain(std::complex<double> tau)
|
||||
{
|
||||
if (tau.imag() <= 0.0) {
|
||||
@@ -103,10 +113,19 @@ inline std::complex<double> reduce_to_fundamental_domain(std::complex<double> ta
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// is_in_fundamental_domain — check membership in F with tolerance tol.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/// `true` iff `τ` lies inside the standard SL(2,ℤ) fundamental domain
|
||||
/// `F = { Im τ > 0, −½ ≤ Re τ < ½, |τ| ≥ 1 }` with tolerance `tol`.
|
||||
///
|
||||
/// This is the half-open domain produced by `reduce_to_fundamental_domain`
|
||||
/// (the right boundary `Re τ = +½ ≡ −½` is excluded via `T`). It is NOT the
|
||||
/// mirror-folded `0 ≤ Re τ ≤ ½` domain produced by `normalizeModulus` (used
|
||||
/// inside `compute_period_matrix`); a τ with `Re τ < 0` is a legitimate member
|
||||
/// of `F` here but would be folded to `Re ≥ 0` by `normalizeModulus`.
|
||||
inline bool is_in_fundamental_domain(std::complex<double> tau, double tol = 1e-9)
|
||||
{
|
||||
if (tau.imag() <= 0.0) return false;
|
||||
if (std::abs(tau.real()) > 0.5 + tol) return false;
|
||||
if (tau.real() < -0.5 - tol) return false; // left boundary closed
|
||||
if (tau.real() > 0.5 - tol) return false; // right boundary Re = +½ excluded (≡ −½)
|
||||
if (std::abs(tau) < 1.0 - tol) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -117,6 +136,15 @@ inline bool is_in_fundamental_domain(std::complex<double> tau, double tol = 1e-9
|
||||
// Computes the period data from the Euclidean holonomy translations.
|
||||
// For genus-1 surfaces, also reduces τ to the fundamental domain.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/// Compute the period data from the Euclidean holonomy translations.
|
||||
/// For genus 1, also normalises `τ` when `reduce` is `true` (default)
|
||||
/// using `normalizeModulus` — the Java-faithful reduction
|
||||
/// (`DiscreteEllipticUtility.normalizeModulus`), which folds τ into
|
||||
/// `0 ≤ Re(τ) ≤ ½`, `Im(τ) ≥ 0`, `|τ| ≥ 1` (the extra `Re ≥ 0` fold
|
||||
/// uses the mirror symmetry `τ ≅ −τ̄`). This matches the upstream Java
|
||||
/// output exactly (Finding 6). For the canonical SL(2,ℤ) domain
|
||||
/// (`−½ ≤ Re τ < ½`, no mirror fold) call `reduce_to_fundamental_domain`
|
||||
/// on `pd.tau` instead.
|
||||
inline PeriodData compute_period_matrix(const HolonomyData& hol, bool reduce = true)
|
||||
{
|
||||
PeriodData pd;
|
||||
@@ -142,7 +170,9 @@ inline PeriodData compute_period_matrix(const HolonomyData& hol, bool reduce = t
|
||||
if (tau.imag() < 0.0) return pd; // degenerate
|
||||
|
||||
if (reduce) {
|
||||
tau = reduce_to_fundamental_domain(tau);
|
||||
// Java-faithful normalisation (Finding 6): folds τ into
|
||||
// 0 ≤ Re ≤ ½, Im ≥ 0, |τ| ≥ 1 via DiscreteEllipticUtility.normalizeModulus.
|
||||
tau = normalizeModulus(tau);
|
||||
pd.in_fundamental_domain = true;
|
||||
}
|
||||
pd.tau = tau;
|
||||
|
||||
156
code/include/pn_geometry.hpp
Normal file
156
code/include/pn_geometry.hpp
Normal file
@@ -0,0 +1,156 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// pn_geometry.hpp
|
||||
//
|
||||
// Projective-metric geometry substrate — faithful port of the
|
||||
// `de.jreality.math.Pn` surface that the Java algorithmic core
|
||||
// uses pervasively:
|
||||
// HyperbolicLayout, SphericalLayout, FundamentalPolygon,
|
||||
// KoebePolyhedron, quasi-isothermic utilities, hyperelliptic θ.
|
||||
//
|
||||
// Porting this ~30-function surface once unblocks every downstream
|
||||
// geometric phase (9c / 10c' / 10e / 11c) instead of re-deriving the
|
||||
// metric ad hoc per phase.
|
||||
//
|
||||
// ┌──────────────────────────────────────────────────────────────────┐
|
||||
// │ Metric signature convention (matches jReality exactly) │
|
||||
// │ │
|
||||
// │ EUCLIDEAN = 0 ⟨u,v⟩ = Σ_{i<last} uᵢvᵢ (spatial) │
|
||||
// │ ELLIPTIC = +1 ⟨u,v⟩ = Σ_{all} uᵢvᵢ (S^n) │
|
||||
// │ HYPERBOLIC = -1 ⟨u,v⟩ = u_last·v_last − Σ_{i<last} uᵢvᵢ │
|
||||
// │ (Minkowski; last coord = timelike) │
|
||||
// │ │
|
||||
// │ HYPERBOLIC uses the timelike-positive ("upper sheet") convention│
|
||||
// │ so a normalised hyperbolic point satisfies ⟨p,p⟩ = +1 and │
|
||||
// │ ⟨p,q⟩ ≥ 1 → d(p,q) = acosh(⟨p,q⟩) directly. │
|
||||
// │ This is identical to projective_math.hpp::hyperbolicDistance, │
|
||||
// │ against which pn_distance_between(..., HYPERBOLIC) is anchored. │
|
||||
// └──────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Vectors are Eigen column vectors (homogeneous, length = dim+1).
|
||||
|
||||
#include <Eigen/Core>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
/// Metric signatures matching `de.jreality.math.Pn`.
|
||||
enum PnMetric : int {
|
||||
PN_EUCLIDEAN = 0,
|
||||
PN_ELLIPTIC = +1,
|
||||
PN_HYPERBOLIC = -1
|
||||
};
|
||||
|
||||
// ── Inner product ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Bilinear form ⟨u,v⟩ for the given metric.
|
||||
/// Spatial part = all but the last coordinate; the last coord is the
|
||||
/// homogeneous/timelike one.
|
||||
inline double pn_inner_product(const Eigen::VectorXd& u,
|
||||
const Eigen::VectorXd& v,
|
||||
int metric)
|
||||
{
|
||||
const int n = static_cast<int>(u.size());
|
||||
const double spat = u.head(n - 1).dot(v.head(n - 1));
|
||||
const double last = u(n - 1) * v(n - 1);
|
||||
switch (metric) {
|
||||
case PN_HYPERBOLIC: return last - spat;
|
||||
case PN_ELLIPTIC: return last + spat;
|
||||
case PN_EUCLIDEAN:
|
||||
default: return spat;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Norm / scale ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Metric norm √|⟨p,p⟩|. Absolute value guards against tiny negative
|
||||
/// round-off in the Euclidean / hyperbolic degenerate cases.
|
||||
inline double pn_norm(const Eigen::VectorXd& p, int metric)
|
||||
{
|
||||
return std::sqrt(std::abs(pn_inner_product(p, p, metric)));
|
||||
}
|
||||
|
||||
/// Dehomogenise: divide by the last component (jReality `Pn.dehomogenize`).
|
||||
inline Eigen::VectorXd pn_dehomogenize(const Eigen::VectorXd& p)
|
||||
{
|
||||
return p / p(p.size() - 1);
|
||||
}
|
||||
|
||||
/// Scale `p` to metric norm `length` (jReality `Pn.setToLength`).
|
||||
/// Returns `p` unchanged when its norm is numerically zero.
|
||||
inline Eigen::VectorXd pn_set_to_length(const Eigen::VectorXd& p,
|
||||
double length, int metric)
|
||||
{
|
||||
const double nrm = pn_norm(p, metric);
|
||||
if (nrm < 1e-300) return p;
|
||||
return p * (length / nrm);
|
||||
}
|
||||
|
||||
/// Normalise to unit metric norm (jReality `Pn.normalize`).
|
||||
inline Eigen::VectorXd pn_normalize(const Eigen::VectorXd& p, int metric)
|
||||
{
|
||||
return pn_set_to_length(p, 1.0, metric);
|
||||
}
|
||||
|
||||
// ── Distance ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Geodesic distance between two points (jReality `Pn.distanceBetween`).
|
||||
/// EUCLIDEAN: ‖p̂_spatial − q̂_spatial‖ (dehomogenised)
|
||||
/// ELLIPTIC: acos(⟨p̂,q̂⟩) (spherical angle, clamped to [−1,1])
|
||||
/// HYPERBOLIC: acosh(⟨p̂,q̂⟩) (clamped ≥ 1)
|
||||
inline double pn_distance_between(const Eigen::VectorXd& p,
|
||||
const Eigen::VectorXd& q,
|
||||
int metric)
|
||||
{
|
||||
if (metric == PN_EUCLIDEAN) {
|
||||
const auto pd = pn_dehomogenize(p);
|
||||
const auto qd = pn_dehomogenize(q);
|
||||
const int n = static_cast<int>(pd.size());
|
||||
return (pd.head(n - 1) - qd.head(n - 1)).norm();
|
||||
}
|
||||
const double np = pn_norm(p, metric);
|
||||
const double nq = pn_norm(q, metric);
|
||||
double c = pn_inner_product(p, q, metric) / (np * nq);
|
||||
if (metric == PN_HYPERBOLIC)
|
||||
return std::acosh(std::max(1.0, c));
|
||||
// ELLIPTIC
|
||||
c = std::clamp(c, -1.0, 1.0);
|
||||
return std::acos(c);
|
||||
}
|
||||
|
||||
// ── Geodesic interpolation ────────────────────────────────────────────────────
|
||||
|
||||
/// Constant-speed geodesic interpolation at parameter t ∈ [0,1]
|
||||
/// (jReality `Pn.linearInterpolation`).
|
||||
/// EUCLIDEAN: affine blend of the dehomogenised points.
|
||||
/// ELLIPTIC: spherical slerp:
|
||||
/// r(t) = (sin((1−t)d)·p̂ + sin(td)·q̂) / sin(d)
|
||||
/// HYPERBOLIC: hyperbolic slerp:
|
||||
/// r(t) = (sinh((1−t)d)·p̂ + sinh(td)·q̂) / sinh(d)
|
||||
/// where d = pn_distance_between(p,q,metric) and p̂,q̂ are unit vectors.
|
||||
inline Eigen::VectorXd pn_linear_interpolation(const Eigen::VectorXd& p,
|
||||
const Eigen::VectorXd& q,
|
||||
double t, int metric)
|
||||
{
|
||||
if (metric == PN_EUCLIDEAN) {
|
||||
const auto pd = pn_dehomogenize(p);
|
||||
const auto qd = pn_dehomogenize(q);
|
||||
return (1.0 - t) * pd + t * qd;
|
||||
}
|
||||
const auto ph = pn_normalize(p, metric);
|
||||
const auto qh = pn_normalize(q, metric);
|
||||
const double d = pn_distance_between(ph, qh, metric);
|
||||
if (d < 1e-12) return ph; // coincident — return either endpoint
|
||||
if (metric == PN_ELLIPTIC) {
|
||||
const double s = std::sin(d);
|
||||
return (std::sin((1.0 - t) * d) * ph + std::sin(t * d) * qh) / s;
|
||||
}
|
||||
// HYPERBOLIC
|
||||
const double s = std::sinh(d);
|
||||
return (std::sinh((1.0 - t) * d) * ph + std::sinh(t * d) * qh) / s;
|
||||
}
|
||||
|
||||
} // namespace conformallab
|
||||
@@ -1,10 +1,14 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
// Projective and hyperbolic geometry utilities.
|
||||
// Ported from de.jreality.math.Pn / Rn and
|
||||
// de.varylab.discreteconformal.uniformization.SurfaceCurveUtility (Java).
|
||||
|
||||
#include <Eigen/Dense>
|
||||
#include <Eigen/Core> // downgraded from <Eigen/Dense>: this header only
|
||||
// uses Matrix/Vector primitives, no decompositions.
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
@@ -12,17 +16,15 @@
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
// Divide a homogeneous vector by its last component.
|
||||
// Corresponds to Java Pn.dehomogenize().
|
||||
/// Dehomogenise: divide a homogeneous vector by its last component.
|
||||
/// Same as Java `Pn.dehomogenize()`.
|
||||
inline Eigen::VectorXd dehomogenize(const Eigen::VectorXd& p) {
|
||||
return p / p(p.size() - 1);
|
||||
}
|
||||
|
||||
// Hyperbolic distance between two homogeneous vectors of the same dimension.
|
||||
// The last component is the "timelike" coordinate (jReality convention).
|
||||
// Inner product: <p,q> = -sum_i p_i*q_i + p_last * q_last
|
||||
// Distance: arcosh(<p̂, q̂>) where p̂ normalises to the hyperboloid.
|
||||
// Corresponds to Java Pn.distanceBetween(p, q, Pn.HYPERBOLIC).
|
||||
/// Hyperbolic distance `arcosh(⟨p̂, q̂⟩)` between two homogeneous
|
||||
/// vectors (last component = timelike coordinate; jReality convention).
|
||||
/// Same as Java `Pn.distanceBetween(p, q, Pn.HYPERBOLIC)`.
|
||||
inline double hyperbolicDistance(const Eigen::VectorXd& p,
|
||||
const Eigen::VectorXd& q) {
|
||||
int n = static_cast<int>(p.size());
|
||||
@@ -34,10 +36,8 @@ inline double hyperbolicDistance(const Eigen::VectorXd& p,
|
||||
return std::acosh(std::max(1.0, inner));
|
||||
}
|
||||
|
||||
// Check whether a homogeneous point p lies on the segment [s[0], s[1]].
|
||||
// Works for n-dimensional homogeneous coords; cross product uses the first
|
||||
// 3 spatial components after dehomogenization (matching jReality's Rn behaviour).
|
||||
// Corresponds to Java SurfaceCurveUtility.isOnSegment().
|
||||
/// `true` iff the homogeneous point `p_h` lies on the segment
|
||||
/// `[s0_h, s1_h]`. Same as Java `SurfaceCurveUtility.isOnSegment()`.
|
||||
inline bool isOnSegment(const Eigen::VectorXd& p_h,
|
||||
const Eigen::VectorXd& s0_h,
|
||||
const Eigen::VectorXd& s1_h) {
|
||||
@@ -63,10 +63,10 @@ inline bool isOnSegment(const Eigen::VectorXd& p_h,
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find the point on `target` that corresponds to `p` on `source`.
|
||||
// The parameter t is determined by hyperbolic distance ratios on `source`,
|
||||
// then applied as a linear interpolation on the dehomogenized `target`.
|
||||
// Corresponds to Java SurfaceCurveUtility.getPointOnCorrespondingSegment().
|
||||
/// Find the point on the target segment `(tgt0, tgt1)` corresponding
|
||||
/// to `p` on the source segment `(src0, src1)`, parametrised by
|
||||
/// hyperbolic distance ratios on the source. Same as Java
|
||||
/// `SurfaceCurveUtility.getPointOnCorrespondingSegment()`.
|
||||
inline Eigen::VectorXd getPointOnCorrespondingSegment(
|
||||
const Eigen::VectorXd& p,
|
||||
const Eigen::VectorXd& src0,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// serialization.hpp
|
||||
//
|
||||
// Phase 5 — Save and load conformal map results in JSON and XML formats.
|
||||
@@ -43,7 +46,7 @@ namespace conformallab {
|
||||
// JSON
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Save solver result (+ optional 2D layout) to a JSON file.
|
||||
/// Save the Newton-solver result (+ optional 2-D layout) to a JSON file.
|
||||
inline void save_result_json(
|
||||
const std::string& path,
|
||||
const NewtonResult& res,
|
||||
@@ -80,8 +83,8 @@ inline void save_result_json(
|
||||
ofs << std::setw(2) << j << "\n";
|
||||
}
|
||||
|
||||
// Load DOF vector from a JSON result file.
|
||||
// Returns the DOF vector; fills out res fields if non-null.
|
||||
/// Load a DOF vector from a JSON result file written by
|
||||
/// `save_result_json`. If `res` is non-null its fields are filled too.
|
||||
inline std::vector<double> load_result_json(
|
||||
const std::string& path,
|
||||
NewtonResult* res = nullptr,
|
||||
@@ -90,31 +93,60 @@ inline std::vector<double> load_result_json(
|
||||
{
|
||||
using json = nlohmann::json;
|
||||
std::ifstream ifs(path);
|
||||
if (!ifs) throw std::runtime_error("Cannot open: " + path);
|
||||
json j; ifs >> j;
|
||||
if (!ifs) throw std::runtime_error("conformallab: cannot open: " + path);
|
||||
|
||||
if (geom && j.contains("geometry"))
|
||||
*geom = j["geometry"].get<std::string>();
|
||||
|
||||
std::vector<double> x = j.at("dof_vector").get<std::vector<double>>();
|
||||
|
||||
if (res) {
|
||||
res->x = x;
|
||||
res->converged = j["solver"]["converged"].get<bool>();
|
||||
res->iterations = j["solver"]["iterations"].get<int>();
|
||||
res->grad_inf_norm = j["solver"]["grad_inf_norm"].get<double>();
|
||||
// V1: wrap parse + field extraction so nlohmann exceptions (parse_error,
|
||||
// type_error, out_of_range) surface as std::runtime_error with the path.
|
||||
json j;
|
||||
try {
|
||||
ifs >> j;
|
||||
} catch (const json::exception& e) {
|
||||
throw std::runtime_error(
|
||||
"conformallab: malformed JSON in " + path + ": " + e.what());
|
||||
}
|
||||
|
||||
if (layout2d && j.contains("layout") && j["layout"]["dim"] == 2) {
|
||||
auto uv = j["layout"]["uv"];
|
||||
layout2d->uv.resize(uv.size());
|
||||
for (std::size_t i = 0; i < uv.size(); ++i)
|
||||
layout2d->uv[i] = { uv[i][0].get<double>(), uv[i][1].get<double>() };
|
||||
layout2d->has_seam = j["layout"].value("has_seam", false);
|
||||
layout2d->success = true;
|
||||
}
|
||||
try {
|
||||
if (geom && j.contains("geometry"))
|
||||
*geom = j["geometry"].get<std::string>();
|
||||
|
||||
return x;
|
||||
// V4: validate required top-level key before accessing it.
|
||||
if (!j.contains("dof_vector"))
|
||||
throw std::runtime_error(
|
||||
"conformallab: result JSON missing field 'dof_vector' in " + path);
|
||||
std::vector<double> x = j.at("dof_vector").get<std::vector<double>>();
|
||||
|
||||
if (res) {
|
||||
// V4: validate nested solver keys before accessing.
|
||||
if (!j.contains("solver"))
|
||||
throw std::runtime_error(
|
||||
"conformallab: result JSON missing field 'solver' in " + path);
|
||||
const auto& s = j.at("solver");
|
||||
for (const char* key : {"converged", "iterations", "grad_inf_norm"}) {
|
||||
if (!s.contains(key))
|
||||
throw std::runtime_error(
|
||||
std::string("conformallab: result JSON missing field 'solver.")
|
||||
+ key + "' in " + path);
|
||||
}
|
||||
res->x = x;
|
||||
res->converged = s.at("converged").get<bool>();
|
||||
res->iterations = s.at("iterations").get<int>();
|
||||
res->grad_inf_norm = s.at("grad_inf_norm").get<double>();
|
||||
}
|
||||
|
||||
if (layout2d && j.contains("layout") && j["layout"]["dim"] == 2) {
|
||||
auto uv = j["layout"]["uv"];
|
||||
layout2d->uv.resize(uv.size());
|
||||
for (std::size_t i = 0; i < uv.size(); ++i)
|
||||
layout2d->uv[i] = { uv[i][0].get<double>(), uv[i][1].get<double>() };
|
||||
layout2d->has_seam = j["layout"].value("has_seam", false);
|
||||
layout2d->success = true;
|
||||
}
|
||||
|
||||
return x;
|
||||
} catch (const json::exception& e) {
|
||||
throw std::runtime_error(
|
||||
"conformallab: malformed JSON in " + path + ": " + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -181,7 +213,7 @@ inline std::vector<double> parse_doubles(const std::string& s)
|
||||
|
||||
} // namespace detail_xml
|
||||
|
||||
// Save solver result (+ optional layout) to an XML file.
|
||||
/// Save the Newton-solver result (+ optional layout) to an XML file.
|
||||
inline void save_result_xml(
|
||||
const std::string& path,
|
||||
const NewtonResult& res,
|
||||
@@ -229,8 +261,9 @@ inline void save_result_xml(
|
||||
ofs << "</ConformalResult>\n";
|
||||
}
|
||||
|
||||
// Load solver result from an XML file written by save_result_xml.
|
||||
// Returns the DOF vector; fills res/geom/layout2d if non-null.
|
||||
/// 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.
|
||||
inline std::vector<double> load_result_xml(
|
||||
const std::string& path,
|
||||
NewtonResult* res = nullptr,
|
||||
@@ -251,9 +284,23 @@ inline std::vector<double> load_result_xml(
|
||||
// Solver metadata
|
||||
else if (line.find("<Solver") != std::string::npos) {
|
||||
if (res) {
|
||||
res->converged = (detail_xml::xml_get_attr(line, "converged") == "true");
|
||||
res->iterations = std::stoi(detail_xml::xml_get_attr(line, "iterations"));
|
||||
res->grad_inf_norm = std::stod(detail_xml::xml_get_attr(line, "grad_inf_norm"));
|
||||
res->converged = (detail_xml::xml_get_attr(line, "converged") == "true");
|
||||
// V2: stoi/stod throw std::invalid_argument on empty or non-numeric
|
||||
// attribute values; wrap and rethrow as runtime_error with context.
|
||||
try {
|
||||
auto iter_str = detail_xml::xml_get_attr(line, "iterations");
|
||||
auto grad_str = detail_xml::xml_get_attr(line, "grad_inf_norm");
|
||||
if (iter_str.empty())
|
||||
throw std::runtime_error("missing attribute 'iterations'");
|
||||
if (grad_str.empty())
|
||||
throw std::runtime_error("missing attribute 'grad_inf_norm'");
|
||||
res->iterations = std::stoi(iter_str);
|
||||
res->grad_inf_norm = std::stod(grad_str);
|
||||
} catch (const std::exception& e) {
|
||||
throw std::runtime_error(
|
||||
"conformallab: malformed XML Solver element in "
|
||||
+ path + ": " + e.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
// DOF vector
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// spherical_functional.hpp
|
||||
//
|
||||
// Energy and gradient of the spherical discrete conformal functional
|
||||
@@ -12,7 +15,9 @@
|
||||
// │ x[e_idx[e]] = λ_e – edge log-length variable (optional) │
|
||||
// │ -1 means "pinned" (u_v = 0 / λ_e = λ°_e fixed) │
|
||||
// │ │
|
||||
// │ Effective log-length: Λ_ij = λ°_ij + u_i + u_j │
|
||||
// │ Effective log-length: Λ_ij = λ_e if edge e carries a DOF, │
|
||||
// │ = λ°_ij + u_i + u_j otherwise │
|
||||
// │ (Java "replacement" convention, Finding 3) │
|
||||
// │ Spherical arc length: l_ij = 2·asin(min(exp(Λ_ij/2), 1)) │
|
||||
// │ │
|
||||
// │ Gradient: │
|
||||
@@ -30,7 +35,9 @@
|
||||
// └──────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include "constants.hpp"
|
||||
#include "spherical_geometry.hpp"
|
||||
#include "gauss_legendre.hpp"
|
||||
#include <CGAL/boost/graph/iterator.h>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
@@ -40,25 +47,36 @@ namespace conformallab {
|
||||
|
||||
// ── Property-map type aliases ─────────────────────────────────────────────────
|
||||
|
||||
/// Property map vertex → `double` for the Spherical functional.
|
||||
using SpherVMapD = ConformalMesh::Property_map<Vertex_index, double>;
|
||||
/// Property map vertex → `int` for the Spherical functional.
|
||||
using SpherVMapI = ConformalMesh::Property_map<Vertex_index, int>;
|
||||
/// Property map edge → `double` for the Spherical functional.
|
||||
using SpherEMapD = ConformalMesh::Property_map<Edge_index, double>;
|
||||
/// Property map edge → `int` for the Spherical functional.
|
||||
using SpherEMapI = ConformalMesh::Property_map<Edge_index, int>;
|
||||
|
||||
// ── Persistent map bundle ─────────────────────────────────────────────────────
|
||||
|
||||
/// Bundle of the five property maps consumed by the Spherical functional.
|
||||
struct SphericalMaps {
|
||||
SpherVMapI v_idx; // DOF index per vertex (-1 = pinned / u_v = 0)
|
||||
SpherEMapI e_idx; // DOF index per edge (-1 = no edge DOF)
|
||||
SpherVMapD theta_v; // target cone angle Θ_v (default 2π)
|
||||
SpherEMapD theta_e; // target edge angle θ_e (default π)
|
||||
SpherEMapD lambda0; // base log-length λ°_e (default 0.0)
|
||||
SpherVMapI v_idx; ///< DOF index per vertex (−1 = pinned / u_v = 0).
|
||||
SpherEMapI e_idx; ///< DOF index per edge (−1 = no edge DOF).
|
||||
SpherVMapD theta_v; ///< Target cone angle Θᵥ (default 2π).
|
||||
SpherEMapD theta_e; ///< Target edge angle θₑ (default π).
|
||||
SpherEMapD lambda0; ///< Base log-length λ⁰ₑ (default 0).
|
||||
};
|
||||
|
||||
// Defaults: theta_v = 2π, theta_e = π, lambda0 = 0.
|
||||
// lambda0 = 0 means exp(λ°/2)=1, i.e., l=π — degenerate unless u_i<0.
|
||||
// For real meshes, set lambda0 from mesh geometry via
|
||||
// compute_lambda0_from_mesh() below.
|
||||
/// Attach the five spherical property maps to `mesh` and return their
|
||||
/// handles. Mirrors `setup_euclidean_maps` but uses the `"sv:"` /
|
||||
/// `"se:"` prefix so the two functionals can coexist on the same mesh
|
||||
/// (useful for cross-validation tests).
|
||||
///
|
||||
/// Defaults match the Euclidean defaults except that `lambda0 = 0` here
|
||||
/// gives `l_e = π` which is degenerate on the unit sphere — always call
|
||||
/// `compute_spherical_lambda0_from_mesh(mesh, m)` next on a real mesh.
|
||||
inline SphericalMaps setup_spherical_maps(ConformalMesh& mesh)
|
||||
{
|
||||
SphericalMaps m;
|
||||
@@ -70,16 +88,52 @@ inline SphericalMaps setup_spherical_maps(ConformalMesh& mesh)
|
||||
return m;
|
||||
}
|
||||
|
||||
// Assign DOF indices 0..n-1 for all vertices (only vertex DOFs).
|
||||
inline int assign_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
||||
/// Assign sequential DOF indices `0..n-1` to all vertices (no edge DOFs).
|
||||
///
|
||||
/// **Note:** this overload assigns indices to ALL vertices unconditionally.
|
||||
/// Any `v_idx` set before the call is overwritten. To pin a gauge vertex,
|
||||
/// either use the two-argument overload below, or set `m.v_idx[v] = -1`
|
||||
/// **after** this call. Pinning before this call has no effect.
|
||||
///
|
||||
/// On closed spherical surfaces exactly one gauge vertex must be pinned
|
||||
/// to remove the global-scale null mode.
|
||||
inline int assign_spherical_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
||||
{
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices()) m.v_idx[v] = idx++;
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Assign DOF indices for all vertices AND edges.
|
||||
inline int assign_all_spherical_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
||||
/// Assign sequential DOF indices to all vertices, pinning `gauge`
|
||||
/// (`m.v_idx[gauge] = -1`). Use this overload on closed spherical
|
||||
/// surfaces to fix the global-scale gauge mode in a single call.
|
||||
///
|
||||
/// \returns The number of free DOFs assigned (`num_vertices − 1`).
|
||||
inline int assign_spherical_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m,
|
||||
Vertex_index gauge)
|
||||
{
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices())
|
||||
m.v_idx[v] = (v == gauge) ? -1 : idx++;
|
||||
return idx;
|
||||
}
|
||||
|
||||
/// \deprecated Use `assign_spherical_vertex_dof_indices`. Kept one release for
|
||||
/// source compatibility (API-naming audit A1, 2026-05-31).
|
||||
[[deprecated("renamed to assign_spherical_vertex_dof_indices")]]
|
||||
inline int assign_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
||||
{ return assign_spherical_vertex_dof_indices(mesh, m); }
|
||||
|
||||
/// \deprecated Use `assign_spherical_vertex_dof_indices`.
|
||||
[[deprecated("renamed to assign_spherical_vertex_dof_indices")]]
|
||||
inline int assign_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m,
|
||||
Vertex_index gauge)
|
||||
{ return assign_spherical_vertex_dof_indices(mesh, m, gauge); }
|
||||
|
||||
/// Assign DOF indices for all vertices AND all edges (vertex-DOFs first,
|
||||
/// then edge-DOFs). Mirrors `assign_euclidean_all_dof_indices` for the
|
||||
/// cyclic spherical formulation.
|
||||
inline int assign_spherical_all_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
||||
{
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices()) m.v_idx[v] = idx++;
|
||||
@@ -87,7 +141,12 @@ inline int assign_all_spherical_dof_indices(ConformalMesh& mesh, SphericalMaps&
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Count variable DOFs.
|
||||
/// \deprecated Use `assign_spherical_all_dof_indices` (API-naming audit A1).
|
||||
[[deprecated("renamed to assign_spherical_all_dof_indices")]]
|
||||
inline int assign_all_spherical_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
|
||||
{ return assign_spherical_all_dof_indices(mesh, m); }
|
||||
|
||||
/// Count the free DOFs (vertices + edges with index `≥ 0`).
|
||||
inline int spherical_dimension(const ConformalMesh& mesh, const SphericalMaps& m)
|
||||
{
|
||||
int dim = 0;
|
||||
@@ -96,10 +155,15 @@ inline int spherical_dimension(const ConformalMesh& mesh, const SphericalMaps& m
|
||||
return dim;
|
||||
}
|
||||
|
||||
// Set lambda0 from mesh vertex positions (unit-sphere assumed):
|
||||
// λ°_e = 2·log(sin(l_e / 2)) where l_e = arccos(p_i · p_j).
|
||||
// Requires vertices to lie on the unit sphere.
|
||||
inline void compute_lambda0_from_mesh(ConformalMesh& mesh, SphericalMaps& m)
|
||||
/// Compute `λ°_e` for every edge from the input vertex positions,
|
||||
/// assuming `mesh` has vertices on the unit sphere.
|
||||
///
|
||||
/// Formula: `λ°_e = 2·log(sin(l_e / 2))` where `l_e = arccos(p_i · p_j)`
|
||||
/// is the spherical arc length of edge `e`.
|
||||
///
|
||||
/// \pre Every vertex `v` of `mesh` lies on the unit sphere (norm = 1).
|
||||
/// \pre No edge is degenerate (`p_i ≠ p_j` and `p_i ≠ -p_j`).
|
||||
inline void compute_spherical_lambda0_from_mesh(ConformalMesh& mesh, SphericalMaps& m)
|
||||
{
|
||||
for (auto e : mesh.edges()) {
|
||||
auto h = mesh.halfedge(e);
|
||||
@@ -113,39 +177,61 @@ inline void compute_lambda0_from_mesh(ConformalMesh& mesh, SphericalMaps& m)
|
||||
if (half_sin > 1e-15)
|
||||
m.lambda0[e] = 2.0 * std::log(half_sin);
|
||||
else
|
||||
m.lambda0[e] = -30.0; // very short edge: essentially 0
|
||||
m.lambda0[e] = LOG_EDGE_LENGTH_FLOOR; // very short edge: essentially 0
|
||||
}
|
||||
}
|
||||
|
||||
/// \deprecated Use `compute_spherical_lambda0_from_mesh` (API-naming audit A2).
|
||||
[[deprecated("renamed to compute_spherical_lambda0_from_mesh")]]
|
||||
inline void compute_lambda0_from_mesh(ConformalMesh& mesh, SphericalMaps& m)
|
||||
{ compute_spherical_lambda0_from_mesh(mesh, m); }
|
||||
|
||||
// ── Evaluation result ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Output of `evaluate_spherical()` — energy plus optional gradient.
|
||||
struct SphericalResult {
|
||||
double energy = 0.0;
|
||||
std::vector<double> gradient;
|
||||
double energy = 0.0; ///< Functional value at input DOFs.
|
||||
std::vector<double> gradient; ///< Gradient ∇E (empty if not requested).
|
||||
};
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Read DOF value from `x` for index `idx`; return 0 if pinned (idx < 0).
|
||||
static inline double spher_dof_val(int idx, const std::vector<double>& x)
|
||||
{
|
||||
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
|
||||
}
|
||||
|
||||
static inline std::size_t spher_hidx(Halfedge_index h)
|
||||
/// Effective spherical log-length using the Java "replacement" convention
|
||||
/// (SphericalFunctional.java:393–400, Finding 3): when edge `e` carries a
|
||||
/// variable (edge DOF), its value *replaces* `λ⁰ + u_i + u_j` entirely;
|
||||
/// otherwise the effective length is `λ⁰_e + u_i + u_j`.
|
||||
///
|
||||
/// In the common vertex-only mode (no edge DOFs) this is identical to the
|
||||
/// additive form `λ⁰_e + u_i + u_j`, so that path is unchanged.
|
||||
static inline double spher_eff_lambda(const SphericalMaps& m,
|
||||
const std::vector<double>& x,
|
||||
Edge_index e,
|
||||
double u_i,
|
||||
double u_j)
|
||||
{
|
||||
return static_cast<std::size_t>(static_cast<std::uint32_t>(h));
|
||||
int ie = m.e_idx[e];
|
||||
return (ie >= 0) ? x[static_cast<std::size_t>(ie)]
|
||||
: (m.lambda0[e] + u_i + u_j);
|
||||
}
|
||||
|
||||
// halfedge_to_index is defined in conformal_mesh.hpp.
|
||||
static inline std::size_t spher_hidx(Halfedge_index h) { return halfedge_to_index(h); }
|
||||
|
||||
// ── Gradient only (no energy) ─────────────────────────────────────────────────
|
||||
|
||||
// Compute gradient G(x).
|
||||
// G_v = Θ_v − Σ_faces α_v(face)
|
||||
// G_e = α_opp(face+) + α_opp(face−) − θ_e
|
||||
//
|
||||
// The corner angle α_v is stored on halfedges using the convention:
|
||||
// h_alpha[h] = corner angle at source(prev(h)) = corner angle at the vertex
|
||||
// ACROSS FROM the edge of halfedge h in its face.
|
||||
// This convention makes both the vertex and edge gradient accumulators natural.
|
||||
/// Compute the Spherical-functional gradient G(x):
|
||||
/// * `G_v = Θ_v − Σ_faces α_v(face)`
|
||||
/// * `G_e = α_opp(face⁺) + α_opp(face⁻) − θ_e`
|
||||
///
|
||||
/// The corner angle α_v is stored on half-edges via the convention
|
||||
/// `h_alpha[h] = corner angle at the vertex ACROSS FROM the edge of h
|
||||
/// in its face`, which makes both gradient accumulators natural.
|
||||
inline std::vector<double> spherical_gradient(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
@@ -173,22 +259,25 @@ inline std::vector<double> spherical_gradient(
|
||||
Edge_index e23 = mesh.edge(h1);
|
||||
Edge_index e31 = mesh.edge(h2);
|
||||
|
||||
// Effective log-length Λ_ij = λ°_ij + u_i + u_j
|
||||
// Effective log-length (Java "replacement" convention, Finding 3):
|
||||
// * edge DOF present → Λ_ij = λ_e (the edge variable)
|
||||
// * vertex-only → Λ_ij = λ°_ij + u_i + u_j
|
||||
double u1 = spher_dof_val(m.v_idx[v1], x);
|
||||
double u2 = spher_dof_val(m.v_idx[v2], x);
|
||||
double u3 = spher_dof_val(m.v_idx[v3], x);
|
||||
|
||||
double lam12 = m.lambda0[e12] + u1 + u2 + spher_dof_val(m.e_idx[e12], x);
|
||||
double lam23 = m.lambda0[e23] + u2 + u3 + spher_dof_val(m.e_idx[e23], x);
|
||||
double lam31 = m.lambda0[e31] + u3 + u1 + spher_dof_val(m.e_idx[e31], x);
|
||||
double lam12 = spher_eff_lambda(m, x, e12, u1, u2);
|
||||
double lam23 = spher_eff_lambda(m, x, e23, u2, u3);
|
||||
double lam31 = spher_eff_lambda(m, x, e31, u3, u1);
|
||||
|
||||
double l12 = spherical_l(lam12);
|
||||
double l23 = spherical_l(lam23);
|
||||
double l31 = spherical_l(lam31);
|
||||
|
||||
SphericalFaceAngles fa = spherical_angles(l12, l23, l31);
|
||||
|
||||
if (!fa.valid) continue; // degenerate face: contributes 0
|
||||
// Do NOT skip degenerate faces: spherical_angles() returns the limiting
|
||||
// angles (matching the Java reference), required for the convex C¹
|
||||
// extension of the energy onto the infeasible region.
|
||||
|
||||
// Store convention: h_alpha[h] = corner angle at source(prev(h))
|
||||
// h0 (e12): opposite vertex is v3 → source(prev(h0)) = source(h2) = v3 → α3
|
||||
@@ -213,38 +302,23 @@ inline std::vector<double> spherical_gradient(
|
||||
G[static_cast<std::size_t>(iv)] = m.theta_v[v] - sum_alpha;
|
||||
}
|
||||
|
||||
// Edge: G_e for λ_e additive (Λ_ij = λ°_ij + u_i + u_j + λ_e).
|
||||
// Edge: G_e = α_opp(face⁺) + α_opp(face⁻) − θ_e (Java-faithful, Finding 3).
|
||||
//
|
||||
// From the Schläfli identity applied to the spherical face,
|
||||
// the contribution of edge DOF λ_e from face f is:
|
||||
// a_f = (2·α_opp − S_f) / 2 where S_f = Σ angles in face f.
|
||||
//
|
||||
// Summing over both adjacent faces:
|
||||
// G_e = a_f+ + a_f−
|
||||
// = α_opp⁺ + α_opp⁻ − (S_f⁺ + S_f⁻) / 2 − θ_e
|
||||
//
|
||||
// For flat (Euclidean) triangles S_f = π, recovering the familiar
|
||||
// α_opp⁺ + α_opp⁻ − π formula. For spherical triangles S_f > π.
|
||||
// With the replacement parameterization (Λ_ij = λ_e directly), the
|
||||
// Schläfli derivative of the spherical edge energy w.r.t. λ_e reduces to
|
||||
// the sum of the two opposite corner angles minus the target θ_e
|
||||
// (default π). This matches SphericalFunctional.java:283–292
|
||||
// `G.add(i, αk + αl − PI)`. Note this drops the −(S_f⁺+S_f⁻)/2 term that
|
||||
// would arise under the additive convention; the two conventions agree on
|
||||
// the vertex-only path (no edge DOFs), which is exercised by every test.
|
||||
for (auto e : mesh.edges()) {
|
||||
int ie = m.e_idx[e];
|
||||
if (ie < 0) continue;
|
||||
auto h = mesh.halfedge(e);
|
||||
auto ho = mesh.opposite(h);
|
||||
double sum = 0.0;
|
||||
if (!mesh.is_border(h)) {
|
||||
double alpha_opp = h_alpha[spher_hidx(h)];
|
||||
double S_f = alpha_opp
|
||||
+ h_alpha[spher_hidx(mesh.next(h))]
|
||||
+ h_alpha[spher_hidx(mesh.prev(h))];
|
||||
sum += (2.0 * alpha_opp - S_f) * 0.5;
|
||||
}
|
||||
if (!mesh.is_border(ho)) {
|
||||
double alpha_opp = h_alpha[spher_hidx(ho)];
|
||||
double S_f = alpha_opp
|
||||
+ h_alpha[spher_hidx(mesh.next(ho))]
|
||||
+ h_alpha[spher_hidx(mesh.prev(ho))];
|
||||
sum += (2.0 * alpha_opp - S_f) * 0.5;
|
||||
}
|
||||
if (!mesh.is_border(h)) sum += h_alpha[spher_hidx(h)];
|
||||
if (!mesh.is_border(ho)) sum += h_alpha[spher_hidx(ho)];
|
||||
G[static_cast<std::size_t>(ie)] = sum - m.theta_e[e];
|
||||
}
|
||||
|
||||
@@ -260,26 +334,16 @@ inline std::vector<double> spherical_gradient(
|
||||
//
|
||||
// 10-point GL nodes and weights on [0, 1] (transformed from [-1, 1]):
|
||||
// t_k = (1 + s_k) / 2, w_k = w_GL_k / 2
|
||||
/// Spherical energy `E(x) = ∫₀¹ ⟨G(t·x), x⟩ dt`, evaluated with
|
||||
/// 10-point Gauss-Legendre quadrature. This is the correct potential
|
||||
/// for any conservative `G = ∇E`; error ≈ O(h²⁰) for smooth G.
|
||||
inline double spherical_energy(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
const SphericalMaps& m)
|
||||
{
|
||||
// 10-point Gauss-Legendre nodes and weights on [-1, 1].
|
||||
static const double gl_s[10] = {
|
||||
-0.9739065285171717, -0.8650633666889845,
|
||||
-0.6794095682990244, -0.4333953941292472,
|
||||
-0.1488743389816312, 0.1488743389816312,
|
||||
0.4333953941292472, 0.6794095682990244,
|
||||
0.8650633666889845, 0.9739065285171717
|
||||
};
|
||||
static const double gl_w[10] = {
|
||||
0.0666713443086881, 0.1494513491505806,
|
||||
0.2190863625159820, 0.2692667193099963,
|
||||
0.2955242247147529, 0.2955242247147529,
|
||||
0.2692667193099963, 0.2190863625159820,
|
||||
0.1494513491505806, 0.0666713443086881
|
||||
};
|
||||
const double* gl_s = gl10_nodes();
|
||||
const double* gl_w = gl10_weights();
|
||||
|
||||
const std::size_t n = x.size();
|
||||
double E = 0.0;
|
||||
@@ -304,6 +368,8 @@ inline double spherical_energy(
|
||||
|
||||
// ── Full evaluation (energy + gradient) ──────────────────────────────────────
|
||||
|
||||
/// Evaluate the Spherical functional at DOFs `x`. Returns energy and
|
||||
/// gradient (toggle via `need_energy` / `need_gradient`).
|
||||
inline SphericalResult evaluate_spherical(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
@@ -319,10 +385,8 @@ inline SphericalResult evaluate_spherical(
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Finite-difference gradient check ─────────────────────────────────────────
|
||||
//
|
||||
// Tests |G[i] − fd[i]| / max(1, |G[i]|) < tol for all DOFs.
|
||||
// Same defaults as the hyper-ideal gradient check (Java FunctionalTest).
|
||||
/// Finite-difference gradient check for the Spherical functional
|
||||
/// (central differences). Same defaults as the Java `FunctionalTest`.
|
||||
inline bool gradient_check_spherical(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x0,
|
||||
@@ -367,8 +431,11 @@ inline bool gradient_check_spherical(
|
||||
// Apply the shift by adding t* to every vertex DOF in x.
|
||||
//
|
||||
// Implementation: bisection on f(t) = Σ_v G_v(x + t·1_v).
|
||||
// f is strictly monotone decreasing (second derivative < 0) for a convex
|
||||
// functional, so bisection converges in O(log₂(2·bracket/tol)) iterations.
|
||||
// f is strictly monotone decreasing because increasing the global scale
|
||||
// increases all effective edge lengths and thereby all corner angles, which
|
||||
// reduces Σ G_v = Σ(Θ_v − Σα_v). This holds for the concave spherical
|
||||
// energy (NSD Hessian) just as well as for a convex one.
|
||||
// Bisection converges in O(log₂(2·bracket/tol)) iterations.
|
||||
//
|
||||
// Parameters:
|
||||
// bracket – initial search interval [−bracket, +bracket] (default 50)
|
||||
@@ -376,6 +443,8 @@ inline bool gradient_check_spherical(
|
||||
//
|
||||
// Returns 0.0 if the zero cannot be bracketed (already at gauge maximum,
|
||||
// or open surface — no shift needed).
|
||||
/// Find the global-scale gauge shift `t*` for the closed-spherical case
|
||||
/// (see comment block above for the maths). Apply via `apply_spherical_gauge`.
|
||||
inline double spherical_gauge_shift(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
@@ -419,7 +488,7 @@ inline double spherical_gauge_shift(
|
||||
|
||||
// ── No sign change (zero may lie at a domain boundary). ───────────────────
|
||||
// Use damped Newton's method with backtracking line search.
|
||||
// f'(t) estimated by forward finite difference.
|
||||
// f'(t) estimated by central finite difference (O(ε²) vs O(ε) for forward).
|
||||
// When the Newton step overshoots the valid domain (ΣG_v jumps back up
|
||||
// because faces become degenerate), backtracking halves the step until
|
||||
// |f| strictly decreases.
|
||||
@@ -430,8 +499,7 @@ inline double spherical_gauge_shift(
|
||||
for (int iter = 0; iter < 120; ++iter) {
|
||||
if (std::abs(ft) < tol) return t;
|
||||
|
||||
double ftp = sum_Gv(t + fd_eps);
|
||||
double dft = (ftp - ft) / fd_eps;
|
||||
double dft = (sum_Gv(t + fd_eps) - sum_Gv(t - fd_eps)) / (2.0 * fd_eps);
|
||||
if (std::abs(dft) < 1e-14) return t; // gradient flat — give up
|
||||
|
||||
double dt_raw = -ft / dft;
|
||||
@@ -456,7 +524,8 @@ inline double spherical_gauge_shift(
|
||||
return t;
|
||||
}
|
||||
|
||||
// Apply the gauge shift in-place: x_v ← x_v + t* for all variable vertices.
|
||||
/// Apply the spherical gauge shift in-place: `x_v ← x_v + t*` for every
|
||||
/// variable vertex, where `t* = spherical_gauge_shift(mesh, x, m, ...)`.
|
||||
inline void apply_spherical_gauge(
|
||||
ConformalMesh& mesh,
|
||||
std::vector<double>& x,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// spherical_geometry.hpp
|
||||
//
|
||||
// Pure-math building blocks for the spherical discrete conformal map.
|
||||
@@ -23,41 +26,30 @@ constexpr double PI_SPHER = PI;
|
||||
|
||||
// ── Effective spherical arc length ────────────────────────────────────────────
|
||||
|
||||
// l(λ) = 2·asin(min(exp(λ/2), 1)).
|
||||
// Clamps exp(λ/2) to [0, 1] so the arcsin stays in domain.
|
||||
/// Spherical arc length `l(λ) = 2·asin(min(exp(λ/2), 1))`.
|
||||
/// Clamps `exp(λ/2)` to `[0, 1]` so `asin` stays in domain.
|
||||
inline double spherical_l(double lambda)
|
||||
{
|
||||
double half = std::exp(lambda * 0.5);
|
||||
if (half >= 1.0) half = 1.0 - 1e-15;
|
||||
if (half >= 1.0) half = ASIN_DOMAIN_GUARD;
|
||||
if (half <= 0.0) return 0.0;
|
||||
return 2.0 * std::asin(half);
|
||||
}
|
||||
|
||||
// ── Interior angles of a spherical triangle ──────────────────────────────────
|
||||
|
||||
/// Interior angles of a spherical triangle, plus a `valid` flag.
|
||||
struct SphericalFaceAngles {
|
||||
double alpha1, alpha2, alpha3; // corner angles at v1, v2, v3
|
||||
bool valid; // false when the three lengths fail the
|
||||
// spherical triangle inequality
|
||||
double alpha1; ///< Corner angle at vertex v₁.
|
||||
double alpha2; ///< Corner angle at vertex v₂.
|
||||
double alpha3; ///< Corner angle at vertex v₃.
|
||||
bool valid; ///< `false` when the three lengths violate the spherical triangle inequality.
|
||||
};
|
||||
|
||||
// Compute corner angles from spherical arc lengths using the half-angle formula.
|
||||
//
|
||||
// Convention (matching the halfedge cycle h0→v1→v2, h1→v2→v3, h2→v3→v1):
|
||||
// l12 – arc length of edge opposite v3 (edge e12)
|
||||
// l23 – arc length of edge opposite v1 (edge e23)
|
||||
// l31 – arc length of edge opposite v2 (edge e31)
|
||||
//
|
||||
// Half-angle formula (spherical law of cosines):
|
||||
// α_k = 2·atan2(sqrt(sin(s-a)·sin(s-b)), sqrt(sin(s)·sin(s-c)))
|
||||
// where a,b are the two edges ADJACENT to vertex k, c is the opposite edge.
|
||||
//
|
||||
// Equivalently (in terms of s-deficiencies):
|
||||
// α1 = 2·atan2( sqrt(sin(s12)·sin(s31)), sqrt(sin(s)·sin(s23)) )
|
||||
// α2 = 2·atan2( sqrt(sin(s12)·sin(s23)), sqrt(sin(s)·sin(s31)) )
|
||||
// α3 = 2·atan2( sqrt(sin(s23)·sin(s31)), sqrt(sin(s)·sin(s12)) )
|
||||
//
|
||||
// where s = (l12+l23+l31)/2 and s_ij = s - l_ij.
|
||||
/// Compute the spherical-triangle corner angles `(α₁, α₂, α₃)` from
|
||||
/// the three arc lengths `(l₁₂, l₂₃, l₃₁)` using the half-angle form
|
||||
/// of the spherical law of cosines. Returns `valid = false` for
|
||||
/// degenerate or out-of-range triangles.
|
||||
inline SphericalFaceAngles spherical_angles(double l12, double l23, double l31)
|
||||
{
|
||||
double s = (l12 + l23 + l31) * 0.5;
|
||||
@@ -65,9 +57,19 @@ inline SphericalFaceAngles spherical_angles(double l12, double l23, double l31)
|
||||
double s23 = s - l23;
|
||||
double s31 = s - l31;
|
||||
|
||||
// Spherical triangle inequalities: all s-deficiencies > 0 and s < π.
|
||||
if (s12 <= 0.0 || s23 <= 0.0 || s31 <= 0.0 || s >= PI_SPHER)
|
||||
return {0.0, 0.0, 0.0, false};
|
||||
// Degenerate spherical triangle: return the *limiting* angles, matching the
|
||||
// Java reference (SphericalFunctional.triangleEnergyAndAlphas). a1 is the
|
||||
// angle opposite l23, a2 opposite l31, a3 opposite l12. `valid` stays false
|
||||
// so the Hessian still skips the face, but the gradient uses these angles
|
||||
// (convex C¹ extension onto the infeasible region).
|
||||
// s12<=0 (Δij<=0) → corner opposite l12 = π → a3 = π
|
||||
// s23<=0 (Δjk<=0) → corner opposite l23 = π → a1 = π
|
||||
// s31<=0 (Δki<=0) → corner opposite l31 = π → a2 = π
|
||||
// s>=π (Δijk>=2π) → all three corners = π
|
||||
if (s12 <= 0.0) return {0.0, 0.0, PI_SPHER, false};
|
||||
if (s23 <= 0.0) return {PI_SPHER, 0.0, 0.0, false};
|
||||
if (s31 <= 0.0) return {0.0, PI_SPHER, 0.0, false};
|
||||
if (s >= PI_SPHER) return {PI_SPHER, PI_SPHER, PI_SPHER, false};
|
||||
|
||||
const double ss = std::sin(s);
|
||||
const double ss12 = std::sin(s12);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// spherical_hessian.hpp
|
||||
//
|
||||
// Analytical Hessian of the spherical discrete conformal energy —
|
||||
@@ -32,6 +35,7 @@
|
||||
#include <Eigen/Sparse>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace conformallab {
|
||||
|
||||
@@ -49,8 +53,18 @@ namespace conformallab {
|
||||
// h2: edge v3-v1 → opposite v2 → w = cot(β2), β2=(π-α3-α1+α2)/2
|
||||
//
|
||||
// Returns valid=false if any β_k is out of range (degenerate face).
|
||||
struct SpherCotWeights { double w12, w23, w31; bool valid; };
|
||||
/// Three spherical "cotangent" weights for the three edges of a face,
|
||||
/// derived from the per-vertex interior angles `α₁, α₂, α₃` via
|
||||
/// `w_ij = cot(β_k)` with `β_k = (π − α_i − α_j + α_k) / 2`.
|
||||
struct SpherCotWeights {
|
||||
double w12; ///< Weight for edge v₁-v₂ (opposite vertex v₃).
|
||||
double w23; ///< Weight for edge v₂-v₃ (opposite vertex v₁).
|
||||
double w31; ///< Weight for edge v₃-v₁ (opposite vertex v₂).
|
||||
bool valid; ///< `false` when any β_k is out of `(0, π/2]` (degenerate face).
|
||||
};
|
||||
|
||||
/// Compute the three spherical cot weights from the three interior
|
||||
/// angles `(α₁, α₂, α₃)` of a spherical triangle. See `SpherCotWeights`.
|
||||
inline SpherCotWeights spherical_cot_weights(double alpha1, double alpha2, double alpha3)
|
||||
{
|
||||
// β for each edge:
|
||||
@@ -79,25 +93,10 @@ inline SpherCotWeights spherical_cot_weights(double alpha1, double alpha2, doubl
|
||||
return {1.0 / tb3, 1.0 / tb1, 1.0 / tb2, true};
|
||||
}
|
||||
|
||||
// ── Analytical Hessian ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Returns the n×n sparse Hessian matrix H where n = spherical_dimension(mesh, m).
|
||||
// x – current DOF vector.
|
||||
//
|
||||
// Derivation: G_v = θ_v − Σ_f α_v^f → H[i,j] = −Σ_f ∂α_i^f/∂u_j
|
||||
//
|
||||
// For a face (v1,v2,v3) with arc-lengths l12,l23,l31 and angles α1,α2,α3,
|
||||
// differentiating the spherical law of cosines
|
||||
// cos(l_opp) = cos(l_a)cos(l_b) + sin(l_a)sin(l_b)cos(α)
|
||||
// gives:
|
||||
// ∂α1/∂l12 = [cot(l12)cos(α1) − cot(l31)] / sin(α1) (adjacent side)
|
||||
// ∂α1/∂l31 = [cot(l31)cos(α1) − cot(l12)] / sin(α1) (adjacent side)
|
||||
// ∂α1/∂l23 = sin(l23) / [sin(l12)sin(l31)sin(α1)] (opposite side)
|
||||
//
|
||||
// Chain rule with ∂l_ij/∂u_k = tan(l_ij/2) (from l = 2·asin(exp(λ/2))):
|
||||
// ∂α1/∂u1 = ∂α1/∂l12·t12 + ∂α1/∂l31·t31
|
||||
// ∂α1/∂u2 = ∂α1/∂l12·t12 + ∂α1/∂l23·t23
|
||||
// ∂α1/∂u3 = ∂α1/∂l23·t23 + ∂α1/∂l31·t31
|
||||
/// Analytical Spherical Hessian via `∂α/∂u` from the spherical law of
|
||||
/// cosines + chain rule `∂l/∂u = tan(l/2)`; returns an n×n sparse
|
||||
/// matrix with `n = spherical_dimension(mesh, m)`. See block comment
|
||||
/// inside the body for the per-face derivation.
|
||||
inline Eigen::SparseMatrix<double> spherical_hessian(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x,
|
||||
@@ -105,6 +104,18 @@ inline Eigen::SparseMatrix<double> spherical_hessian(
|
||||
{
|
||||
const int n = spherical_dimension(mesh, m);
|
||||
|
||||
// Only the vertex block of the spherical Hessian is implemented here. If any
|
||||
// edge DOF is variable, the edge-edge and vertex-edge blocks present in the
|
||||
// Java reference (conformalHessian) are missing, which would leave singular
|
||||
// zero rows/cols. Fail loudly instead of silently returning a rank-deficient
|
||||
// matrix (mirrors the euclidean_hessian guard — Finding 4).
|
||||
for (auto e : mesh.edges()) {
|
||||
if (m.e_idx[e] >= 0)
|
||||
throw std::logic_error(
|
||||
"spherical_hessian: edge DOFs are not supported "
|
||||
"(only the vertex-block cotangent Laplacian is implemented)");
|
||||
}
|
||||
|
||||
std::vector<Eigen::Triplet<double>> trips;
|
||||
trips.reserve(static_cast<std::size_t>(n) * 9);
|
||||
|
||||
@@ -214,10 +225,8 @@ inline Eigen::SparseMatrix<double> spherical_hessian(
|
||||
return H;
|
||||
}
|
||||
|
||||
// ── Finite-difference Hessian check ──────────────────────────────────────────
|
||||
//
|
||||
// Compares the analytical Hessian column-by-column against
|
||||
// H_fd[:, j] = (G(x + ε·eⱼ) − G(x − ε·eⱼ)) / (2ε).
|
||||
/// FD Hessian check for the Spherical functional. Compares analytic
|
||||
/// `H` column-by-column to `(G(x+εeⱼ) − G(x−εeⱼ)) / (2ε)`.
|
||||
inline bool hessian_check_spherical(
|
||||
ConformalMesh& mesh,
|
||||
const std::vector<double>& x0,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
#pragma once
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
|
||||
#include <Eigen/Dense>
|
||||
#include <igl/opengl/glfw/Viewer.h>
|
||||
|
||||
namespace viewer_utils {
|
||||
|
||||
// Deklaration (Implementation in viewer.cpp)
|
||||
/// Open an interactive libigl OpenGL viewer window showing the mesh
|
||||
/// `(V, F)`. Built only when `WITH_VIEWER=ON`; declaration here, body
|
||||
/// in `viewer.cpp`.
|
||||
void simple_visualize(Eigen::MatrixXd& V, Eigen::MatrixXi& F);
|
||||
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// conformallab_cli.cpp
|
||||
//
|
||||
// ConformalLab++ command-line interface.
|
||||
@@ -9,9 +12,13 @@
|
||||
// The tool:
|
||||
// 1. Loads an OFF/OBJ/PLY mesh.
|
||||
// 2. Sets up DOF maps + computes λ° from the input geometry.
|
||||
// 3. Pins one vertex (Euclidean/Spherical) or uses all-free DOFs (HyperIdeal).
|
||||
// 3. Euclidean: solves a genuine conformal-flattening problem with target
|
||||
// cone angle Θ_v = 2π (zero curvature). Open meshes pin the boundary and
|
||||
// flatten the interior; closed meshes pin one vertex and enforce
|
||||
// Gauss-Bonnet. x = 0 is NOT the solution, so Newton does real work.
|
||||
// 4. Runs Newton until convergence.
|
||||
// 5. Computes a 2-D (Euclidean / HyperIdeal) or 3-D (Spherical) layout.
|
||||
// 5. Computes a 2-D (Euclidean / HyperIdeal) or 3-D (Spherical) layout;
|
||||
// closed surfaces are cut along the tree-cotree cut graph first.
|
||||
// 6. Saves the layout as an OFF file and optionally serialises the result
|
||||
// to JSON and/or XML.
|
||||
// 7. Optionally shows the input mesh in a viewer (-s flag, requires WITH_VIEWER).
|
||||
@@ -24,6 +31,9 @@
|
||||
#include "newton_solver.hpp"
|
||||
#include "layout.hpp"
|
||||
#include "serialization.hpp"
|
||||
#include "gauss_bonnet.hpp"
|
||||
#include "cut_graph.hpp"
|
||||
#include "period_matrix.hpp"
|
||||
|
||||
#include <CLI11.hpp>
|
||||
#include <iostream>
|
||||
@@ -46,28 +56,41 @@ using cl::Edge_index;
|
||||
// Shared helpers (mirroring test_pipeline.cpp patterns)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Pin vertex 0, assign 0..n-1 to the rest
|
||||
static int pin_first_vertex(ConformalMesh& mesh, cl::EuclideanMaps& maps)
|
||||
// Assign Euclidean vertex DOFs for a genuine conformal-flattening problem.
|
||||
//
|
||||
// The target cone angle Θ_v = 2π (set by `setup_euclidean_maps`) asks for a
|
||||
// *flat* metric — zero discrete Gaussian curvature at every free vertex. We do
|
||||
// NOT overwrite it with the input angle sums, so x = 0 is generally NOT the
|
||||
// solution and Newton has to do real work.
|
||||
//
|
||||
// • Open mesh (disk/cylinder…): pin the boundary (u = 0, original boundary
|
||||
// lengths) and free the interior → fixed-boundary conformal flattening.
|
||||
// • Closed mesh: pin one vertex to fix the scale gauge, free the rest, and
|
||||
// call `enforce_gauss_bonnet` so the flat target is topology-consistent
|
||||
// (no shift for a torus, uniform cone angles for genus 0).
|
||||
//
|
||||
// Returns the number of free DOFs and reports whether the mesh has a boundary.
|
||||
static int assign_euclidean_flattening_dofs(ConformalMesh& mesh,
|
||||
cl::EuclideanMaps& maps,
|
||||
bool& has_boundary)
|
||||
{
|
||||
auto vit = mesh.vertices().begin();
|
||||
Vertex_index v0 = *vit++;
|
||||
maps.v_idx[v0] = -1;
|
||||
int idx = 0;
|
||||
for (; vit != mesh.vertices().end(); ++vit)
|
||||
maps.v_idx[*vit] = idx++;
|
||||
return idx;
|
||||
}
|
||||
has_boundary = false;
|
||||
for (auto v : mesh.vertices())
|
||||
if (mesh.is_border(v)) { has_boundary = true; break; }
|
||||
|
||||
// Natural theta for Euclidean: make x=0 the equilibrium
|
||||
static void set_natural_euclidean_theta(ConformalMesh& mesh, cl::EuclideanMaps& maps, int n)
|
||||
{
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||||
auto G = cl::euclidean_gradient(mesh, x0, maps);
|
||||
for (auto v : mesh.vertices()) {
|
||||
int iv = maps.v_idx[v];
|
||||
if (iv < 0) continue;
|
||||
maps.theta_v[v] -= G[static_cast<std::size_t>(iv)];
|
||||
int idx = 0;
|
||||
if (has_boundary) {
|
||||
for (auto v : mesh.vertices())
|
||||
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
|
||||
} else {
|
||||
bool pinned = false;
|
||||
for (auto v : mesh.vertices()) {
|
||||
if (!pinned) { maps.v_idx[v] = -1; pinned = true; }
|
||||
else maps.v_idx[v] = idx++;
|
||||
}
|
||||
cl::enforce_gauss_bonnet(mesh, maps);
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Natural theta for HyperIdeal at base point (b=1, a=0.5) to avoid x=0 singularity
|
||||
@@ -107,26 +130,57 @@ static int run_euclidean(ConformalMesh& mesh,
|
||||
const std::string& out_xml,
|
||||
bool verbose)
|
||||
{
|
||||
// Setup
|
||||
// Setup — Θ_v = 2π (flat target) by default; lengths from the input mesh.
|
||||
auto maps = cl::setup_euclidean_maps(mesh);
|
||||
cl::compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// DOF assignment: pin vertex 0
|
||||
int n = pin_first_vertex(mesh, maps);
|
||||
if (n <= 0) { std::cerr << "Error: mesh has only one vertex.\n"; return 1; }
|
||||
// DOF assignment for a genuine flattening problem (see helper).
|
||||
bool has_boundary = false;
|
||||
int n = assign_euclidean_flattening_dofs(mesh, maps, has_boundary);
|
||||
if (n <= 0) { std::cerr << "Error: no free vertices to solve for.\n"; return 1; }
|
||||
|
||||
// Natural target angles
|
||||
set_natural_euclidean_theta(mesh, maps, n);
|
||||
const int g = has_boundary ? -1 : cl::genus(mesh);
|
||||
if (verbose) {
|
||||
std::cout << " topology: " << (has_boundary ? "open (boundary pinned)"
|
||||
: "closed")
|
||||
<< ", free DOFs=" << n;
|
||||
if (!has_boundary) std::cout << ", genus=" << g;
|
||||
std::cout << "\n";
|
||||
}
|
||||
|
||||
// Newton
|
||||
// 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);
|
||||
|
||||
if (!res.converged && verbose)
|
||||
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
|
||||
if (!res.converged)
|
||||
std::cerr << "[warn] Newton did not converge (|grad|="
|
||||
<< res.grad_inf_norm << ", iter=" << res.iterations << ")\n";
|
||||
|
||||
// Layout
|
||||
cl::Layout2D layout = cl::euclidean_layout(mesh, res.x, maps);
|
||||
// Layout. For a closed surface we cut along the tree-cotree cut graph so
|
||||
// the result is a single planar fundamental domain rather than overlapping
|
||||
// face copies. For genus 1 we also recover the holonomy lattice generators
|
||||
// and report the period ratio τ.
|
||||
cl::Layout2D layout;
|
||||
cl::HolonomyData hol;
|
||||
bool have_tau = false;
|
||||
cl::PeriodData pd;
|
||||
if (!has_boundary && g >= 1) {
|
||||
cl::CutGraph cg = cl::compute_cut_graph(mesh);
|
||||
layout = cl::euclidean_layout(mesh, res.x, maps, &cg, &hol, /*normalise=*/true);
|
||||
if (g == 1 && hol.translations.size() >= 2) {
|
||||
try {
|
||||
pd = cl::compute_period_matrix(hol, /*reduce=*/true);
|
||||
have_tau = std::isfinite(pd.tau.real())
|
||||
&& std::isfinite(pd.tau.imag())
|
||||
&& pd.tau.imag() > 0.0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "[warn] period-matrix τ extraction failed: "
|
||||
<< e.what() << "\n";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
layout = cl::euclidean_layout(mesh, res.x, maps);
|
||||
}
|
||||
|
||||
// Output
|
||||
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout);
|
||||
@@ -146,6 +200,13 @@ static int run_euclidean(ConformalMesh& mesh,
|
||||
<< " iter=" << res.iterations
|
||||
<< " |grad|_inf=" << std::scientific << std::setprecision(3)
|
||||
<< res.grad_inf_norm << "\n";
|
||||
if (have_tau) {
|
||||
std::cout << std::fixed << std::setprecision(6)
|
||||
<< " period ratio τ = " << pd.tau.real()
|
||||
<< (pd.tau.imag() >= 0.0 ? " + " : " - ")
|
||||
<< std::abs(pd.tau.imag()) << "i"
|
||||
<< " (genus 1, reduced to fundamental domain)\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";
|
||||
@@ -161,9 +222,20 @@ static int run_spherical(ConformalMesh& mesh,
|
||||
const std::string& out_xml,
|
||||
bool verbose)
|
||||
{
|
||||
// Spherical uniformisation targets a closed genus-0 surface (sphere).
|
||||
for (auto v : mesh.vertices())
|
||||
if (mesh.is_border(v)) {
|
||||
std::cerr << "Error: spherical mode needs a closed mesh; this mesh "
|
||||
"has a boundary. Use '-g euclidean' for open meshes.\n";
|
||||
return 1;
|
||||
}
|
||||
if (int g = cl::genus(mesh); g != 0)
|
||||
std::cerr << "[warn] spherical uniformisation assumes genus 0; this mesh "
|
||||
"has genus " << g << " — convergence is not guaranteed.\n";
|
||||
|
||||
auto maps = cl::setup_spherical_maps(mesh);
|
||||
cl::compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = cl::assign_vertex_dof_indices(mesh, maps);
|
||||
cl::compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
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);
|
||||
@@ -205,7 +277,7 @@ static int run_hyper_ideal(ConformalMesh& mesh,
|
||||
bool verbose)
|
||||
{
|
||||
auto maps = cl::setup_hyper_ideal_maps(mesh);
|
||||
int n = cl::assign_all_dof_indices(mesh, maps);
|
||||
int n = cl::assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
// Natural targets at base point (b=1, a=0.5)
|
||||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "viewer_utils.h"
|
||||
|
||||
namespace viewer_utils {
|
||||
|
||||
@@ -27,6 +27,15 @@ target_include_directories(conformallab_tests PRIVATE
|
||||
|
||||
target_link_libraries(conformallab_tests PRIVATE GTest::gtest_main)
|
||||
|
||||
# Fast test-build mode (lever #10): -O0 -g overrides the inherited
|
||||
# Release-mode -O3 + -DNDEBUG. Applies only to this test target;
|
||||
# library/installable code is never affected.
|
||||
if(CONFORMALLAB_FAST_TEST_BUILD)
|
||||
target_compile_options(conformallab_tests PRIVATE
|
||||
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-O0 -g -UNDEBUG>
|
||||
)
|
||||
endif()
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(conformallab_tests DISCOVERY_TIMEOUT 60)
|
||||
|
||||
|
||||
@@ -53,8 +53,9 @@ add_executable(conformallab_cgal_tests
|
||||
|
||||
# ── Java parity: geometry utility tests ──────────────────────────────────
|
||||
# Ported from CuttinUtilityTest, UnwrapUtilityTest,
|
||||
# ConvergenceUtilityTests, HomologyTest (tests 1–6).
|
||||
# Test 7 (genus-2 homology) as GTEST_SKIP stub until Phase 8.
|
||||
# ConvergenceUtilityTests, HomologyTest. All tests active —
|
||||
# the v0.7.0 genus-2 homology stub was implemented in Phase 7
|
||||
# (HomologyGenerators.Genus2_FourCutEdges, brezel2.obj).
|
||||
test_geometry_utils.cpp
|
||||
|
||||
# ── Scalability smoke tests ────────────────────────────────────────────────
|
||||
@@ -85,6 +86,15 @@ add_executable(conformallab_cgal_tests
|
||||
# newton_inversive_distance (FD Hessian).
|
||||
test_newton_phase9a.cpp
|
||||
|
||||
# ── Tier-3 Java cross-validation: Lawson square-tiled HyperIdeal ─────────
|
||||
# Low-level half-edge genus-2 generator + golden-vector convergence.
|
||||
test_lawson_hyperideal.cpp
|
||||
|
||||
# ── Pn projective-metric substrate (de.jreality.math.Pn port) ───────────
|
||||
# Inner product / norm / distance / geodesic interpolation in
|
||||
# Euclidean, Elliptic, Hyperbolic signatures.
|
||||
test_pn_geometry.cpp
|
||||
|
||||
# ── Phase 8b-Lite: CGAL entry wrappers for the 4 non-Euclidean modes ─────
|
||||
# Spherical, HyperIdeal, CircleP-Euclidean, Inversive-Distance via
|
||||
# <CGAL/Discrete_*.h> public API + Conformal_layout.h wrapper.
|
||||
@@ -115,8 +125,135 @@ target_compile_options(conformallab_cgal_tests PRIVATE
|
||||
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-Wno-unused-parameter>
|
||||
)
|
||||
|
||||
# Fast test-build mode (lever #10): -O0 -g overrides the inherited
|
||||
# Release-mode -O3 + -DNDEBUG. Applies only to this test target.
|
||||
if(CONFORMALLAB_FAST_TEST_BUILD)
|
||||
target_compile_options(conformallab_cgal_tests PRIVATE
|
||||
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-O0 -g -UNDEBUG>
|
||||
)
|
||||
endif()
|
||||
|
||||
# ── Low-memory build mode (for RAM-constrained CI runners, e.g. Raspberry Pi) ──
|
||||
#
|
||||
# Problem: CGAL + Eigen at -O3 drives cc1plus peak RAM to ~600-800 MB per
|
||||
# Unity compilation unit on ARM64 Linux. A 1600 MB container limit with a
|
||||
# batch of 4 files per unit causes OOM-kill during the build.
|
||||
#
|
||||
# This flag enables three orthogonal memory-saving measures:
|
||||
#
|
||||
# 1. -O0 (no debug info): drops cc1plus backend RAM by ~60-70 %.
|
||||
# Optimizer passes (inlining, register allocation, constant propagation)
|
||||
# dominate the backend. At -O0 they are entirely skipped → peak per
|
||||
# TU falls from ~700 MB to ~150-200 MB on ARM64.
|
||||
# We omit -g deliberately: debug info adds ~30-40 % object-file size
|
||||
# and increases linker RSS. CI needs "does it compile + do tests pass",
|
||||
# not debuggability.
|
||||
#
|
||||
# 2. PCH OFF: the precompiled header itself consumes ~200 MB to compile
|
||||
# and is re-read by every TU. Disabling it saves the one-time PCH
|
||||
# compilation cost; each TU re-parses CGAL headers, but at -O0 this
|
||||
# is fast.
|
||||
#
|
||||
# 3. UNITY_BUILD_BATCH_SIZE=1: one source file per unity unit. Removes
|
||||
# the "4 files × CGAL parse cost" multiplier; each cc1plus process
|
||||
# only sees one file worth of templates.
|
||||
#
|
||||
# 4. Linker memory flag (GCC/Clang only): --no-keep-memory tells GNU ld
|
||||
# to release symbol table memory after each input file instead of
|
||||
# keeping it for cross-reference. Reduces linker RSS by 15-25 % at
|
||||
# the cost of slightly longer link time.
|
||||
#
|
||||
# Activate with:
|
||||
# cmake -S code -B build -DWITH_CGAL_TESTS=ON \
|
||||
# -DCONFORMALLAB_LOW_MEMORY_BUILD=ON
|
||||
# Expected peak per cc1plus: ~150-200 MB → fits in 1800-2000 MB container.
|
||||
# Test runtime is 2-4× slower than Release (CGAL traversals unoptimized),
|
||||
# but correctness is unaffected.
|
||||
option(CONFORMALLAB_LOW_MEMORY_BUILD
|
||||
"Build CGAL tests with -O0, no PCH, UNITY_BATCH_SIZE=1 for RAM-constrained CI." OFF)
|
||||
|
||||
if(CONFORMALLAB_LOW_MEMORY_BUILD)
|
||||
message(STATUS "CONFORMALLAB_LOW_MEMORY_BUILD active: -O0, no PCH, unity batch 1.")
|
||||
|
||||
# 1. -O0, no debug info
|
||||
target_compile_options(conformallab_cgal_tests PRIVATE
|
||||
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-O0 -UNDEBUG>
|
||||
)
|
||||
|
||||
# 2. PCH off — force-override the option so the block below is skipped
|
||||
set(CONFORMALLAB_USE_PCH OFF CACHE BOOL "" FORCE)
|
||||
|
||||
# 3. Unity batch size = 1 (one source file per compilation unit)
|
||||
set_target_properties(conformallab_cgal_tests PROPERTIES
|
||||
UNITY_BUILD ON
|
||||
UNITY_BUILD_MODE BATCH
|
||||
UNITY_BUILD_BATCH_SIZE 1)
|
||||
|
||||
# 4. Linker memory hint (GNU ld / lld)
|
||||
target_link_options(conformallab_cgal_tests PRIVATE
|
||||
$<$<CXX_COMPILER_ID:GNU>:-Wl,--no-keep-memory>)
|
||||
endif()
|
||||
|
||||
target_link_libraries(conformallab_cgal_tests PRIVATE GTest::gtest_main)
|
||||
|
||||
# ── Compile-time speed-up: precompiled headers ───────────────────────────────
|
||||
#
|
||||
# The CGAL+Eigen template soup dominates every TU in this target:
|
||||
# measured at 5.9 s per minimal "include <CGAL/Discrete_conformal_map.h>"
|
||||
# TU on Apple M1. A shared PCH absorbs that cost once, slashing the
|
||||
# total wall-clock from ~78 s (j8) to ~25 s (3×).
|
||||
#
|
||||
# Opt-out with -DCONFORMALLAB_USE_PCH=OFF if the PCH itself misbehaves
|
||||
# (e.g. older toolchains that don't share PCH across translation units
|
||||
# reliably) — falls back to the historical "every TU re-parses CGAL"
|
||||
# build mode.
|
||||
option(CONFORMALLAB_USE_PCH
|
||||
"Enable precompiled headers for the CGAL test target." ON)
|
||||
|
||||
if(CONFORMALLAB_USE_PCH)
|
||||
# Per-target Unity Build property takes precedence over the global
|
||||
# CMAKE_UNITY_BUILD; honour CONFORMALLAB_DEV_BUILD's preference here
|
||||
# so `-DCONFORMALLAB_DEV_BUILD=ON` truly turns Unity Build off for
|
||||
# incremental-rebuild workflows.
|
||||
if(NOT CONFORMALLAB_DEV_BUILD)
|
||||
set_target_properties(conformallab_cgal_tests PROPERTIES
|
||||
# Unity-builds amortise the per-TU CGAL+Eigen header cost
|
||||
# across several tests in the same compile. Batch size 4
|
||||
# keeps gtest's TEST(...) macros + per-file `using
|
||||
# namespace …` from colliding while still cutting parser
|
||||
# cost ~4×.
|
||||
UNITY_BUILD ON
|
||||
UNITY_BUILD_MODE BATCH
|
||||
UNITY_BUILD_BATCH_SIZE 4)
|
||||
endif()
|
||||
|
||||
target_precompile_headers(conformallab_cgal_tests PRIVATE
|
||||
# CGAL headers that every test transitively includes.
|
||||
<CGAL/Surface_mesh.h>
|
||||
<CGAL/Simple_cartesian.h>
|
||||
<CGAL/Kernel_traits.h>
|
||||
<CGAL/boost/graph/iterator.h>
|
||||
<CGAL/Polygon_mesh_processing/triangulate_faces.h>
|
||||
|
||||
# Eigen blocks that drive the slowest template instantiations
|
||||
# (SelfAdjointEigenSolver<Matrix<2,2>>, ColPivHouseholderQR<
|
||||
# Matrix<complex,3,3>>, sparse Cholesky + QR fallback).
|
||||
<Eigen/Dense>
|
||||
<Eigen/Sparse>
|
||||
<Eigen/SparseCholesky>
|
||||
<Eigen/SparseQR>
|
||||
|
||||
# GoogleTest itself; every test includes it.
|
||||
<gtest/gtest.h>
|
||||
|
||||
# std headers that appear in every test.
|
||||
<vector>
|
||||
<string>
|
||||
<cmath>
|
||||
<complex>
|
||||
)
|
||||
endif()
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(conformallab_cgal_tests
|
||||
TEST_PREFIX "cgal."
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_cgal_phase8b_lite.cpp
|
||||
//
|
||||
// Phase 8b-Lite — Smoke tests for the four new CGAL-style entry functions
|
||||
@@ -186,3 +189,241 @@ TEST(CGALPhase8bLite, Layout_EuclideanWrapper_RoundTrip)
|
||||
EXPECT_TRUE(std::isfinite(uv.y()));
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 6. output_uv_map named parameter — integrated layout step
|
||||
//
|
||||
// Phase 8b-Lite extension (2026-05-22): if the caller supplies a property
|
||||
// map via `CGAL::parameters::output_uv_map(pmap)`, the entry function runs
|
||||
// the appropriate `*_layout()` after Newton and writes the per-vertex
|
||||
// coordinates into `pmap`. This closes the prior UX gap where users had
|
||||
// to call the wrapper, then re-set up maps, then call the legacy layout
|
||||
// API separately.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(CGALPhase8bLite, OutputUvMap_Euclidean_PopulatesPmap)
|
||||
{
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
auto mesh = make_quad_strip();
|
||||
|
||||
auto uv_map = mesh.add_property_map<Vertex_index, K::Point_2>(
|
||||
"v:test_uv", K::Point_2(0, 0)).first;
|
||||
|
||||
auto res = CGAL::discrete_conformal_map_euclidean(
|
||||
mesh,
|
||||
CGAL::parameters::output_uv_map(uv_map));
|
||||
|
||||
ASSERT_TRUE(res.converged);
|
||||
|
||||
// The map must be populated with finite values.
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = uv_map[v];
|
||||
EXPECT_TRUE(std::isfinite(p.x())) << "non-finite UV.x at vertex " << v.idx();
|
||||
EXPECT_TRUE(std::isfinite(p.y())) << "non-finite UV.y at vertex " << v.idx();
|
||||
}
|
||||
|
||||
// At least one vertex must have moved off the origin (the layout
|
||||
// did NOT just return defaults).
|
||||
bool any_nonzero = false;
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = uv_map[v];
|
||||
if (std::abs(p.x()) + std::abs(p.y()) > 1e-10) { any_nonzero = true; break; }
|
||||
}
|
||||
EXPECT_TRUE(any_nonzero) << "every UV is exactly (0,0) — layout did not run";
|
||||
}
|
||||
|
||||
TEST(CGALPhase8bLite, OutputUvMap_Spherical_PopulatesXyz)
|
||||
{
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
auto mesh = make_tetrahedron();
|
||||
|
||||
auto xyz_map = mesh.add_property_map<Vertex_index, K::Point_3>(
|
||||
"v:test_xyz", K::Point_3(0, 0, 0)).first;
|
||||
|
||||
auto res = CGAL::discrete_conformal_map_spherical(
|
||||
mesh,
|
||||
CGAL::parameters::output_uv_map(xyz_map));
|
||||
|
||||
ASSERT_TRUE(res.converged);
|
||||
|
||||
// Every output point must lie on (or very near) the unit sphere.
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = xyz_map[v];
|
||||
const double r = std::sqrt(p.x()*p.x() + p.y()*p.y() + p.z()*p.z());
|
||||
EXPECT_NEAR(r, 1.0, 1e-6) << "vertex " << v.idx() << " not on unit sphere";
|
||||
}
|
||||
}
|
||||
|
||||
TEST(CGALPhase8bLite, OutputUvMap_HyperIdeal_PointsInPoincareDisk)
|
||||
{
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
auto mesh = make_tetrahedron();
|
||||
|
||||
auto uv_map = mesh.add_property_map<Vertex_index, K::Point_2>(
|
||||
"v:test_uv_hyp", K::Point_2(0, 0)).first;
|
||||
|
||||
// Named-parameter chaining is not supported yet — pass output_uv_map only.
|
||||
auto res = CGAL::discrete_conformal_map_hyper_ideal(
|
||||
mesh,
|
||||
CGAL::parameters::output_uv_map(uv_map));
|
||||
|
||||
// The wrapper must complete and return a well-formed result struct
|
||||
// regardless of whether Newton fully converges with the default
|
||||
// Θ/θ targets in 200 iterations. We only verify that *if* the
|
||||
// layout step ran (which happens only on converged Newton), the
|
||||
// output is finite — Poincaré-disk geometric check is conditional.
|
||||
EXPECT_EQ(res.b_per_vertex.size(), num_vertices(mesh));
|
||||
EXPECT_EQ(res.a_per_edge.size(), num_edges(mesh));
|
||||
|
||||
if (res.converged) {
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = uv_map[v];
|
||||
const double r2 = p.x()*p.x() + p.y()*p.y();
|
||||
EXPECT_LE(r2, 1.0 + 1e-6)
|
||||
<< "vertex " << v.idx() << " outside Poincaré disk (|p|² = " << r2 << ")";
|
||||
}
|
||||
}
|
||||
// (else: Newton did not reach equilibrium; UV pmap is left at its
|
||||
// default (0,0) per the wrapper's "if (nr.converged)" guard.
|
||||
// No assertion needed; this is documented behaviour.)
|
||||
}
|
||||
|
||||
TEST(CGALPhase8bLite, OutputUvMap_InversiveDistance_PopulatesPmap)
|
||||
{
|
||||
// Inversive-Distance: per-vertex u_i = log r_i. With output_uv_map
|
||||
// the entry function reconstructs effective Euclidean edge lengths via
|
||||
// the Bowers-Stephenson identity and reuses the euclidean_layout
|
||||
// priority-BFS to populate per-vertex Point_2 coordinates.
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
auto mesh = make_quad_strip();
|
||||
|
||||
auto uv_map = mesh.add_property_map<Vertex_index, K::Point_2>(
|
||||
"v:test_uv_id", K::Point_2(0, 0)).first;
|
||||
|
||||
auto res = CGAL::discrete_inversive_distance_map(
|
||||
mesh, CGAL::parameters::output_uv_map(uv_map));
|
||||
|
||||
ASSERT_TRUE(res.converged) << "ID Newton did not converge on quad_strip";
|
||||
EXPECT_EQ(res.u_per_vertex.size(), num_vertices(mesh));
|
||||
// Every UV must be finite; not all zero.
|
||||
bool any_nonzero = false;
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = uv_map[v];
|
||||
ASSERT_TRUE(std::isfinite(p.x()));
|
||||
ASSERT_TRUE(std::isfinite(p.y()));
|
||||
if (std::abs(p.x()) > 1e-9 || std::abs(p.y()) > 1e-9) any_nonzero = true;
|
||||
}
|
||||
EXPECT_TRUE(any_nonzero) << "all UVs are zero — layout did not run";
|
||||
}
|
||||
|
||||
TEST(CGALPhase8bLite, OutputUvMap_CPEuclidean_ThrowsClearly)
|
||||
{
|
||||
// CP-Euclidean is face-based; its natural layout is a per-face
|
||||
// circle packing in ℝ², not a per-vertex Point_2 map. The entry
|
||||
// throws std::runtime_error with a helpful message rather than
|
||||
// silently producing nonsense. See doc/architecture/locked-vs-flexible.md.
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
auto mesh = make_quad_strip();
|
||||
|
||||
auto uv_map = mesh.add_property_map<Vertex_index, K::Point_2>(
|
||||
"v:test_uv_cp", K::Point_2(0, 0)).first;
|
||||
|
||||
EXPECT_THROW(
|
||||
CGAL::discrete_circle_packing_euclidean(
|
||||
mesh, CGAL::parameters::output_uv_map(uv_map)),
|
||||
std::runtime_error)
|
||||
<< "expected discrete_circle_packing_euclidean to reject "
|
||||
"`output_uv_map(...)` (face-based DOF, Phase 9c).";
|
||||
|
||||
// Sanity: without output_uv_map the entry function still works fine.
|
||||
auto res = CGAL::discrete_circle_packing_euclidean(mesh);
|
||||
// Convergence depends on the mesh; we only check no-throw + a sane
|
||||
// shape of the result struct.
|
||||
EXPECT_EQ(res.rho_per_face.size(), num_faces(mesh));
|
||||
}
|
||||
|
||||
TEST(CGALPhase8bLite, OutputUvMap_Absent_DoesNotRunLayout)
|
||||
{
|
||||
// Sanity: without the parameter, no layout work happens. Verified
|
||||
// here only via the fact that the call still succeeds and produces
|
||||
// the same u-vector as before.
|
||||
auto mesh = make_quad_strip();
|
||||
auto res = CGAL::discrete_conformal_map_euclidean(mesh);
|
||||
EXPECT_TRUE(res.converged);
|
||||
EXPECT_EQ(res.u_per_vertex.size(), num_vertices(mesh));
|
||||
}
|
||||
|
||||
TEST(CGALPhase8bLite, OutputUvMap_NormaliseLayout_TakesEffect)
|
||||
{
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
auto mesh = make_quad_strip();
|
||||
|
||||
auto uv_raw = mesh.add_property_map<Vertex_index, K::Point_2>(
|
||||
"v:test_uv_raw", K::Point_2(0, 0)).first;
|
||||
auto uv_norm = mesh.add_property_map<Vertex_index, K::Point_2>(
|
||||
"v:test_uv_norm", K::Point_2(0, 0)).first;
|
||||
|
||||
auto res1 = CGAL::discrete_conformal_map_euclidean(
|
||||
mesh, CGAL::parameters::output_uv_map(uv_raw));
|
||||
auto res2 = CGAL::discrete_conformal_map_euclidean(
|
||||
mesh, CGAL::parameters::output_uv_map(uv_norm));
|
||||
// (We can only pass one named parameter at a time without chaining;
|
||||
// test the toggle by running the wrapper twice and verifying the
|
||||
// raw call works. The normalise_layout flag is exercised in
|
||||
// internal unit tests via direct calls to normalise_euclidean.)
|
||||
ASSERT_TRUE(res1.converged);
|
||||
ASSERT_TRUE(res2.converged);
|
||||
// Both maps populated to finite values.
|
||||
for (auto v : mesh.vertices()) {
|
||||
EXPECT_TRUE(std::isfinite(uv_raw[v].x()));
|
||||
EXPECT_TRUE(std::isfinite(uv_norm[v].x()));
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 7. Named-parameter chaining via pipe-operator
|
||||
//
|
||||
// CGAL's `.a().b().c()` chaining requires modifying CGAL upstream, which
|
||||
// we don't do. conformallab++ provides a `|` operator that achieves the
|
||||
// same effect by left-to-right composition. These tests verify that the
|
||||
// chain is read back correctly by the entry functions.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(CGALPhase8bLite, NamedParamPipe_MultipleParamsTakeEffect)
|
||||
{
|
||||
using K = CGAL::Simple_cartesian<double>;
|
||||
auto mesh = make_quad_strip();
|
||||
|
||||
auto uv = mesh.add_property_map<Vertex_index, K::Point_2>(
|
||||
"v:pipe_uv", K::Point_2(0, 0)).first;
|
||||
|
||||
// Chain three parameters using `|`.
|
||||
auto params = CGAL::parameters::gradient_tolerance(1e-12)
|
||||
| CGAL::parameters::max_iterations(500)
|
||||
| CGAL::parameters::output_uv_map(uv);
|
||||
|
||||
auto res = CGAL::discrete_conformal_map_euclidean(mesh, params);
|
||||
|
||||
EXPECT_TRUE(res.converged);
|
||||
EXPECT_LT(res.gradient_norm, 1e-10); // tight tolerance applied
|
||||
EXPECT_LE(res.iterations, 500);
|
||||
// UV pmap was populated.
|
||||
bool any_nonzero = false;
|
||||
for (auto v : mesh.vertices()) {
|
||||
if (std::abs(uv[v].x()) + std::abs(uv[v].y()) > 1e-10) {
|
||||
any_nonzero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(any_nonzero);
|
||||
}
|
||||
|
||||
TEST(CGALPhase8bLite, NamedParamPipe_TwoParams)
|
||||
{
|
||||
// Pipe two parameters and verify both take effect.
|
||||
auto mesh = make_triangle();
|
||||
auto params = CGAL::parameters::max_iterations(0)
|
||||
| CGAL::parameters::gradient_tolerance(1e-6);
|
||||
auto res = CGAL::discrete_conformal_map_euclidean(mesh, params);
|
||||
EXPECT_EQ(res.iterations, 0); // max_iterations(0) blocks the loop
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_cgal_traits_mvp.cpp
|
||||
//
|
||||
// Phase 8 MVP — first tests for the new CGAL-style public API.
|
||||
@@ -108,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)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_conformal_mesh.cpp
|
||||
//
|
||||
// Phase 3a — CGAL Surface_mesh infrastructure tests.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_cp_euclidean_functional.cpp
|
||||
//
|
||||
// Phase 9a.1 — CPEuclideanFunctional (BPS 2010) tests.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_euclidean_functional.cpp
|
||||
//
|
||||
// Phase 3d — EuclideanCyclicFunctional ported to ConformalMesh.
|
||||
@@ -23,9 +26,13 @@
|
||||
#include "euclidean_geometry.hpp"
|
||||
#include "euclidean_functional.hpp"
|
||||
#include "euclidean_hessian.hpp"
|
||||
#include "clausen.hpp"
|
||||
#include "mesh_io.hpp"
|
||||
#include "newton_solver.hpp"
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
using namespace conformallab;
|
||||
|
||||
@@ -118,7 +125,16 @@ TEST(EuclideanFunctional, AngleSumEqualsPi)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Degenerate triangle → valid = false
|
||||
// Degenerate triangle — limiting angles (Finding-F, java-port-audit item 1)
|
||||
//
|
||||
// The BPS-energy convex C¹ extension assigns the *limiting* angles when the
|
||||
// triangle inequality is violated: the corner OPPOSITE the over-long edge
|
||||
// gets π, the other two get 0. These tests lock that behaviour in for
|
||||
// both euclidean_angles_from_lengths() and the gradient accumulation.
|
||||
//
|
||||
// Before the java-port-audit Finding 1 fix, the degenerate-face code
|
||||
// returned {0,0,0} and the gradient skipped the face entirely, producing
|
||||
// the wrong gradient on near-flip configurations.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(EuclideanFunctional, DegenerateTriangleReturnsFalse)
|
||||
@@ -128,6 +144,88 @@ TEST(EuclideanFunctional, DegenerateTriangleReturnsFalse)
|
||||
EXPECT_FALSE(fa.valid);
|
||||
}
|
||||
|
||||
TEST(EuclideanFunctional, DegenerateTriangle_LimitingAngles_L23TooLong)
|
||||
{
|
||||
// l23 = 10 >> l12 + l31 = 2 → α₁ = π (at v1, opposite l23), α₂=α₃=0.
|
||||
auto fa = euclidean_angles_from_lengths(1.0, 10.0, 1.0);
|
||||
EXPECT_FALSE(fa.valid);
|
||||
EXPECT_NEAR(fa.alpha1, PI, 1e-12) << "corner opposite over-long l23 must be π";
|
||||
EXPECT_NEAR(fa.alpha2, 0.0, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha3, 0.0, 1e-12);
|
||||
}
|
||||
|
||||
TEST(EuclideanFunctional, DegenerateTriangle_LimitingAngles_L31TooLong)
|
||||
{
|
||||
// l31 too long → α₂ = π (at v2, opposite l31).
|
||||
auto fa = euclidean_angles_from_lengths(1.0, 1.0, 10.0);
|
||||
EXPECT_FALSE(fa.valid);
|
||||
EXPECT_NEAR(fa.alpha2, PI, 1e-12) << "corner opposite over-long l31 must be π";
|
||||
EXPECT_NEAR(fa.alpha1, 0.0, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha3, 0.0, 1e-12);
|
||||
}
|
||||
|
||||
TEST(EuclideanFunctional, DegenerateTriangle_LimitingAngles_L12TooLong)
|
||||
{
|
||||
// l12 too long → α₃ = π (at v3, opposite l12).
|
||||
auto fa = euclidean_angles_from_lengths(10.0, 1.0, 1.0);
|
||||
EXPECT_FALSE(fa.valid);
|
||||
EXPECT_NEAR(fa.alpha3, PI, 1e-12) << "corner opposite over-long l12 must be π";
|
||||
EXPECT_NEAR(fa.alpha1, 0.0, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha2, 0.0, 1e-12);
|
||||
}
|
||||
|
||||
TEST(EuclideanFunctional, DegenerateTriangle_GradientPicksUpPiCorner)
|
||||
{
|
||||
// Build a single triangle. Force a degenerate effective-length by
|
||||
// setting a large negative lambda0 on two edges so l12 >> l23 + l31.
|
||||
//
|
||||
// DOFs: all vertices free. x = 0 (no conformal scaling).
|
||||
// lambda0: e_opp_v3 (i.e. l12) is huge; the other two are near-zero.
|
||||
// Expected: the gradient at v3 (opposite l12) picks up −π from the
|
||||
// degenerate face; G_v3 = Θ_v3 − π = 2π − π = π.
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
|
||||
// Identify edges: h0=halfedge(face), source(h0)=v1, source(next(h0))=v2, etc.
|
||||
auto f = *mesh.faces().begin();
|
||||
auto h0 = mesh.halfedge(f);
|
||||
auto h1 = mesh.next(h0);
|
||||
auto h2 = mesh.next(h1);
|
||||
|
||||
// Assign large lambda0 to the edge opposite v3 (= edge of h0, i.e. e12).
|
||||
Edge_index e12 = mesh.edge(h0);
|
||||
Edge_index e23 = mesh.edge(h1);
|
||||
Edge_index e31 = mesh.edge(h2);
|
||||
maps.lambda0[e12] = 20.0; // l12 = exp(10) ≈ 22026 — hugely over-long
|
||||
maps.lambda0[e23] = 0.0;
|
||||
maps.lambda0[e31] = 0.0;
|
||||
|
||||
int n = assign_euclidean_vertex_dof_indices(mesh, maps);
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
|
||||
auto G = euclidean_gradient(mesh, x, maps);
|
||||
|
||||
// v3 = source(h2). Its gradient component should include the π corner.
|
||||
Vertex_index v3 = mesh.source(h2);
|
||||
int iv3 = maps.v_idx[v3];
|
||||
ASSERT_GE(iv3, 0);
|
||||
|
||||
// G_v3 = Θ_v3 − α3. The degenerate face gives α3 = π.
|
||||
// Θ_v3 defaults to 2π, so G_v3 = 2π − π = π.
|
||||
EXPECT_NEAR(G[static_cast<std::size_t>(iv3)], PI, 1e-10)
|
||||
<< "Gradient at v3 must include the π limiting angle from the degenerate face";
|
||||
|
||||
// v1 and v2 get α = 0 from the degenerate face → G_vi = Θ − 0 = 2π.
|
||||
Vertex_index v1 = mesh.source(h0);
|
||||
Vertex_index v2 = mesh.source(h1);
|
||||
int iv1 = maps.v_idx[v1], iv2 = maps.v_idx[v2];
|
||||
ASSERT_GE(iv1, 0); ASSERT_GE(iv2, 0);
|
||||
EXPECT_NEAR(G[static_cast<std::size_t>(iv1)], TWO_PI, 1e-10)
|
||||
<< "Gradient at v1 must be 2π (angle contribution = 0)";
|
||||
EXPECT_NEAR(G[static_cast<std::size_t>(iv2)], TWO_PI, 1e-10)
|
||||
<< "Gradient at v2 must be 2π (angle contribution = 0)";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Gradient check: default right-isosceles triangle, vertex DOFs only
|
||||
//
|
||||
@@ -287,3 +385,438 @@ TEST(EuclideanFunctional, GradientCheck_MixedPinnedVertices)
|
||||
EXPECT_TRUE(gradient_check_euclidean(mesh, x, maps))
|
||||
<< "Gradient check failed for mixed pinned/variable vertices";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Golden-value oracle — pin the Euclidean angle formula and the 2·Л(α) energy
|
||||
// term bit-for-bit against the upstream Java reference (EuclideanCyclicFunctional
|
||||
// .triangleEnergyAndAlphas, lines 341-361), captured by running the compiled Java
|
||||
// library (openjdk 17) with the real de.varylab…Clausen.Л on these exact edge
|
||||
// lengths. Companion to HyperIdealGoldenJava and SphericalGoldenJava: locks the
|
||||
// absolute angle/energy values against an independent implementation, catching
|
||||
// silent index/sign drift the curl-free path-integral gradient check cannot see.
|
||||
//
|
||||
// To regenerate: /tmp/oracle/EucOracle.java. Values are Java printf %.17g.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
TEST(EuclideanGoldenJava, AngleAndLobachevskyEnergyFromLengths)
|
||||
{
|
||||
auto check = [](double l12, double l23, double l31,
|
||||
double a1_g, double a2_g, double a3_g, double L_g) {
|
||||
auto fa = euclidean_angles_from_lengths(l12, l23, l31);
|
||||
EXPECT_TRUE(fa.valid);
|
||||
EXPECT_NEAR(fa.alpha1, a1_g, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha2, a2_g, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha3, a3_g, 1e-12);
|
||||
const double Lterm = 2.0 * Lobachevsky(fa.alpha1)
|
||||
+ 2.0 * Lobachevsky(fa.alpha2)
|
||||
+ 2.0 * Lobachevsky(fa.alpha3);
|
||||
EXPECT_NEAR(Lterm, L_g, 1e-12);
|
||||
};
|
||||
check(1.0, 1.2, 0.9,
|
||||
1.3637649752769678, 0.82416964552030680, 0.95365803279251860,
|
||||
1.9456273836230942);
|
||||
check(1.0, 1.0, 1.0,
|
||||
1.0471975511965979, 1.0471975511965979, 1.0471975511965979,
|
||||
2.0298832128193070);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FULL-MESH golden oracle — the strongest cross-check: drives the REAL upstream
|
||||
// EuclideanCyclicFunctional (openjdk 17) on a tetrahedron loaded from a shared
|
||||
// OBJ (identical topology + geometry to make_tetrahedron()), and pins BOTH the
|
||||
// per-vertex gradient G_v = Θ_v − Σα AND the energy difference ΔE = E(x) − E(0)
|
||||
// bit-for-bit against it.
|
||||
//
|
||||
// This closes audit missing-test item 5 (full-mesh energy + gradient at a known
|
||||
// x). It is genuinely independent of the C++ implementation in two ways:
|
||||
// • the gradient is the upstream library's own analytic gradient (not an FD
|
||||
// check, which only proves curl-freeness of the C++ self-consistent energy);
|
||||
// • the energy is Java's CLOSED-FORM functional value, whereas C++ computes it
|
||||
// as a Gauss-Legendre PATH INTEGRAL of its gradient — two different methods
|
||||
// that must agree on ΔE (the initialEnergy constant and the φ·λ⁰ term cancel
|
||||
// in the difference, and there are no edge DOFs here).
|
||||
//
|
||||
// Setup parity (verified against UnwrapUtility.prepareInvariantDataEuclidean):
|
||||
// closed mesh, ALL 4 vertices variable (no pin), Θ_v = 2π, no edge DOFs,
|
||||
// λ°_e = 2·log(|p_i − p_j|), per-vertex u(P) = 0.10·X − 0.07·Y + 0.13·Z.
|
||||
//
|
||||
// To regenerate: /tmp/oracle/{tet.obj,EucMeshOracle.java}. Values are Java %.17g.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
TEST(EuclideanGoldenJava, FullMeshGradientAndEnergy_Tetrahedron)
|
||||
{
|
||||
constexpr double TWO_PI = 2.0 * 3.14159265358979323846264338328;
|
||||
|
||||
auto mesh = make_tetrahedron(); // same 4 vertices as /tmp/oracle/tet.obj
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// All four vertices are DOFs (Java prepareInvariantData makes every interior
|
||||
// vertex variable on a closed mesh); Θ_v = 2π; no edge DOFs.
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices()) {
|
||||
maps.v_idx[v] = idx++;
|
||||
maps.theta_v[v] = TWO_PI;
|
||||
}
|
||||
|
||||
auto u_of = [](const Point3& p) {
|
||||
return 0.10 * p.x() - 0.07 * p.y() + 0.13 * p.z();
|
||||
};
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(idx), 0.0);
|
||||
for (auto v : mesh.vertices())
|
||||
x[static_cast<std::size_t>(maps.v_idx[v])] = u_of(mesh.point(v));
|
||||
|
||||
auto G = euclidean_gradient(mesh, x, maps);
|
||||
|
||||
// Java golden gradients keyed by vertex position (order-independent lookup).
|
||||
struct GoldRow { double X, Y, Z, G; };
|
||||
const GoldRow gold[4] = {
|
||||
{ 1, 1, 1, 3.5277511803984396},
|
||||
{ 1, -1, -1, 3.2837611905358440},
|
||||
{-1, 1, -1, 2.3437485291237100},
|
||||
{-1, -1, 1, 3.4111097143011780},
|
||||
};
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = mesh.point(v);
|
||||
const double g = G[static_cast<std::size_t>(maps.v_idx[v])];
|
||||
bool matched = false;
|
||||
for (const auto& row : gold) {
|
||||
if (std::abs(p.x() - row.X) < 1e-9 &&
|
||||
std::abs(p.y() - row.Y) < 1e-9 &&
|
||||
std::abs(p.z() - row.Z) < 1e-9) {
|
||||
EXPECT_NEAR(g, row.G, 1e-12)
|
||||
<< "gradient mismatch at (" << p.x() << "," << p.y()
|
||||
<< "," << p.z() << ")";
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(matched) << "unexpected vertex position";
|
||||
}
|
||||
|
||||
// ΔE = E(x) − E(0). C++ path integral vs Java closed-form functional value.
|
||||
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
|
||||
const double dE = euclidean_energy(mesh, x, maps)
|
||||
- euclidean_energy(mesh, x0, maps);
|
||||
EXPECT_NEAR(dE, 0.15962619236187336, 1e-12);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Java cross-validation (Tier 1, GREEN) — circular-edge φ wiring (no solver)
|
||||
//
|
||||
// The "circular hole edge" of EuclideanCyclicConvergenceTest works by setting a
|
||||
// non-default edge turn angle φ_e. Since the cyclic edge gradient is exactly
|
||||
// G_e = α_opp(f⁺) + α_opp(f⁻) − φ_e,
|
||||
// lowering φ_e by 0.1 must raise G_e by exactly 0.1 — independent of geometry —
|
||||
// and must leave every other gradient component untouched. This pins the φ
|
||||
// wiring without needing the (not-yet-implemented) edge-DOF Hessian, so it runs
|
||||
// today and is the evaluation-level prerequisite of the DISABLED convergence
|
||||
// test below.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
TEST(EuclideanFunctional, CyclicCircularEdge_PhiEntersGradient_CatHead)
|
||||
{
|
||||
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;
|
||||
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices())
|
||||
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
|
||||
for (auto e : mesh.edges())
|
||||
maps.e_idx[e] = idx++;
|
||||
const int n = idx;
|
||||
ASSERT_GT(n, 0);
|
||||
|
||||
Edge_index e_circ{};
|
||||
bool found = false;
|
||||
for (auto e : mesh.edges()) {
|
||||
auto h = mesh.halfedge(e);
|
||||
auto ho = mesh.opposite(h);
|
||||
if (mesh.is_border(h) || mesh.is_border(ho)) continue;
|
||||
e_circ = e; found = true; break;
|
||||
}
|
||||
ASSERT_TRUE(found) << "no interior edge found on cathead";
|
||||
const std::size_t ie = static_cast<std::size_t>(maps.e_idx[e_circ]);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
|
||||
maps.phi_e[e_circ] = PI;
|
||||
auto G1 = euclidean_gradient(mesh, x, maps);
|
||||
maps.phi_e[e_circ] = PI - 0.1;
|
||||
auto G2 = euclidean_gradient(mesh, x, maps);
|
||||
|
||||
// Lowering φ_e by 0.1 raises exactly this edge's gradient component by 0.1.
|
||||
EXPECT_NEAR(0.1, G2[ie] - G1[ie], 1e-12)
|
||||
<< "circular-edge φ target not wired into the cyclic gradient";
|
||||
|
||||
// No other gradient component changes.
|
||||
double max_other = 0.0;
|
||||
for (std::size_t k = 0; k < G1.size(); ++k)
|
||||
if (k != ie) max_other = std::max(max_other, std::abs(G2[k] - G1[k]));
|
||||
EXPECT_LT(max_other, 1e-12)
|
||||
<< "changing one φ_e perturbed unrelated gradient components";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Java cross-validation (Tier 1) — EuclideanCyclicConvergenceTest
|
||||
//
|
||||
// Ports de.varylab.discreteconformal.functional.EuclideanCyclicConvergenceTest:
|
||||
// prescribe a non-default edge turn angle φ = π − 0.1 on one interior edge of
|
||||
// cathead.obj ("circular hole edge"), solve the cyclic Euclidean functional
|
||||
// (vertex + edge DOFs), then assert the realised opposite-corner-angle sum
|
||||
// across that edge equals π − 0.1.
|
||||
//
|
||||
// The C++ edge gradient is G_e = α_opp(f⁺) + α_opp(f⁻) − φ_e, so at the
|
||||
// solution (G_e = 0) the geometric angle sum equals φ_e — exactly the Java
|
||||
// assertion `circularEdge.getAlpha() + opposite.getAlpha() == π − 0.1`.
|
||||
//
|
||||
// "Natural targets" first make x = 0 the equilibrium (so the *only* deviation
|
||||
// is the prescribed φ); the test therefore FAILS if the solver ignores a
|
||||
// non-default φ (the sum would stay at its natural value, not π − 0.1).
|
||||
//
|
||||
// Enabled 2026-05-30: `newton_euclidean` now uses the block-FD edge-DOF Hessian
|
||||
// (`euclidean_hessian_block_fd_sym`) for cyclic layouts, so the full solve runs.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
TEST(EuclideanFunctional, CyclicCircularEdge_CatHead_JavaXVal)
|
||||
{
|
||||
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;
|
||||
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// DOFs: interior vertices (border pinned) + exactly ONE edge DOF on the
|
||||
// "circular" edge (Java marks a single circularHoleEdge). Giving every edge
|
||||
// a DOF would make λ_e redundant with u_i+u_j (a V-dim null space) and stall
|
||||
// Newton; the single extra edge variable keeps the system well-posed.
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices())
|
||||
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
|
||||
|
||||
// Pick one interior edge: both incident faces present, both endpoints interior.
|
||||
Edge_index circular{};
|
||||
bool found = false;
|
||||
for (auto e : mesh.edges()) {
|
||||
auto h = mesh.halfedge(e);
|
||||
auto ho = mesh.opposite(h);
|
||||
if (mesh.is_border(h) || mesh.is_border(ho)) continue;
|
||||
if (mesh.is_border(mesh.source(h)) || mesh.is_border(mesh.target(h))) continue;
|
||||
circular = e; found = true; break;
|
||||
}
|
||||
ASSERT_TRUE(found) << "no interior edge found on cathead";
|
||||
maps.e_idx[circular] = idx++; // the single circular-edge DOF
|
||||
const int n = idx;
|
||||
ASSERT_GT(n, 0);
|
||||
const std::size_t ie = static_cast<std::size_t>(maps.e_idx[circular]);
|
||||
|
||||
// Natural targets: set Θ_v / φ_e so that x = 0 is the equilibrium (G(0)=0),
|
||||
// so the only deviation is the prescribed circular φ below.
|
||||
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)];
|
||||
}
|
||||
maps.phi_e[circular] += G0[ie];
|
||||
|
||||
// Prescribe the circular edge turn angle φ = π − 0.1 (Java CustomEdgeInfo.phi).
|
||||
const double phi_target = PI - 0.1;
|
||||
maps.phi_e[circular] = phi_target;
|
||||
|
||||
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-11, /*max_iter=*/200);
|
||||
ASSERT_TRUE(res.converged)
|
||||
<< "Newton did not converge; ||G||=" << res.grad_inf_norm;
|
||||
|
||||
// Realised geometric opposite-corner-angle sum = φ_e + G_e(x*) (= α_opp+α_opp).
|
||||
auto Gf = euclidean_gradient(mesh, res.x, maps);
|
||||
const double realised = maps.phi_e[circular] + Gf[ie];
|
||||
EXPECT_NEAR(phi_target, realised, 1e-9)
|
||||
<< "prescribed circular edge turn angle π−0.1 not realised at the solution";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Correctness of the block-FD edge-DOF (cyclic) Hessian
|
||||
//
|
||||
// Validates `euclidean_hessian_block_fd` directly: on a tetrahedron with the
|
||||
// full cyclic DOF layout (4 vertex + 6 edge), every entry must match the
|
||||
// column-wise finite difference of `euclidean_gradient` (the true Jacobian of
|
||||
// G). This pins the per-face output sign mapping (−α vertex / +α_opp edge) and
|
||||
// the scatter independently of the convergence test above.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
TEST(EuclideanFunctional, CyclicHessian_BlockFD_MatchesGradientFD_Tetrahedron)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
const int n = assign_euclidean_all_dof_indices(mesh, maps); // 4 + 6 = 10
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
for (int i = 0; i < 4; ++i) x[static_cast<std::size_t>(i)] = -0.15; // vertices
|
||||
for (int i = 4; i < n; ++i) x[static_cast<std::size_t>(i)] = 0.05; // edges
|
||||
|
||||
auto H = euclidean_hessian_block_fd(mesh, x, maps);
|
||||
|
||||
const double eps = 1e-6;
|
||||
std::vector<double> xp = x, xm = x;
|
||||
double max_err = 0.0;
|
||||
for (int j = 0; j < n; ++j) {
|
||||
const std::size_t sj = static_cast<std::size_t>(j);
|
||||
xp[sj] = x[sj] + eps;
|
||||
xm[sj] = x[sj] - eps;
|
||||
auto Gp = euclidean_gradient(mesh, xp, maps);
|
||||
auto Gm = euclidean_gradient(mesh, xm, maps);
|
||||
xp[sj] = xm[sj] = x[sj];
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const double fd = (Gp[static_cast<std::size_t>(i)]
|
||||
- Gm[static_cast<std::size_t>(i)]) / (2.0 * eps);
|
||||
max_err = std::max(max_err, std::abs(H.coeff(i, j) - fd));
|
||||
}
|
||||
}
|
||||
EXPECT_LT(max_err, 1e-5)
|
||||
<< "block-FD cyclic Hessian disagrees with the gradient finite difference";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Analytic cyclic Hessian == block-FD (and == gradient FD)
|
||||
//
|
||||
// `euclidean_hessian_analytic` is the closed-form counterpart of
|
||||
// `euclidean_hessian_block_fd`. On the full cyclic layout they must agree to
|
||||
// round-off, and both must match the gradient finite difference. This validates
|
||||
// the analytic derivation (∂α_i/∂s_i = ℓ_i²/4A, ∂α_i/∂s_j = ½cot α_i − ℓ_j²/4A).
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
TEST(EuclideanFunctional, CyclicHessian_Analytic_MatchesBlockFD_Tetrahedron)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
const int n = assign_euclidean_all_dof_indices(mesh, maps); // 4 + 6 = 10
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
for (int i = 0; i < 4; ++i) x[static_cast<std::size_t>(i)] = -0.15;
|
||||
for (int i = 4; i < n; ++i) x[static_cast<std::size_t>(i)] = 0.05;
|
||||
|
||||
auto Ha = euclidean_hessian_analytic(mesh, x, maps);
|
||||
auto Hb = euclidean_hessian_block_fd(mesh, x, maps);
|
||||
|
||||
// Analytic vs block-FD.
|
||||
double max_ab = 0.0;
|
||||
for (int i = 0; i < n; ++i)
|
||||
for (int j = 0; j < n; ++j)
|
||||
max_ab = std::max(max_ab, std::abs(Ha.coeff(i, j) - Hb.coeff(i, j)));
|
||||
EXPECT_LT(max_ab, 1e-6)
|
||||
<< "analytic cyclic Hessian disagrees with block-FD";
|
||||
|
||||
// Analytic vs gradient FD (independent ground truth).
|
||||
const double eps = 1e-6;
|
||||
std::vector<double> xp = x, xm = x;
|
||||
double max_af = 0.0;
|
||||
for (int j = 0; j < n; ++j) {
|
||||
const std::size_t sj = static_cast<std::size_t>(j);
|
||||
xp[sj] = x[sj] + eps;
|
||||
xm[sj] = x[sj] - eps;
|
||||
auto Gp = euclidean_gradient(mesh, xp, maps);
|
||||
auto Gm = euclidean_gradient(mesh, xm, maps);
|
||||
xp[sj] = xm[sj] = x[sj];
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const double fd = (Gp[static_cast<std::size_t>(i)]
|
||||
- Gm[static_cast<std::size_t>(i)]) / (2.0 * eps);
|
||||
max_af = std::max(max_af, std::abs(Ha.coeff(i, j) - fd));
|
||||
}
|
||||
}
|
||||
EXPECT_LT(max_af, 1e-5)
|
||||
<< "analytic cyclic Hessian disagrees with the gradient finite difference";
|
||||
|
||||
// Symmetry (Hessian of a scalar energy).
|
||||
double max_sym = 0.0;
|
||||
for (int i = 0; i < n; ++i)
|
||||
for (int j = 0; j < n; ++j)
|
||||
max_sym = std::max(max_sym, std::abs(Ha.coeff(i, j) - Ha.coeff(j, i)));
|
||||
EXPECT_LT(max_sym, 1e-9) << "analytic cyclic Hessian is not symmetric";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// DOF assignment gauge-vertex overload (Finding-D, external-audit-2026-05-30)
|
||||
//
|
||||
// The single-argument assign_euclidean_vertex_dof_indices() overwrites ALL
|
||||
// v_idx unconditionally — setting a pin *before* the call has no effect.
|
||||
// The two-argument overload (gauge vertex) pins the requested vertex in a
|
||||
// single pass. These tests verify:
|
||||
// (a) single-argument: gauge must be set AFTER, not before.
|
||||
// (b) two-argument: the gauge vertex gets v_idx = -1, others are sequential.
|
||||
// (c) Newton converges correctly when the gauge overload is used.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(EuclideanDOFAssignment, SingleArg_PinBeforeHasNoEffect)
|
||||
{
|
||||
// Pin first vertex before the call → the call overwrites it → not pinned.
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
auto first = *mesh.vertices().begin();
|
||||
|
||||
maps.v_idx[first] = -1; // set pin BEFORE — should have no effect
|
||||
assign_euclidean_vertex_dof_indices(mesh, maps);
|
||||
|
||||
EXPECT_GE(maps.v_idx[first], 0)
|
||||
<< "Pre-call pin was overwritten: v_idx[first] must be >= 0 after the call";
|
||||
EXPECT_EQ(euclidean_dimension(mesh, maps),
|
||||
static_cast<int>(mesh.number_of_vertices()))
|
||||
<< "All vertices should be free after single-arg assign";
|
||||
}
|
||||
|
||||
TEST(EuclideanDOFAssignment, TwoArg_GaugeIsPinnedOthersAreSequential)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
auto first = *mesh.vertices().begin();
|
||||
|
||||
int n = assign_euclidean_vertex_dof_indices(mesh, maps, first);
|
||||
|
||||
EXPECT_EQ(maps.v_idx[first], -1)
|
||||
<< "Gauge vertex must have v_idx = -1";
|
||||
EXPECT_EQ(n, static_cast<int>(mesh.number_of_vertices()) - 1)
|
||||
<< "Returned DOF count must be num_vertices - 1";
|
||||
EXPECT_EQ(euclidean_dimension(mesh, maps), n)
|
||||
<< "euclidean_dimension must equal returned count";
|
||||
|
||||
// All non-gauge vertices must have distinct indices in [0, n).
|
||||
std::vector<int> seen;
|
||||
for (auto v : mesh.vertices()) {
|
||||
int iv = maps.v_idx[v];
|
||||
if (v == first) continue;
|
||||
EXPECT_GE(iv, 0);
|
||||
EXPECT_LT(iv, n);
|
||||
seen.push_back(iv);
|
||||
}
|
||||
std::sort(seen.begin(), seen.end());
|
||||
for (int i = 0; i < n; ++i)
|
||||
EXPECT_EQ(seen[static_cast<std::size_t>(i)], i)
|
||||
<< "DOF indices must be sequential 0..n-1";
|
||||
}
|
||||
|
||||
TEST(EuclideanDOFAssignment, TwoArg_NewtonConvergesWithGaugeOverload)
|
||||
{
|
||||
// End-to-end: use the gauge overload, then run Newton — confirms the
|
||||
// pinned-vertex DOF layout is consistent with the solver.
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
auto first = *mesh.vertices().begin();
|
||||
|
||||
int n = assign_euclidean_vertex_dof_indices(mesh, maps, first);
|
||||
|
||||
// Natural-theta: set targets = actual angle sums at x=0 so x*=0 is the solution.
|
||||
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, 50);
|
||||
EXPECT_TRUE(res.converged)
|
||||
<< "Newton did not converge with gauge-overload DOF assignment";
|
||||
EXPECT_LT(res.grad_inf_norm, 1e-9);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_euclidean_hessian.cpp
|
||||
//
|
||||
// Phase 3f — Euclidean cotangent-Laplace Hessian.
|
||||
@@ -58,6 +61,47 @@ TEST(EuclideanHessian, CotWeights_RightIsoscelesTriangle)
|
||||
EXPECT_NEAR(cw.cot3, 1.0, 1e-12); // 45° at v3
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// N5: cotangent weights on a SLIVER triangle match a high-precision reference.
|
||||
//
|
||||
// The area is now computed by Kahan's stable side-length formula instead of
|
||||
// the naive 2·√(t12·t23·t31·l123). On a thin (sliver) triangle the naive
|
||||
// product of near-equal-length differences loses precision, biasing every
|
||||
// cotangent weight. Here we cross-check against an independent law-of-cosines
|
||||
// reference in long double (80-bit on x86 CI — a genuine high-precision oracle;
|
||||
// equal to double on ARM64, where it still serves as a regression cross-check).
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(EuclideanHessian, CotWeights_SliverMatchesHighPrecisionReference)
|
||||
{
|
||||
// Thin isosceles: short base l12, two near-unit legs (apex angle ≈ 0.01 rad).
|
||||
const double l12 = 0.01, l23 = 1.0, l31 = 1.0;
|
||||
auto cw = euclidean_cot_weights(l12, l23, l31);
|
||||
ASSERT_TRUE(cw.valid);
|
||||
|
||||
// cot at the vertex opposite side `opp`, with adjacent sides s1, s2:
|
||||
// cos = (s1² + s2² − opp²) / (2·s1·s2), sin = √((1−cos)(1+cos)).
|
||||
auto cot_ref = [](long double opp, long double s1, long double s2) {
|
||||
long double cosA = (s1 * s1 + s2 * s2 - opp * opp) / (2.0L * s1 * s2);
|
||||
long double sinA = std::sqrt((1.0L - cosA) * (1.0L + cosA));
|
||||
return static_cast<double>(cosA / sinA);
|
||||
};
|
||||
const double c1 = cot_ref(l23, l12, l31); // v1 opposite l23
|
||||
const double c2 = cot_ref(l31, l12, l23); // v2 opposite l31
|
||||
const double c3 = cot_ref(l12, l23, l31); // v3 opposite l12 (needle angle)
|
||||
|
||||
auto rel = [](double a, double b) {
|
||||
return std::abs(a - b) / std::max(1.0, std::abs(b));
|
||||
};
|
||||
EXPECT_LT(rel(cw.cot1, c1), 1e-9);
|
||||
EXPECT_LT(rel(cw.cot2, c2), 1e-9);
|
||||
EXPECT_LT(rel(cw.cot3, c3), 1e-9);
|
||||
|
||||
EXPECT_TRUE(std::isfinite(cw.cot1) &&
|
||||
std::isfinite(cw.cot2) &&
|
||||
std::isfinite(cw.cot3));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Hessian is symmetric: H[i,j] == H[j,i]
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -211,3 +255,42 @@ TEST(EuclideanHessian, FDCheck_MixedPinnedVertices)
|
||||
EXPECT_TRUE(hessian_check_euclidean(mesh, x, maps))
|
||||
<< "FD Hessian check failed for mixed pinned/variable vertices";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Edge-DOF guard (Finding-G, java-port-audit item 2)
|
||||
//
|
||||
// euclidean_hessian() (vertex-only cotangent Laplacian) must throw
|
||||
// std::logic_error when any edge DOF is active. Without this guard the
|
||||
// function would silently return a Hessian with zero rows/cols for the
|
||||
// edge DOFs, causing SimplicialLDLT to fail in a hard-to-diagnose way.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(EuclideanHessian, EdgeDOFGuard_Throws)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
assign_euclidean_all_dof_indices(mesh, maps); // assigns vertex + edge DOFs
|
||||
|
||||
const int n = euclidean_dimension(mesh, maps);
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
|
||||
EXPECT_THROW(euclidean_hessian(mesh, x, maps), std::logic_error)
|
||||
<< "euclidean_hessian must throw when edge DOFs are present";
|
||||
}
|
||||
|
||||
TEST(EuclideanHessian, EdgeDOFGuard_VertexOnlyDoesNotThrow)
|
||||
{
|
||||
// Vertex-only layout must NOT trigger the guard.
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
auto gauge = *mesh.vertices().begin();
|
||||
assign_euclidean_vertex_dof_indices(mesh, maps, gauge);
|
||||
|
||||
const int n = euclidean_dimension(mesh, maps);
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
|
||||
EXPECT_NO_THROW(euclidean_hessian(mesh, x, maps))
|
||||
<< "euclidean_hessian must not throw for vertex-only DOF layout";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_geometry_utils.cpp
|
||||
//
|
||||
// Port of the Java ConformalLab geometry utility tests.
|
||||
@@ -490,8 +493,8 @@ TEST(SphericalLayout, SphericalTetrahedron_NewtonConverges_AngleSumsTwoPi)
|
||||
ConformalMesh mesh = make_spherical_tetrahedron();
|
||||
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps); // SphericalMaps version
|
||||
int n = assign_vertex_dof_indices(mesh, maps); // pins gauge_vertex, assigns DOFs
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps); // SphericalMaps version
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps); // pins gauge_vertex, assigns DOFs
|
||||
// Note: enforce_gauss_bonnet not needed — natural theta from mesh satisfies Σ(2π-Θ)>0.
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_hyper_ideal_functional.cpp
|
||||
//
|
||||
// Phase 3b — HyperIdealFunctional ported to ConformalMesh.
|
||||
@@ -38,10 +41,10 @@ static std::vector<double> make_x_all_variable(
|
||||
ConformalMesh& mesh, HyperIdealMaps& maps,
|
||||
double b_val, double a_val)
|
||||
{
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
std::vector<double> x(static_cast<std::size_t>(n));
|
||||
|
||||
// Vertices first, then edges (matching assign_all_dof_indices order)
|
||||
// Vertices first, then edges (matching assign_hyper_ideal_all_dof_indices order)
|
||||
for (auto v : mesh.vertices())
|
||||
x[static_cast<std::size_t>(maps.v_idx[v])] = b_val;
|
||||
for (auto e : mesh.edges())
|
||||
@@ -59,7 +62,7 @@ TEST(HyperIdealFunctional, HessianSymmetryCheck)
|
||||
// Hessian is now implemented (numerical FD). Verify it is symmetric.
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.5);
|
||||
auto H = hyper_ideal_hessian_sym(mesh, x, maps);
|
||||
@@ -83,7 +86,7 @@ TEST(HyperIdealFunctional, GradientCheck_AllHyperIdealTriangle)
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
auto x = make_x_all_variable(mesh, maps, /*b=*/1.0, /*a=*/0.5);
|
||||
|
||||
EXPECT_TRUE(gradient_check(mesh, x, maps))
|
||||
EXPECT_TRUE(gradient_check_hyper_ideal(mesh, x, maps))
|
||||
<< "Finite-difference gradient check failed on all-hyper-ideal triangle";
|
||||
}
|
||||
|
||||
@@ -100,7 +103,7 @@ TEST(HyperIdealFunctional, GradientCheck_ExtendedDomain)
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
auto x = make_x_all_variable(mesh, maps, /*b=*/2.0, /*a=*/1.5);
|
||||
|
||||
EXPECT_TRUE(gradient_check(mesh, x, maps))
|
||||
EXPECT_TRUE(gradient_check_hyper_ideal(mesh, x, maps))
|
||||
<< "Finite-difference gradient check failed in extended domain";
|
||||
}
|
||||
|
||||
@@ -117,7 +120,7 @@ TEST(HyperIdealFunctional, GradientCheck_TetrahedronAllVariable)
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
auto x = make_x_all_variable(mesh, maps, /*b=*/1.0, /*a=*/0.5);
|
||||
|
||||
EXPECT_TRUE(gradient_check(mesh, x, maps))
|
||||
EXPECT_TRUE(gradient_check_hyper_ideal(mesh, x, maps))
|
||||
<< "Finite-difference gradient check failed on all-variable tetrahedron";
|
||||
}
|
||||
|
||||
@@ -133,7 +136,7 @@ TEST(HyperIdealFunctional, EnergyFiniteAtTestPoint)
|
||||
{
|
||||
auto mesh = make_quad_strip();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
// Values taken from the Java testFunctionalAtNaNValue spirit:
|
||||
// large-ish but positive DOF values that could expose degenerate paths.
|
||||
@@ -174,7 +177,7 @@ TEST(HyperIdealFunctional, GradientCheck_MixedIdealHyperIdeal)
|
||||
// DOF vector: [b1, b2, a_e0, a_e1, a_e2]
|
||||
std::vector<double> x = {1.0, 1.0, 0.5, 0.5, 0.5};
|
||||
|
||||
EXPECT_TRUE(gradient_check(mesh, x, maps))
|
||||
EXPECT_TRUE(gradient_check_hyper_ideal(mesh, x, maps))
|
||||
<< "Finite-difference gradient check failed for mixed ideal / hyper-ideal";
|
||||
}
|
||||
|
||||
@@ -191,6 +194,97 @@ TEST(HyperIdealFunctional, GradientCheck_Fan6AllVariable)
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
auto x = make_x_all_variable(mesh, maps, /*b=*/1.0, /*a=*/0.5);
|
||||
|
||||
EXPECT_TRUE(gradient_check(mesh, x, maps))
|
||||
EXPECT_TRUE(gradient_check_hyper_ideal(mesh, x, maps))
|
||||
<< "Finite-difference gradient check failed on fan-6 mesh";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Guard test: face_energy() must throw for 2+ ideal vertices in one face.
|
||||
//
|
||||
// This tests Finding-A from doc/reviewer/external-audit-2026-05-30.md.
|
||||
//
|
||||
// The Java reference (HyperIdealFunctional.java lines 222-231) silently applies
|
||||
// the one-ideal-vertex volume formula to the first ideal vertex found, ignoring
|
||||
// any additional ideal vertices in the same face. That is mathematically wrong
|
||||
// for two-ideal / three-ideal faces. The C++ port detects this at runtime and
|
||||
// throws std::logic_error instead of silently producing a wrong energy value.
|
||||
//
|
||||
// Tests cover:
|
||||
// (a) Two ideal vertices in the same face (v1+v2 ideal, v3 hyper-ideal)
|
||||
// (b) All three vertices ideal
|
||||
// (c) Exactly one ideal vertex — must NOT throw (valid configuration)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(HyperIdealFunctional, MultiIdealGuard_TwoIdealVertices_Throws)
|
||||
{
|
||||
// Triangle mesh: 3 vertices, 3 edges, 1 face (open mesh, single face).
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
|
||||
// Assign edge DOFs to all three edges.
|
||||
int eidx = 0;
|
||||
for (auto e : mesh.edges()) maps.e_idx[e] = eidx++;
|
||||
|
||||
// Pin v1 and v2 (ideal), make only v3 hyper-ideal.
|
||||
auto vit = mesh.vertices().begin();
|
||||
Vertex_index v1 = *vit++;
|
||||
Vertex_index v2 = *vit++;
|
||||
// v3 remains pinned (default v_idx = -1, i.e. ideal too — see below).
|
||||
|
||||
maps.v_idx[v1] = -1; // ideal
|
||||
maps.v_idx[v2] = -1; // ideal
|
||||
maps.v_idx[*vit] = 3; // hyper-ideal: DOF index 3 (after 3 edge DOFs)
|
||||
|
||||
// DOF vector: [a_e0, a_e1, a_e2, b_v3]
|
||||
std::vector<double> x = {0.5, 0.5, 0.5, 1.0};
|
||||
|
||||
// evaluate_hyper_ideal calls face_energy() which must detect 2 ideal vertices
|
||||
// and throw std::logic_error.
|
||||
EXPECT_THROW(
|
||||
evaluate_hyper_ideal(mesh, x, maps, /*energy=*/true, /*gradient=*/false),
|
||||
std::logic_error)
|
||||
<< "Expected std::logic_error for face with two ideal vertices";
|
||||
}
|
||||
|
||||
TEST(HyperIdealFunctional, MultiIdealGuard_AllThreeIdealVertices_Throws)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
|
||||
// Only edge DOFs — all vertices remain ideal (default v_idx = -1).
|
||||
int eidx = 0;
|
||||
for (auto e : mesh.edges()) maps.e_idx[e] = eidx++;
|
||||
|
||||
// DOF vector: [a_e0, a_e1, a_e2]
|
||||
std::vector<double> x = {0.5, 0.5, 0.5};
|
||||
|
||||
EXPECT_THROW(
|
||||
evaluate_hyper_ideal(mesh, x, maps, /*energy=*/true, /*gradient=*/false),
|
||||
std::logic_error)
|
||||
<< "Expected std::logic_error for face with all three ideal vertices";
|
||||
}
|
||||
|
||||
TEST(HyperIdealFunctional, MultiIdealGuard_ExactlyOneIdeal_DoesNotThrow)
|
||||
{
|
||||
// Exactly one ideal vertex per face must NOT throw — it is the supported
|
||||
// one-ideal-vertex configuration (Kolpakov-Mednykh formula).
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
|
||||
// All edges variable.
|
||||
int eidx = 0;
|
||||
for (auto e : mesh.edges()) maps.e_idx[e] = eidx++;
|
||||
|
||||
// Pin only v1 (ideal); v2 and v3 are hyper-ideal.
|
||||
auto vit = mesh.vertices().begin();
|
||||
maps.v_idx[*vit] = -1; ++vit; // v1: ideal
|
||||
maps.v_idx[*vit] = 3; ++vit; // v2: hyper-ideal, DOF 3
|
||||
maps.v_idx[*vit] = 4; // v3: hyper-ideal, DOF 4
|
||||
|
||||
// DOF vector: [a_e0, a_e1, a_e2, b_v2, b_v3]
|
||||
std::vector<double> x = {0.5, 0.5, 0.5, 1.0, 1.0};
|
||||
|
||||
EXPECT_NO_THROW(
|
||||
evaluate_hyper_ideal(mesh, x, maps, /*energy=*/true, /*gradient=*/false))
|
||||
<< "Unexpected throw for valid one-ideal-vertex configuration";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_hyper_ideal_hessian.cpp
|
||||
//
|
||||
// Phase 9b — Hyper-ideal Hessian: block-FD vs full-FD cross-validation.
|
||||
@@ -76,7 +79,7 @@ TEST(HyperIdealHessian, PureHelperMatchesMeshHelper)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto m = setup_hyper_ideal_maps(mesh);
|
||||
const int n = assign_all_dof_indices(mesh, m);
|
||||
const int n = assign_hyper_ideal_all_dof_indices(mesh, m);
|
||||
auto x = natural_x(mesh, m);
|
||||
|
||||
for (auto f : mesh.faces()) {
|
||||
@@ -116,7 +119,7 @@ TEST(HyperIdealHessian, BlockFD_MatchesFullFD_ClosedTetrahedron)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto m = setup_hyper_ideal_maps(mesh);
|
||||
const int n = assign_all_dof_indices(mesh, m);
|
||||
const int n = assign_hyper_ideal_all_dof_indices(mesh, m);
|
||||
auto x = natural_x(mesh, m);
|
||||
|
||||
auto H_full = hyper_ideal_hessian_sym (mesh, x, m);
|
||||
@@ -138,7 +141,7 @@ TEST(HyperIdealHessian, BlockFD_MatchesFullFD_Open3FaceMesh)
|
||||
{
|
||||
auto mesh = make_open_3face_mesh();
|
||||
auto m = setup_hyper_ideal_maps(mesh);
|
||||
const int n = assign_all_dof_indices(mesh, m);
|
||||
const int n = assign_hyper_ideal_all_dof_indices(mesh, m);
|
||||
auto x = natural_x(mesh, m);
|
||||
|
||||
auto H_full = hyper_ideal_hessian_sym (mesh, x, m);
|
||||
@@ -193,7 +196,7 @@ TEST(HyperIdealHessian, BlockFD_IsPSD)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto m = setup_hyper_ideal_maps(mesh);
|
||||
const int n = assign_all_dof_indices(mesh, m);
|
||||
const int n = assign_hyper_ideal_all_dof_indices(mesh, m);
|
||||
auto x = natural_x(mesh, m);
|
||||
|
||||
auto H = hyper_ideal_hessian_block_fd_sym(mesh, x, m);
|
||||
@@ -223,7 +226,7 @@ TEST(HyperIdealHessian, BlockFD_SparsityMatchesFaceAdjacency)
|
||||
{
|
||||
auto mesh = make_open_3face_mesh();
|
||||
auto m = setup_hyper_ideal_maps(mesh);
|
||||
const int n = assign_all_dof_indices(mesh, m);
|
||||
const int n = assign_hyper_ideal_all_dof_indices(mesh, m);
|
||||
auto x = natural_x(mesh, m);
|
||||
|
||||
auto H = hyper_ideal_hessian_block_fd(mesh, x, m);
|
||||
@@ -304,7 +307,7 @@ TEST(HyperIdealHessian, BlockFD_FasterThanFullFD)
|
||||
// Theoretical ratio: ~42×. We assert ≥ 3× to leave wide CI tolerance.
|
||||
auto mesh = make_tet_strip(100);
|
||||
auto m = setup_hyper_ideal_maps(mesh);
|
||||
const int n = assign_all_dof_indices(mesh, m);
|
||||
const int n = assign_hyper_ideal_all_dof_indices(mesh, m);
|
||||
auto x = natural_x(mesh, m);
|
||||
|
||||
using clk = std::chrono::steady_clock;
|
||||
@@ -335,3 +338,81 @@ TEST(HyperIdealHessian, BlockFD_FasterThanFullFD)
|
||||
EXPECT_GE(ms_full, 3 * ms_block)
|
||||
<< "Block-FD should be at least 3× faster than full-FD on this mesh";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// N3 — Scale-floor clamp modes (HardJava vs SmoothBarrier)
|
||||
//
|
||||
// The vertex-scale floor can be applied two ways (HyperIdealScaleClamp):
|
||||
// • HardJava (default): b<0 → floor. Faithful to Java, but only C⁰ — and
|
||||
// in fact value-discontinuous at b=0 (jumps from `floor` to 0).
|
||||
// • SmoothBarrier: floor + softplus_β(b−floor). C¹ everywhere, ≈ b away
|
||||
// from the floor, and keeps b ≥ floor > 0 smoothly.
|
||||
// These tests pin the contract of both modes and the default-preservation.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(HyperIdealClamp, SmoothBarrierIsContinuousAndC1AcrossZero)
|
||||
{
|
||||
const auto Hard = HyperIdealScaleClamp::HardJava;
|
||||
const auto Smooth = HyperIdealScaleClamp::SmoothBarrier;
|
||||
auto f = [](double b, HyperIdealScaleClamp mode) {
|
||||
return clamp_hyper_ideal_scale(b, /*variable=*/true, mode);
|
||||
};
|
||||
|
||||
const double eps = 1e-6;
|
||||
|
||||
// HardJava jumps in VALUE at b=0 (floor on the left, 0 on the right).
|
||||
EXPECT_GT(std::abs(f(-eps, Hard) - f(+eps, Hard)), 0.5 * HYPER_IDEAL_SCALE_FLOOR);
|
||||
|
||||
// SmoothBarrier is continuous in value across b=0 …
|
||||
EXPECT_NEAR(f(-eps, Smooth), f(+eps, Smooth), 1e-4);
|
||||
|
||||
// … and C¹: the one-sided slopes across b=0 agree (the HardJava slopes,
|
||||
// 0 on the left and 1 on the right, would not).
|
||||
auto slope = [&](double b0, HyperIdealScaleClamp mode) {
|
||||
return (f(b0 + eps, mode) - f(b0 - eps, mode)) / (2.0 * eps);
|
||||
};
|
||||
const double sL = slope(-1e-3, Smooth);
|
||||
const double sR = slope(+1e-3, Smooth);
|
||||
EXPECT_NEAR(sL, sR, 5e-2) << "SmoothBarrier derivative should be continuous";
|
||||
}
|
||||
|
||||
TEST(HyperIdealClamp, SmoothBarrierStaysAboveFloorAndApproachesIdentity)
|
||||
{
|
||||
const auto Smooth = HyperIdealScaleClamp::SmoothBarrier;
|
||||
auto f = [&](double b) { return clamp_hyper_ideal_scale(b, true, Smooth); };
|
||||
|
||||
// At or above the floor for any input (mathematically > floor; for very
|
||||
// negative b the softplus underflows to 0 in double, giving exactly floor).
|
||||
EXPECT_GE(f(-1e6), HYPER_IDEAL_SCALE_FLOOR);
|
||||
EXPECT_GE(f(-1.0), HYPER_IDEAL_SCALE_FLOOR);
|
||||
EXPECT_GT(f(0.0), 0.0);
|
||||
EXPECT_LT(f(-1e6) - HYPER_IDEAL_SCALE_FLOOR, 1e-9); // converges down to floor
|
||||
|
||||
// ≈ identity well above the floor (the normal operating regime).
|
||||
EXPECT_NEAR(f(1.0), 1.0, 1e-9);
|
||||
EXPECT_NEAR(f(5.0), 5.0, 1e-12);
|
||||
|
||||
// Pinned DOFs are never clamped, regardless of mode.
|
||||
EXPECT_EQ(clamp_hyper_ideal_scale(-3.0, /*variable=*/false, Smooth), -3.0);
|
||||
}
|
||||
|
||||
// Away from the feasibility boundary (all b = 1 ≫ floor), the two modes must
|
||||
// produce the same Hessian — SmoothBarrier only differs near b ≈ floor, so the
|
||||
// default-mode parity results are not perturbed in the normal regime.
|
||||
TEST(HyperIdealClamp, ModesAgreeAwayFromBoundary)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto m = setup_hyper_ideal_maps(mesh);
|
||||
const int n = assign_all_dof_indices(mesh, m);
|
||||
auto x = natural_x(mesh, m); // b = 1, a = 0.5 — all well above the floor
|
||||
|
||||
auto H_hard = hyper_ideal_hessian_block_fd_sym(
|
||||
mesh, x, m, 1e-5, HyperIdealScaleClamp::HardJava);
|
||||
auto H_soft = hyper_ideal_hessian_block_fd_sym(
|
||||
mesh, x, m, 1e-5, HyperIdealScaleClamp::SmoothBarrier);
|
||||
|
||||
Eigen::MatrixXd Dh(H_hard), Ds(H_soft);
|
||||
EXPECT_LT((Dh - Ds).cwiseAbs().maxCoeff(), 1e-9)
|
||||
<< "clamp modes must agree when every scale is well above the floor";
|
||||
(void)n;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_inversive_distance_functional.cpp
|
||||
//
|
||||
// Phase 9a.2 — Inversive-distance functional (Luo 2004) tests.
|
||||
|
||||
305
code/tests/cgal/test_lawson_hyperideal.cpp
Normal file
305
code/tests/cgal/test_lawson_hyperideal.cpp
Normal file
@@ -0,0 +1,305 @@
|
||||
// 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_hyper_ideal_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 π") << ")";
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// N3 — SmoothBarrier clamp mode converges to the same golden solution
|
||||
//
|
||||
// The C¹ smooth-barrier scale floor is an opt-in alternative to the Java hard
|
||||
// clamp. On this well-posed problem the scales stay well above the floor
|
||||
// throughout the solve, so the barrier is ≈ identity and the converged vector
|
||||
// must match the same golden classes as the HardJava run above — demonstrating
|
||||
// the smooth mode is a drop-in that does not move the solution when the clamp
|
||||
// region is never entered.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
TEST(LawsonHyperIdeal, SmoothBarrierConvergesToGoldenVector)
|
||||
{
|
||||
std::set<Edge_index> original;
|
||||
ConformalMesh m = make_lawson_square_tiled(&original);
|
||||
|
||||
HyperIdealMaps maps = setup_hyper_ideal_maps(m);
|
||||
const int n = assign_all_dof_indices(m, maps);
|
||||
ASSERT_EQ(n, 4 + 18);
|
||||
|
||||
for (auto e : m.edges())
|
||||
if (original.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=*/200,
|
||||
/*hess_eps=*/1e-5,
|
||||
HyperIdealScaleClamp::SmoothBarrier);
|
||||
ASSERT_TRUE(res.converged)
|
||||
<< "SmoothBarrier 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 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") << ")";
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_layout.cpp
|
||||
//
|
||||
// Phase 5 — Layout / embedding tests.
|
||||
@@ -21,6 +24,7 @@
|
||||
#include "serialization.hpp"
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
#include <filesystem>
|
||||
|
||||
@@ -175,8 +179,8 @@ TEST(Layout, Spherical_PreservesArcLengths)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
// Solve to identity
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||||
@@ -209,8 +213,8 @@ TEST(Layout, Spherical_PositionsOnUnitSphere)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
auto layout = spherical_layout(mesh, x, maps);
|
||||
@@ -230,7 +234,7 @@ TEST(Layout, HyperIdeal_SuccessAndFinitePositions)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
// Natural equilibrium at (b=1, a=0.5)
|
||||
std::vector<double> xbase(static_cast<std::size_t>(n));
|
||||
@@ -367,3 +371,67 @@ TEST(Serialization, XML_RoundTrip)
|
||||
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// I2: serialization throws on malformed / schema-violating input
|
||||
//
|
||||
// These tests cover the throw paths in load_result_json / load_result_xml
|
||||
// that were previously untested (I2 in the test-coverage audit).
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(Serialization, LoadResultJson_ThrowsOnMissingFile)
|
||||
{
|
||||
EXPECT_THROW(load_result_json("/tmp/does_not_exist_conflab.json"),
|
||||
std::runtime_error);
|
||||
}
|
||||
|
||||
TEST(Serialization, LoadResultJson_ThrowsOnMalformedJson)
|
||||
{
|
||||
const std::string path = "/tmp/conflab_bad.json";
|
||||
{ std::ofstream ofs(path); ofs << "{not valid json!!!"; }
|
||||
EXPECT_THROW(load_result_json(path), std::runtime_error);
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
TEST(Serialization, LoadResultJson_ThrowsOnMissingDofVector)
|
||||
{
|
||||
// V4: a valid JSON object but without the required "dof_vector" key.
|
||||
const std::string path = "/tmp/conflab_nodof.json";
|
||||
{ std::ofstream ofs(path); ofs << R"({"geometry":"euclidean"})"; }
|
||||
EXPECT_THROW(load_result_json(path), std::runtime_error);
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
TEST(Serialization, LoadResultJson_ThrowsOnMissingSolverField)
|
||||
{
|
||||
// V4: has dof_vector but solver block is absent when res != nullptr.
|
||||
const std::string path = "/tmp/conflab_nosolver.json";
|
||||
{ std::ofstream ofs(path); ofs << R"({"dof_vector":[0.1,0.2]})"; }
|
||||
NewtonResult res;
|
||||
EXPECT_THROW(load_result_json(path, &res), std::runtime_error);
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
TEST(Serialization, LoadResultXml_ThrowsOnMissingFile)
|
||||
{
|
||||
EXPECT_THROW(load_result_xml("/tmp/does_not_exist_conflab.xml"),
|
||||
std::runtime_error);
|
||||
}
|
||||
|
||||
TEST(Serialization, LoadResultXml_ThrowsOnMalformedSolverAttribute)
|
||||
{
|
||||
// V2: non-numeric attribute causes stoi/stod error that must surface
|
||||
// as runtime_error, not std::invalid_argument.
|
||||
const std::string path = "/tmp/conflab_badxml.xml";
|
||||
{
|
||||
std::ofstream ofs(path);
|
||||
ofs << R"(<?xml version="1.0"?>
|
||||
<ConformalResult geometry="euclidean" vertices="4" faces="4">
|
||||
<Solver converged="true" iterations="not_a_number" grad_inf_norm="1e-9"/>
|
||||
<DOFVector n="1">0.0</DOFVector>
|
||||
</ConformalResult>)";
|
||||
}
|
||||
NewtonResult res;
|
||||
EXPECT_THROW(load_result_xml(path, &res), std::runtime_error);
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_mesh_io.cpp
|
||||
//
|
||||
// Phase 4b — CGAL::IO mesh round-trip tests.
|
||||
@@ -15,6 +18,7 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <stdexcept>
|
||||
|
||||
using namespace conformallab;
|
||||
@@ -108,6 +112,30 @@ TEST(MeshIO, LoadMeshThrowsOnMissingFile)
|
||||
EXPECT_THROW(load_mesh("/tmp/does_not_exist_conflab.off"), std::runtime_error);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// I3: load_mesh throws on non-triangulated mesh
|
||||
//
|
||||
// The quad-strip mesh has 4-vertex faces; load_mesh must reject it at the
|
||||
// I/O boundary rather than letting the quad faces flow silently into the
|
||||
// triangle-only functionals.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(MeshIO, LoadMeshThrowsOnNonTriangulatedMesh)
|
||||
{
|
||||
// Write a minimal OFF quad-face mesh (2 quads, not triangles).
|
||||
auto path = tmp_path("quad.off");
|
||||
{
|
||||
std::ofstream ofs(path);
|
||||
ofs << "OFF\n6 2 0\n"
|
||||
<< "0 0 0\n1 0 0\n1 1 0\n0 1 0\n"
|
||||
<< "2 0 0\n2 1 0\n"
|
||||
<< "4 0 1 2 3\n"
|
||||
<< "4 1 4 5 2\n";
|
||||
}
|
||||
EXPECT_THROW(load_mesh(path), std::runtime_error);
|
||||
rm(path);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Vertex positions survive a round-trip (OFF)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -168,3 +196,42 @@ TEST(MeshIO, SaveLoadConvenienceWrappers)
|
||||
|
||||
rm(path);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// V3: load_mesh throws on non-finite vertex coordinate (NaN / Inf)
|
||||
//
|
||||
// A mesh file containing NaN or Inf vertex coordinates must be rejected at
|
||||
// the I/O boundary — before the coordinates can silently poison the solver.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(MeshIO, LoadMeshThrowsOnNaNCoordinate)
|
||||
{
|
||||
// Write a minimal OFF triangle with a NaN in the first vertex.
|
||||
auto path = tmp_path("nan.off");
|
||||
{
|
||||
std::ofstream ofs(path);
|
||||
ofs << "OFF\n3 1 0\n"
|
||||
<< "nan 0.0 0.0\n"
|
||||
<< "1.0 0.0 0.0\n"
|
||||
<< "0.0 1.0 0.0\n"
|
||||
<< "3 0 1 2\n";
|
||||
}
|
||||
EXPECT_THROW(load_mesh(path), std::runtime_error);
|
||||
rm(path);
|
||||
}
|
||||
|
||||
TEST(MeshIO, LoadMeshThrowsOnInfCoordinate)
|
||||
{
|
||||
// Write a minimal OFF triangle with Inf in the y-coordinate.
|
||||
auto path = tmp_path("inf.off");
|
||||
{
|
||||
std::ofstream ofs(path);
|
||||
ofs << "OFF\n3 1 0\n"
|
||||
<< "0.0 0.0 0.0\n"
|
||||
<< "1.0 inf 0.0\n"
|
||||
<< "0.0 1.0 0.0\n"
|
||||
<< "3 0 1 2\n";
|
||||
}
|
||||
EXPECT_THROW(load_mesh(path), std::runtime_error);
|
||||
rm(path);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_newton_phase9a.cpp
|
||||
//
|
||||
// Phase 9a Newton solvers — convergence tests for the two new
|
||||
@@ -13,6 +16,7 @@
|
||||
#include "newton_solver.hpp"
|
||||
#include "cp_euclidean_functional.hpp"
|
||||
#include "inversive_distance_functional.hpp"
|
||||
#include "inversive_distance_hessian.hpp"
|
||||
#include "mesh_builder.hpp"
|
||||
#include "conformal_mesh.hpp"
|
||||
|
||||
@@ -229,6 +233,49 @@ TEST(NewtonPhase9a, InversiveDistance_PerturbedTetrahedron_Converges)
|
||||
EXPECT_LT(res.grad_inf_norm, 1e-8);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 6b. Inversive-Distance block-FD Hessian == full-FD Hessian (B1 port)
|
||||
//
|
||||
// The solver was switched from the O(n·F) full-FD Hessian to the O(F)
|
||||
// per-face block-FD Hessian. The two must agree to FD rounding on any
|
||||
// configuration — including a perturbed (non-equilibrium) one, where the
|
||||
// off-diagonal coupling is non-trivial. This is the locality-lemma
|
||||
// cross-validation that licenses the faster path.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(NewtonPhase9a, InversiveDistance_BlockFDHessianMatchesFullFD)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto m = setup_inversive_distance_maps(mesh);
|
||||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||||
|
||||
// Pin one vertex; index the rest.
|
||||
auto vit = mesh.vertices().begin();
|
||||
m.v_idx[*vit++] = -1;
|
||||
int n = 0;
|
||||
for (; vit != mesh.vertices().end(); ++vit) m.v_idx[*vit] = n++;
|
||||
|
||||
// Evaluate the two Hessians away from equilibrium so off-diagonals are live.
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
for (int i = 0; i < n; ++i) x[static_cast<std::size_t>(i)] = 0.07 * (i + 1);
|
||||
|
||||
auto H_full = inversive_distance_hessian_sym(mesh, x, m);
|
||||
auto H_block = inversive_distance_hessian_block_fd_sym(mesh, x, m);
|
||||
|
||||
ASSERT_EQ(H_full.rows(), H_block.rows());
|
||||
ASSERT_EQ(H_full.cols(), H_block.cols());
|
||||
|
||||
Eigen::MatrixXd Df = Eigen::MatrixXd(H_full);
|
||||
Eigen::MatrixXd Db = Eigen::MatrixXd(H_block);
|
||||
double max_abs_diff = (Df - Db).cwiseAbs().maxCoeff();
|
||||
EXPECT_LT(max_abs_diff, 1e-7)
|
||||
<< "block-FD and full-FD Hessians must agree to FD rounding;\n"
|
||||
<< "max |Δ| = " << max_abs_diff;
|
||||
|
||||
// Sanity: the matrices are non-trivial (not both accidentally zero).
|
||||
EXPECT_GT(Df.cwiseAbs().maxCoeff(), 1e-3);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 7. CP-Euclidean Newton — uses analytic Hessian (NOT FD)
|
||||
//
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_newton_solver.cpp
|
||||
//
|
||||
// Phase 4 — Newton solver tests.
|
||||
@@ -74,8 +77,8 @@ TEST(NewtonSolver, Spherical_ConvergesFromPerturbation)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), -0.2);
|
||||
auto res = newton_spherical(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/50);
|
||||
@@ -93,8 +96,8 @@ TEST(NewtonSolver, Spherical_FewIterations)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), -0.2);
|
||||
auto res = newton_spherical(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/50);
|
||||
@@ -111,8 +114,8 @@ TEST(NewtonSolver, Spherical_ConvergesFromLargePerturbation)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), -0.5);
|
||||
auto res = newton_spherical(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/100);
|
||||
@@ -131,8 +134,8 @@ TEST(NewtonSolver, Spherical_ResultFieldsConsistent)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), -0.1);
|
||||
auto res = newton_spherical(mesh, x0, maps, /*tol=*/1e-8);
|
||||
@@ -303,7 +306,7 @@ TEST(NewtonSolver, HyperIdeal_ConvergesTriangleAllVariable)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
// xbase = (b=1.0, a=0.5) is the equilibrium after natural-target setup.
|
||||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||||
@@ -328,7 +331,7 @@ TEST(NewtonSolver, HyperIdeal_ResultFieldsConsistent)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||||
|
||||
@@ -354,7 +357,7 @@ TEST(NewtonSolver, HyperIdeal_ConvergesTetrahedron)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||||
|
||||
@@ -381,7 +384,7 @@ TEST(NewtonSolver, HyperIdeal_SparseQRFallbackNoCrash)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
// Leave targets at their default (0): solver tries to solve but the
|
||||
// "equilibrium" is at some unknown x*. With valid starting point the
|
||||
@@ -497,4 +500,240 @@ TEST(SparseQRFallback, Euclidean_ClosedMeshNoPinConverges)
|
||||
<< "Euclidean Newton on closed tetrahedron (no pin) must converge via SparseQR; "
|
||||
"grad_inf_norm = " << res.grad_inf_norm;
|
||||
EXPECT_LT(res.grad_inf_norm, 1e-8);
|
||||
EXPECT_EQ(res.status, NewtonStatus::Converged);
|
||||
// N7: the closed mesh with no pinned vertex has a gauge-singular Hessian.
|
||||
// That near-singularity must be observable in the diagnostics — either the
|
||||
// SparseQR fallback fired, or (when LDLT "succeeds" with a ~0 pivot, which is
|
||||
// exactly the silent case N7 targets) the smallest LDLT pivot is tiny.
|
||||
EXPECT_TRUE(res.sparse_qr_fallback_used || res.min_ldlt_pivot < 1e-6)
|
||||
<< "gauge singularity should surface via fallback flag or min_ldlt_pivot; "
|
||||
<< "fallback=" << res.sparse_qr_fallback_used
|
||||
<< " min_pivot=" << res.min_ldlt_pivot;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// C1: solve_linear_system exposes the ok flag via the new out-parameter
|
||||
//
|
||||
// The C1 audit finding was that solve_linear_system silently dropped the
|
||||
// internal ok flag, making double-solver failure invisible to callers.
|
||||
// These tests verify that the new optional bool* ok parameter is correctly
|
||||
// wired: ok=true for successful solves, and the parameter is optional (null
|
||||
// is safe).
|
||||
//
|
||||
// Note: constructing a matrix where Eigen's SparseQR hard-fails is not
|
||||
// practical — SparseQR reports Eigen::Success even for rank-0 inputs,
|
||||
// returning a zero min-norm solution. The observable contract for callers is
|
||||
// therefore: ok=true whenever a solver reports success; ok=false only when
|
||||
// both report failure (an edge case Eigen essentially never produces). The
|
||||
// fix in C1 ensures that if that edge case ever fires it propagates to the
|
||||
// caller — previously it was silently swallowed.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(SparseQRFallback, OkFlag_TrueOnFullRankSystem)
|
||||
{
|
||||
// 3×3 diagonal PD matrix: LDLT succeeds → ok must be true.
|
||||
Eigen::SparseMatrix<double> A(3, 3);
|
||||
A.insert(0, 0) = 1.0;
|
||||
A.insert(1, 1) = 2.0;
|
||||
A.insert(2, 2) = 3.0;
|
||||
A.makeCompressed();
|
||||
|
||||
Eigen::VectorXd rhs(3);
|
||||
rhs << 1.0, 4.0, 9.0;
|
||||
|
||||
bool fallback = true;
|
||||
bool ok = false;
|
||||
Eigen::VectorXd x = conformallab::solve_linear_system(A, rhs, &fallback, &ok);
|
||||
|
||||
EXPECT_TRUE(ok) << "Full-rank system: ok must be true";
|
||||
EXPECT_FALSE(fallback) << "Full-rank system: LDLT path, no fallback expected";
|
||||
EXPECT_NEAR(x[0], 1.0, 1e-12);
|
||||
EXPECT_NEAR(x[1], 2.0, 1e-12);
|
||||
EXPECT_NEAR(x[2], 3.0, 1e-12);
|
||||
}
|
||||
|
||||
TEST(SparseQRFallback, OkFlag_TrueOnSingularSystemViaFallback)
|
||||
{
|
||||
// Singular matrix (zero row/col 1) — LDLT fails, SparseQR succeeds → ok=true.
|
||||
Eigen::SparseMatrix<double> A(3, 3);
|
||||
A.insert(0, 0) = 2.0;
|
||||
A.insert(2, 2) = 3.0;
|
||||
A.makeCompressed();
|
||||
|
||||
Eigen::VectorXd rhs(3);
|
||||
rhs << 2.0, 0.0, 3.0;
|
||||
|
||||
bool fallback = false;
|
||||
bool ok = false;
|
||||
Eigen::VectorXd x = conformallab::solve_linear_system(A, rhs, &fallback, &ok);
|
||||
|
||||
EXPECT_TRUE(ok) << "Singular-but-consistent system: SparseQR succeeds → ok must be true";
|
||||
EXPECT_TRUE(fallback) << "Singular system: fallback must have been triggered";
|
||||
}
|
||||
|
||||
TEST(SparseQRFallback, OkFlag_NullPointerIsSafe)
|
||||
{
|
||||
// Calling with ok=nullptr must not crash (backward-compatibility).
|
||||
Eigen::SparseMatrix<double> A(2, 2);
|
||||
A.insert(0, 0) = 1.0;
|
||||
A.insert(1, 1) = 1.0;
|
||||
A.makeCompressed();
|
||||
|
||||
Eigen::VectorXd rhs(2);
|
||||
rhs << 3.0, 5.0;
|
||||
|
||||
EXPECT_NO_THROW({
|
||||
Eigen::VectorXd x = conformallab::solve_linear_system(A, rhs, nullptr, nullptr);
|
||||
EXPECT_NEAR(x[0], 3.0, 1e-12);
|
||||
EXPECT_NEAR(x[1], 5.0, 1e-12);
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// H2 / B2 — newton_core reuses the line-search gradient
|
||||
//
|
||||
// All five solvers now share detail::newton_core. B2: the gradient computed at
|
||||
// the accepted line-search point is carried into the next iteration's
|
||||
// convergence check instead of being recomputed at the loop top. On a
|
||||
// unit-Hessian quadratic, exact Newton reaches the minimum in one step, so the
|
||||
// total gradient-eval count is exactly 2 (1 initial + 1 accepted trial); the
|
||||
// pre-B2 loop would have recomputed G at the top of iteration 1 → 3.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(NewtonCore, ReusesLineSearchGradient_B2_Convex)
|
||||
{
|
||||
const std::vector<double> xstar = {1.0, -2.0, 3.5};
|
||||
int grad_calls = 0;
|
||||
|
||||
auto grad = [&](const std::vector<double>& x) {
|
||||
++grad_calls;
|
||||
std::vector<double> g(x.size());
|
||||
for (std::size_t i = 0; i < x.size(); ++i) g[i] = x[i] - xstar[i];
|
||||
return g; // G(x) = x − x*, H = +I
|
||||
};
|
||||
auto hess = [&](const std::vector<double>&) {
|
||||
Eigen::SparseMatrix<double> H(3, 3);
|
||||
for (int i = 0; i < 3; ++i) H.insert(i, i) = 1.0;
|
||||
H.makeCompressed();
|
||||
return H;
|
||||
};
|
||||
|
||||
auto res = conformallab::detail::newton_core(
|
||||
std::vector<double>{0.0, 0.0, 0.0}, grad, hess,
|
||||
/*concave=*/false, /*tol=*/1e-9, /*max_iter=*/50);
|
||||
|
||||
ASSERT_TRUE(res.converged);
|
||||
EXPECT_EQ(res.iterations, 1);
|
||||
EXPECT_NEAR(res.x[0], 1.0, 1e-9);
|
||||
EXPECT_NEAR(res.x[1], -2.0, 1e-9);
|
||||
EXPECT_NEAR(res.x[2], 3.5, 1e-9);
|
||||
EXPECT_EQ(grad_calls, 2)
|
||||
<< "B2: the loop-top gradient must be reused from the line search";
|
||||
}
|
||||
|
||||
// Concave path (spherical-style): H is NSD, newton_core factors −H and solves
|
||||
// (−H)·Δx = G. G(x) = x* − x with H = −I makes x* a maximiser; the core must
|
||||
// still converge in one step.
|
||||
TEST(NewtonCore, ConcavePathConverges)
|
||||
{
|
||||
const std::vector<double> xstar = {0.5, -1.5, 2.0};
|
||||
int grad_calls = 0;
|
||||
|
||||
auto grad = [&](const std::vector<double>& x) {
|
||||
++grad_calls;
|
||||
std::vector<double> g(x.size());
|
||||
for (std::size_t i = 0; i < x.size(); ++i) g[i] = xstar[i] - x[i];
|
||||
return g; // G(x) = x* − x, H = −I
|
||||
};
|
||||
auto hess = [&](const std::vector<double>&) {
|
||||
Eigen::SparseMatrix<double> H(3, 3);
|
||||
for (int i = 0; i < 3; ++i) H.insert(i, i) = -1.0;
|
||||
H.makeCompressed();
|
||||
return H;
|
||||
};
|
||||
|
||||
auto res = conformallab::detail::newton_core(
|
||||
std::vector<double>{0.0, 0.0, 0.0}, grad, hess,
|
||||
/*concave=*/true, /*tol=*/1e-9, /*max_iter=*/50);
|
||||
|
||||
ASSERT_TRUE(res.converged);
|
||||
EXPECT_EQ(res.iterations, 1);
|
||||
EXPECT_NEAR(res.x[0], 0.5, 1e-9);
|
||||
EXPECT_NEAR(res.x[1], -1.5, 1e-9);
|
||||
EXPECT_NEAR(res.x[2], 2.0, 1e-9);
|
||||
EXPECT_EQ(grad_calls, 2);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// I1 / H1 / N7 — newton_core termination status, iteration count, diagnostics
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
namespace {
|
||||
// 1×1 sparse identity / scalar helpers for the synthetic newton_core tests.
|
||||
inline Eigen::SparseMatrix<double> diag_sparse(std::initializer_list<double> d)
|
||||
{
|
||||
const int n = static_cast<int>(d.size());
|
||||
Eigen::SparseMatrix<double> H(n, n);
|
||||
int i = 0;
|
||||
for (double v : d) { H.insert(i, i) = v; ++i; }
|
||||
H.makeCompressed();
|
||||
return H;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// I1: a converging solve reports status == Converged and the N7 pivot is the
|
||||
// (well-conditioned) unit diagonal; no SparseQR fallback.
|
||||
TEST(NewtonCore, Status_Converged_AndDiagnostics_N7)
|
||||
{
|
||||
const std::vector<double> xstar = {2.0, -1.0};
|
||||
auto grad = [&](const std::vector<double>& x) {
|
||||
return std::vector<double>{ x[0] - xstar[0], x[1] - xstar[1] };
|
||||
};
|
||||
auto hess = [&](const std::vector<double>&) { return diag_sparse({1.0, 1.0}); };
|
||||
|
||||
auto res = conformallab::detail::newton_core(
|
||||
std::vector<double>{0.0, 0.0}, grad, hess, /*concave=*/false, 1e-9, 50);
|
||||
|
||||
EXPECT_TRUE(res.converged);
|
||||
EXPECT_EQ(res.status, conformallab::NewtonStatus::Converged);
|
||||
EXPECT_EQ(res.iterations, 1);
|
||||
EXPECT_FALSE(res.sparse_qr_fallback_used); // N7
|
||||
EXPECT_NEAR(res.min_ldlt_pivot, 1.0, 1e-12); // N7: D = I
|
||||
}
|
||||
|
||||
// I1: a slow (linearly-convergent cubic) problem that does not reach tol within
|
||||
// max_iter reports MaxIterations, with iterations == max_iter (H1).
|
||||
TEST(NewtonCore, Status_MaxIterations)
|
||||
{
|
||||
// G(x) = x³, H = 3x² → Newton map x ← (2/3)x (linear convergence).
|
||||
auto grad = [&](const std::vector<double>& x) {
|
||||
return std::vector<double>{ x[0]*x[0]*x[0] };
|
||||
};
|
||||
auto hess = [&](const std::vector<double>& x) {
|
||||
return diag_sparse({ 3.0 * x[0] * x[0] });
|
||||
};
|
||||
|
||||
auto res = conformallab::detail::newton_core(
|
||||
std::vector<double>{1.0}, grad, hess, /*concave=*/false, /*tol=*/1e-8, /*max_iter=*/3);
|
||||
|
||||
EXPECT_FALSE(res.converged);
|
||||
EXPECT_EQ(res.status, conformallab::NewtonStatus::MaxIterations);
|
||||
EXPECT_EQ(res.iterations, 3);
|
||||
}
|
||||
|
||||
// I1/H1: a constant non-zero gradient cannot be reduced by any step, so the
|
||||
// line search stalls on the first iteration → LineSearchStalled, iterations == 0.
|
||||
TEST(NewtonCore, Status_LineSearchStalled)
|
||||
{
|
||||
auto grad = [&](const std::vector<double>&) {
|
||||
return std::vector<double>{ 1.0, 1.0 }; // constant, independent of x
|
||||
};
|
||||
auto hess = [&](const std::vector<double>&) { return diag_sparse({1.0, 1.0}); };
|
||||
|
||||
auto res = conformallab::detail::newton_core(
|
||||
std::vector<double>{0.0, 0.0}, grad, hess, /*concave=*/false, 1e-9, 50);
|
||||
|
||||
EXPECT_FALSE(res.converged);
|
||||
EXPECT_EQ(res.status, conformallab::NewtonStatus::LineSearchStalled);
|
||||
EXPECT_EQ(res.iterations, 0); // H1: no step completed
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_phase6.cpp
|
||||
//
|
||||
// Phase 6 — Tests for:
|
||||
@@ -134,6 +137,78 @@ TEST(GaussBonnet, ManuallySetAnalyticalTheta_PassesCheck)
|
||||
EXPECT_NO_THROW(check_gauss_bonnet(m, maps));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GaussBonnet — HyperIdeal API guard (Finding-B from external-audit-2026-05-30)
|
||||
//
|
||||
// gauss_bonnet_sum(mesh, HyperIdealMaps) and
|
||||
// enforce_gauss_bonnet(mesh, HyperIdealMaps) are intentionally DELETED.
|
||||
//
|
||||
// Reason: the correct hyperbolic Gauss–Bonnet identity is
|
||||
// Σ_v (2π − Θ_v) − Area(M) = 2π · χ(M)
|
||||
// not the Euclidean form Σ(2π−Θ_v) = 2π·χ. For a regular (Θ_v=2π) genus-2
|
||||
// surface: Σ(2π−2π)=0 but 2π·χ=−4π, so the Euclidean check would always
|
||||
// throw "deficit = 4π" for a perfectly valid HyperIdeal target.
|
||||
//
|
||||
// Compile-time enforcement: gauss_bonnet_sum / enforce_gauss_bonnet with
|
||||
// HyperIdealMaps are = delete, so any accidental call is a compile error.
|
||||
// The static_asserts below confirm this is wired correctly.
|
||||
//
|
||||
// The runtime test shows the discrepancy numerically: even for a regular
|
||||
// tetrahedron where all Θ_v=2π (a valid all-hyper-ideal starting point),
|
||||
// the "Euclidean sum" is 0 but the correct RHS for χ=2 is 4π — the
|
||||
// Euclidean check would be off by 4π.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Invocability check via SFINAE: tries to form the call expression in an
|
||||
// unevaluated context; a deleted function causes substitution failure.
|
||||
namespace {
|
||||
template <typename Maps>
|
||||
auto try_gb_sum(int) -> decltype(gauss_bonnet_sum(
|
||||
std::declval<const ConformalMesh&>(),
|
||||
std::declval<const Maps&>()), std::true_type{});
|
||||
template <typename>
|
||||
std::false_type try_gb_sum(...);
|
||||
} // namespace
|
||||
|
||||
static_assert(!decltype(try_gb_sum<HyperIdealMaps>(0))::value,
|
||||
"gauss_bonnet_sum must NOT be invocable with HyperIdealMaps");
|
||||
static_assert( decltype(try_gb_sum<EuclideanMaps>(0))::value,
|
||||
"gauss_bonnet_sum must still be invocable with EuclideanMaps");
|
||||
static_assert( decltype(try_gb_sum<SphericalMaps>(0))::value,
|
||||
"gauss_bonnet_sum must still be invocable with SphericalMaps");
|
||||
|
||||
TEST(GaussBonnet, HyperIdeal_EuclideanSumDiscrepancy_DocumentsWhyCheckIsDeleted)
|
||||
{
|
||||
// On a tetrahedron (χ=2) with all Θ_v = 2π:
|
||||
// Euclidean sum Σ(2π−2π) = 0
|
||||
// but 2π·χ = 4π
|
||||
// deficit (Euclidean formula) = 0 − 4π = −4π ← WRONG check for HyperIdeal
|
||||
//
|
||||
// The HyperIdeal identity is Σ(2π−Θ_v) − Area = 2π·χ.
|
||||
// Area > 0 for any non-degenerate hyperbolic metric, so the real deficit
|
||||
// would be much smaller. This test documents the mismatch numerically
|
||||
// so any future re-introduction of the HyperIdeal overload is caught.
|
||||
auto m = make_tetrahedron();
|
||||
auto hi_maps = setup_hyper_ideal_maps(m);
|
||||
// All theta_v default to 2π (regular vertex target).
|
||||
|
||||
// Access the raw property map directly (not via the deleted bundle overload)
|
||||
// to compute the Euclidean-style sum — just for documentation purposes.
|
||||
double euclid_sum = gauss_bonnet_sum(m, hi_maps.theta_v); // raw map: OK
|
||||
double rhs = gauss_bonnet_rhs(m); // 2π·χ = 4π
|
||||
|
||||
EXPECT_NEAR(euclid_sum, 0.0, 1e-12) // Σ(2π−2π) = 0
|
||||
<< "Expected Euclidean sum = 0 for all-regular HyperIdeal targets";
|
||||
EXPECT_NEAR(rhs, 4.0 * M_PI, 1e-12) // 2π·χ(tetrahedron) = 4π
|
||||
<< "Expected RHS = 4π for tetrahedron (χ=2)";
|
||||
|
||||
// The Euclidean deficit would be 0 − 4π = −4π: completely wrong for HyperIdeal.
|
||||
// If check_gauss_bonnet were called with HyperIdealMaps it would ALWAYS throw
|
||||
// here, even though Θ_v=2π is a valid regular-vertex HyperIdeal target.
|
||||
EXPECT_NEAR(euclid_sum - rhs, -4.0 * M_PI, 1e-10)
|
||||
<< "Euclidean deficit for HyperIdeal target = −4π: confirms the deleted API is correct";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// CutGraph — tree-cotree algorithm
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -359,7 +434,7 @@ static Layout2D make_hyper_ideal_layout_normalised(bool normalise)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> xbase(static_cast<std::size_t>(n));
|
||||
for (auto v : mesh.vertices()) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_phase7.cpp
|
||||
//
|
||||
// Phase 7 — Tests for Java-parity layout features:
|
||||
@@ -17,6 +20,9 @@
|
||||
#include "layout.hpp"
|
||||
#include "period_matrix.hpp"
|
||||
#include "fundamental_domain.hpp"
|
||||
#include "mesh_io.hpp"
|
||||
#include "cut_graph.hpp"
|
||||
#include "gauss_bonnet.hpp"
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <complex>
|
||||
@@ -332,12 +338,271 @@ TEST(PeriodMatrix, ComputePeriodMatrix_UnitSquare)
|
||||
TEST(PeriodMatrix, ComputePeriodMatrix_ReducedTau_InFD)
|
||||
{
|
||||
// ω_1 = (1, 0), ω_2 = (0.5, 0.25) → τ = 0.5 + 0.25i
|
||||
// |τ| = sqrt(0.25 + 0.0625) ≈ 0.559 < 1 → needs S step
|
||||
// |τ| = sqrt(0.25 + 0.0625) ≈ 0.559 < 1 → needs S step.
|
||||
// compute_period_matrix reduces with normalizeModulus (Finding 6), whose
|
||||
// mirror-folded target domain is { 0 ≤ Re ≤ ½, Im > 0, |τ| ≥ 1 } — note the
|
||||
// RIGHT boundary Re = +½ is CLOSED here. This is NOT the half-open SL(2,ℤ)
|
||||
// domain of is_in_fundamental_domain (−½ ≤ Re < ½), which excludes Re = +½
|
||||
// because +½ ≡ −½ under T. For this input normalizeModulus lands exactly on
|
||||
// τ = ½ + i, so we must check the normalizeModulus domain, not the SL(2,ℤ)
|
||||
// one (asserting is_in_fundamental_domain here would wrongly fail on +½).
|
||||
HolonomyData hol;
|
||||
hol.translations = { Eigen::Vector2d(1.0, 0.0), Eigen::Vector2d(0.5, 0.25) };
|
||||
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
|
||||
EXPECT_TRUE(pd.in_fundamental_domain);
|
||||
EXPECT_TRUE(is_in_fundamental_domain(pd.tau, 1e-9));
|
||||
const double tol = 1e-9;
|
||||
EXPECT_GT(pd.tau.imag(), 0.0);
|
||||
EXPECT_GE(pd.tau.real(), 0.0 - tol); // 0 ≤ Re (mirror fold)
|
||||
EXPECT_LE(pd.tau.real(), 0.5 + tol); // Re ≤ ½ (closed right edge)
|
||||
EXPECT_GE(std::abs(pd.tau), 1.0 - tol); // |τ| ≥ 1
|
||||
// Concretely: τ = ½ + i.
|
||||
EXPECT_NEAR(pd.tau.real(), 0.5, 1e-12);
|
||||
EXPECT_NEAR(pd.tau.imag(), 1.0, 1e-12);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Golden-value oracle — pin normalizeModulus (the τ reduction used by
|
||||
// compute_period_matrix, Finding 6) bit-for-bit against the upstream Java
|
||||
// reference (de.varylab.discreteconformal.util.DiscreteEllipticUtility.
|
||||
// normalizeModulus), captured by calling the compiled Java method (openjdk 17)
|
||||
// on these exact τ. This locks the sign/fold conventions of the SL(2,ℤ)+mirror
|
||||
// reduction (0 ≤ Re ≤ ½, Im ≥ 0, |τ| ≥ 1) against an independent implementation,
|
||||
// catching drift the existing in-FD membership checks cannot (they only assert
|
||||
// the result lies in F, not that it is the SAME representative Java picks).
|
||||
//
|
||||
// To regenerate: /tmp/oracle/TauOracle.java. Values are Java printf %.17g.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
TEST(PeriodMatrix, NormalizeModulus_GoldenJava)
|
||||
{
|
||||
auto chk = [](double re, double im, double re_g, double im_g) {
|
||||
C n = conformallab::normalizeModulus(C(re, im));
|
||||
EXPECT_NEAR(n.real(), re_g, 1e-12);
|
||||
EXPECT_NEAR(n.imag(), im_g, 1e-12);
|
||||
};
|
||||
chk(0.3, 0.5, 0.11764705882352933, 1.4705882352941178); // |τ|<1 → S + folds
|
||||
chk(-0.4, 1.3, 0.40000000000000000, 1.3000000000000000); // Re<0 mirror fold
|
||||
chk(2.7, 0.8, 0.41095890410958880, 1.0958904109589043); // large Re → T
|
||||
chk(0.1, 2.0, 0.10000000000000000, 2.0000000000000000); // already in F
|
||||
chk(-1.6, 0.9, 0.41237113402061850, 0.92783505154639180); // T + S + mirror
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// End-to-end holonomy → τ on real genus-1 torus meshes
|
||||
//
|
||||
// Regression test for the holonomy-extraction bug: euclidean_holonomy() developed
|
||||
// the cut surface along a BFS dual tree that crossed the primal-tree edges freely.
|
||||
// Relative to that tree the cut graph's 2g generator edges were NOT generators —
|
||||
// some were null-homotopic — so the two developed copies of a "cut" edge landed
|
||||
// on top of each other and compute_period_matrix() got ω ≈ 0 (→ τ = 0 / NaN /
|
||||
// huge). The fix develops across the cut graph's OWN dual spanning tree T* only
|
||||
// (CutGraph::is_dual_tree), unfolding the surface onto a true disk so the cut
|
||||
// edges become the boundary identifications that carry the lattice generators.
|
||||
//
|
||||
// Analytic target. The bundled meshes are tori of REVOLUTION (major radius R,
|
||||
// minor radius r, R > r), not abstract square/hexagonal flat tori. Their
|
||||
// conformal modulus is purely imaginary,
|
||||
//
|
||||
// τ = i · √(R² − r²) / r (reduced so |τ| ≥ 1)
|
||||
//
|
||||
// derived from the flat-conformal change of variable dψ = r/(R + r cos φ) dφ on
|
||||
// the induced metric ds² = (R + r cos φ)² dθ² + r² dφ²; the ψ-period is
|
||||
// 2πr/√(R²−r²), giving the rectangular lattice ratio above. Re(τ) = 0 follows
|
||||
// from the meridian ⟂ longitude reflection symmetry. The coarse polygonal cross
|
||||
// sections (square/hex/octagon) approximate the circular value from above; the
|
||||
// gap shrinks as the cross section gains sides.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
namespace {
|
||||
|
||||
// Run the full pipeline solve → cut → layout → period matrix on a torus mesh and
|
||||
// return the reduced τ together with the two raw holonomy generators.
|
||||
struct TorusTau {
|
||||
std::complex<double> tau;
|
||||
std::vector<Eigen::Vector2d> omega;
|
||||
bool converged = false;
|
||||
};
|
||||
|
||||
TorusTau run_torus_pipeline(const std::string& file)
|
||||
{
|
||||
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/off/" + file;
|
||||
ConformalMesh mesh = load_mesh(path);
|
||||
|
||||
EuclideanMaps maps = setup_euclidean_maps(mesh); // Θ_v = 2π (flat target)
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
int idx = 0;
|
||||
bool pinned = false;
|
||||
for (auto v : mesh.vertices()) {
|
||||
if (!pinned) { maps.v_idx[v] = -1; pinned = true; }
|
||||
else maps.v_idx[v] = idx++;
|
||||
}
|
||||
enforce_gauss_bonnet(mesh, maps);
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
|
||||
auto res = newton_euclidean(mesh, x0, maps);
|
||||
|
||||
CutGraph cg = compute_cut_graph(mesh);
|
||||
HolonomyData hol;
|
||||
euclidean_layout(mesh, res.x, maps, &cg, &hol, /*normalise=*/false);
|
||||
|
||||
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
|
||||
return TorusTau{pd.tau, hol.translations, res.converged};
|
||||
}
|
||||
|
||||
// Reduced conformal modulus of a torus of revolution (major R, minor r).
|
||||
double revolution_tau_imag(double R, double r)
|
||||
{
|
||||
return std::sqrt(R * R - r * r) / r; // ≥ 1 form (|τ| ≥ 1)
|
||||
}
|
||||
|
||||
void check_torus(const std::string& file, double R, double r, double rel_tol)
|
||||
{
|
||||
TorusTau t = run_torus_pipeline(file);
|
||||
ASSERT_TRUE(t.converged) << file << ": Newton did not converge";
|
||||
|
||||
// Generators must be non-degenerate (the bug collapsed them to ~0).
|
||||
ASSERT_EQ(t.omega.size(), 2u);
|
||||
EXPECT_GT(t.omega[0].norm(), 1e-3) << file << ": ω₁ degenerate";
|
||||
EXPECT_GT(t.omega[1].norm(), 1e-3) << file << ": ω₂ degenerate";
|
||||
|
||||
EXPECT_TRUE(std::isfinite(t.tau.real()) && std::isfinite(t.tau.imag()))
|
||||
<< file << ": τ is not finite (" << t.tau.real() << "+" << t.tau.imag() << "i)";
|
||||
EXPECT_GT(t.tau.imag(), 0.0) << file << ": τ must lie in the upper half-plane";
|
||||
EXPECT_TRUE(is_in_fundamental_domain(t.tau, 1e-6))
|
||||
<< file << ": τ = " << t.tau.real() << "+" << t.tau.imag() << "i not in F";
|
||||
|
||||
// Re(τ) = 0 by the meridian ⟂ longitude reflection symmetry.
|
||||
EXPECT_NEAR(t.tau.real(), 0.0, 0.05)
|
||||
<< file << ": Re(τ) should vanish for a torus of revolution";
|
||||
|
||||
const double expected = revolution_tau_imag(R, r);
|
||||
EXPECT_NEAR(t.tau.imag(), expected, rel_tol * expected)
|
||||
<< file << ": Im(τ) = " << t.tau.imag()
|
||||
<< " vs analytic i·√(R²−r²)/r = " << expected;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// 4×4 torus of revolution: R = 2, r = 1 → τ = i√3 ≈ 1.732i.
|
||||
// Square (4-gon) cross section → coarsest circle approximation, looser tolerance.
|
||||
TEST(HolonomyEndToEnd, Torus4x4_TauMatchesRevolutionModulus)
|
||||
{
|
||||
check_torus("torus_4x4.off", /*R=*/2.0, /*r=*/1.0, /*rel_tol=*/0.10);
|
||||
}
|
||||
|
||||
// Hexagonal 6×6 torus of revolution: R = 3, r = 1 → τ = i√8 ≈ 2.828i.
|
||||
TEST(HolonomyEndToEnd, TorusHex6x6_TauMatchesRevolutionModulus)
|
||||
{
|
||||
check_torus("torus_hex_6x6.off", /*R=*/3.0, /*r=*/1.0, /*rel_tol=*/0.05);
|
||||
}
|
||||
|
||||
// Octagonal 8×8 torus of revolution: R = 3, r = 1 → τ = i√8 ≈ 2.828i.
|
||||
TEST(HolonomyEndToEnd, Torus8x8_TauMatchesRevolutionModulus)
|
||||
{
|
||||
check_torus("torus_8x8.off", /*R=*/3.0, /*r=*/1.0, /*rel_tol=*/0.05);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Finding-H (java-port-audit item 7, external-audit-2026-05-30):
|
||||
// End-to-end torus with Re(τ) < 0 before normalizeModulus
|
||||
//
|
||||
// torus_skewed_4x4.off is a flat torus on a parallelogram lattice
|
||||
// ω₁ = (4, 0) ω₂ = (−1, 4)
|
||||
// The raw τ = ω₂/ω₁ = (−0.25 + i), Re < 0.
|
||||
// After normalizeModulus the mirror fold gives τ = (0.25 + i), Re ≥ 0.
|
||||
//
|
||||
// This guards against a regression where compute_period_matrix uses
|
||||
// reduce_to_fundamental_domain (old code, no mirror fold) instead of
|
||||
// normalizeModulus (Java-faithful, finding 6 fix) — in that case the
|
||||
// pipeline would silently report τ with Re < 0 instead of Re ≥ 0.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(HolonomyEndToEnd, SkewedTorus_ReTauNegativeBeforeNorm_FoldedToPositive)
|
||||
{
|
||||
// ── Load the skewed flat torus ────────────────────────────────────────
|
||||
const std::string path =
|
||||
std::string(CONFORMALLAB_DATA_DIR) + "/off/torus_skewed_4x4.off";
|
||||
ConformalMesh mesh = load_mesh(path);
|
||||
ASSERT_GT(mesh.number_of_vertices(), 0u) << "Failed to load torus_skewed_4x4.off";
|
||||
ASSERT_EQ(conformallab::euler_characteristic(mesh), 0)
|
||||
<< "Mesh must be a torus (χ=0)";
|
||||
|
||||
// ── Run the full pipeline ─────────────────────────────────────────────
|
||||
EuclideanMaps maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
int idx = 0;
|
||||
bool pinned = false;
|
||||
for (auto v : mesh.vertices()) {
|
||||
if (!pinned) { maps.v_idx[v] = -1; pinned = true; }
|
||||
else maps.v_idx[v] = idx++;
|
||||
}
|
||||
enforce_gauss_bonnet(mesh, maps);
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
|
||||
auto res = newton_euclidean(mesh, x0, maps);
|
||||
ASSERT_TRUE(res.converged) << "Newton did not converge on skewed flat torus";
|
||||
|
||||
CutGraph cg = compute_cut_graph(mesh);
|
||||
HolonomyData hol;
|
||||
euclidean_layout(mesh, res.x, maps, &cg, &hol, /*normalise=*/false);
|
||||
|
||||
ASSERT_EQ(hol.translations.size(), 2u) << "Expected exactly 2 holonomy generators";
|
||||
|
||||
// ── Raw τ (no normalization) must have Re < 0 ─────────────────────────
|
||||
// This confirms the mesh geometry does produce a τ with negative real
|
||||
// part, making the normalizeModulus step non-trivial.
|
||||
PeriodData pd_raw = compute_period_matrix(hol, /*reduce=*/false);
|
||||
EXPECT_LT(pd_raw.tau.real(), 0.0)
|
||||
<< "Raw τ must have Re < 0 for this skewed lattice"
|
||||
<< " (got Re = " << pd_raw.tau.real() << ")";
|
||||
|
||||
// ── Normalized τ must have Re ≥ 0 (normalizeModulus was applied) ─────
|
||||
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
|
||||
EXPECT_GE(pd.tau.real(), -1e-10)
|
||||
<< "Normalized τ must have Re ≥ 0 (normalizeModulus mirror fold)"
|
||||
<< " (got Re = " << pd.tau.real() << ")";
|
||||
EXPECT_GT(pd.tau.imag(), 0.0)
|
||||
<< "τ must lie in the upper half-plane";
|
||||
EXPECT_GE(std::abs(pd.tau), 1.0 - 1e-9)
|
||||
<< "|τ| ≥ 1 (fundamental domain condition)";
|
||||
|
||||
// ── Additional fundamental-domain conditions ───────────────────────────
|
||||
// These are the normalizeModulus guarantees (Finding 6 / java-port-audit).
|
||||
EXPECT_LE(pd.tau.real(), 0.5 + 1e-9)
|
||||
<< "normalizeModulus must produce Re(τ) ≤ ½";
|
||||
// The exact value depends on which generators tree-cotree finds;
|
||||
// we do NOT assert a specific numeric value here (generator choice is
|
||||
// an implementation detail of the tree-cotree algorithm, not of
|
||||
// normalizeModulus). The assertions above are sufficient to confirm
|
||||
// that the mirror fold was applied.
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Finding-H synthetic sanity: compute_period_matrix with explicit Re(τ)<0
|
||||
// holonomy verifies the mirror fold numerically (no mesh, no tree-cotree).
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(HolonomyEndToEnd, SyntheticHolonomy_NegativeReTau_NormalizedToPositive)
|
||||
{
|
||||
// Lattice: ω₁=(4,0), ω₂=(-1,4) → τ_raw = (-1+4i)/4 = -0.25+i
|
||||
// normalizeModulus: Re=-0.25 < 0 → mirror: τ = -conj(τ) = +0.25+i
|
||||
HolonomyData hol;
|
||||
hol.translations = {
|
||||
Eigen::Vector2d(4.0, 0.0),
|
||||
Eigen::Vector2d(-1.0, 4.0)
|
||||
};
|
||||
|
||||
PeriodData pd_raw = compute_period_matrix(hol, /*reduce=*/false);
|
||||
EXPECT_NEAR(pd_raw.tau.real(), -0.25, 1e-10) << "Raw Re(τ) must be -0.25";
|
||||
EXPECT_NEAR(pd_raw.tau.imag(), 1.0, 1e-10) << "Raw Im(τ) must be 1.0";
|
||||
|
||||
PeriodData pd = compute_period_matrix(hol, /*reduce=*/true);
|
||||
EXPECT_GE(pd.tau.real(), 0.0 - 1e-9) << "Normalized Re(τ) ≥ 0";
|
||||
EXPECT_LE(pd.tau.real(), 0.5 + 1e-9) << "Normalized Re(τ) ≤ ½";
|
||||
EXPECT_NEAR(pd.tau.real(), 0.25, 1e-9) << "Mirror fold: Re = -0.25 → +0.25";
|
||||
EXPECT_NEAR(pd.tau.imag(), 1.0, 1e-9) << "Im(τ) preserved by mirror fold";
|
||||
EXPECT_GE(std::abs(pd.tau), 1.0 - 1e-9) << "|τ| ≥ 1";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -483,3 +748,4 @@ TEST(TilingNeighbourhood, EmptyHolonomy_ReturnsSingleTile)
|
||||
auto tiles = tiling_neighbourhood(lay, hol);
|
||||
EXPECT_EQ(tiles.size(), 1u);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_pipeline.cpp
|
||||
//
|
||||
// Phase 4c — End-to-end pipeline tests and library-user examples.
|
||||
@@ -145,8 +148,8 @@ TEST(Pipeline, Spherical_TetrahedronToEquilibrium)
|
||||
// ── Steps 1–3 ─────────────────────────────────────────────────────────
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
// ── Step 4: solve ─────────────────────────────────────────────────────
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), -0.2);
|
||||
@@ -175,7 +178,7 @@ TEST(Pipeline, HyperIdeal_TriangleRoundTrip)
|
||||
// ── Steps 1–3 ─────────────────────────────────────────────────────────
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
|
||||
// ── Step 4: natural targets (equilibrium at b=1.0, a=0.5) ────────────
|
||||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||||
@@ -271,7 +274,7 @@ TEST(Pipeline, AllThreeGeometries_QuadStrip)
|
||||
{
|
||||
auto mesh = make_quad_strip();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
int n = assign_hyper_ideal_all_dof_indices(mesh, maps);
|
||||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||||
std::vector<double> x0 = xbase;
|
||||
for (auto& v : x0) v += 0.1;
|
||||
@@ -283,8 +286,8 @@ TEST(Pipeline, AllThreeGeometries_QuadStrip)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), -0.1);
|
||||
auto res = newton_spherical(mesh, x0, maps);
|
||||
EXPECT_TRUE(res.converged) << "Spherical: tetrahedron should converge";
|
||||
|
||||
135
code/tests/cgal/test_pn_geometry.cpp
Normal file
135
code/tests/cgal/test_pn_geometry.cpp
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_pn_geometry.cpp
|
||||
//
|
||||
// Verifies pn_geometry.hpp against:
|
||||
// (a) closed-form analytic golden values, and
|
||||
// (b) the already-verified projective_math.hpp::hyperbolicDistance
|
||||
// (regression anchor for the HYPERBOLIC sign convention).
|
||||
//
|
||||
// Mirrored Java source: de.jreality.math.Pn
|
||||
|
||||
#include "pn_geometry.hpp"
|
||||
#include "projective_math.hpp"
|
||||
#include <gtest/gtest.h>
|
||||
#include <Eigen/Core>
|
||||
#include <cmath>
|
||||
|
||||
using namespace conformallab;
|
||||
|
||||
namespace {
|
||||
Eigen::VectorXd V(std::initializer_list<double> xs) {
|
||||
Eigen::VectorXd v(static_cast<int>(xs.size()));
|
||||
int i = 0;
|
||||
for (double x : xs) v(i++) = x;
|
||||
return v;
|
||||
}
|
||||
constexpr double PN_PI = 3.14159265358979323846;
|
||||
} // namespace
|
||||
|
||||
// ── Inner product per signature ──────────────────────────────────────────────
|
||||
TEST(PnGeometry, InnerProductSignatures)
|
||||
{
|
||||
auto u = V({1.0, 2.0, 3.0}); // spatial=(1,2), last=3
|
||||
auto v = V({4.0, 5.0, 6.0}); // spatial=(4,5), last=6
|
||||
// spat = 1·4 + 2·5 = 14, last = 3·6 = 18
|
||||
EXPECT_NEAR(pn_inner_product(u, v, PN_EUCLIDEAN), 14.0, 1e-12);
|
||||
EXPECT_NEAR(pn_inner_product(u, v, PN_ELLIPTIC), 14.0+18.0, 1e-12);
|
||||
EXPECT_NEAR(pn_inner_product(u, v, PN_HYPERBOLIC), 18.0-14.0, 1e-12);
|
||||
}
|
||||
|
||||
// ── Euclidean distance ────────────────────────────────────────────────────────
|
||||
TEST(PnGeometry, EuclideanDistance)
|
||||
{
|
||||
// 3-4-5 right triangle in the plane (homogeneous w=1).
|
||||
auto p = V({0.0, 0.0, 1.0});
|
||||
auto q = V({3.0, 4.0, 1.0});
|
||||
EXPECT_NEAR(pn_distance_between(p, q, PN_EUCLIDEAN), 5.0, 1e-12);
|
||||
|
||||
// Homogeneous scaling must not change the distance.
|
||||
auto q2 = V({6.0, 8.0, 2.0}); // same point as q after dehomogenize
|
||||
EXPECT_NEAR(pn_distance_between(p, q2, PN_EUCLIDEAN), 5.0, 1e-12);
|
||||
}
|
||||
|
||||
// ── Elliptic distance = spherical angle ───────────────────────────────────────
|
||||
TEST(PnGeometry, EllipticDistanceIsAngle)
|
||||
{
|
||||
auto e1 = V({1.0, 0.0, 0.0});
|
||||
auto e2 = V({0.0, 1.0, 0.0});
|
||||
auto d = V({1.0, 1.0, 0.0}); // 45° between e1 and e2
|
||||
|
||||
EXPECT_NEAR(pn_distance_between(e1, e2, PN_ELLIPTIC), PN_PI / 2.0, 1e-12);
|
||||
EXPECT_NEAR(pn_distance_between(e1, d, PN_ELLIPTIC), PN_PI / 4.0, 1e-12);
|
||||
EXPECT_NEAR(pn_distance_between(e1, e1, PN_ELLIPTIC), 0.0, 1e-12);
|
||||
}
|
||||
|
||||
// ── Hyperbolic distance: closed form + projective_math.hpp anchor ─────────────
|
||||
TEST(PnGeometry, HyperbolicDistanceClosedFormAndAnchor)
|
||||
{
|
||||
// Upper hyperboloid: p = apex (0,0,1); q = (sinh r, 0, cosh r) at distance r.
|
||||
const double r = 0.873;
|
||||
auto p = V({0.0, 0.0, 1.0});
|
||||
auto q = V({std::sinh(r), 0.0, std::cosh(r)});
|
||||
|
||||
EXPECT_NEAR(pn_distance_between(p, q, PN_HYPERBOLIC), r, 1e-10);
|
||||
|
||||
// Regression anchor: identical to projective_math.hpp::hyperbolicDistance.
|
||||
EXPECT_NEAR(pn_distance_between(p, q, PN_HYPERBOLIC),
|
||||
hyperbolicDistance(p, q), 1e-12);
|
||||
|
||||
// Scale-invariance: homogeneous rescaling must not change distance.
|
||||
auto q2 = (2.5 * q).eval();
|
||||
EXPECT_NEAR(pn_distance_between(p, q2, PN_HYPERBOLIC), r, 1e-10);
|
||||
}
|
||||
|
||||
// ── Norm / setToLength / normalize ───────────────────────────────────────────
|
||||
TEST(PnGeometry, NormAndScaling)
|
||||
{
|
||||
auto p = V({3.0, 4.0, 0.0});
|
||||
EXPECT_NEAR(pn_norm(p, PN_ELLIPTIC), 5.0, 1e-12);
|
||||
|
||||
auto s = pn_set_to_length(p, 2.0, PN_ELLIPTIC);
|
||||
EXPECT_NEAR(pn_norm(s, PN_ELLIPTIC), 2.0, 1e-12);
|
||||
|
||||
auto u = pn_normalize(p, PN_ELLIPTIC);
|
||||
EXPECT_NEAR(pn_norm(u, PN_ELLIPTIC), 1.0, 1e-12);
|
||||
|
||||
// A unit hyperboloid sheet point already has hyperbolic norm 1.
|
||||
auto h = V({std::sinh(0.6), 0.0, std::cosh(0.6)});
|
||||
EXPECT_NEAR(pn_norm(h, PN_HYPERBOLIC), 1.0, 1e-12);
|
||||
}
|
||||
|
||||
// ── Geodesic interpolation ────────────────────────────────────────────────────
|
||||
TEST(PnGeometry, LinearInterpolationGeodesic)
|
||||
{
|
||||
// EUCLIDEAN: affine midpoint.
|
||||
{
|
||||
auto p = V({0.0, 0.0, 1.0});
|
||||
auto q = V({4.0, 0.0, 1.0});
|
||||
auto m = pn_linear_interpolation(p, q, 0.5, PN_EUCLIDEAN);
|
||||
EXPECT_NEAR(m(0), 2.0, 1e-12);
|
||||
}
|
||||
|
||||
// ELLIPTIC: midpoint of e1,e2 is at half the π/2 arc = π/4 from each.
|
||||
{
|
||||
auto e1 = V({1.0, 0.0, 0.0});
|
||||
auto e2 = V({0.0, 1.0, 0.0});
|
||||
auto m = pn_linear_interpolation(e1, e2, 0.5, PN_ELLIPTIC);
|
||||
EXPECT_NEAR(pn_distance_between(e1, m, PN_ELLIPTIC), PN_PI / 4.0, 1e-10);
|
||||
EXPECT_NEAR(pn_distance_between(e2, m, PN_ELLIPTIC), PN_PI / 4.0, 1e-10);
|
||||
// Endpoint recovery at t=0.
|
||||
auto m0 = pn_linear_interpolation(e1, e2, 0.0, PN_ELLIPTIC);
|
||||
EXPECT_NEAR(pn_distance_between(e1, m0, PN_ELLIPTIC), 0.0, 1e-10);
|
||||
}
|
||||
|
||||
// HYPERBOLIC: midpoint is at exactly half the geodesic distance from each end.
|
||||
{
|
||||
const double r = 1.2;
|
||||
auto p = V({0.0, 0.0, 1.0});
|
||||
auto q = V({std::sinh(r), 0.0, std::cosh(r)});
|
||||
auto m = pn_linear_interpolation(p, q, 0.5, PN_HYPERBOLIC);
|
||||
EXPECT_NEAR(pn_distance_between(p, m, PN_HYPERBOLIC), r / 2.0, 1e-9);
|
||||
EXPECT_NEAR(pn_distance_between(q, m, PN_HYPERBOLIC), r / 2.0, 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_scalability_smoke.cpp
|
||||
//
|
||||
// Scalability smoke tests — convergence on large real-world meshes.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_spherical_functional.cpp (Phase 3c + 3e)
|
||||
//
|
||||
// Phase 3c — SphericalFunctional ported to ConformalMesh.
|
||||
@@ -24,6 +27,8 @@
|
||||
#include "mesh_builder.hpp"
|
||||
#include "spherical_functional.hpp"
|
||||
#include "spherical_hessian.hpp"
|
||||
#include "spherical_geometry.hpp"
|
||||
#include "clausen.hpp"
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
@@ -47,8 +52,8 @@ TEST(SphericalFunctional, GradientCheck_Hessian)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
|
||||
|
||||
@@ -117,8 +122,8 @@ TEST(SphericalFunctional, GradientCheck_OctaFaceVertex)
|
||||
{
|
||||
auto mesh = make_octahedron_face();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
// Small uniform conformal factor: shrink the triangle slightly.
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -0.3);
|
||||
@@ -138,8 +143,8 @@ TEST(SphericalFunctional, GradientCheck_SpherTetVertex)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
|
||||
|
||||
@@ -158,11 +163,15 @@ TEST(SphericalFunctional, GradientCheck_SpherTetAllDofs)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_all_spherical_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_all_dof_indices(mesh, maps);
|
||||
|
||||
// Small but non-zero values; vertex DOFs negative, edge DOFs zero.
|
||||
// Edge DOF adjusts the effective log-length Λ_ij = λ°_ij + u_i + u_j + λ_e.
|
||||
// Replacement parameterization (Finding 3): when an edge carries a DOF its
|
||||
// value *replaces* λ°_ij + u_i + u_j entirely, so Λ_ij = λ_e. Here the edge
|
||||
// DOFs stay at 0 and only the vertex DOFs are perturbed; this checks that the
|
||||
// gradient is curl-free (energy = Schläfli path integral), not Java-faithfulness
|
||||
// of the edge formula — that is locked separately by
|
||||
// EdgeGradient_RegularTetClosedForm below.
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
// Set vertex DOFs (indices 0..3) to -0.2 to keep triangle well-formed.
|
||||
for (int i = 0; i < 4; ++i) x[static_cast<std::size_t>(i)] = -0.2;
|
||||
@@ -171,6 +180,59 @@ TEST(SphericalFunctional, GradientCheck_SpherTetAllDofs)
|
||||
<< "Gradient check failed on spherical tetrahedron (all DOFs)";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Closed-form oracle for the edge-DOF gradient (Finding 3, missing-test item 4)
|
||||
//
|
||||
// The FD gradient check above can only confirm that G is conservative — the
|
||||
// spherical energy is *defined* as the path integral of G, so the energy↔gradient
|
||||
// FD agreement is automatic and CANNOT detect a wrong-but-conservative edge
|
||||
// formula. This test instead pins the edge gradient against an independent,
|
||||
// closed-form geometric value, so it would fail if the Finding-3 formula
|
||||
// (G_e = α_opp⁺ + α_opp⁻ − θ_e, dropping the additive −(S⁺+S⁻)/2 term) ever
|
||||
// regressed.
|
||||
//
|
||||
// Geometry: the regular spherical tetrahedron has all edges a = arccos(−1/3),
|
||||
// so by the spherical law of cosines every interior corner angle is
|
||||
// cos α = (cos a − cos²a)/sin²a = cos a/(1+cos a) = (−1/3)/(2/3) = −1/2
|
||||
// ⇒ α = 2π/3.
|
||||
// Each edge is shared by two faces, so both opposite angles equal 2π/3 and
|
||||
// G_e = 2π/3 + 2π/3 − θ_e with θ_e = π (default) = π/3.
|
||||
//
|
||||
// Setup: all edges carry DOFs, set to their λ⁰ (the replacement convention then
|
||||
// reproduces the original tetrahedron metric exactly), vertex DOFs left at 0.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(SphericalFunctional, EdgeGradient_RegularTetClosedForm)
|
||||
{
|
||||
const double PI_ = std::acos(-1.0);
|
||||
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_all_dof_indices(mesh, maps);
|
||||
|
||||
// Edge DOF = λ⁰ → Λ_ij = λ⁰ → reproduces the arccos(−1/3) tetrahedron.
|
||||
// Vertex DOFs stay at 0 (ignored by the replacement convention for DOF edges).
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
int n_edge_dofs = 0;
|
||||
for (auto e : mesh.edges()) {
|
||||
int ie = maps.e_idx[e];
|
||||
if (ie >= 0) { x[static_cast<std::size_t>(ie)] = maps.lambda0[e]; ++n_edge_dofs; }
|
||||
}
|
||||
ASSERT_EQ(n_edge_dofs, 6) << "regular tetrahedron must have 6 edge DOFs";
|
||||
|
||||
auto G = spherical_gradient(mesh, x, maps);
|
||||
|
||||
const double expected = PI_ / 3.0; // 2·(2π/3) − π
|
||||
for (auto e : mesh.edges()) {
|
||||
int ie = maps.e_idx[e];
|
||||
if (ie < 0) continue;
|
||||
EXPECT_NEAR(G[static_cast<std::size_t>(ie)], expected, 1e-9)
|
||||
<< "edge gradient at DOF " << ie
|
||||
<< " must equal the closed-form value π/3 (Finding 3)";
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Angles are finite at a known interior point
|
||||
//
|
||||
@@ -183,8 +245,8 @@ TEST(SphericalFunctional, AnglesFiniteAtKnownPoint)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
// u_i = -1.5: contracts the triangle heavily but stays non-degenerate.
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -1.5);
|
||||
@@ -225,8 +287,8 @@ TEST(SphericalFunctional, GradientCheck_SpherFan4Vertex)
|
||||
mesh.add_face(center, rim[i], rim[(i + 1) % n_rim]);
|
||||
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int ndof = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int ndof = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(ndof), -0.3);
|
||||
|
||||
@@ -245,7 +307,7 @@ TEST(SphericalFunctional, GradientCheck_MixedPinnedVertices)
|
||||
{
|
||||
auto mesh = make_octahedron_face();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// Pin v0, make v1 and v2 variable.
|
||||
auto vit = mesh.vertices().begin();
|
||||
@@ -278,8 +340,8 @@ TEST(SphericalFunctional, GaugeFix_SpherTetVertexZerosSumGv)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
// Off-gauge starting point: all u_i = -0.5
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -0.5);
|
||||
@@ -322,8 +384,8 @@ TEST(SphericalFunctional, GaugeFix_ApplyInPlace)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
// x = -0.3: compressed but inside the valid spherical domain.
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -0.3);
|
||||
@@ -347,8 +409,8 @@ TEST(SphericalFunctional, GaugeFix_AlreadyAtGaugeReturnsTNearZero)
|
||||
// at the gauge maximum (by symmetry, ΣG_v = 0).
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
double t = spherical_gauge_shift(mesh, x, maps);
|
||||
@@ -356,3 +418,286 @@ TEST(SphericalFunctional, GaugeFix_AlreadyAtGaugeReturnsTNearZero)
|
||||
EXPECT_NEAR(t, 0.0, 1e-5)
|
||||
<< "Gauge shift from the symmetric point should be ~0; got " << t;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Golden-value oracle — pin the spherical law-of-cosines angle formula, the β
|
||||
// half-angle relations, and the Lobachevsky energy term bit-for-bit against the
|
||||
// upstream Java reference (SphericalFunctional.triangleEnergyAndAlphas, lines
|
||||
// 401-453), captured by running the compiled Java library (openjdk 17) with the
|
||||
// real de.varylab…Clausen.Л on these exact arc lengths. Unlike the Schläfli
|
||||
// path-integral gradient check (which only verifies curl-freeness), this locks
|
||||
// the absolute angle/β/energy values against an independent implementation,
|
||||
// catching any silent index/sign/convention drift in the spherical port.
|
||||
//
|
||||
// To regenerate: /tmp/oracle/SphereOracle.java (recipe in doc/reviewer/
|
||||
// java-port-audit.md). Values are Java printf %.17g. Tolerance 1e-12.
|
||||
//
|
||||
// Index map (C++ spherical_angles ↔ Java): with l12=lij, l23=ljk, l31=lki the
|
||||
// returned alpha1/alpha2/alpha3 are Java αi/αj/αk (angle opposite ljk/lki/lij).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
TEST(SphericalGoldenJava, AngleBetaEnergyFromLengths)
|
||||
{
|
||||
auto check = [](double lij, double ljk, double lki,
|
||||
double ai_g, double aj_g, double ak_g,
|
||||
double bi_g, double bj_g, double bk_g, double L_g) {
|
||||
auto fa = spherical_angles(lij, ljk, lki);
|
||||
EXPECT_TRUE(fa.valid);
|
||||
EXPECT_NEAR(fa.alpha1, ai_g, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha2, aj_g, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha3, ak_g, 1e-12);
|
||||
|
||||
const double ai = fa.alpha1, aj = fa.alpha2, ak = fa.alpha3;
|
||||
const double bi = 0.5 * (PI + ai - aj - ak);
|
||||
const double bj = 0.5 * (PI - ai + aj - ak);
|
||||
const double bk = 0.5 * (PI - ai - aj + ak);
|
||||
EXPECT_NEAR(bi, bi_g, 1e-12);
|
||||
EXPECT_NEAR(bj, bj_g, 1e-12);
|
||||
EXPECT_NEAR(bk, bk_g, 1e-12);
|
||||
|
||||
const double Lterm =
|
||||
Lobachevsky(ai) + Lobachevsky(aj) + Lobachevsky(ak) +
|
||||
Lobachevsky(bi) + Lobachevsky(bj) + Lobachevsky(bk) +
|
||||
Lobachevsky(0.5 * (PI - ai - aj - ak));
|
||||
EXPECT_NEAR(Lterm, L_g, 1e-12);
|
||||
};
|
||||
|
||||
// Scalene spherical triangle (all Δ > 0, Δ_ijk < 2π).
|
||||
check(1.0, 1.2, 0.9,
|
||||
1.5305813141072122, 0.99684981272794600, 1.1246069519101605,
|
||||
1.2753586015294494, 0.74162710015018330, 0.86938423933239760,
|
||||
1.3576100185550408);
|
||||
// Equilateral spherical triangle.
|
||||
check(0.7, 0.7, 0.7,
|
||||
1.1225596283199812, 1.1225596283199812, 1.1225596283199812,
|
||||
1.0095165126349062, 1.0095165126349060, 1.0095165126349060,
|
||||
1.6806828584976297);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FULL-MESH golden oracle (spherical) — drives the REAL upstream SphericalFunctional
|
||||
// on the shared tetrahedron (scaled so every arc length stays < π) and pins BOTH
|
||||
// the per-vertex gradient G_v = Θ_v − Σα AND ΔE = E(x) − E(0) bit-for-bit.
|
||||
//
|
||||
// IMPORTANT — the oracle calls Java's `conformalEnergyAndGradient` (the RAW
|
||||
// θ−Σα gradient), NOT `evaluate()`: `evaluate()` first runs a 1-D Brent
|
||||
// maximization over the global-scale gauge direction (`maximizeInNegativeDirection`),
|
||||
// which the C++ `spherical_gradient` deliberately does NOT — C++ factors that
|
||||
// gauge into the Newton solver's `spherical_gauge_shift` instead. Comparing
|
||||
// against `evaluate()` would (wrongly) drive every component to ~0. The raw
|
||||
// gradient is the piece that corresponds 1:1 to the C++ functional.
|
||||
//
|
||||
// Setup parity (UnwrapUtility.prepareInvariantDataHyperbolicAndSpherical, scale):
|
||||
// closed mesh, ALL 4 vertices variable, Θ_v = 2π, no edge DOFs,
|
||||
// λ°_e = 2·log(SCALE·|p_i − p_j|) (Java uses chord length × scale, whereas the
|
||||
// C++ compute_spherical_lambda0_from_mesh helper assumes unit-sphere vertices and uses
|
||||
// ARC length — so we set λ° directly here to match Java exactly),
|
||||
// per-vertex u(P) = 0.10·X − 0.07·Y + 0.13·Z, SCALE = 0.2.
|
||||
//
|
||||
// To regenerate: /tmp/oracle/{tet.obj,SphereMeshOracle.java}. Values are Java %.17g.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
TEST(SphericalGoldenJava, FullMeshGradientAndEnergy_Tetrahedron)
|
||||
{
|
||||
constexpr double TWO_PI = 2.0 * 3.14159265358979323846264338328;
|
||||
constexpr double SCALE = 0.2;
|
||||
|
||||
auto mesh = make_tetrahedron(); // same 4 vertices as /tmp/oracle/tet.obj
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
|
||||
// λ°_e = 2·log(SCALE · chord), matching Java's prepareInvariantData(scale).
|
||||
for (auto e : mesh.edges()) {
|
||||
auto h = mesh.halfedge(e);
|
||||
const auto& p1 = mesh.point(mesh.source(h));
|
||||
const auto& p2 = mesh.point(mesh.target(h));
|
||||
const double dx = p1.x() - p2.x(), dy = p1.y() - p2.y(), dz = p1.z() - p2.z();
|
||||
const double chord = std::sqrt(dx*dx + dy*dy + dz*dz);
|
||||
maps.lambda0[e] = 2.0 * std::log(SCALE * chord);
|
||||
}
|
||||
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices()) {
|
||||
maps.v_idx[v] = idx++;
|
||||
maps.theta_v[v] = TWO_PI;
|
||||
}
|
||||
|
||||
auto u_of = [](const Point3& p) {
|
||||
return 0.10 * p.x() - 0.07 * p.y() + 0.13 * p.z();
|
||||
};
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(idx), 0.0);
|
||||
for (auto v : mesh.vertices())
|
||||
x[static_cast<std::size_t>(maps.v_idx[v])] = u_of(mesh.point(v));
|
||||
|
||||
auto G = spherical_gradient(mesh, x, maps);
|
||||
|
||||
struct GoldRow { double X, Y, Z, G; };
|
||||
const GoldRow gold[4] = {
|
||||
{ 1, 1, 1, 2.7671034786104927},
|
||||
{ 1, -1, -1, 2.5216834054857546},
|
||||
{-1, 1, -1, 1.5401761310866633},
|
||||
{-1, -1, 1, 2.6518569531861100},
|
||||
};
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = mesh.point(v);
|
||||
const double g = G[static_cast<std::size_t>(maps.v_idx[v])];
|
||||
bool matched = false;
|
||||
for (const auto& row : gold) {
|
||||
if (std::abs(p.x() - row.X) < 1e-9 &&
|
||||
std::abs(p.y() - row.Y) < 1e-9 &&
|
||||
std::abs(p.z() - row.Z) < 1e-9) {
|
||||
EXPECT_NEAR(g, row.G, 1e-12)
|
||||
<< "gradient mismatch at (" << p.x() << "," << p.y()
|
||||
<< "," << p.z() << ")";
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(matched) << "unexpected vertex position";
|
||||
}
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(idx), 0.0);
|
||||
const double dE = spherical_energy(mesh, x, maps)
|
||||
- spherical_energy(mesh, x0, maps);
|
||||
EXPECT_NEAR(dE, 0.16409141487397116, 1e-12);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FULL-MESH EDGE-DOF golden oracle (spherical, Finding 3) — the missing solution-
|
||||
// level Java oracle for the edge-DOF gradient path. Makes two opposite edges of
|
||||
// the shared tetrahedron variable (replacement parameterization: λ_e = x[e_idx],
|
||||
// no u-shift) and pins BOTH the vertex gradient (Θ−Σα) AND the edge gradient
|
||||
// (α_opp⁺ + α_opp⁻ − θ_e, θ_e = π) bit-for-bit against the REAL upstream
|
||||
// SphericalFunctional.conformalEnergyAndGradient (raw gradient, not evaluate()).
|
||||
//
|
||||
// This is a pure GRADIENT oracle (no ΔE): with an edge DOF, x = 0 means λ_e = 0
|
||||
// ⇒ spherical arc length = π (degenerate), so the path-integral-from-origin
|
||||
// energy reference is ill-defined — the gradient at a fixed non-degenerate x is
|
||||
// the unambiguous Finding-3 quantity. It does NOT touch the spherical Hessian,
|
||||
// so it is unaffected by the Finding-4 edge-DOF Hessian guard.
|
||||
//
|
||||
// To regenerate: /tmp/oracle/SphereEdgeOracle.java. Values are Java %.17g.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
TEST(SphericalGoldenJava, FullMeshEdgeDofGradient_Tetrahedron)
|
||||
{
|
||||
constexpr double TWO_PI = 2.0 * 3.14159265358979323846264338328;
|
||||
constexpr double PI_C = 3.14159265358979323846264338328;
|
||||
constexpr double SCALE = 0.2;
|
||||
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
|
||||
for (auto e : mesh.edges()) {
|
||||
auto h = mesh.halfedge(e);
|
||||
const auto& p1 = mesh.point(mesh.source(h));
|
||||
const auto& p2 = mesh.point(mesh.target(h));
|
||||
const double dx = p1.x()-p2.x(), dy = p1.y()-p2.y(), dz = p1.z()-p2.z();
|
||||
maps.lambda0[e] = 2.0 * std::log(SCALE * std::sqrt(dx*dx + dy*dy + dz*dz));
|
||||
maps.theta_e[e] = PI_C;
|
||||
maps.e_idx[e] = -1;
|
||||
}
|
||||
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices()) { maps.v_idx[v] = idx++; maps.theta_v[v] = TWO_PI; }
|
||||
|
||||
auto pos = [&](Vertex_index v){ return mesh.point(v); };
|
||||
auto same = [](const Point3& p, double X, double Y, double Z) {
|
||||
return std::abs(p.x()-X)<1e-9 && std::abs(p.y()-Y)<1e-9 && std::abs(p.z()-Z)<1e-9;
|
||||
};
|
||||
// Edge connects positions (aX,aY,aZ)–(bX,bY,bZ) in either order?
|
||||
auto connects = [&](Edge_index e, const double a[3], const double b[3]) {
|
||||
auto h = mesh.halfedge(e);
|
||||
const Point3& s = pos(mesh.source(h));
|
||||
const Point3& t = pos(mesh.target(h));
|
||||
return (same(s,a[0],a[1],a[2]) && same(t,b[0],b[1],b[2])) ||
|
||||
(same(s,b[0],b[1],b[2]) && same(t,a[0],a[1],a[2]));
|
||||
};
|
||||
const double A[3]={1,1,1}, B[3]={1,-1,-1}, C[3]={-1,1,-1}, D[3]={-1,-1,1};
|
||||
|
||||
// Make edges A–B and C–D variable (replacement parameterization).
|
||||
int edof = idx; // edge DOFs start after the 4 vertex DOFs
|
||||
Edge_index eAB, eCD;
|
||||
for (auto e : mesh.edges()) {
|
||||
if (connects(e, A, B)) { maps.e_idx[e] = edof++; eAB = e; }
|
||||
else if (connects(e, C, D)) { maps.e_idx[e] = edof++; eCD = e; }
|
||||
}
|
||||
ASSERT_EQ(edof, 6); // 4 vertex + 2 edge DOFs
|
||||
|
||||
auto u_of = [](const Point3& p){ return 0.10*p.x() - 0.07*p.y() + 0.13*p.z(); };
|
||||
|
||||
std::vector<double> x(6, 0.0);
|
||||
for (auto v : mesh.vertices())
|
||||
x[static_cast<std::size_t>(maps.v_idx[v])] = u_of(mesh.point(v));
|
||||
// Edge DOF value = λ⁰_e + 0.1 (same perturbation as the Java oracle).
|
||||
x[static_cast<std::size_t>(maps.e_idx[eAB])] = maps.lambda0[eAB] + 0.1;
|
||||
x[static_cast<std::size_t>(maps.e_idx[eCD])] = maps.lambda0[eCD] + 0.1;
|
||||
|
||||
auto G = spherical_gradient(mesh, x, maps);
|
||||
|
||||
// Vertex gradients keyed by position.
|
||||
struct VRow { double X, Y, Z, G; };
|
||||
const VRow vg[4] = {
|
||||
{ 1, 1, 1, 2.5036751008175546},
|
||||
{ 1, -1, -1, 2.2303362601050205},
|
||||
{-1, 1, -1, 1.8463710314817632},
|
||||
{-1, -1, 1, 2.7499719487341570},
|
||||
};
|
||||
for (auto v : mesh.vertices()) {
|
||||
const auto& p = mesh.point(v);
|
||||
const double g = G[static_cast<std::size_t>(maps.v_idx[v])];
|
||||
bool m = false;
|
||||
for (auto& r : vg)
|
||||
if (same(p, r.X, r.Y, r.Z)) { EXPECT_NEAR(g, r.G, 1e-12); m = true; break; }
|
||||
EXPECT_TRUE(m);
|
||||
}
|
||||
// Edge gradients: A–B and C–D (Java golden values).
|
||||
EXPECT_NEAR(G[static_cast<std::size_t>(maps.e_idx[eAB])], -0.35189517043413690, 1e-12);
|
||||
EXPECT_NEAR(G[static_cast<std::size_t>(maps.e_idx[eCD])], -0.44101986058895950, 1e-12);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Degenerate spherical triangle — limiting angles (Finding-F, java-port-audit item 1)
|
||||
//
|
||||
// spherical_angles() must return the limiting angles (π opposite the
|
||||
// over-long edge, 0/0 elsewhere) when the spherical triangle inequality
|
||||
// is violated, with valid = false. This mirrors the Euclidean behaviour
|
||||
// and is required for the convex C¹ BPS extension.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(SphericalFunctional, DegenerateTriangle_LimitingAngles_S12TooLong)
|
||||
{
|
||||
// s12 > s23 + s31: s12 = 2.5, s23 = s31 = 0.5 (all < π so valid arc lengths)
|
||||
// s23 < 0 → actually use s-based check
|
||||
// Easier: use s12 = π − ε (nearly degenerate hemisphere edge)
|
||||
// and very short s23, s31 so s12 > s23 + s31.
|
||||
const double s12 = 2.0, s23 = 0.4, s31 = 0.4; // s12 > s23+s31 = 0.8
|
||||
auto fa = spherical_angles(s12, s23, s31);
|
||||
EXPECT_FALSE(fa.valid);
|
||||
// s12 is the edge opposite v3 → α3 = π
|
||||
EXPECT_NEAR(fa.alpha3, PI, 1e-12)
|
||||
<< "corner opposite over-long s12 must be π";
|
||||
EXPECT_NEAR(fa.alpha1, 0.0, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha2, 0.0, 1e-12);
|
||||
}
|
||||
|
||||
TEST(SphericalFunctional, DegenerateTriangle_LimitingAngles_S23TooLong)
|
||||
{
|
||||
// s23 > s12 + s31 → α1 = π
|
||||
const double s12 = 0.4, s23 = 2.0, s31 = 0.4;
|
||||
auto fa = spherical_angles(s12, s23, s31);
|
||||
EXPECT_FALSE(fa.valid);
|
||||
EXPECT_NEAR(fa.alpha1, PI, 1e-12)
|
||||
<< "corner opposite over-long s23 must be π";
|
||||
EXPECT_NEAR(fa.alpha2, 0.0, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha3, 0.0, 1e-12);
|
||||
}
|
||||
|
||||
TEST(SphericalFunctional, DegenerateTriangle_LimitingAngles_S31TooLong)
|
||||
{
|
||||
// s31 > s12 + s23 → α2 = π
|
||||
const double s12 = 0.4, s23 = 0.4, s31 = 2.0;
|
||||
auto fa = spherical_angles(s12, s23, s31);
|
||||
EXPECT_FALSE(fa.valid);
|
||||
EXPECT_NEAR(fa.alpha2, PI, 1e-12)
|
||||
<< "corner opposite over-long s31 must be π";
|
||||
EXPECT_NEAR(fa.alpha1, 0.0, 1e-12);
|
||||
EXPECT_NEAR(fa.alpha3, 0.0, 1e-12);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// test_spherical_hessian.cpp
|
||||
//
|
||||
// Phase 3f — Spherical cotangent-Laplace Hessian.
|
||||
@@ -77,8 +80,8 @@ TEST(SphericalHessian, HessianIsSymmetric)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
|
||||
auto H = spherical_hessian(mesh, x, maps);
|
||||
@@ -103,8 +106,8 @@ TEST(SphericalHessian, ConstantVectorNotInNullSpace)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
|
||||
auto H = spherical_hessian(mesh, x, maps);
|
||||
@@ -130,8 +133,8 @@ TEST(SphericalHessian, HessianIsNegativeSemiDefiniteAtEquilibrium)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
// x = 0 is the equilibrium for the regular spherical tetrahedron.
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
@@ -153,8 +156,8 @@ TEST(SphericalHessian, FDCheck_OctaFaceVertex)
|
||||
{
|
||||
auto mesh = make_octahedron_face();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -0.3);
|
||||
|
||||
@@ -170,8 +173,8 @@ TEST(SphericalHessian, FDCheck_SpherTetVertex)
|
||||
{
|
||||
auto mesh = make_spherical_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_vertex_dof_indices(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
int n = assign_spherical_vertex_dof_indices(mesh, maps);
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), -0.2);
|
||||
|
||||
@@ -187,7 +190,7 @@ TEST(SphericalHessian, FDCheck_MixedPinnedVertices)
|
||||
{
|
||||
auto mesh = make_octahedron_face();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps);
|
||||
compute_spherical_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
auto vit = mesh.vertices().begin();
|
||||
Vertex_index v0 = *vit++;
|
||||
@@ -203,3 +206,31 @@ TEST(SphericalHessian, FDCheck_MixedPinnedVertices)
|
||||
EXPECT_TRUE(hessian_check_spherical(mesh, x, maps))
|
||||
<< "FD Hessian check failed for mixed pinned/variable vertices";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// I4: spherical_hessian throws on edge DOFs
|
||||
//
|
||||
// The spherical Hessian only implements the vertex-block cotangent Laplacian;
|
||||
// if any edge has a free DOF index (e_idx >= 0) it must fail loudly rather
|
||||
// than returning a silently rank-deficient matrix.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(SphericalHessian, ThrowsOnEdgeDOF)
|
||||
{
|
||||
// Build a tetrahedron and assign one edge as a free DOF.
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_spherical_maps(mesh);
|
||||
compute_lambda0_from_mesh(mesh, maps); // spherical variant (A2 rename pending)
|
||||
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices())
|
||||
maps.v_idx[v] = idx++;
|
||||
const int n_v = idx;
|
||||
|
||||
// Give one edge a free DOF index to trigger the guard.
|
||||
auto e0 = *mesh.edges().begin();
|
||||
maps.e_idx[e0] = n_v; // first free edge DOF
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n_v + 1), 0.0);
|
||||
EXPECT_THROW(spherical_hessian(mesh, x, maps), std::logic_error);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Port of de.varylab.discreteconformal.functional.ClausenTest (Java/JUnit).
|
||||
// Reference values computed with Mathematica.
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Port of de.varylab.discreteconformal.util.DiscreteEllipticUtilityTest (Java/JUnit).
|
||||
// Tests the normalizeModulus function that moves a complex number tau into the
|
||||
// fundamental domain of the modular group SL(2,Z).
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Port of de.varylab.discreteconformal.functional.HyperIdealUtilityTest (Java/JUnit).
|
||||
|
||||
#include "hyper_ideal_utility.hpp"
|
||||
#include "hyper_ideal_geometry.hpp"
|
||||
#include "clausen.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <complex>
|
||||
|
||||
using conformallab::calculateTetrahedronVolume;
|
||||
using conformallab::calculateTetrahedronVolumeWithIdealVertexAtGamma;
|
||||
@@ -90,3 +95,43 @@ TEST(HyperIdealUtilityTest, CompareGeneralAndIdealFormulaCase2) {
|
||||
double V = calculateTetrahedronVolume(bi, bj, bk, ai, aj, ak);
|
||||
EXPECT_NEAR(Ve, V, 1e-12);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Golden-value oracle tests — pin the C++ pure-math core bit-for-bit against the
|
||||
// upstream Java reference (de.varylab.discreteconformal.functional.{Clausen,
|
||||
// HyperIdealUtility}), captured by running the compiled Java library (openjdk 17)
|
||||
// on these exact inputs. Unlike the FD gradient checks (which only verify
|
||||
// curl-freeness, since the C++ energy is the path-integral of its own gradient),
|
||||
// these lock the absolute values of the math-critical helpers against an
|
||||
// independent implementation, catching any silent convention/formula drift.
|
||||
//
|
||||
// To regenerate: see doc/reviewer/java-port-audit.md (oracle harness recipe).
|
||||
// Values are Java's Double.toString output (shortest round-trip). Tolerance is
|
||||
// 1e-12 (well above the ~1e-15 inter-platform libm divergence for these ranges).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
TEST(HyperIdealGoldenJava, ClausenLobachevskyImLi2) {
|
||||
EXPECT_NEAR(conformallab::clausen2(0.7), 0.954448086482735, 1e-12);
|
||||
EXPECT_NEAR(conformallab::clausen2(2.5), 0.4335982032355327, 1e-12);
|
||||
EXPECT_NEAR(conformallab::clausen2(-1.3), -0.9897032532295984, 1e-12);
|
||||
EXPECT_NEAR(Lobachevsky(0.9), 0.4121734067662043, 1e-12);
|
||||
EXPECT_NEAR(conformallab::ImLi2(std::complex<double>(0.3, 0.4)),
|
||||
0.46136289181910894, 1e-12);
|
||||
}
|
||||
|
||||
TEST(HyperIdealGoldenJava, ZetaFamily) {
|
||||
EXPECT_NEAR(conformallab::zeta13(0.5, 0.7, 0.9), 2.663195966482385, 1e-12);
|
||||
EXPECT_NEAR(conformallab::zeta14(0.4, 0.8), 1.8262295633065202, 1e-12);
|
||||
EXPECT_NEAR(conformallab::zeta15(0.6), 2.216976794676588, 1e-12);
|
||||
EXPECT_NEAR(conformallab::zeta(0.5, 0.7, 0.9), 1.6156519307269948, 1e-12);
|
||||
}
|
||||
|
||||
TEST(HyperIdealGoldenJava, TetrahedronVolumes) {
|
||||
// Generic non-degenerate generalized hyperbolic tetrahedron.
|
||||
EXPECT_NEAR(calculateTetrahedronVolume(0.6, 0.7, 0.8, 0.5, 0.9, 0.4),
|
||||
2.9074633382516435, 1e-12);
|
||||
// One ideal vertex at gamma, non-symmetric angles (Java test2 inputs).
|
||||
EXPECT_NEAR(calculateTetrahedronVolumeWithIdealVertexAtGamma(
|
||||
0.6623267054958116, 1.437248992086214, 1.0420169560077686,
|
||||
0.6896178197389236, 0.5195634857410114, 0.6304500578493993),
|
||||
2.0459750326926214, 1e-12);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Port of de.varylab.discreteconformal.plugin.HyperIdealVisualizationPluginTest (Java/JUnit).
|
||||
//
|
||||
// Tests the conversion from a hyperbolic circle (hyperboloid model)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2024-2026 Tarik Moussa.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Port of de.varylab.discreteconformal.math.MatrixUtilityTest (Java/JUnit).
|
||||
|
||||
#include "matrix_utility.hpp"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user