Examples

The examples/ folder contains runnable demos. They need PyVista:

pip install ".[examples]"   # or: pip install pyvista

Interactive projection

examples/interactive_projection.py projects a movable point onto a single triangle through three maps at once, and shows each projection live as you drag x/y/z sliders:

  • vertex-to-vertex (EmbP2PMap) — snaps to the nearest triangle vertex.

  • vertex-to-face (EmbPreciseMap) — the closest point on the triangle face.

  • kernel, row-normalized (EmbKernelDenseDistMap) — a blur-weighted average of the vertices (the expected position), tuned live with a blur slider.

The key idea is that positions are used directly as embeddings, so the map’s embedding space is plain geometry and each projected location is obtained purely through the library’s function transfer P.pull_back(V) — no bespoke projection code. The map builders live in a single MAPS dict, so adding another representation makes it appear in the demo automatically.

Note

The window is interactive, so run it locally rather than in a headless environment:

python examples/interactive_projection.py            # interactive window
python examples/interactive_projection.py --no-show  # headless numeric check

Source

  1"""Interactive projection of a moving point onto a mesh, via densemaps.
  2
  3A single triangle plays the role of the target shape ``S1``; a movable point plays the role of the
  4(single-vertex) source shape ``S2``. We build a correspondence map ``S2 -> S1`` from *positions as
  5embeddings*, so the map's embedding space is plain 3D geometry and its projection is the geometric
  6closest point. The projected location is obtained purely through the library's function transfer
  7``P.pull_back(V)`` — no bespoke projection code:
  8
  9- ``EmbP2PMap``            (vertex-to-vertex): pull_back picks the nearest triangle **vertex**.
 10- ``EmbPreciseMap``        (vertex-to-face):   pull_back returns the closest point on the **face**
 11  (barycentric combination of the 3 vertices).
 12- ``EmbKernelDenseDistMap`` (kernel, row-normalized): pull_back returns a soft, blur-weighted
 13  **average** of the vertices (the expected position) -- generally not the surface footpoint.
 14
 15All projections are shown at once. Move the point with the x/y/z sliders and tune the kernel
 16sharpness with the blur slider. Sliders update continuously as you drag.
 17
 18Run:
 19    python examples/interactive_projection.py            # interactive window
 20    python examples/interactive_projection.py --no-show  # headless numeric check
 21"""
 22
 23import os
 24import sys
 25
 26import numpy as np
 27
 28# Allow running straight from a checkout (`python examples/interactive_projection.py`) without
 29# installing the package first.
 30try:
 31    from densemaps.numpy.maps import EmbP2PMap, EmbPreciseMap, EmbKernelDenseDistMap
 32except ModuleNotFoundError:
 33    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 34    from densemaps.numpy.maps import EmbP2PMap, EmbPreciseMap, EmbKernelDenseDistMap
 35
 36# --- Geometry: target shape S1 is a single triangle -------------------------------------------
 37V = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])  # (3, 3) vertices
 38F = np.array([[0, 1, 2]])  # (1, 3) faces
 39INIT_P = np.array([0.3, 0.3, 0.6])  # initial position of the movable point
 40INIT_BLUR = 0.3  # initial kernel blur (Gaussian std over embedding distances)
 41
 42# Each map: display color + a builder that returns a densemaps map S2 -> S1 from the point `p`
 43# and the current `blur` (ignored by the maps that don't use it). Add another map here and it
 44# shows up in the demo automatically.
 45MAPS = {
 46    "vertex-to-vertex": ("orange", lambda p, blur: EmbP2PMap(V, p)),
 47    "vertex-to-face": ("green", lambda p, blur: EmbPreciseMap(V, p, F)),
 48    "kernel (row-norm)": ("purple", lambda p, blur: EmbKernelDenseDistMap(V, p, blur=blur)),
 49}
 50
 51
 52def project(point, blur=INIT_BLUR):
 53    """Project ``point`` through every map. Pure/testable — no PyVista involved.
 54
 55    Parameters
 56    ----------
 57    point : array-like, shape (3,)
 58        Position of the movable point (the single vertex of S2).
 59    blur : float
 60        Gaussian blur used by the kernel map (ignored by the others).
 61
 62    Returns
 63    -------
 64    dict[str, tuple[np.ndarray, str]]
 65        ``{map_name: (projected_xyz (3,), color)}``.
 66    """
 67    p = np.asarray(point, dtype=float).reshape(1, 3)  # (n2=1, 3) embedding == position
 68    out = {}
 69    for name, (color, build) in MAPS.items():
 70        proj = build(p, blur).pull_back(V)[0]  # transfer the vertex-coordinate function -> (3,)
 71        out[name] = (np.asarray(proj, dtype=float), color)
 72    return out
 73
 74
 75def smoke_check():
 76    """Headless numeric sanity check (no display)."""
 77    projections = project(INIT_P, INIT_BLUR)
 78    print(f"point = {INIT_P.tolist()}  blur = {INIT_BLUR}")
 79    for name, (proj, _) in projections.items():
 80        print(f"  {name:>18s} -> {np.round(proj, 6).tolist()}")
 81
 82    vv = projections["vertex-to-vertex"][0]
 83    vf = projections["vertex-to-face"][0]
 84
 85    # vertex-to-vertex must land exactly on one of the triangle vertices.
 86    assert np.isclose(np.linalg.norm(V - vv, axis=1), 0.0).any(), "v2v is not a triangle vertex"
 87    # vertex-to-face must lie in the triangle plane (z = 0 here).
 88    assert abs(vf[2]) < 1e-9, "v2f is not in the triangle plane"
 89
 90    # Kernel point sharpens toward the nearest vertex as blur -> 0 and toward the centroid as
 91    # blur -> inf (a convex combination of the vertices either way).
 92    ker_sharp = project(INIT_P, blur=0.02)["kernel (row-norm)"][0]
 93    ker_soft = project(INIT_P, blur=50.0)["kernel (row-norm)"][0]
 94    assert np.linalg.norm(ker_sharp - vv) < 1e-3, "kernel should snap to nearest vertex at low blur"
 95    assert (
 96        np.linalg.norm(ker_soft - V.mean(0)) < 1e-2
 97    ), "kernel should tend to centroid at high blur"
 98    print("smoke check OK")
 99
100
101def run_interactive():
102    import pyvista as pv
103
104    p0 = INIT_P.copy()
105    state = {"p": p0.copy(), "blur": INIT_BLUR}
106
107    plotter = pv.Plotter()
108    legend = "red = point\n" + "\n".join(f"{color} = {name}" for name, (color, _) in MAPS.items())
109    plotter.add_text(legend, font_size=10, position="upper_right")
110
111    # Triangle surface + its vertices.
112    tri = pv.PolyData(V, faces=np.hstack([[3], [0, 1, 2]]))
113    plotter.add_mesh(tri, color="lightsteelblue", opacity=0.4, show_edges=True, line_width=2)
114    plotter.add_point_labels(
115        V, ["v0", "v1", "v2"], font_size=14, point_size=10, render_points_as_spheres=True
116    )
117
118    # Movable point.
119    pt_mesh = pv.PolyData(p0.reshape(1, 3))
120    plotter.add_mesh(pt_mesh, color="red", point_size=18, render_points_as_spheres=True)
121
122    # One projection point + connector line per map.
123    proj_meshes, line_meshes = {}, {}
124    for name, (proj, color) in project(p0, state["blur"]).items():
125        proj_meshes[name] = pv.PolyData(proj.reshape(1, 3))
126        plotter.add_mesh(
127            proj_meshes[name], color=color, point_size=16, render_points_as_spheres=True
128        )
129        line_meshes[name] = pv.Line(p0, proj)
130        plotter.add_mesh(line_meshes[name], color=color, line_width=2)
131
132    def refresh():
133        p = state["p"]
134        pt_mesh.points = p.reshape(1, 3)
135        for name, (proj, _) in project(p, state["blur"]).items():
136            proj_meshes[name].points = proj.reshape(1, 3)
137            line_meshes[name].points = np.vstack([p, proj])
138        plotter.render()
139
140    def make_axis_cb(axis):
141        def cb(value):
142            state["p"][axis] = value
143            refresh()
144
145        return cb
146
147    def blur_cb(value):
148        state["blur"] = value
149        refresh()
150
151    # Stacked sliders: x, y, z (position) then blur (kernel sharpness). `interaction_event="always"`
152    # makes them update continuously while dragging, not only on release.
153    for axis, label in enumerate("xyz"):
154        y = 0.90 - 0.12 * axis
155        plotter.add_slider_widget(
156            make_axis_cb(axis),
157            rng=(-1.5, 1.5),
158            value=float(p0[axis]),
159            title=label,
160            pointa=(0.025, y),
161            pointb=(0.31, y),
162            style="modern",
163            interaction_event="always",
164        )
165
166    plotter.add_slider_widget(
167        blur_cb,
168        rng=(0.05, 1.5),
169        value=float(state["blur"]),
170        title="blur",
171        pointa=(0.025, 0.90 - 0.12 * 3),
172        pointb=(0.31, 0.90 - 0.12 * 3),
173        style="modern",
174        interaction_event="always",
175    )
176
177    plotter.add_axes()
178    plotter.show()
179
180
181def main():
182    if "--no-show" in sys.argv:
183        smoke_check()
184    else:
185        run_interactive()
186
187
188if __name__ == "__main__":
189    main()