Mouse Picking¶
click cubes to change their colour.
▶ Run in browserTags: 3d
Uses the engine’s built-in object picking: give a node a CollisionShape3D
with pickable=True and override on_picked. Each click casts a ray
(SceneTree.input_cast) and the nearest pickable collider’s owner receives
on_picked. For the manual approach (casting a world-space ray with
screen_to_ray and querying self.physics.raycast yourself) see raycast.py.
Usage: uv run python examples/features/3d/picking.py uv run python examples/features/3d/picking.py –test
Source¶
1"""Mouse Picking: click cubes to change their colour.
2
3Uses the engine's built-in object picking: give a node a ``CollisionShape3D``
4with ``pickable=True`` and override ``on_picked``. Each click casts a ray
5(``SceneTree.input_cast``) and the nearest pickable collider's owner receives
6``on_picked``. For the manual approach (casting a world-space ray with
7``screen_to_ray`` and querying ``self.physics.raycast`` yourself) see ``raycast.py``.
8
9Usage:
10 uv run python examples/features/3d/picking.py
11 uv run python examples/features/3d/picking.py --test
12"""
13
14from simvx.core import (
15 Camera3D,
16 CollisionShape3D,
17 Material,
18 Mesh,
19 MeshInstance3D,
20 Node,
21 Signal,
22 SphereShape3D,
23 Text2D,
24)
25from simvx.graphics import App
26
27
28class PickableCube(MeshInstance3D):
29 """A cube that responds to mouse clicks."""
30
31 picked = Signal(str) # emits this cube's label whenever it is clicked
32
33 _COLOURS = [
34 (0.9, 0.1, 0.1, 1),
35 (0.1, 0.9, 0.1, 1),
36 (0.1, 0.1, 0.9, 1),
37 (0.9, 0.9, 0.1, 1),
38 (0.9, 0.1, 0.9, 1),
39 (0.1, 0.9, 0.9, 1),
40 ]
41
42 def __init__(self, label="Cube", colour_idx=0, **kwargs):
43 mat = Material(colour=self._COLOURS[colour_idx % len(self._COLOURS)])
44 super().__init__(material=mat, **kwargs)
45 self.label = label
46 self._colour_idx = colour_idx
47
48 def on_ready(self):
49 # Inscribed sphere (touches face centres) for tight picking
50 shape = CollisionShape3D(shape=SphereShape3D(radius=0.5))
51 shape.pickable = True
52 self.add_child(shape)
53
54 def on_picked(self, event):
55 """Called when this node is picked by mouse ray."""
56 self._colour_idx = (self._colour_idx + 1) % len(self._COLOURS)
57 self.material.colour = self._COLOURS[self._colour_idx]
58 self.picked.emit(self.label)
59
60
61class PickingScene(Node):
62 def on_ready(self):
63 # Camera: looking down Y axis
64 cam = Camera3D(position=(0, -15, 0))
65 cam.look_at((0, 0, 0), up=(0, 0, 1))
66 self.add_child(cam)
67
68 # Shared mesh
69 cube_mesh = Mesh.cube()
70
71 # Grid of pickable cubes
72 positions = [
73 (-3, 0, -2),
74 (0, 0, -2),
75 (3, 0, -2),
76 (-3, 0, 2),
77 (0, 0, 2),
78 (3, 0, 2),
79 ]
80 for i, pos in enumerate(positions):
81 cube = PickableCube(
82 label=f"Cube_{i}",
83 colour_idx=i,
84 mesh=cube_mesh,
85 position=pos,
86 )
87 cube.picked.connect(self._on_cube_picked)
88 self.add_child(cube)
89
90 # HUD
91 self.add_child(Text2D(text="Click cubes to change colour", position=(10, 10), font_scale=1.5))
92 self._status = self.add_child(Text2D(text="Nothing picked yet", position=(10, 40), font_scale=1.2))
93
94 def _on_cube_picked(self, label):
95 self._status.text = f"Picked {label}"
96
97
98def _selftest() -> bool:
99 """Headless: click each cube where it appears on screen and check who answers.
100
101 Nothing casts a ray here. The click is a simulated mouse press, and the frame
102 loop turns it into the cast exactly as it does on the desktop, so what runs is
103 the whole path: the press, the ray, the pickable collider it hits first, and
104 ``on_picked`` on the node that owns it. Picking the RIGHT cube is the claim,
105 so each one is clicked in turn and the label it reports back is compared to
106 the one that was aimed at.
107 """
108 import numpy as np
109
110 from simvx.core.testing import InputSimulator
111 from simvx.graphics.testing import assert_not_blank, save_png
112
113 WIDTH, HEIGHT = 1280, 720
114 app = App(title="Picking Demo", width=WIDTH, height=HEIGHT, visible=False)
115 scene = PickingScene(name="PickingScene")
116 sim = InputSimulator()
117 seen: dict[str, object] = {}
118
119 def to_screen(camera, world) -> tuple[float, float]:
120 """Where a world point lands on screen: the inverse of ``screen_to_ray``."""
121 proj = camera.projection_matrix(WIDTH / HEIGHT)
122 clip = proj @ camera.view_matrix @ np.array([*world, 1.0], dtype=np.float32)
123 ndc = clip[:3] / clip[3]
124 ndc_y = -ndc[1] if proj[1, 1] < 0 else ndc[1]
125 return ((ndc[0] + 1.0) * 0.5 * WIDTH, (1.0 - ndc_y) * 0.5 * HEIGHT)
126
127 cubes: list[PickableCube] = []
128 plan: list[tuple[int, PickableCube]] = [] # (frame, cube) -- one click each, spaced out
129
130 def on_frame(idx: int, _t: float) -> bool:
131 if idx == 0:
132 cubes.extend(c for c in scene.children if isinstance(c, PickableCube))
133 camera = next(c for c in scene.children if isinstance(c, Camera3D))
134 seen["camera"] = camera
135 seen["start_colours"] = [tuple(c.material.colour) for c in cubes]
136 plan.extend((4 + i * 4, cube) for i, cube in enumerate(cubes))
137 seen["last_frame"] = plan[-1][0] + 8
138 for frame, cube in plan:
139 if idx == frame:
140 sim.click(to_screen(seen["camera"], tuple(cube.position)))
141 elif idx == frame + 1:
142 seen.setdefault("answers", []).append((cube.label, scene._status.text))
143 if idx == seen["last_frame"]:
144 seen["after_colours"] = [tuple(c.material.colour) for c in cubes]
145 # A click on the empty corner must reach nothing at all.
146 seen["before_miss"] = scene._status.text
147 sim.click((8.0, 8.0))
148 elif idx == seen["last_frame"] + 1:
149 seen["after_miss"] = scene._status.text
150 return True
151
152 frames = app.run_headless(scene, frames=40, on_frame=on_frame, capture_frames=[39])
153 assert_not_blank(frames[0])
154 save_png(frames[0], "/tmp/picking_test.png")
155
156 ok = True
157
158 def check(label: str, passed: bool, detail: str) -> None:
159 nonlocal ok
160 ok = ok and passed
161 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
162
163 answers = seen["answers"]
164 wrong = [(aimed, got) for aimed, got in answers if got != f"Picked {aimed}"]
165 check(
166 "each cube is picked by clicking where it appears, and no other",
167 len(answers) == len(cubes) and not wrong,
168 f"{len(answers)} clicks, {len(answers) - len(wrong)} answered by the cube aimed at",
169 )
170
171 # on_picked advances the cube's colour, so every cube must have moved on by
172 # exactly one step of its own palette after exactly one pick each.
173 palette = PickableCube._COLOURS
174
175 def index_of(colour) -> int:
176 """Which palette entry this colour is (the material stores it as float32)."""
177 return min(range(len(palette)), key=lambda i: max(abs(a - b) for a, b in zip(palette[i], colour, strict=True)))
178
179 stepped = [
180 index_of(after) == (index_of(before) + 1) % len(palette)
181 for before, after in zip(seen["start_colours"], seen["after_colours"], strict=True)
182 ]
183 check(
184 "picking a cube steps it to the next colour in its palette",
185 all(stepped),
186 f"{sum(stepped)}/{len(stepped)} cubes advanced one step",
187 )
188
189 check(
190 "a click on empty space picks nothing",
191 seen["after_miss"] == seen["before_miss"],
192 f"status stayed at {seen['after_miss']!r}",
193 )
194
195 print("screenshot: /tmp/picking_test.png")
196 print("SELFTEST:", "PASS" if ok else "FAIL")
197 return ok
198
199
200if __name__ == "__main__":
201 import sys
202
203 if "--test" in sys.argv:
204 sys.exit(0 if _selftest() else 1)
205 app = App(title="Picking Demo", width=1280, height=720)
206 app.run(PickingScene())