Mouse Picking

click cubes to change their colour.

▶ Run in browser

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