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