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>
17 KiB
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 definitionscode/include/CGAL/Discrete_conformal_map.h— wiring in three entriescode/tests/cgal/test_cgal_phase8b_lite.cpp— the OutputUvMap_* testsdoc/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:
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 (Springborn–Schmies–Bobenko 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:
- Pick a seed triangle
T₀; place its three vertices canonically. - 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). - The BFS uses a priority queue keyed on BFS depth
(
euclidean_layoutincode/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
#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 ( |
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
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:
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)
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)
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:
// 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_parameterreturns either the wrapped value or a sentinelParam_not_found. The check isstd::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 ifensures that if the user did not passoutput_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.convergedfor a reason: trilateration on a non-convergeduproduces 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
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
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
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:
- Takes-effect test. Pass the parameter; verify the side effect actually happens (pmap is populated, values are non-default).
- 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.
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:
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):
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: 1–2 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\internalDoxygen comment specifying the property-map key/value types and default behaviour. - User-facing helper in the same file's
CGAL::parametersnamespace, with a public Doxygen comment. get_parameter + constexpr ifpattern 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.