Files
ConformalLabpp/doc/tutorials/add-output-uv-map.md
Tarik Moussa eb393537f3 docs: 5-document meeting prep — tutorials + research note + status + architecture
External-reviewer-visit prep package (Springborn-Bobenko PhD alumnus,
2026-05-26).  All five documents target the same audience: a
mathematician who wants to evaluate, extend, or contribute to
conformallab++.  Goal: make the project maximally hackable BEFORE the
meeting.  Code unchanged in this commit — pure documentation.

Files added
───────────

1. **doc/tutorials/block-fd-hessian.md** (460 lines)
   Step-by-step tutorial on the per-face block-FD Hessian pattern
   shipped in Phase 9b (96× speed-up).  Matches the style of
   add-inversive-distance.md.  Covers:
   * The per-face locality lemma (mathematical justification).
   * Cost analysis (full-FD vs block-FD vs analytic).
   * Implementation walkthrough through face_angles_from_local_dofs +
     hyper_ideal_hessian_block_fd.
   * Porting checklist for applying the same pattern to a new
     functional.
   * The four cross-validation criteria.
   * When NOT to use block-FD + upgrade path to Phase 9b-analytic.

2. **doc/tutorials/add-output-uv-map.md** (477 lines)
   Tutorial for the `output_uv_map` named-parameter pattern shipped in
   PR #14.  Covers:
   * The UX problem (two-step pipeline → one-call wrapper).
   * The CGAL named-parameter mechanism + how the entry functions
     wire it (get_parameter + constexpr if).
   * Step-by-step recipe for adding a new named parameter (worked
     example: hypothetical `output_holonomy_map`).
   * The five test patterns for verification.
   * Why CP-Euclidean (face-DOF) and Inversive-Distance (Luo-edge-length)
     do not yet support output_uv_map — what is needed to add them.

3. **doc/math/hyperideal-hessian-derivation.md** (805 lines)
   Research-quality LaTeX-formatted derivation of the analytic
   HyperIdeal Hessian via the Schläfli identity (Phase 9b-analytic
   preparation).  Covers:
   * Schläfli identity (1858/60) — gradient and second-order form.
   * Derivatives of ζ, ζ₁₃, ζ₁₄, ζ₁₅ (all hyper-ideal-to-fully-ideal cases).
   * Chain rule for ∂β_i/∂(b,a) and ∂α_ij/∂(b,a) — case-split on the
     four α_ij branches.
   * Per-face 6×6 block formulas.
   * Acceptance criteria for the future implementation.
   * Implementation outline (Conformal_map header sketch).
   * Appendix A: sign / argument-order pitfalls reading the code.
   * References: Schläfli 1858, Milnor 1982, Vinberg 1993, Cho-Kim 1999,
     Rivin, Glickenstein 2011, Springborn 2020, BPS 2015.

4. **doc/roadmap/porting-status.md** (~250 lines)
   Operational snapshot of "where is each piece of Java math today"
   at v0.9.0.  Sections:
   * 25 000 lines of Java in one table (ported / worth porting /
     intentionally skipped breakdown).
   * Five DCE models — full status matrix with Java port status,
     Hessian type, Newton support, CGAL entry, UV-output capability.
   * Topology + solver infrastructure status.
   * CGAL public API map + known limitations (no chaining, Surface_mesh
     only, submission-readiness gaps).
   * Reverse cross-reference: Java class → C++ port location (or
     "skipped: replaced by …" / "in roadmap: phase X").
   * Things in C++ that the Java original does NOT have (research
     extensions track).
   * "How to use the library today" quickstart.

5. **doc/architecture/locked-vs-flexible.md** (~270 lines)
   12-item architecture-decision review with tier classification
   (🔴 load-bearing / 🟡 semi-fixed / 🟢 opportunistic).  Each item
   includes: locked-since date, cost to change, when to revisit,
   recommended posture for new contributors.  Key insight stated up
   front: "the load-bearing decisions are all good in 2026".  Closes
   with five open questions for the external reviewer — items where
   a second opinion would genuinely help (Phase 9c algorithm choice,
   Phase 10a priorities, analytic-Hessian payoff justification,
   CGAL upstream vs independent distribution, geometry-central
   cross-validation).

Total: ~2 250 lines across five new docs.

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

17 KiB
Raw Blame History

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:

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

#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.

Current limitation: no chaining

The CGAL machinery supports chaining named parameters via .member() syntax for the standard CGAL tags (e.g. geom_traits, vertex_point_map). Package-local tags do not yet have member-function counterparts, so

// DOES NOT COMPILE:
CGAL::parameters::output_uv_map(uv).normalise_layout(true);

is currently rejected. Pass one parameter per call instead. This is the same limitation noted in test_cgal_phase8b_lite.cpp:

// Named-parameter chaining (`a.b().c()`) is not currently supported on
// the package-local tags; pass one parameter per call instead.

Adding member-function chaining is a separate Phase 8b extension — requires writing a derived Named_function_parameters subclass that exposes .output_uv_map(...), .normalise_layout(...), etc.


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_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

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:

  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.

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: 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.