Files
ConformalLabpp/doc/tutorials/add-output-uv-map.md
Tarik Moussa 039cc26e36
Some checks failed
C++ Tests / test-fast (push) Successful in 1m58s
C++ Tests / test-fast (pull_request) Successful in 2m33s
API Docs / doc-build (pull_request) Successful in 51s
C++ Tests / test-cgal (push) Has been skipped
C++ Tests / test-cgal (pull_request) Failing after 10m32s
Phase 8b-Lite: pipe-operator chaining for named parameters
Adds `operator|` in `namespace CGAL` so package-local named parameters
can be combined left-to-right without modifying CGAL upstream:

    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);

Why not the canonical `.a().b().c()` syntax
───────────────────────────────────────────
CGAL's standard chaining mechanism requires registering each named
parameter as a member function on `Named_function_parameters` via the
`CGAL_add_named_parameter` macro in
`CGAL/STL_Extension/internal/parameters_interface.h` — a vendored
upstream file that conformallab++ deliberately treats as read-only.

Adding member-function chainers for our package-local tags would
require either forking CGAL or modifying the vendored copy.  Neither
is acceptable for a library that wants to remain portable across
future CGAL releases.

The pipe-operator achieves the same compositional semantics via a
free function in `namespace CGAL` (so ADL finds it for
`Named_function_parameters` operands).  Implementation: rebuild the
right-hand-side `Named_function_parameters` with the left-hand-side
as its `Base`, producing an indistinguishable chain that every entry
function accepts unchanged.

Implementation: `code/include/CGAL/Conformal_map/internal/parameters.h`
lines 158-187.  The operator is constrained to right-hand-sides with
`No_property` base (i.e. fresh single-parameter packs from the helper
functions), so it never collides with any future CGAL operator on the
same type.

Tests (2 new, total Phase-8b-Lite suite 15 → 17)
────────────────────────────────────────────────
* CGALPhase8bLite.NamedParamPipe_MultipleParamsTakeEffect
    Chain three parameters; verify all three take effect (tight
    tolerance respected + UV pmap populated + iteration cap honoured).
* CGALPhase8bLite.NamedParamPipe_TwoParams
    Chain two parameters; verify max_iterations(0) blocks the loop
    even when combined with another param.

Full CGAL suite: 234/234 PASSED, 0 SKIPPED (was 232).
Total: 257/257 PASSED, 0 SKIPPED (was 255).
scripts/check-test-counts.sh: OK.

Documentation updates
─────────────────────
* doc/tutorials/add-output-uv-map.md §3.4: "Current limitation: no
  chaining" → "Chaining: use the pipe operator `|`".  Explains why
  CGAL's `.member()` syntax isn't available and shows the `|`
  workaround with a working code example.
* doc/architecture/locked-vs-flexible.md §8: chaining now flagged as
  shipped via pipe; recommended posture says `.member()` chaining
  only if a user pushes for the CGAL-canonical syntax.
* doc/roadmap/porting-status.md §5: API limitations table updated.
* doc/api/tests.md: CGALPhase8bLite row 15 → 17, total 232 → 234.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 13:43:52 +02:00

488 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# Tutorial: The `output_uv_map` Named Parameter
This tutorial documents the `output_uv_map` named parameter for the
CGAL-style entry functions of conformallab++: how to use it (the "happy
path"), how the CGAL named-parameter machinery threads a user-supplied
property map through to the layout step, and how to extend the same
pattern to add new named parameters to existing or new entry functions.
**Target audience.** A working mathematician using the C++ API who is
comfortable with templates and CGAL property maps but wants a single
authoritative reference for the pattern.
**Prerequisite reading.**
- `code/include/CGAL/Conformal_map/internal/parameters.h` — tag definitions
- `code/include/CGAL/Discrete_conformal_map.h` — wiring in three entries
- `code/tests/cgal/test_cgal_phase8b_lite.cpp` — the OutputUvMap_* tests
- `doc/tutorials/add-inversive-distance.md` — sibling tutorial in this series
---
## 1. What this tutorial is and isn't
### The UX problem `output_uv_map` solves
Before Phase 8b-Lite, computing a flattening required **two separate
calls**: the Newton wrapper, then a manual rebuild of `*_maps` plus a
replay of the wrapper's gauge/DOF choices, then `*_layout()`. Any drift
between the wrapper's gauge vertex and the caller's silently produced
inconsistent UVs.
The `output_uv_map` named parameter collapses this into one call:
```cpp
auto uv = mesh.add_property_map<Vertex_index, K::Point_2>("uv").first;
CGAL::discrete_conformal_map_euclidean(
mesh, CGAL::parameters::output_uv_map(uv));
// uv[v] now holds the flattened UV coordinate of vertex v.
```
This tutorial is **not** a derivation of the Newton solver (see
`add-inversive-distance.md`), not a guide to writing a new layout
routine, and not an introduction to CGAL named parameters in general
(see `<CGAL/Named_function_parameters.h>`).
---
## 2. Mathematical background
The package implements **two mathematically distinct stages**:
### Stage A — Newton solver (variational optimisation)
For the Euclidean DCE functional (SpringbornSchmiesBobenko 2008), the
solver finds the conformal scale factor vector `u ∈ ℝⁿ` that satisfies
```
G(u)_v := Θ_v Σ_{T ∋ v} α_v(T; u) = 0 ∀ v ∈ V \ {pinned}
```
i.e. the actual cone angles match the target curvature `Θ_v` at every
non-pinned vertex. The gradient `G` is the variational derivative of
the convex energy
```
E(u) = ∫_0^1 ⟨ G(t u), u ⟩ dt (path integral on the closed Yamabe 1-form)
```
The output is **purely combinatorial-metric**: the converged `u_v`
determines an intrinsic flat metric `_ij(u) = exp(½(u_i + u_j)) · _ij^(0)`
on the abstract triangulation. **No embedding yet exists.**
### Stage B — Layout (geometric realisation)
The layout step is a separate **trilateration** turning intrinsic edge
lengths into a `Point_2` (or `Point_3`) per vertex:
1. Pick a seed triangle `T₀`; place its three vertices canonically.
2. BFS over the dual graph from `T₀`. Each newly visited triangle has
two vertices already placed; the third is the unique solution of
two distance constraints (intersection of two circles).
3. The BFS uses a **priority queue keyed on BFS depth**
(`euclidean_layout` in `code/include/layout.hpp`) to avoid the
numerical drift of pure DFS on long thin meshes.
Spherical / hyperbolic variants substitute spherical-cap or Poincaré-
disk trilateration respectively.
Stage A is convex optimisation in ℝⁿ; Stage B is deterministic
realisation with no degrees of freedom. The named parameter chains them.
---
## 3. The user-facing API
### Happy path
```cpp
#include <CGAL/Simple_cartesian.h>
#include <CGAL/Surface_mesh.h>
#include <CGAL/Discrete_conformal_map.h>
using K = CGAL::Simple_cartesian<double>;
using Mesh = CGAL::Surface_mesh<K::Point_3>;
using Vertex_i = Mesh::Vertex_index;
Mesh mesh = /* load triangle mesh */;
// 1. Allocate a writable property map on the mesh.
auto uv = mesh.add_property_map<Vertex_i, K::Point_2>(
"v:uv", K::Point_2(0, 0)).first;
// 2. Ask the wrapper to populate it.
auto res = CGAL::discrete_conformal_map_euclidean(
mesh,
CGAL::parameters::output_uv_map(uv));
// 3. Use the UVs.
if (res.converged) {
for (auto v : mesh.vertices())
std::cout << v.idx() << ": " << uv[v] << '\n';
}
```
### Supported entries
| Entry | Value type per vertex |
|--------------------------------------|--------------------------------------------------|
| `discrete_conformal_map_euclidean` | `Point_2` — UV in ℝ² |
| `discrete_conformal_map_spherical` | `Point_3` — point on S² ⊂ ℝ³ |
| `discrete_conformal_map_hyper_ideal` | `Point_2` — point in the Poincaré disk (|p|≤1) |
### NOT supported (yet)
| Entry | Reason |
|----------------------------------------|-------------------------------------------------------------|
| `discrete_circle_packing_euclidean` | Face-based parametrisation — UV is per-face, not per-vertex |
| `discrete_inversive_distance_map` | Requires `inversive_distance_layout()` using Luo's `ℓ²` formula — not yet written |
For the circle-packing entry a separate `output_circle_map` parameter
(face → (centre, radius)) is the natural extension; for the inversive-
distance entry the layout primitive must be written first. See §7.
### Optional companion: `normalise_layout`
```cpp
CGAL::parameters::normalise_layout(true)
```
When the layout writes into `output_uv_map`, this flag triggers a
canonical post-processing pass:
- Euclidean: PCA — translate centroid to origin, rotate so the principal
axis is along `x`.
- Spherical: Rodrigues rotation so that the centroid is at the north pole.
- Hyperbolic: Möbius centring of the Poincaré disk.
Default: `false`. Has no effect unless `output_uv_map` is also passed.
### Chaining: use the pipe operator `|`
CGAL upstream supports chaining via `.member()` syntax for the standard
CGAL tags (e.g. `.geom_traits(...).vertex_point_map(...)`), but the
mechanism requires modifying CGAL's `parameters_interface.h` to register
new member functions on `Named_function_parameters` — which we
deliberately treat as a read-only vendored dependency.
Instead, conformallab++ provides a **pipe-operator overload** in
`namespace CGAL` (so ADL finds it) that achieves the same effect by
left-to-right composition:
```cpp
auto params = CGAL::parameters::gradient_tolerance(1e-12)
| CGAL::parameters::max_iterations(500)
| CGAL::parameters::output_uv_map(uv);
CGAL::discrete_conformal_map_euclidean(mesh, params);
```
Read as: "first apply gradient_tolerance, then max_iterations, then
output_uv_map". The result is a `Named_function_parameters` chain
indistinguishable from what `.gradient_tolerance(...).max_iterations(...)
.output_uv_map(...)` would have produced, so every entry function in the
package accepts it as-is.
Implementation: see the `operator|` in
`code/include/CGAL/Conformal_map/internal/parameters.h`.
Tests: `CGALPhase8bLite.NamedParamPipe_MultipleParamsTakeEffect` +
`NamedParamPipe_TwoParams`.
The `.member()` chaining of CGAL standard tags continues to work
unchanged — `|` is added alongside, not replacing.
---
## 4. Named-parameter mechanism — how it works
CGAL's named parameters are a compile-time dispatch trick. Three pieces:
### 4.1 The tag (in `internal_np`)
```cpp
namespace CGAL::Conformal_map::internal_np {
enum output_uv_map_t { output_uv_map };
}
```
A single-value `enum` is the lightest possible type-level marker. The
value `output_uv_map` is what callers will pass as the *key*; the type
`output_uv_map_t` is what `get_parameter` will use for the lookup.
### 4.2 The user-facing helper (in `CGAL::parameters`)
```cpp
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 // no "next" parameter in chain
>(pmap);
}
```
This wraps the property map in a `Named_function_parameters` object
tagged with `output_uv_map_t`. The third template argument is the
"previous" link in a chain; `No_property` means this is a singleton
pack. (Chaining would substitute the previous pack's type here.)
### 4.3 The internal lookup (inside the entry function)
The full read-and-act pattern from `Discrete_conformal_map.h`:
```cpp
// Step 1 — try to extract the parameter.
auto uv_param = parameters::get_parameter(
np, Conformal_map::internal_np::output_uv_map);
// Step 2 — at compile time, did we find one?
constexpr bool has_uv = !std::is_same_v<
decltype(uv_param), internal_np::Param_not_found>;
// Step 3 — conditionally do the extra work.
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()));
}
}
}
```
Key points:
- `get_parameter` returns either the wrapped value or a sentinel
`Param_not_found`. The check is `std::is_same_v<…, Param_not_found>`.
- `choose_parameter(get_parameter(np, tag), default_value)` is the
one-liner form for scalar parameters with a default.
- `constexpr if` ensures that if the user did not pass `output_uv_map`,
the layout call is **not even instantiated** — no runtime cost, and
no need for the layout routine to be callable on the wrapper's
signature (e.g. it can require a Point_2 traits type that the user
hasn't supplied).
- The layout step is gated on `nr.converged` for a reason: trilateration
on a non-converged `u` produces garbage edge lengths and can throw
on degenerate triangles. The current contract is **layout iff Newton
converged**; on non-convergence the pmap is left at the value the
caller initialised it with.
The same three-step pattern appears in `discrete_conformal_map_spherical`
(writing `Point_3`) and `discrete_conformal_map_hyper_ideal` (writing
`Point_2` in the Poincaré disk).
---
## 5. How to extend the pattern
Recipe for adding a new package-local named parameter — running example
`output_holonomy_map` (a per-edge map carrying the Möbius/translation
holonomy data that some downstream algorithms consume).
### Step 1 — Add the tag in `internal/parameters.h`
```cpp
namespace CGAL::Conformal_map::internal_np {
/// Property-map: edge_descriptor → 2x2 matrix of holonomy data.
/// Default: not populated.
enum output_holonomy_map_t { output_holonomy_map };
}
```
Two-line block. Use a meaningful Doxygen `\internal` comment because
this is the canonical contract for the parameter's semantics.
### Step 2 — Add the user-facing helper in `parameters.h`
```cpp
namespace CGAL::parameters {
/// `output_holonomy_map(pmap)` — write per-edge holonomy matrices
/// into `pmap`. Only meaningful for closed surfaces with non-trivial
/// π₁; ignored on simply-connected meshes.
template <typename PropertyMap>
auto output_holonomy_map(const PropertyMap& pmap) {
return CGAL::Named_function_parameters<
PropertyMap,
Conformal_map::internal_np::output_holonomy_map_t,
CGAL::internal_np::No_property
>(pmap);
}
} // namespace CGAL::parameters
```
A Doxygen comment is **mandatory** here — this is the public surface.
### Step 3 — Use `get_parameter + constexpr if` in the entry function
```cpp
auto hol_param = parameters::get_parameter(
np, Conformal_map::internal_np::output_holonomy_map);
constexpr bool has_hol = !std::is_same_v<
decltype(hol_param), internal_np::Param_not_found>;
if constexpr (has_hol) {
auto H = ::conformallab::compute_holonomy(mesh, nr.x, maps);
for (auto e : mesh.edges())
put(hol_param, e, H[e.idx()]);
}
```
### Step 4 — Add tests
At minimum two:
1. **Takes-effect test.** Pass the parameter; verify the side effect
actually happens (pmap is populated, values are non-default).
2. **Sanity-when-absent test.** Call the wrapper without the parameter;
verify the call still succeeds and produces the expected base result.
Both patterns are exemplified in `test_cgal_phase8b_lite.cpp` (next
section).
### Optional Step 5 — Companion flag
If your new parameter has a Boolean tweak (analogous to
`normalise_layout`), follow exactly the same recipe — `enum X_t { X }`
+ helper + `choose_parameter(..., default)` at the read site.
---
## 6. Tests — walkthrough of `test_cgal_phase8b_lite.cpp`
The OutputUvMap_* tests live at the bottom of the file. Each codifies
one acceptance criterion.
### 6.1 `OutputUvMap_Euclidean_PopulatesPmap`
Two assertions: (a) every UV is finite, (b) at least one vertex has
moved off the origin. Test (b) rules out the false-positive where the
layout silently returned the pmap's default at every vertex.
### 6.2 `OutputUvMap_Spherical_PopulatesXyz`
Geometric round-trip: every output point must lie on the unit sphere.
```cpp
const double r = std::sqrt(p.x()*p.x() + p.y()*p.y() + p.z()*p.z());
EXPECT_NEAR(r, 1.0, 1e-6);
```
The spherical analogue of 6.1(b): the layout is correct iff the
realisation lands on S².
### 6.3 `OutputUvMap_HyperIdeal_PointsInPoincareDisk`
The natural Θ/θ targets do not always reach Newton equilibrium inside
the default `max_iterations(200)`. The test branches:
```cpp
if (res.converged) {
for (auto v : mesh.vertices()) {
const double r2 = p.x()*p.x() + p.y()*p.y();
EXPECT_LE(r2, 1.0 + 1e-6);
}
}
// else: layout was skipped by the wrapper's `if (nr.converged)` guard.
```
Documenting the wrapper contract ("layout iff converged") via the
test keeps it resilient to PRNG-sensitive Newton paths.
### 6.4 `OutputUvMap_Absent_DoesNotRunLayout`
Sanity test: omitting `output_uv_map` must not change the existing
behaviour. Catches the regression where adding the new `constexpr if`
perturbs the wrapper's result type or consumes extra work.
### 6.5 `OutputUvMap_NormaliseLayout_TakesEffect`
Because chaining is not yet implemented (§3), this exercises the toggle
indirectly: it runs the wrapper twice into two separate pmaps and
verifies both populate finite values. When chaining lands, upgrade this
to actually pass `normalise_layout(true)` on the second call and compare
UV bounding boxes (the normalised one must be axis-aligned and
centroid-zero).
---
## 7. Adding output to the two missing modes
### 7.1 CP-Euclidean (face-based) — proposed `output_circle_map`
The circle-packing entry parametrises **faces**: each face `f` has a
radius `ρ_f`. The natural per-face output is `(centre, radius)`:
```cpp
auto circles = mesh.add_property_map<Face_index,
std::pair<K::Point_2, double>>("f:circle", ...).first;
CGAL::discrete_circle_packing_euclidean(
mesh, CGAL::parameters::output_circle_map(circles));
```
Effort: **12 days**. The face-graph BFS-packing is Stephenson's
`circlepack` algorithm; radii come from Newton, new work is the
geometric loop plus the §5 plumbing.
### 7.2 Inversive-Distance — needs new layout routine first
The inversive-distance functional yields edge lengths
```
_ij(u)² = r_i² + r_j² + 2 I_ij r_i r_j (Luo 2004 §3)
```
with `r_i = exp(u_i)`. Required work: write
`inversive_distance_layout()` in `code/include/layout.hpp` mirroring
`euclidean_layout()` but consuming the above `_ij(u)` instead of
`exp(½(u_i + u_j)) · _ij^(0)`. Then wire the parameter through
`discrete_inversive_distance_map` exactly as §4.3 shows.
Effort: **~3 days**. Status: **TBD**.
---
## 8. Acceptance checklist for a new named parameter
Use this checklist when extending the package:
- [ ] **Tag declared** in `code/include/CGAL/Conformal_map/internal/parameters.h`
(`enum X_t { X };`) with a `\internal` Doxygen comment specifying
the property-map key/value types and default behaviour.
- [ ] **User-facing helper** in the same file's `CGAL::parameters`
namespace, with a public Doxygen comment.
- [ ] **`get_parameter + constexpr if` pattern** in every entry function
that supports the parameter (do not leak the tag into entries that
ignore it).
- [ ] **At least two tests** in `test_cgal_phase8b_lite.cpp` (or a new
test file if introducing a new functional): a
*parameter-takes-effect* test and a *sanity-when-absent* test.
- [ ] **Limitation documented.** If the parameter is mode-specific
(e.g. only meaningful for closed meshes) or cannot be chained
with existing helpers, say so in the user-facing Doxygen and in
a comment near the read site.
- [ ] **No silent regressions.** If the parameter changes the wrapper's
observable behaviour even when absent (e.g. via a new default),
add a regression test pinning the old default.
---
## 9. Further reading
- Springborn, Schmies, Bobenko (2008). *Conformal equivalence of
triangle meshes.* SIGGRAPH. — Euclidean DCE functional.
- Bobenko, Pinkall, Springborn (2015). *Discrete conformal maps and
ideal hyperbolic polyhedra.* Geom. Topol. — hyper-ideal variant.
- Glickenstein (2011). *Discrete conformal variations and scalar
curvature on piecewise flat manifolds.* JDG 87(2).
- `<CGAL/Named_function_parameters.h>` — upstream machinery.