diff --git a/docs/source/examples/gui/layouts.rst b/docs/source/examples/gui/layouts.rst index 50fe400df..fc29eeeee 100644 --- a/docs/source/examples/gui/layouts.rst +++ b/docs/source/examples/gui/layouts.rst @@ -82,7 +82,7 @@ Code "Quality", options=["Low", "Medium", "High"], initial_value="Medium" ) - # Add some visual objects to demonstrate the controls + # Add some visual objects to demonstrate the controls. server.scene.add_icosphere( name="demo_sphere", radius=sphere_radius.value, diff --git a/docs/source/examples/gui/plotly_integration.rst b/docs/source/examples/gui/plotly_integration.rst index 8eb4c6a8f..472e148d6 100644 --- a/docs/source/examples/gui/plotly_integration.rst +++ b/docs/source/examples/gui/plotly_integration.rst @@ -60,7 +60,7 @@ Code line_plot = server.gui.add_plotly(figure=create_sinusoidal_wave(line_plot_time)) # Plot type 2: Image plot. - # Use Path to handle relative paths correctly from any working directory + # Use Path to handle relative paths correctly from any working directory. from pathlib import Path assets_dir = Path(__file__).parent.parent / "assets" diff --git a/docs/source/examples/gui/plots_as_images.rst b/docs/source/examples/gui/plots_as_images.rst index e737a8d0a..e5f5f5f1d 100644 --- a/docs/source/examples/gui/plots_as_images.rst +++ b/docs/source/examples/gui/plots_as_images.rst @@ -73,7 +73,7 @@ Code left_pad = int(y_text_size[0] * 1.5) + extra_padding # Space for y-axis labels right_pad = int(10 * font_scale) + extra_padding - # Calculate top padding, accounting for title if present + # Calculate top padding, accounting for title if present. top_pad = int(10 * font_scale) + extra_padding title_font_scale = font_scale * 1.5 # Make title slightly larger if title is not None: @@ -89,11 +89,11 @@ Code plot_height = height - top_pad - bottom_pad assert plot_width > 0 and plot_height > 0 - # Create image with specified background color + # Create image with specified background color. img = np.ones((total_height, total_width, 3), dtype=np.uint8) img[:] = background_color - # Create plot area with specified color + # Create plot area with specified color. plot_area = np.ones((plot_height, plot_width, 3), dtype=np.uint8) plot_area[:] = plot_area_color img[top_pad : top_pad + plot_height, left_pad : left_pad + plot_width] = plot_area @@ -135,7 +135,7 @@ Code img, [pts], False, line_color, thickness=line_thickness, lineType=cv2.LINE_AA ) - # Draw title if specified + # Draw title if specified. if title is not None: title_size = cv2.getTextSize(title, font, title_font_scale, 1)[0] title_x = left_pad + (plot_width - title_size[0]) // 2 diff --git a/docs/source/examples/interaction/batched_scene_node_drag.rst b/docs/source/examples/interaction/batched_scene_node_drag.rst index 7ad700f72..18bca3c61 100644 --- a/docs/source/examples/interaction/batched_scene_node_drag.rst +++ b/docs/source/examples/interaction/batched_scene_node_drag.rst @@ -170,7 +170,7 @@ Code active_mode: str | None = None grab_body: np.ndarray | None = None # body-frame grab, for translate/rotate spring_target: np.ndarray | None = None # world target for the spring - teleport_offset: np.ndarray | None = None # cursor → instance position + teleport_offset: np.ndarray | None = None # cursor -> instance position ext_torque = np.zeros(3) shutdown = threading.Event() @@ -199,7 +199,7 @@ Code # Spring acts on the *grab point* (body-frame # ``grab_body`` mapped to world via the body's # current pose). Off-center grabs naturally - # produce a torque (lever × force) on top of + # produce a torque (lever x force) on top of # the linear pull, so dragging an edge yanks # AND spins the body. Force is zero at drag # start (grab_world == spring_target == click) @@ -257,133 +257,118 @@ Code # Drag (no modifier): teleport. # ========================================================================== - @handle.on_drag_start("left") + @handle.on_drag("left") async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: nonlocal active_idx, active_mode, spring_target, teleport_offset - i = event.instance_index - if i is None: - return - set_instance_color(i, TELEPORT_COLOR) - active_idx_gui.value = str(i) - active_mode_gui.value = "teleport" - with lock: - active_idx = i - active_mode = "teleport" - cursor = np.array(event.start_position) - teleport_offset = position_arr[i] - cursor - spring_target = cursor - - @handle.on_drag_update("left") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal spring_target - with lock: - spring_target = np.array(event.end_position) - - @handle.on_drag_end("left") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal active_idx, active_mode, spring_target, teleport_offset - i = event.instance_index - if i is not None: - set_instance_color(i, IDLE_COLOR) - active_idx_gui.value = "-" - active_mode_gui.value = "idle" - with lock: - active_idx = None - active_mode = None - spring_target = None - teleport_offset = None + if event.phase == "start": + i = event.instance_index + if i is None: + return + set_instance_color(i, TELEPORT_COLOR) + active_idx_gui.value = str(i) + active_mode_gui.value = "teleport" + with lock: + active_idx = i + active_mode = "teleport" + cursor = np.array(event.start_position) + teleport_offset = position_arr[i] - cursor + spring_target = cursor + elif event.phase == "update": + with lock: + spring_target = np.array(event.end_position) + elif event.phase == "end": + i = event.instance_index + if i is not None: + set_instance_color(i, IDLE_COLOR) + active_idx_gui.value = "-" + active_mode_gui.value = "idle" + with lock: + active_idx = None + active_mode = None + spring_target = None + teleport_offset = None # ========================================================================== # Cmd/Ctrl + drag: spring-pull that instance (linear velocity). # ========================================================================== - @handle.on_drag_start("left", modifier="cmd/ctrl") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal active_idx, active_mode, grab_body, spring_target - i = event.instance_index - if i is None: - return - set_instance_color(i, TRANSLATE_COLOR) - active_idx_gui.value = str(i) - active_mode_gui.value = "translate" - with lock: - active_idx = i - active_mode = "translate" - grab_world = np.array(event.start_position) - grab_body = compute_grab_body(i, grab_world) - spring_target = grab_world - - @handle.on_drag_update("left", modifier="cmd/ctrl") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal spring_target - with lock: - spring_target = np.array(event.end_position) - - @handle.on_drag_end("left", modifier="cmd/ctrl") + @handle.on_drag("left", modifier="cmd/ctrl") async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: nonlocal active_idx, active_mode, grab_body, spring_target - i = event.instance_index - if i is not None: - set_instance_color(i, IDLE_COLOR) - active_idx_gui.value = "-" - active_mode_gui.value = "idle" - with lock: - active_idx = None - active_mode = None - grab_body = None - spring_target = None + if event.phase == "start": + i = event.instance_index + if i is None: + return + set_instance_color(i, TRANSLATE_COLOR) + active_idx_gui.value = str(i) + active_mode_gui.value = "translate" + with lock: + active_idx = i + active_mode = "translate" + grab_world = np.array(event.start_position) + grab_body = compute_grab_body(i, grab_world) + spring_target = grab_world + elif event.phase == "update": + with lock: + spring_target = np.array(event.end_position) + elif event.phase == "end": + i = event.instance_index + if i is not None: + set_instance_color(i, IDLE_COLOR) + active_idx_gui.value = "-" + active_mode_gui.value = "idle" + with lock: + active_idx = None + active_mode = None + grab_body = None + spring_target = None # ========================================================================== # Cmd/Ctrl + Shift + drag: rotate that instance around the drag arrow # (angular velocity). # ========================================================================== - @handle.on_drag_start("left", modifier="cmd/ctrl+shift") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal active_idx, active_mode, grab_body, spring_target, ext_torque - i = event.instance_index - if i is None: - return - set_instance_color(i, ROTATE_COLOR) - active_idx_gui.value = str(i) - active_mode_gui.value = "rotate" - with lock: - active_idx = i - active_mode = "rotate" - grab_world = np.array(event.start_position) - grab_body = compute_grab_body(i, grab_world) - # Pin the grab point to where it was clicked — the instance - # rotates around this point. - spring_target = grab_world - ext_torque = np.zeros(3) - - @handle.on_drag_update("left", modifier="cmd/ctrl+shift") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal ext_torque - # Drag vector = rotation axis (direction) × spin magnitude (length). - # Use the *frozen* spring_target (click point at drag-start) rather - # than ``event.start_position``, which is live and tracks the - # instance's current pose — keeps the gesture independent of - # spring stiffness. - with lock: - assert spring_target is not None - drag_vec = np.array(event.end_position) - spring_target - ext_torque = TORQUE_K * drag_vec - - @handle.on_drag_end("left", modifier="cmd/ctrl+shift") + @handle.on_drag("left", modifier="cmd/ctrl+shift") async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: nonlocal active_idx, active_mode, grab_body, spring_target, ext_torque - i = event.instance_index - if i is not None: - set_instance_color(i, IDLE_COLOR) - active_idx_gui.value = "-" - active_mode_gui.value = "idle" - with lock: - active_idx = None - active_mode = None - grab_body = None - spring_target = None - ext_torque = np.zeros(3) + if event.phase == "start": + i = event.instance_index + if i is None: + return + set_instance_color(i, ROTATE_COLOR) + active_idx_gui.value = str(i) + active_mode_gui.value = "rotate" + with lock: + active_idx = i + active_mode = "rotate" + grab_world = np.array(event.start_position) + grab_body = compute_grab_body(i, grab_world) + # Pin the grab point to where it was clicked -- the instance + # rotates around this point. + spring_target = grab_world + ext_torque = np.zeros(3) + elif event.phase == "update": + # Drag vector = rotation axis (direction) x spin magnitude (length). + # Use the *frozen* spring_target (click point at drag-start) rather + # than ``event.start_position``, which is live and tracks the + # instance's current pose -- keeps the gesture independent of + # spring stiffness. + with lock: + assert spring_target is not None + drag_vec = np.array(event.end_position) - spring_target + ext_torque = TORQUE_K * drag_vec + elif event.phase == "end": + i = event.instance_index + if i is not None: + set_instance_color(i, IDLE_COLOR) + active_idx_gui.value = "-" + active_mode_gui.value = "idle" + with lock: + active_idx = None + active_mode = None + grab_body = None + spring_target = None + ext_torque = np.zeros(3) try: while True: diff --git a/docs/source/examples/interaction/scene_node_drag.rst b/docs/source/examples/interaction/scene_node_drag.rst index 36c84b8da..c7ba57922 100644 --- a/docs/source/examples/interaction/scene_node_drag.rst +++ b/docs/source/examples/interaction/scene_node_drag.rst @@ -61,7 +61,7 @@ Code # ----- Physics parameters ---------------------------------------------------- MASS = 1.0 # Moment of inertia for a uniform unit cube: m * a^2 / 6. Scalar because - # the box is cubic — symmetry gives equal principal moments. + # the box is cubic -- symmetry gives equal principal moments. INERTIA = 1.0 / 6.0 # Velocity damping (applied multiplicatively each tick). Equivalent to a @@ -69,14 +69,14 @@ Code LINEAR_DAMPING = 4.0 # 1/s ANGULAR_DAMPING = 4.0 # 1/s - # Spring stiffness. The same spring is used for both modes — in translate + # Spring stiffness. The same spring is used for both modes -- in translate # it pulls the grab point toward the cursor; in rotate it pins the grab # point to the click location so the body can orbit around it. SPRING_K = 60.0 - # Torque scale for the rotate gesture (world drag vector → torque along + # Torque scale for the rotate gesture (world drag vector -> torque along # the same vector). With a ~1 world-unit drag, this puts the angular - # acceleration around ~6 rad/s² given the unit-cube inertia, so a + # acceleration around ~6 rad/s^2 given the unit-cube inertia, so a # sustained drag can reach ~1 rev/s before damping cuts in. TORQUE_K = 1.5 @@ -118,7 +118,7 @@ Code angular_v = np.zeros(3) # Active-drag parameters. The spring mechanism drives both - # Cmd-modified gestures — the difference is where `spring_target` + # Cmd-modified gestures -- the difference is where `spring_target` # lives (moving with the cursor vs. locked to the click point) and # whether `ext_torque` is nonzero. The teleport path bypasses the # spring entirely and directly pins the pose. @@ -204,109 +204,94 @@ Code return R_mat.T @ (grab_world - position) # ========================================================================== - # Drag (no modifier): teleport — rigid follow, no physics. + # Drag (no modifier): teleport -- rigid follow, no physics. # ========================================================================== - @handle.on_drag_start("left") + @handle.on_drag("left") async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: nonlocal teleport_cursor, teleport_drag_offset - handle.color = TELEPORT_COLOR - with lock: - cursor = np.array(event.start_position) - teleport_cursor = cursor - # Fixed offset from cursor to box center. Re-adding this each - # tick keeps the grab point under the cursor as it moves. - teleport_drag_offset = position - cursor - - @handle.on_drag_update("left") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal teleport_cursor - with lock: - teleport_cursor = np.array(event.end_position) - - @handle.on_drag_end("left") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal teleport_cursor, teleport_drag_offset - del event - handle.color = IDLE_COLOR - with lock: - teleport_cursor = None - teleport_drag_offset = None + if event.phase == "start": + handle.color = TELEPORT_COLOR + with lock: + cursor = np.array(event.start_position) + teleport_cursor = cursor + # Fixed offset from cursor to box center. Re-adding + # each tick keeps the grab point under the cursor as + # it moves. + teleport_drag_offset = position - cursor + elif event.phase == "update": + with lock: + teleport_cursor = np.array(event.end_position) + else: # "end" + handle.color = IDLE_COLOR + with lock: + teleport_cursor = None + teleport_drag_offset = None # ========================================================================== # Cmd/Ctrl + drag: spring pull on the grab point toward the cursor. # ========================================================================== - @handle.on_drag_start("left", modifier="cmd/ctrl") + @handle.on_drag("left", modifier="cmd/ctrl") async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: nonlocal grab_body, spring_target - handle.color = TRANSLATE_COLOR - with lock: - grab_world = np.array(event.start_position) - grab_body = compute_grab_body(grab_world) - spring_target = grab_world - - @handle.on_drag_update("left", modifier="cmd/ctrl") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal spring_target - with lock: - spring_target = np.array(event.end_position) - - @handle.on_drag_end("left", modifier="cmd/ctrl") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal grab_body, spring_target - del event - handle.color = IDLE_COLOR - with lock: - grab_body = None - spring_target = None + if event.phase == "start": + handle.color = TRANSLATE_COLOR + with lock: + grab_world = np.array(event.start_position) + grab_body = compute_grab_body(grab_world) + spring_target = grab_world + elif event.phase == "update": + with lock: + spring_target = np.array(event.end_position) + else: # "end" + handle.color = IDLE_COLOR + with lock: + grab_body = None + spring_target = None # ========================================================================== # Cmd/Ctrl + Shift + drag: rotate *around* the drag arrow. # - # The grab point is pinned in world space (spring_target locked to - # start.position), and an external torque along the drag vector spins - # the body around that pin. Geometrically, the rotation axis is the - # line through ``start.position`` parallel to the drag arrow — i.e. - # the arrow itself. Drag length scales spin speed. + # The grab point is pinned in world space (spring_target locked + # to start.position), and an external torque along the drag vector + # spins the body around that pin. Geometrically, the rotation axis + # is the line through ``start.position`` parallel to the drag + # arrow. Drag length scales spin speed. # ========================================================================== - @handle.on_drag_start("left", modifier="cmd/ctrl+shift") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal grab_body, spring_target, ext_torque - handle.color = ROTATE_COLOR - with lock: - grab_world = np.array(event.start_position) - grab_body = compute_grab_body(grab_world) - # Pin the grab point to where it was clicked. This stays put - # for the duration of the drag — the body rotates around it. - spring_target = grab_world - ext_torque = np.zeros(3) - - @handle.on_drag_update("left", modifier="cmd/ctrl+shift") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal ext_torque - # Drag vector (world space) used directly as the torque: its - # direction is the rotation axis, its length the magnitude. The - # visible drag arrow coincides with the instantaneous axis. - # Use the *frozen* spring_target (the click point at drag-start) - # rather than ``event.start_position``, which is live and tracks - # the body's current pose — making the gesture independent of - # spring stiffness. - with lock: - assert spring_target is not None - drag_vec = np.array(event.end_position) - spring_target - ext_torque = TORQUE_K * drag_vec - - @handle.on_drag_end("left", modifier="cmd/ctrl+shift") + @handle.on_drag("left", modifier="cmd/ctrl+shift") async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: nonlocal grab_body, spring_target, ext_torque - del event - handle.color = IDLE_COLOR - with lock: - grab_body = None - spring_target = None - ext_torque = np.zeros(3) + if event.phase == "start": + handle.color = ROTATE_COLOR + with lock: + grab_world = np.array(event.start_position) + grab_body = compute_grab_body(grab_world) + # Pin the grab point to where it was clicked. This + # stays put for the duration of the drag -- the body + # rotates around it. + spring_target = grab_world + ext_torque = np.zeros(3) + elif event.phase == "update": + # Drag vector (world space) used directly as torque: its + # direction is the rotation axis, its length the + # magnitude. The visible drag arrow coincides with the + # instantaneous axis. Use the frozen ``spring_target`` + # (click point at drag-start) rather than + # ``event.start_position``, which is live and tracks the + # body's current pose -- making the gesture independent + # of spring stiffness. + with lock: + assert spring_target is not None + drag_vec = np.array(event.end_position) - spring_target + ext_torque = TORQUE_K * drag_vec + else: # "end" + handle.color = IDLE_COLOR + with lock: + grab_body = None + spring_target = None + ext_torque = np.zeros(3) try: while True: diff --git a/docs/source/examples/interaction/scene_pointer.rst b/docs/source/examples/interaction/scene_pointer.rst index 231e11798..040996145 100644 --- a/docs/source/examples/interaction/scene_pointer.rst +++ b/docs/source/examples/interaction/scene_pointer.rst @@ -152,7 +152,7 @@ Code & (vertices_proj < np.array(event.screen_max)) ).all(axis=1)[..., None] - # Update the mesh color based on whether the vertices are inside the box + # Update the mesh color based on whether the vertices are inside the box. mesh.visual.vertex_colors = np.where( # type: ignore mask, (0.5, 0.0, 0.7, 1.0), (0.9, 0.9, 0.9, 1.0) ) diff --git a/docs/source/examples/scene/arrows.rst b/docs/source/examples/scene/arrows.rst index 1f9fc0c4a..627f71dee 100644 --- a/docs/source/examples/scene/arrows.rst +++ b/docs/source/examples/scene/arrows.rst @@ -34,6 +34,7 @@ Code import time import numpy as np + import viser @@ -49,7 +50,7 @@ Code colors = np.zeros((N, 3), dtype=np.uint8) for i in range(N): - # Distribute arrows in a spiral pattern + # Distribute arrows in a spiral pattern. theta = i * 0.3 r = 1.0 + i * 0.02 x = r * np.cos(theta) @@ -59,7 +60,7 @@ Code points[i, 0] = [0, y, 0] # start points[i, 1] = [x, y, z] # end - # Color gradient from blue to red based on height + # Color gradient from blue to red based on height. color_value = int(255 * (y / (N * 0.05))) colors[i] = [color_value, 0, 255 - color_value] diff --git a/docs/source/examples/scene/lighting.rst b/docs/source/examples/scene/lighting.rst index 88e1f3d92..971c351d1 100644 --- a/docs/source/examples/scene/lighting.rst +++ b/docs/source/examples/scene/lighting.rst @@ -77,7 +77,7 @@ Code position=np.array([0.0, 0.0, -2.0]), ) - # adding controls to custom lights in the scene + # adding controls to custom lights in the scene. server.scene.add_transform_controls( "/control0", position=(0.0, 10.0, 5.0), scale=2.0 ) @@ -116,7 +116,7 @@ Code ) with server.gui.add_folder("Grid Shadows"): - # Create grid shadows toggle + # Create grid shadows toggle. grid_shadows = server.gui.add_slider( "Intensity", min=0.0, diff --git a/examples/03_interaction/06_scene_node_drag.py b/examples/03_interaction/06_scene_node_drag.py index 0fa6e90dd..9a7c8c6de 100644 --- a/examples/03_interaction/06_scene_node_drag.py +++ b/examples/03_interaction/06_scene_node_drag.py @@ -195,106 +195,91 @@ def compute_grab_body(grab_world: np.ndarray) -> np.ndarray: # Drag (no modifier): teleport -- rigid follow, no physics. # ========================================================================== - @handle.on_drag_start("left") + @handle.on_drag("left") async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: nonlocal teleport_cursor, teleport_drag_offset - handle.color = TELEPORT_COLOR - with lock: - cursor = np.array(event.start_position) - teleport_cursor = cursor - # Fixed offset from cursor to box center. Re-adding this each - # tick keeps the grab point under the cursor as it moves. - teleport_drag_offset = position - cursor - - @handle.on_drag_update("left") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal teleport_cursor - with lock: - teleport_cursor = np.array(event.end_position) - - @handle.on_drag_end("left") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal teleport_cursor, teleport_drag_offset - del event - handle.color = IDLE_COLOR - with lock: - teleport_cursor = None - teleport_drag_offset = None + if event.phase == "start": + handle.color = TELEPORT_COLOR + with lock: + cursor = np.array(event.start_position) + teleport_cursor = cursor + # Fixed offset from cursor to box center. Re-adding + # each tick keeps the grab point under the cursor as + # it moves. + teleport_drag_offset = position - cursor + elif event.phase == "update": + with lock: + teleport_cursor = np.array(event.end_position) + else: # "end" + handle.color = IDLE_COLOR + with lock: + teleport_cursor = None + teleport_drag_offset = None # ========================================================================== # Cmd/Ctrl + drag: spring pull on the grab point toward the cursor. # ========================================================================== - @handle.on_drag_start("left", modifier="cmd/ctrl") + @handle.on_drag("left", modifier="cmd/ctrl") async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: nonlocal grab_body, spring_target - handle.color = TRANSLATE_COLOR - with lock: - grab_world = np.array(event.start_position) - grab_body = compute_grab_body(grab_world) - spring_target = grab_world - - @handle.on_drag_update("left", modifier="cmd/ctrl") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal spring_target - with lock: - spring_target = np.array(event.end_position) - - @handle.on_drag_end("left", modifier="cmd/ctrl") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal grab_body, spring_target - del event - handle.color = IDLE_COLOR - with lock: - grab_body = None - spring_target = None + if event.phase == "start": + handle.color = TRANSLATE_COLOR + with lock: + grab_world = np.array(event.start_position) + grab_body = compute_grab_body(grab_world) + spring_target = grab_world + elif event.phase == "update": + with lock: + spring_target = np.array(event.end_position) + else: # "end" + handle.color = IDLE_COLOR + with lock: + grab_body = None + spring_target = None # ========================================================================== # Cmd/Ctrl + Shift + drag: rotate *around* the drag arrow. # - # The grab point is pinned in world space (spring_target locked to - # start.position), and an external torque along the drag vector spins - # the body around that pin. Geometrically, the rotation axis is the - # line through ``start.position`` parallel to the drag arrow -- i.e. - # the arrow itself. Drag length scales spin speed. + # The grab point is pinned in world space (spring_target locked + # to start.position), and an external torque along the drag vector + # spins the body around that pin. Geometrically, the rotation axis + # is the line through ``start.position`` parallel to the drag + # arrow. Drag length scales spin speed. # ========================================================================== - @handle.on_drag_start("left", modifier="cmd/ctrl+shift") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal grab_body, spring_target, ext_torque - handle.color = ROTATE_COLOR - with lock: - grab_world = np.array(event.start_position) - grab_body = compute_grab_body(grab_world) - # Pin the grab point to where it was clicked. This stays put - # for the duration of the drag -- the body rotates around it. - spring_target = grab_world - ext_torque = np.zeros(3) - - @handle.on_drag_update("left", modifier="cmd/ctrl+shift") - async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: - nonlocal ext_torque - # Drag vector (world space) used directly as the torque: its - # direction is the rotation axis, its length the magnitude. The - # visible drag arrow coincides with the instantaneous axis. - # Use the *frozen* spring_target (the click point at drag-start) - # rather than ``event.start_position``, which is live and tracks - # the body's current pose -- making the gesture independent of - # spring stiffness. - with lock: - assert spring_target is not None - drag_vec = np.array(event.end_position) - spring_target - ext_torque = TORQUE_K * drag_vec - - @handle.on_drag_end("left", modifier="cmd/ctrl+shift") + @handle.on_drag("left", modifier="cmd/ctrl+shift") async def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: nonlocal grab_body, spring_target, ext_torque - del event - handle.color = IDLE_COLOR - with lock: - grab_body = None - spring_target = None - ext_torque = np.zeros(3) + if event.phase == "start": + handle.color = ROTATE_COLOR + with lock: + grab_world = np.array(event.start_position) + grab_body = compute_grab_body(grab_world) + # Pin the grab point to where it was clicked. This + # stays put for the duration of the drag -- the body + # rotates around it. + spring_target = grab_world + ext_torque = np.zeros(3) + elif event.phase == "update": + # Drag vector (world space) used directly as torque: its + # direction is the rotation axis, its length the + # magnitude. The visible drag arrow coincides with the + # instantaneous axis. Use the frozen ``spring_target`` + # (click point at drag-start) rather than + # ``event.start_position``, which is live and tracks the + # body's current pose -- making the gesture independent + # of spring stiffness. + with lock: + assert spring_target is not None + drag_vec = np.array(event.end_position) - spring_target + ext_torque = TORQUE_K * drag_vec + else: # "end" + handle.color = IDLE_COLOR + with lock: + grab_body = None + spring_target = None + ext_torque = np.zeros(3) try: while True: diff --git a/examples/03_interaction/07_batched_scene_node_drag.py b/examples/03_interaction/07_batched_scene_node_drag.py index 4b61ced6c..e643ed0b4 100644 --- a/examples/03_interaction/07_batched_scene_node_drag.py +++ b/examples/03_interaction/07_batched_scene_node_drag.py @@ -249,133 +249,118 @@ def compute_grab_body(idx: int, grab_world: np.ndarray) -> np.ndarray: # Drag (no modifier): teleport. # ========================================================================== - @handle.on_drag_start("left") + @handle.on_drag("left") async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: nonlocal active_idx, active_mode, spring_target, teleport_offset - i = event.instance_index - if i is None: - return - set_instance_color(i, TELEPORT_COLOR) - active_idx_gui.value = str(i) - active_mode_gui.value = "teleport" - with lock: - active_idx = i - active_mode = "teleport" - cursor = np.array(event.start_position) - teleport_offset = position_arr[i] - cursor - spring_target = cursor - - @handle.on_drag_update("left") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal spring_target - with lock: - spring_target = np.array(event.end_position) - - @handle.on_drag_end("left") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal active_idx, active_mode, spring_target, teleport_offset - i = event.instance_index - if i is not None: - set_instance_color(i, IDLE_COLOR) - active_idx_gui.value = "-" - active_mode_gui.value = "idle" - with lock: - active_idx = None - active_mode = None - spring_target = None - teleport_offset = None + if event.phase == "start": + i = event.instance_index + if i is None: + return + set_instance_color(i, TELEPORT_COLOR) + active_idx_gui.value = str(i) + active_mode_gui.value = "teleport" + with lock: + active_idx = i + active_mode = "teleport" + cursor = np.array(event.start_position) + teleport_offset = position_arr[i] - cursor + spring_target = cursor + elif event.phase == "update": + with lock: + spring_target = np.array(event.end_position) + elif event.phase == "end": + i = event.instance_index + if i is not None: + set_instance_color(i, IDLE_COLOR) + active_idx_gui.value = "-" + active_mode_gui.value = "idle" + with lock: + active_idx = None + active_mode = None + spring_target = None + teleport_offset = None # ========================================================================== # Cmd/Ctrl + drag: spring-pull that instance (linear velocity). # ========================================================================== - @handle.on_drag_start("left", modifier="cmd/ctrl") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal active_idx, active_mode, grab_body, spring_target - i = event.instance_index - if i is None: - return - set_instance_color(i, TRANSLATE_COLOR) - active_idx_gui.value = str(i) - active_mode_gui.value = "translate" - with lock: - active_idx = i - active_mode = "translate" - grab_world = np.array(event.start_position) - grab_body = compute_grab_body(i, grab_world) - spring_target = grab_world - - @handle.on_drag_update("left", modifier="cmd/ctrl") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal spring_target - with lock: - spring_target = np.array(event.end_position) - - @handle.on_drag_end("left", modifier="cmd/ctrl") + @handle.on_drag("left", modifier="cmd/ctrl") async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: nonlocal active_idx, active_mode, grab_body, spring_target - i = event.instance_index - if i is not None: - set_instance_color(i, IDLE_COLOR) - active_idx_gui.value = "-" - active_mode_gui.value = "idle" - with lock: - active_idx = None - active_mode = None - grab_body = None - spring_target = None + if event.phase == "start": + i = event.instance_index + if i is None: + return + set_instance_color(i, TRANSLATE_COLOR) + active_idx_gui.value = str(i) + active_mode_gui.value = "translate" + with lock: + active_idx = i + active_mode = "translate" + grab_world = np.array(event.start_position) + grab_body = compute_grab_body(i, grab_world) + spring_target = grab_world + elif event.phase == "update": + with lock: + spring_target = np.array(event.end_position) + elif event.phase == "end": + i = event.instance_index + if i is not None: + set_instance_color(i, IDLE_COLOR) + active_idx_gui.value = "-" + active_mode_gui.value = "idle" + with lock: + active_idx = None + active_mode = None + grab_body = None + spring_target = None # ========================================================================== # Cmd/Ctrl + Shift + drag: rotate that instance around the drag arrow # (angular velocity). # ========================================================================== - @handle.on_drag_start("left", modifier="cmd/ctrl+shift") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal active_idx, active_mode, grab_body, spring_target, ext_torque - i = event.instance_index - if i is None: - return - set_instance_color(i, ROTATE_COLOR) - active_idx_gui.value = str(i) - active_mode_gui.value = "rotate" - with lock: - active_idx = i - active_mode = "rotate" - grab_world = np.array(event.start_position) - grab_body = compute_grab_body(i, grab_world) - # Pin the grab point to where it was clicked -- the instance - # rotates around this point. - spring_target = grab_world - ext_torque = np.zeros(3) - - @handle.on_drag_update("left", modifier="cmd/ctrl+shift") - async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: - nonlocal ext_torque - # Drag vector = rotation axis (direction) x spin magnitude (length). - # Use the *frozen* spring_target (click point at drag-start) rather - # than ``event.start_position``, which is live and tracks the - # instance's current pose -- keeps the gesture independent of - # spring stiffness. - with lock: - assert spring_target is not None - drag_vec = np.array(event.end_position) - spring_target - ext_torque = TORQUE_K * drag_vec - - @handle.on_drag_end("left", modifier="cmd/ctrl+shift") + @handle.on_drag("left", modifier="cmd/ctrl+shift") async def _(event: viser.SceneNodeDragEvent[viser.BatchedMeshHandle]) -> None: nonlocal active_idx, active_mode, grab_body, spring_target, ext_torque - i = event.instance_index - if i is not None: - set_instance_color(i, IDLE_COLOR) - active_idx_gui.value = "-" - active_mode_gui.value = "idle" - with lock: - active_idx = None - active_mode = None - grab_body = None - spring_target = None - ext_torque = np.zeros(3) + if event.phase == "start": + i = event.instance_index + if i is None: + return + set_instance_color(i, ROTATE_COLOR) + active_idx_gui.value = str(i) + active_mode_gui.value = "rotate" + with lock: + active_idx = i + active_mode = "rotate" + grab_world = np.array(event.start_position) + grab_body = compute_grab_body(i, grab_world) + # Pin the grab point to where it was clicked -- the instance + # rotates around this point. + spring_target = grab_world + ext_torque = np.zeros(3) + elif event.phase == "update": + # Drag vector = rotation axis (direction) x spin magnitude (length). + # Use the *frozen* spring_target (click point at drag-start) rather + # than ``event.start_position``, which is live and tracks the + # instance's current pose -- keeps the gesture independent of + # spring stiffness. + with lock: + assert spring_target is not None + drag_vec = np.array(event.end_position) - spring_target + ext_torque = TORQUE_K * drag_vec + elif event.phase == "end": + i = event.instance_index + if i is not None: + set_instance_color(i, IDLE_COLOR) + active_idx_gui.value = "-" + active_mode_gui.value = "idle" + with lock: + active_idx = None + active_mode = None + grab_body = None + spring_target = None + ext_torque = np.zeros(3) try: while True: diff --git a/src/viser/_messages.py b/src/viser/_messages.py index 92d8d3a19..511d60c4f 100644 --- a/src/viser/_messages.py +++ b/src/viser/_messages.py @@ -1369,14 +1369,6 @@ class SetSceneNodeVisibilityMessage(Message, include_in_scene_serialization=True visible: bool -@dataclasses.dataclass -class SetSceneNodeClickableMessage(Message, include_in_scene_serialization=True): - """Set the clickability of a particular node in the scene.""" - - name: str - clickable: bool - - @dataclasses.dataclass(frozen=True) class DragBinding: """A drag input combination: button + exact-match modifier set. @@ -1406,6 +1398,23 @@ class SetSceneNodeDragBindingsMessage(Message, include_in_scene_serialization=Fa bindings: Tuple[DragBinding, ...] +@dataclasses.dataclass +class SetSceneNodeClickBindingsMessage(Message, include_in_scene_serialization=False): + """Declare the click-input combinations a scene node listens for. + + Sent as a full set; empty ``bindings`` means the node is not + clickable. Mirrors :class:`SetSceneNodeDragBindingsMessage` for the + click channel. Click and drag share the same `DragBinding` shape -- + button + exact-match modifier. + + Excluded from scene serialization for the same reason as the drag + sibling -- click callbacks live on the server. + """ + + name: str + bindings: Tuple[DragBinding, ...] + + @dataclasses.dataclass class SceneNodeClickMessage(Message, include_in_scene_serialization=False): """Message for clicked objects.""" diff --git a/src/viser/_scene_api.py b/src/viser/_scene_api.py index 94138780c..47e2ffc70 100644 --- a/src/viser/_scene_api.py +++ b/src/viser/_scene_api.py @@ -40,6 +40,7 @@ CameraFrustumHandle, CylinderHandle, DirectionalLightHandle, + DragPhase, FrameHandle, GaussianSplatHandle, GlbHandle, @@ -2671,71 +2672,48 @@ def _get_client_handle(self, client_id: ClientId) -> ClientHandle: async def _handle_transform_controls_updates( self, client_id: ClientId, message: _messages.TransformControlsUpdateMessage ) -> None: - """Callback for handling transform gizmo messages.""" + """Apply pose update and fire `update_cb` with phase="update".""" handle = self._handle_from_transform_controls_name.get(message.name, None) if handle is None: return - # Update state. - wxyz = np.array(message.wxyz) - position = np.array(message.position) - handle._impl.wxyz = wxyz - handle._impl.position = position + handle._impl.wxyz = np.array(message.wxyz) + handle._impl.position = np.array(message.position) handle._impl_aux.last_updated = time.time() - # Trigger callbacks. - event = TransformControlsEvent( - client=self._get_client_handle(client_id), - client_id=client_id, - target=handle, - ) - for cb in handle._impl_aux.update_cb: - if asyncio.iscoroutinefunction(cb): - await cb(event) - else: - self._thread_executor.submit(cb, event).add_done_callback( - print_threadpool_errors - ) + await self._fire_transform_controls_callbacks(client_id, handle, "update") if handle._impl_aux.sync_cb is not None: handle._impl_aux.sync_cb(client_id, handle) async def _handle_transform_controls_drag_start( self, client_id: ClientId, message: _messages.TransformControlsDragStartMessage ) -> None: - """Callback for handling transform control drag start messages.""" handle = self._handle_from_transform_controls_name.get(message.name, None) if handle is None: return - - # Trigger callbacks. - event = TransformControlsEvent( - client=self._get_client_handle(client_id), - client_id=client_id, - target=handle, - ) - for cb in handle._impl_aux.drag_start_cb: - if asyncio.iscoroutinefunction(cb): - await cb(event) - else: - self._thread_executor.submit(cb, event).add_done_callback( - print_threadpool_errors - ) + await self._fire_transform_controls_callbacks(client_id, handle, "start") async def _handle_transform_controls_drag_end( self, client_id: ClientId, message: _messages.TransformControlsDragEndMessage ) -> None: - """Callback for handling transform control drag end messages.""" handle = self._handle_from_transform_controls_name.get(message.name, None) if handle is None: return + await self._fire_transform_controls_callbacks(client_id, handle, "end") - # Trigger callbacks. + async def _fire_transform_controls_callbacks( + self, + client_id: ClientId, + handle: TransformControlsHandle, + phase: DragPhase, + ) -> None: event = TransformControlsEvent( client=self._get_client_handle(client_id), client_id=client_id, target=handle, + phase=phase, ) - for cb in handle._impl_aux.drag_end_cb: + for cb in handle._impl_aux.update_cb: if asyncio.iscoroutinefunction(cb): await cb(event) else: @@ -2840,7 +2818,7 @@ async def _dispatch_drag_callbacks( Shared by ``_handle_node_drag`` (live messages) and ``_drop_active_drags_for_client`` (synthetic end on disconnect).""" input = _DragInput(button=message.button, modifier=message.modifier) - matching = handle._dispatch_drag(message.phase, input) + matching = handle._dispatch_drag(input) if not matching: return @@ -2848,6 +2826,7 @@ async def _dispatch_drag_callbacks( client=self._get_client_handle(client_id), client_id=client_id, target=cast(_RaycastSupportedSceneNodeHandle, handle), + phase=message.phase, instance_index=message.instance_index, start_position=message.start_position, start_screen_pos=message.start_screen_pos, diff --git a/src/viser/_scene_handles.py b/src/viser/_scene_handles.py index 91fa3a540..0b0978459 100644 --- a/src/viser/_scene_handles.py +++ b/src/viser/_scene_handles.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import copy import dataclasses import warnings @@ -175,10 +176,14 @@ class _SceneNodeHandleState: ) visible: bool = True click_cb: list[_ClickCallbackEntry] = dataclasses.field(default_factory=list) - drag_cb: dict[DragPhase, list[_DragCallbackEntry]] = dataclasses.field( - default_factory=lambda: {"start": [], "update": [], "end": []} - ) + drag_cb: list[_DragCallbackEntry] = dataclasses.field(default_factory=list) removed: bool = False + # Last bindings tuple published to the client. Used to dedup + # redundant ``SetSceneNodeClickBindingsMessage`` emits — without + # this, a no-op ``remove_click_callback("foo")`` for an + # unregistered callback resends an empty bindings tuple. + # ``None`` until the first publish. + _last_published_click_bindings: tuple[_messages.DragBinding, ...] | None = None class _SceneNodeMessage(Protocol): @@ -312,9 +317,8 @@ def remove(self) -> None: i += 1 # Clear stale per-node interaction state (click + drag) before - # we tear down handles. ``SetSceneNodeClickableMessage`` and - # ``SetSceneNodeDragBindingsMessage`` are name-keyed in the - # persistent buffer and aren't purged by + # we tear down handles. The bindings messages are name-keyed in + # the persistent buffer and aren't purged by # ``RemoveSceneNodeMessage``, so without an empty replacement a # future node created with the same name would inherit stale # interaction state on late-joining clients. Has to run for @@ -334,13 +338,15 @@ def remove(self) -> None: continue impl = handle._impl if len(impl.click_cb) > 0: + # Empty the per-node click-binding set so a re-created + # node with the same name doesn't inherit the prior + # node's modifier filters from the persistent buffer. api._websock_interface.queue_message( - _messages.SetSceneNodeClickableMessage(node_name, False) + _messages.SetSceneNodeClickBindingsMessage(node_name, ()) ) - if any(entries for entries in impl.drag_cb.values()): + if impl.drag_cb: if not api._is_drag_active_for(node_name): - for entries in impl.drag_cb.values(): - entries.clear() + impl.drag_cb.clear() api._websock_interface.queue_message( _messages.SetSceneNodeDragBindingsMessage(node_name, ()) ) @@ -403,6 +409,12 @@ class TransformControlsEvent: """ID of client that triggered this event.""" target: TransformControlsHandle """Transform controls handle that was affected.""" + phase: DragPhase + """Drag lifecycle phase: ``"start"`` when the user grabs a handle, + ``"update"`` on every pose change while dragging, ``"end"`` at + release. ``target.wxyz`` and ``target.position`` reflect the + current pose on every phase (start/end fire at the same pose as + the surrounding update).""" NoneOrCoroutine = TypeVar("NoneOrCoroutine", None, Coroutine) @@ -418,6 +430,11 @@ class SceneNodeDragEvent(Generic[TSceneNodeHandle]): """ID of client that triggered this event.""" target: TSceneNodeHandle """Scene node that is being dragged.""" + phase: DragPhase + """Drag lifecycle phase: ``"start"`` at press, ``"update"`` on + every throttled pointermove (~20Hz), ``"end"`` at release. A + single drag fires exactly one ``"start"``, zero or more + ``"update"``s, and exactly one ``"end"``.""" instance_index: int | None """Instance index within a batched scene node (e.g. batched meshes, batched GLBs, batched axes); ``None`` for non-batched nodes. Frozen @@ -448,42 +465,38 @@ class SceneNodeDragEvent(Generic[TSceneNodeHandle]): class _RaycastSupportedSceneNodeHandle(SceneNodeHandle): def _sync_drag_bindings(self) -> None: - """Recompute the union of registered (button, modifiers) across all - phases and push it to the client as a full binding set.""" + """Recompute the union of registered (button, modifiers) and + push it to the client as a full binding set.""" seen: set[Tuple[_messages.DragButton, _messages.KeyModifier | None]] = set() bindings: list[_messages.DragBinding] = [] - for entries in self._impl.drag_cb.values(): - for entry in entries: - key = (entry.button, entry.modifier) - if key in seen: - continue - seen.add(key) - bindings.append( - _messages.DragBinding(button=entry.button, modifier=entry.modifier) - ) + for entry in self._impl.drag_cb: + key = (entry.button, entry.modifier) + if key in seen: + continue + seen.add(key) + bindings.append( + _messages.DragBinding(button=entry.button, modifier=entry.modifier) + ) self._impl.api._websock_interface.queue_message( _messages.SetSceneNodeDragBindingsMessage(self._impl.name, tuple(bindings)) ) def _has_any_drag_callbacks(self) -> bool: - return any(entries for entries in self._impl.drag_cb.values()) + return bool(self._impl.drag_cb) def _dispatch_drag( - self, phase: DragPhase, input: _DragInput + self, input: _DragInput ) -> list[ Callable[ [SceneNodeDragEvent[_RaycastSupportedSceneNodeHandle]], None | Coroutine ] ]: - """Return the callbacks whose filter matches this input. - - Encapsulates ``_impl.drag_cb`` so dispatch doesn't depend on the - internal storage layout.""" + """Return the callbacks whose filter matches this input.""" from ._scene_api import _drag_input_matches_filter return [ entry.callback - for entry in self._impl.drag_cb[phase] + for entry in self._impl.drag_cb if _drag_input_matches_filter(input, entry.button, entry.modifier) ] @@ -497,7 +510,6 @@ def _validate_button(button: _messages.DragButton) -> None: def _register_drag_callback( self: Self, - phase: DragPhase, button: _messages.DragButton, modifier: _messages.KeyModifier | None = None, ) -> Callable[ @@ -521,25 +533,24 @@ def decorator( button=button, modifier=normalized, ) - # Skip if an equivalent entry is already registered -- - # otherwise double-registration would fire the callback twice - # per matching event. `_DragCallbackEntry` is a plain - # dataclass so tuple/dataclass equality catches duplicates. - if entry not in self._impl.drag_cb[phase]: - self._impl.drag_cb[phase].append(entry) + # Skip duplicate registration -- without this, the same + # callback fires twice per matching event. Equality is by + # tuple/dataclass value. + if entry not in self._impl.drag_cb: + self._impl.drag_cb.append(entry) self._sync_drag_bindings() return func return decorator @overload - def on_drag_start( + def on_drag( self: Self, button: Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine], ) -> Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine]: ... @overload - def on_drag_start( + def on_drag( self: Self, button: _messages.DragButton = ..., *, @@ -549,20 +560,24 @@ def on_drag_start( Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine], ]: ... - def on_drag_start( + def on_drag( self: Self, button: Union[_messages.DragButton, Callable[..., Any]] = "left", *, modifier: _messages.KeyModifier | None = None, ) -> Any: - """Attach a callback for when dragging starts. + """Attach a callback for the full drag lifecycle. - (Experimental) Scene-node drag callbacks may change in future - releases. + Fires three times per gesture: once with + ``event.phase == "start"`` at press, zero or more times with + ``"update"`` (throttled pointermove), once with ``"end"`` at + release. ``end`` fires even on cancellation paths (window + blur, pointer cancel, node removed mid-drag) so per-drag + state can be released. - Usable as a bare decorator (``@handle.on_drag_start``, defaults - to ``button="left"`` and no modifiers) or with arguments - (``@handle.on_drag_start("left", modifier="cmd/ctrl")``). + Usable as a bare decorator (``@handle.on_drag``, defaults to + ``button="left"`` and no modifiers) or with arguments + (``@handle.on_drag("left", modifier="cmd/ctrl")``). Args: button: Mouse button that triggers the drag. One of @@ -575,124 +590,37 @@ def on_drag_start( node intercepts the gesture -- the camera only orbits on empty-space drags. - Note on ordering: drag callbacks fire in three phases per - gesture (start → update* → end). Synchronous (``def``) - callbacks are submitted to a thread pool fire-and-forget and - can run out of order -- an ``update`` may begin before - ``start`` finishes, leaving any state set in ``start`` (e.g. - a captured grab point) ``None`` when ``update`` reads it. To - get strict ordering, define your callbacks as ``async def``; - async callbacks are awaited on the event loop, which preserves - their phase order so long as you don't ``await`` inside them. + Note on ordering: synchronous (``def``) callbacks are submitted + to a thread pool fire-and-forget and can run out of order -- an + ``"update"`` phase may begin before ``"start"`` finishes, + leaving any state set in ``"start"`` ``None`` when ``"update"`` + reads it. To get strict ordering, define your callback as + ``async def``; async callbacks are awaited on the event loop, + which preserves phase order so long as you don't ``await`` + inside them. """ - return self._dispatch_on_drag("start", button, modifier) - - @overload - def on_drag_update( - self: Self, - button: Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine], - ) -> Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine]: ... - - @overload - def on_drag_update( - self: Self, - button: _messages.DragButton = ..., - *, - modifier: _messages.KeyModifier | None = ..., - ) -> Callable[ - [Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine]], - Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine], - ]: ... - - def on_drag_update( - self: Self, - button: Union[_messages.DragButton, Callable[..., Any]] = "left", - *, - modifier: _messages.KeyModifier | None = None, - ) -> Any: - """Attach a callback for drag updates. See :meth:`on_drag_start` for argument docs. - - (Experimental) Scene-node drag callbacks may change in future - releases.""" - return self._dispatch_on_drag("update", button, modifier) - - @overload - def on_drag_end( - self: Self, - button: Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine], - ) -> Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine]: ... - - @overload - def on_drag_end( - self: Self, - button: _messages.DragButton = ..., - *, - modifier: _messages.KeyModifier | None = ..., - ) -> Callable[ - [Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine]], - Callable[[SceneNodeDragEvent[Self]], NoneOrCoroutine], - ]: ... - - def on_drag_end( - self: Self, - button: Union[_messages.DragButton, Callable[..., Any]] = "left", - *, - modifier: _messages.KeyModifier | None = None, - ) -> Any: - """Attach a callback for when dragging ends. See :meth:`on_drag_start` for argument docs. - - (Experimental) Scene-node drag callbacks may change in future - releases.""" - return self._dispatch_on_drag("end", button, modifier) - - def _dispatch_on_drag( - self: Self, - phase: DragPhase, - button_or_func: Union[_messages.DragButton, Callable[..., Any]], - modifier: _messages.KeyModifier | None, - ) -> Any: - """Bare-decorator (a callable in the first slot) registers - immediately with default ``button="left"``; otherwise - ``button_or_func`` is the button literal.""" - if callable(button_or_func): - return self._register_drag_callback(phase, "left", modifier)( - button_or_func # type: ignore[arg-type] + if callable(button): + # Bare-decorator form: @handle.on_drag -- defaults to + # button="left" and no modifiers. + return self._register_drag_callback("left", modifier)( + button # type: ignore[arg-type] ) - return self._register_drag_callback(phase, button_or_func, modifier) + return self._register_drag_callback(button, modifier) - def _remove_drag_callback( - self, - phase: DragPhase, - callback: Literal["all"] | Callable, - ) -> None: + def remove_drag_callback(self, callback: Literal["all"] | Callable = "all") -> None: + """Remove drag callbacks from the scene node. + + ``callback="all"`` removes every drag callback; a specific + function removes only entries whose callback identity matches. + """ if callback == "all": - self._impl.drag_cb[phase].clear() + self._impl.drag_cb.clear() else: - self._impl.drag_cb[phase] = [ - entry - for entry in self._impl.drag_cb[phase] - if entry.callback != callback + self._impl.drag_cb = [ + entry for entry in self._impl.drag_cb if entry.callback != callback ] self._sync_drag_bindings() - def remove_drag_start_callback( - self, callback: Literal["all"] | Callable = "all" - ) -> None: - """Remove drag start callbacks from the scene node.""" - self._remove_drag_callback("start", callback) - - def remove_drag_update_callback( - self, callback: Literal["all"] | Callable = "all" - ) -> None: - """Remove drag update callbacks from the scene node.""" - self._remove_drag_callback("update", callback) - - def remove_drag_end_callback( - self, callback: Literal["all"] | Callable = "all" - ) -> None: - """Remove drag end callbacks from the scene node.""" - self._remove_drag_callback("end", callback) - @overload def on_click( self: Self, @@ -737,12 +665,6 @@ def on_click( normalized_modifier = _messages._normalize_key_modifier(modifier) def register(callback: Callable) -> Callable: - # Mark the node clickable only when a callback actually - # lands -- an unapplied decorator factory shouldn't leave - # the client thinking the node is clickable. - self._impl.api._websock_interface.queue_message( - _messages.SetSceneNodeClickableMessage(self._impl.name, True) - ) self._impl.click_cb.append( _ClickCallbackEntry( callback=cast( @@ -755,6 +677,7 @@ def register(callback: Callable) -> Callable: modifier=normalized_modifier, ) ) + self._publish_click_state() return callback if func is None: @@ -775,10 +698,31 @@ def remove_click_callback( self._impl.click_cb = [ entry for entry in self._impl.click_cb if entry.callback != callback ] - if len(self._impl.click_cb) == 0: - self._impl.api._websock_interface.queue_message( - _messages.SetSceneNodeClickableMessage(self._impl.name, False) - ) + self._publish_click_state() + + def _publish_click_state(self) -> None: + """Publish ``SetSceneNodeClickBindingsMessage`` to the client + only when the bindings tuple has changed since the last + publish. Without the dedup, a no-op + ``remove_click_callback("nonexistent")`` would still emit an + empty bindings tuple. + + The client derives `clickable` from `bindings.length > 0`; no + separate flag is sent. + """ + bindings = tuple( + _messages.DragBinding(button="left", modifier=entry.modifier) + for entry in self._impl.click_cb + ) + if self._impl._last_published_click_bindings == bindings: + return + # Queue the message BEFORE committing the cache. If + # ``queue_message`` raises, the cache stays at its previous + # value so the next state change retries the publish. + self._impl.api._websock_interface.queue_message( + _messages.SetSceneNodeClickBindingsMessage(self._impl.name, bindings) + ) + self._impl._last_published_click_bindings = bindings class CameraFrustumHandle( @@ -1352,15 +1296,36 @@ class LabelHandle( class _TransformControlsState: last_updated: float update_cb: list[Callable[[TransformControlsEvent], None | Coroutine]] - drag_start_cb: list[Callable[[TransformControlsEvent], None | Coroutine]] = ( - dataclasses.field(default_factory=list) - ) - drag_end_cb: list[Callable[[TransformControlsEvent], None | Coroutine]] = ( - dataclasses.field(default_factory=list) - ) sync_cb: None | Callable[[ClientId, TransformControlsHandle], None] = None +def _phase_filtered_wrapper( + phase: DragPhase, + func: Callable[[TransformControlsEvent], NoneOrCoroutine], +) -> Callable[[TransformControlsEvent], None | Coroutine]: + """Build an ``update_cb`` entry for the deprecated + ``on_drag_start`` / ``on_drag_end`` methods. Tagged so + ``remove_*`` can locate it by the original ``func`` identity.""" + + if asyncio.iscoroutinefunction(func): + + async def async_wrapper(event: TransformControlsEvent) -> None: + if event.phase == phase: + await func(event) # type: ignore[misc] + + async_wrapper._wraps = func # type: ignore[attr-defined] + async_wrapper._phase_filter = phase # type: ignore[attr-defined] + return async_wrapper + + def sync_wrapper(event: TransformControlsEvent) -> None: + if event.phase == phase: + func(event) + + sync_wrapper._wraps = func # type: ignore[attr-defined] + sync_wrapper._phase_filter = phase # type: ignore[attr-defined] + return sync_wrapper + + class TransformControlsHandle( SceneNodeHandle, _messages.TransformControlsProps, @@ -1378,13 +1343,18 @@ def update_timestamp(self) -> float: def on_update( self, func: Callable[[TransformControlsEvent], NoneOrCoroutine] ) -> Callable[[TransformControlsEvent], NoneOrCoroutine]: - """Attach a callback for when the gizmo is moved. - - The callback can be either a standard function or an async function: - - Standard functions (def) will be executed in a threadpool. - - Async functions (async def) will be executed in the event loop. - - Using async functions can be useful for reducing race conditions. + """Attach a callback for the full gizmo drag lifecycle. + + Fires three times per gesture: once with + ``event.phase == "start"`` when the user grabs a handle, on + every pose change with ``"update"``, and once with ``"end"`` at + release. ``target.wxyz`` and ``target.position`` reflect the + current pose on every phase. + + Callbacks may be ``def`` (run in a threadpool) or ``async def`` + (awaited on the event loop). Async preserves phase order so + long as you don't ``await`` inside; threadpool callbacks may + run out of order. """ self._impl_aux.update_cb.append(func) return func @@ -1394,73 +1364,64 @@ def remove_update_callback( ) -> None: """Remove update callbacks from the transform controls. - Args: - callback: Either "all" to remove all callbacks, or a specific callback function to remove. + ``callback="all"`` removes every callback regardless of which + method registered it; a specific function removes entries + whose identity matches (including wrappers installed by the + deprecated :meth:`on_drag_start` / :meth:`on_drag_end`). """ if callback == "all": self._impl_aux.update_cb.clear() else: self._impl_aux.update_cb = [ - cb for cb in self._impl_aux.update_cb if cb != callback + cb + for cb in self._impl_aux.update_cb + if cb != callback and getattr(cb, "_wraps", None) is not callback ] + @deprecated("Use `on_update` and check `event.phase == 'start'`.") def on_drag_start( self, func: Callable[[TransformControlsEvent], NoneOrCoroutine] ) -> Callable[[TransformControlsEvent], NoneOrCoroutine]: - """Attach a callback for when dragging starts ("mouse down"). - - The callback can be either a standard function or an async function: - - Standard functions (def) will be executed in a threadpool. - - Async functions (async def) will be executed in the event loop. - - Using async functions can be useful for reducing race conditions. - """ - self._impl_aux.drag_start_cb.append(func) + """Deprecated. Use :meth:`on_update` and gate on + ``event.phase == "start"`` inside the handler.""" + self._impl_aux.update_cb.append(_phase_filtered_wrapper("start", func)) return func + @deprecated("Use `on_update` and check `event.phase == 'end'`.") def on_drag_end( self, func: Callable[[TransformControlsEvent], NoneOrCoroutine] ) -> Callable[[TransformControlsEvent], NoneOrCoroutine]: - """Attach a callback for when dragging end ("mouse up"). - - The callback can be either a standard function or an async function: - - Standard functions (def) will be executed in a threadpool. - - Async functions (async def) will be executed in the event loop. - - Using async functions can be useful for reducing race conditions. - """ - self._impl_aux.drag_end_cb.append(func) + """Deprecated. Use :meth:`on_update` and gate on + ``event.phase == "end"`` inside the handler.""" + self._impl_aux.update_cb.append(_phase_filtered_wrapper("end", func)) return func + @deprecated("Use `remove_update_callback`.") def remove_drag_start_callback( self, callback: Literal["all"] | Callable = "all" ) -> None: - """Remove drag start callbacks from the transform controls. - - Args: - callback: Either "all" to remove all callbacks, or a specific callback function to remove. - """ - if callback == "all": - self._impl_aux.drag_start_cb.clear() - else: - self._impl_aux.drag_start_cb = [ - cb for cb in self._impl_aux.drag_start_cb if cb != callback - ] + """Deprecated. Use :meth:`remove_update_callback`.""" + self._remove_phase_wrappers("start", callback) + @deprecated("Use `remove_update_callback`.") def remove_drag_end_callback( self, callback: Literal["all"] | Callable = "all" ) -> None: - """Remove drag end callbacks from the transform controls. + """Deprecated. Use :meth:`remove_update_callback`.""" + self._remove_phase_wrappers("end", callback) - Args: - callback: Either "all" to remove all callbacks, or a specific callback function to remove. - """ - if callback == "all": - self._impl_aux.drag_end_cb.clear() - else: - self._impl_aux.drag_end_cb = [ - cb for cb in self._impl_aux.drag_end_cb if cb != callback - ] + def _remove_phase_wrappers( + self, phase: DragPhase, callback: Literal["all"] | Callable + ) -> None: + def keep(cb: Callable[..., Any]) -> bool: + tag = getattr(cb, "_phase_filter", None) + if tag != phase: + return True # Not one of ours. + if callback == "all": + return False + return getattr(cb, "_wraps", None) is not callback + + self._impl_aux.update_cb = [cb for cb in self._impl_aux.update_cb if keep(cb)] def remove(self) -> None: """Remove the node from the scene.""" diff --git a/src/viser/client/src/App.tsx b/src/viser/client/src/App.tsx index 4ab271dec..c810c92a8 100644 --- a/src/viser/client/src/App.tsx +++ b/src/viser/client/src/App.tsx @@ -12,6 +12,7 @@ import * as THREE from "three"; import { Canvas, useThree, useFrame } from "@react-three/fiber"; import React, { useEffect, useMemo } from "react"; import { ViewerMutable } from "./ViewerContext"; +import { InteractionController } from "./pointer/interactionController"; import { Anchor, Box, @@ -32,8 +33,8 @@ import { SceneNodeThreeObject } from "./SceneTree"; import { DragLayer } from "./DragLayer"; import { KeyModifier, + hasCmdCtrl, keyModifierFromEvent, - matchesModifierFilter, } from "./dragUtils"; import { shallowArrayEqual } from "./utils/shallowArrayEqual"; import { @@ -219,26 +220,22 @@ function ViewerRoot() { getRenderRequestState: "ready", getRenderRequest: null, - // Interaction state. - scenePointerInfo: { - filtersByEventType: new Map(), - dragStart: [0, 0], - dragEnd: [0, 0], - isDragging: false, - modifierAtDown: null, - activeEventTypes: new Set(), - }, - // Skinned mesh state. skinnedMeshState: {}, // Per-node pose data (non-reactive, read in useFrame). nodePoseData: {}, - - // Global hover state tracking. - hoveredElementsCount: 0, }); + const interaction = React.useMemo( + () => + new InteractionController({ + getCameraControl: () => mutable.current.cameraControl, + getCanvas: () => mutable.current.canvas, + }), + [], + ); + // Create the scene tree state and extract store and actions. const sceneTreeState = useSceneTreeState( mutable.current.nodeRefFromName, @@ -306,6 +303,7 @@ function ViewerRoot() { useInitialCamera: initialCameraState.store, initialCameraActions: initialCameraState.actions, mutable, + interaction, }; return ( @@ -460,6 +458,7 @@ function NotificationsPanel() { */ function ViewerCanvas({ children }: { children: React.ReactNode }) { const viewer = React.useContext(ViewerContext)!; + const interaction = viewer.interaction; const sendClickThrottled = useThrottledMessageSender(20).send; const theme = useMantineTheme(); const { ref: inViewRef, inView } = useInView(); @@ -470,122 +469,142 @@ function ViewerCanvas({ children }: { children: React.ReactNode }) { [], ); - // Handle pointer down event. I don't think we need useCallback here, since - // remounts should be very rare. - const handlePointerDown = (e: React.PointerEvent) => { - const { mutable } = viewer; - const pointerInfo = mutable.current.scenePointerInfo; - if (pointerInfo.filtersByEventType.size === 0) return; - - const canvasBbox = mutable.current.canvas!.getBoundingClientRect(); - pointerInfo.dragStart = [ - e.clientX - canvasBbox.left, - e.clientY - canvasBbox.top, - ]; - pointerInfo.dragEnd = pointerInfo.dragStart; - - if (ndcFromPointerXy(viewer, pointerInfo.dragEnd) === null) return; - if (pointerInfo.isDragging) return; - - // Capture modifier state at gesture start; mid-gesture changes - // shouldn't perturb dispatch (matches drag-callback semantics). - const modifier = keyModifierFromEvent(e); - - // Gate engagement on modifier match. If no registered filter for - // any enabled event_type matches the held modifiers, this isn't a - // scene-pointer gesture -- let camera controls handle it. - const activeEventTypes = new Set<"click" | "rect-select">(); - for (const [eventType, filters] of pointerInfo.filtersByEventType) { - if (filters.some((f) => matchesModifierFilter(modifier, f))) { - activeEventTypes.add(eventType); - } - } - if (activeEventTypes.size === 0) return; - - pointerInfo.modifierAtDown = modifier; - pointerInfo.activeEventTypes = activeEventTypes; - pointerInfo.isDragging = true; - mutable.current.cameraControl!.enabled = false; - - const ctx = mutable.current.canvas2d!.getContext("2d")!; - ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - }; - - // Handle pointer move event. - const handlePointerMove = (e: React.PointerEvent) => { - const { mutable } = viewer; - const pointerInfo = mutable.current.scenePointerInfo; - if (!pointerInfo.isDragging) return; - - const canvasBbox = mutable.current.canvas!.getBoundingClientRect(); - const pointerXy: [number, number] = [ - e.clientX - canvasBbox.left, - e.clientY - canvasBbox.top, - ]; - - if (ndcFromPointerXy(viewer, pointerXy) === null) return; - pointerInfo.dragEnd = pointerXy; - - // Check if pointer moved enough to be considered a drag. - if ( - Math.abs(pointerInfo.dragEnd[0] - pointerInfo.dragStart[0]) <= 3 && - Math.abs(pointerInfo.dragEnd[1] - pointerInfo.dragStart[1]) <= 3 - ) - return; - - // Draw selection rectangle only if rect-select was active for - // this gesture's modifier state at pointerdown. - if (pointerInfo.activeEventTypes.has("rect-select")) { - const ctx = mutable.current.canvas2d!.getContext("2d")!; + // Render the rect-select overlay onto the canvas2d layer. Called + // from the pointer handlers when motion in a committed rect-select + // gesture should repaint. Theme is read at draw time (not captured) + // so a runtime theme change reflects immediately. + const drawRectSelectOverlay = React.useCallback( + (rect: { startXy: [number, number]; endXy: [number, number] } | null) => { + const c2d = viewer.mutable.current.canvas2d; + if (c2d === null) return; + const ctx = c2d.getContext("2d"); + if (ctx === null) return; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + if (rect === null) return; + const [sx, sy] = rect.startXy; + const [ex, ey] = rect.endXy; ctx.beginPath(); ctx.fillStyle = theme.primaryColor; ctx.strokeStyle = "blue"; ctx.globalAlpha = 0.2; - ctx.fillRect( - pointerInfo.dragStart[0], - pointerInfo.dragStart[1], - pointerInfo.dragEnd[0] - pointerInfo.dragStart[0], - pointerInfo.dragEnd[1] - pointerInfo.dragStart[1], - ); + ctx.fillRect(sx, sy, ex - sx, ey - sy); ctx.globalAlpha = 1.0; ctx.stroke(); + }, + [theme, viewer], + ); + + const cancelActiveScenePointer = React.useCallback(() => { + interaction.cancelAny(); + drawRectSelectOverlay(null); + }, [drawRectSelectOverlay, interaction]); + + // Held-modifier tracking. Three sources keep `hoverSet`'s + // `heldModifier` in sync with reality: + // - `keydown`/`keyup`: live updates while the canvas is focused. + // - `blur`: drops the modifier; a release out of focus may not + // deliver `keyup`. + // - `pointermove`: reconciles from the event's modifier flags so + // a focus regain with the modifier still held recovers without + // waiting for the next keypress. + React.useEffect(() => { + const onBlur = () => { + cancelActiveScenePointer(); + interaction.hover.setHeldModifier(null); + }; + const isFormElement = (el: Element | null): boolean => { + if (el === null) return false; + const tag = el.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") { + return true; + } + return (el as HTMLElement).isContentEditable; + }; + const onKey = (e: KeyboardEvent) => { + // Skip while typing in form controls so Shift in a TextInput + // doesn't flicker the canvas cursor. + if ( + isFormElement(e.target as Element | null) || + isFormElement(document.activeElement) + ) { + return; + } + interaction.hover.setHeldModifier(keyModifierFromEvent(e)); + }; + window.addEventListener("blur", onBlur); + window.addEventListener("keydown", onKey); + window.addEventListener("keyup", onKey); + return () => { + window.removeEventListener("blur", onBlur); + window.removeEventListener("keydown", onKey); + window.removeEventListener("keyup", onKey); + cancelActiveScenePointer(); + }; + }, [cancelActiveScenePointer, interaction]); + + // Canvas pointer handlers thin-delegate to the gestures module. + // Side effects (camera lock, overlay draw, wire dispatch) happen + // here based on the returned outcome. + const canvasXyFromEvent = React.useCallback( + (e: React.PointerEvent): [number, number] => { + const bbox = viewer.mutable.current.canvas!.getBoundingClientRect(); + return [e.clientX - bbox.left, e.clientY - bbox.top]; + }, + [viewer], + ); + + const handlePointerDown = (e: React.PointerEvent) => { + const xy = canvasXyFromEvent(e); + const next = interaction.scenePointer.onPointerDown({ + pointerId: e.pointerId, + button: e.nativeEvent.button, + modifier: keyModifierFromEvent(e), + xy, + insideViewport: ndcFromPointerXy(viewer, xy) !== null, + }); + if (next.kind !== "scene-rect-select") return; + // Capture the pointer so subsequent move/up/cancel for this + // pointer id are delivered to the canvas regardless of cursor + // travel. Closes the off-canvas release leak. + try { + (e.currentTarget as Element).setPointerCapture(e.pointerId); + } catch { + /* setPointerCapture may throw on some legacy paths; harmless. */ } }; - // Handle pointer up event. - const handlePointerUp = () => { - const { mutable } = viewer; - const pointerInfo = mutable.current.scenePointerInfo; - const wasDragging = pointerInfo.isDragging; - - // Reset gesture state and erase the rectangle overlay before any - // early return -- otherwise a server callback removed mid-gesture - // can leave stale ``isDragging`` or a drawn rectangle behind. - mutable.current.cameraControl!.enabled = true; - pointerInfo.isDragging = false; - const activeEventTypes = pointerInfo.activeEventTypes; - pointerInfo.activeEventTypes = new Set(); - const ctx = mutable.current.canvas2d!.getContext("2d")!; - ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - if (!wasDragging || activeEventTypes.size === 0) return; - - const modifier = pointerInfo.modifierAtDown; - - // Disambiguate click vs rect-select by displacement (same 3-pixel - // threshold as handlePointerMove uses for drawing the rectangle). - const moved = - Math.abs(pointerInfo.dragEnd[0] - pointerInfo.dragStart[0]) > 3 || - Math.abs(pointerInfo.dragEnd[1] - pointerInfo.dragStart[1]) > 3; - if (!moved && activeEventTypes.has("click")) { - sendClickMessage( + const handlePointerMove = (e: React.PointerEvent) => { + // Reconcile modifier state on every pointer event -- recovers from + // focus-regain with the modifier still held without waiting for a + // keypress. + interaction.hover.setHeldModifier(keyModifierFromEvent(e)); + const xy = canvasXyFromEvent(e); + const repaint = interaction.scenePointer.onPointerMove({ + pointerId: e.pointerId, + xy, + }); + if (repaint) { + const g = interaction.scenePointer.getGesture(); + if (g.kind === "scene-rect-select") { + drawRectSelectOverlay({ startXy: g.startXy, endXy: g.endXy }); + } + } + }; + + const handlePointerUp = (e: React.PointerEvent) => { + const outcome = interaction.scenePointer.onPointerUp({ + pointerId: e.pointerId, + }); + drawRectSelectOverlay(null); + if (outcome.kind === "scene-click") { + sendClickMessage(viewer, outcome.xy, outcome.modifier, sendClickThrottled); + } else if (outcome.kind === "scene-rect-select") { + sendRectSelectMessage( viewer, - pointerInfo.dragEnd, - modifier, + { dragStart: outcome.startXy, dragEnd: outcome.endXy }, + outcome.modifier, sendClickThrottled, ); - } else if (moved && activeEventTypes.has("rect-select")) { - sendRectSelectMessage(viewer, pointerInfo, modifier, sendClickThrottled); } }; @@ -619,10 +638,34 @@ function ViewerCanvas({ children }: { children: React.ReactNode }) { (viewer.mutable.current.canvas = el)} + ref={(el) => { + viewer.mutable.current.canvas = el; + interaction.hover.refresh(); + }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} + onPointerCancel={(e) => { + interaction.cancelPointer(e.pointerId); + drawRectSelectOverlay(null); + }} + onContextMenu={(e) => { + // Suppress the browser context menu only for ctrl/cmd-modified + // gestures that match a registered scene-pointer filter. macOS + // fires contextmenu on ctrl+click, so without this a ctrl+click + // scene-pointer callback would also pop the OS menu. + // + // We deliberately do NOT suppress plain right-clicks even when + // a ``modifier=None`` filter is registered: scene-pointer click + // callbacks are conventionally left-click, and stealing the + // canvas's right-click menu wholesale (losing the inspector) + // would surprise users who registered an unmodified callback. + const modifier = keyModifierFromEvent(e); + if (!hasCmdCtrl(modifier)) return; + if (interaction.scenePointer.anyFilterMatches(modifier)) { + e.preventDefault(); + } + }} shadows="percentage" dpr={fixedDpr ?? undefined} > @@ -1012,6 +1055,7 @@ function SceneContextSetter() { useEffect(() => { const w = window as any; w.__viserMutable = mutable.current; + w.__viserPointer = viewer.interaction.testApi(); // Expose a shim for E2E tests. w.__viserSceneTree = { getState: () => viewer.useSceneTree.getAll(), @@ -1036,6 +1080,7 @@ function SceneContextSetter() { return () => { delete w.__viserMutable; + delete w.__viserPointer; delete w.__viserSceneTree; delete w.__viserTestpoints; }; diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 665029590..61201c0f0 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -579,7 +579,10 @@ export function SynchronizedCameraControls() { return ( <> (viewerMutable.cameraControl = controls)} + ref={(controls) => { + viewerMutable.cameraControl = controls; + viewer.interaction.cameraLocks.apply(); + }} minDistance={0.01} dollySpeed={0.3} smoothTime={0.05} diff --git a/src/viser/client/src/DragLayer.tsx b/src/viser/client/src/DragLayer.tsx index c47a2e68f..747749948 100644 --- a/src/viser/client/src/DragLayer.tsx +++ b/src/viser/client/src/DragLayer.tsx @@ -5,9 +5,7 @@ * - the active-drag state (only one drag active at a time across all nodes) * - the drag-indicator arrow (one ArrowHelper per viewer, not per node) * - the window pointermove / pointerup / pointercancel / blur listeners - * - the camera-control disable/re-enable around a drag (stashes the exact - * instance so a camera-type swap mid-drag doesn't leave the old one - * disabled) + * - the camera-control lease around a drag * * Scene nodes interact with this layer through the context `useDragLayer()` * hook: on pointerdown they match their bindings against the input and @@ -281,12 +279,12 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { activeDrag.cleanup(); activeDragRef.current = null; dragArrow.visible = false; - // Re-enable the *same* camera control instance we disabled -- a - // camera-type swap during the drag would have replaced - // viewerMutable.cameraControl, and restoring the new one would - // leave the stashed one disabled forever. - if (activeDrag.cameraControl !== null) { - activeDrag.cameraControl.enabled = true; + // Drop the camera-control lock. `cameraLock` reapplies to the + // current instance, which handles a mid-drag camera-type swap + // (the old instance was re-enabled at swap time; the new one's + // `enabled` flag flips back to true here). + if (activeDrag.releaseCameraLock !== null) { + activeDrag.releaseCameraLock(); } }, [ @@ -309,8 +307,8 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { input, bindings, }) => { - if (activeDragRef.current !== null) return; - if (!anyBindingMatches(bindings, input)) return; + if (activeDragRef.current !== null) return false; + if (!anyBindingMatches(bindings, input)) return false; // Convert the raycast hit point to world coords. The frame of // ``eventPoint`` depends on which raycast produced it: @@ -350,7 +348,7 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { instanceWorldStart, frameScratches.drag, ); - if (computed === null) return; + if (computed === null) return false; const startLocalOffset = startWorld .clone() .applyMatrix4(instanceWorldStart.invert()); @@ -407,6 +405,12 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { startWorld, ); + // Assign the active-drag state first, *then* acquire the + // camera lock. If lock acquisition throws between the two we + // never reach the assignment, so no half-initialised state + // strands the lock. Acquire-after-assign also means + // `stopActiveDrag` is reachable for any teardown after this + // point. activeDragRef.current = { nodeName, instanceIndex, @@ -420,18 +424,18 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { endPointWorld: startWorld.clone(), endPointerXy: [pointerXy[0], pointerXy[1]], input, - cameraControl: viewerMutable.cameraControl, + releaseCameraLock: null, cleanup, }; + activeDragRef.current.releaseCameraLock = + viewer.interaction.cameraLocks.acquire("node-drag"); dragArrow.visible = false; - if (viewerMutable.cameraControl !== null) { - viewerMutable.cameraControl.enabled = false; - } window.addEventListener("pointermove", handleWindowPointerMove); window.addEventListener("pointerup", handleWindowPointerUp); window.addEventListener("pointercancel", handleWindowPointerUp); window.addEventListener("blur", handleWindowBlur); sendDragMessage(activeDragRef.current, "start", false); + return true; }, stopIfNodeIs: (nodeName) => { if (activeDragRef.current?.nodeName === nodeName) { diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index 3afdf62be..77a1f54d5 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -224,15 +224,10 @@ function useMessageHandler() { // filters to gate gesture engagement (no rectangle drawn for a // modifier that no callback matches). case "ScenePointerEnableMessage": { - const filters = viewerMutable.scenePointerInfo.filtersByEventType; - if (message.modifiers.length === 0) { - filters.delete(message.event_type); - } else { - filters.set(message.event_type, [...message.modifiers]); - } - - viewerMutable.canvas!.style.cursor = - filters.size > 0 ? "pointer" : "auto"; + viewer.interaction.scenePointer.applyFiltersDelta( + message.event_type, + message.modifiers, + ); return; } @@ -565,20 +560,19 @@ function useMessageHandler() { delete viewerMutable.skinnedMeshState[message.name]; return; } - // Set the clickability of a particular scene node. - case "SetSceneNodeClickableMessage": { + // Set the drag-binding set for a particular scene node. + case "SetSceneNodeDragBindingsMessage": { return { kind: "sceneNodeAttrUpdate", targetNode: message.name, - updates: { clickable: message.clickable }, + updates: { dragBindings: message.bindings }, }; } - // Set the drag-binding set for a particular scene node. - case "SetSceneNodeDragBindingsMessage": { + case "SetSceneNodeClickBindingsMessage": { return { kind: "sceneNodeAttrUpdate", targetNode: message.name, - updates: { dragBindings: message.bindings }, + updates: { clickBindings: message.bindings }, }; } // Update props of a GUI component -- accumulated and applied in batch. diff --git a/src/viser/client/src/SceneTree.tsx b/src/viser/client/src/SceneTree.tsx index 53910307e..72f6e9909 100644 --- a/src/viser/client/src/SceneTree.tsx +++ b/src/viser/client/src/SceneTree.tsx @@ -705,17 +705,22 @@ export function SceneNodeThreeObject(props: { name: string }) { ); const [unmount, setUnmount] = React.useState(false); - const clickable = - viewer.useSceneTree(props.name, (node) => node?.clickable) ?? false; - // shallowArrayEqual: the server echoes a fresh `bindings` array on every - // `SetSceneNodeDragBindingsMessage` even if the content is unchanged; this + // shallowArrayEqual: the server echoes a fresh `bindings` array on + // every binding update even if the content is unchanged; this // prevents spurious re-renders when the binding set is identical. + const clickBindings = + viewer.useSceneTree( + props.name, + (node) => node?.clickBindings, + shallowArrayEqual, + ) ?? EMPTY_DRAG_BINDINGS; const dragBindings = viewer.useSceneTree( props.name, (node) => node?.dragBindings, shallowArrayEqual, ) ?? EMPTY_DRAG_BINDINGS; + const clickable = clickBindings.length > 0; const draggable = dragBindings.length > 0; const interactive = clickable || draggable; const objRef = React.useRef(null); @@ -795,6 +800,7 @@ export function SceneNodeThreeObject(props: { name: string }) { // Drag state lives in the viewer-level DragLayer -- this component only // dispatches pointer events into it and reads bindings for matching. const dragLayer = useDragLayer(); + const interaction = viewer.interaction; const getPointerXy = React.useCallback( (clientX: number, clientY: number): [number, number] => { @@ -836,10 +842,7 @@ export function SceneNodeThreeObject(props: { name: string }) { // Pointer is no longer over this mesh, reset hover state. hoveredRef.current.isHovered = false; hoveredRef.current.instanceId = null; - viewerMutable.hoveredElementsCount--; - if (viewerMutable.hoveredElementsCount === 0) { - document.body.style.cursor = "auto"; - } + interaction.hover.setHovered(props.name, false); } } } @@ -886,10 +889,7 @@ export function SceneNodeThreeObject(props: { name: string }) { ) { hoveredRef.current.isHovered = false; hoveredRef.current.instanceId = null; - viewerMutable.hoveredElementsCount--; - if (viewerMutable.hoveredElementsCount === 0) { - document.body.style.cursor = "auto"; - } + interaction.hover.setHovered(props.name, false); } // If a node disappears mid-drag, end the drag cleanly. @@ -931,10 +931,8 @@ export function SceneNodeThreeObject(props: { name: string }) { // Handle case where interactivity is toggled off while still hovered. if (!interactive && hoveredRef.current.isHovered) { hoveredRef.current.isHovered = false; - viewerMutable.hoveredElementsCount--; - if (viewerMutable.hoveredElementsCount === 0) { - document.body.style.cursor = "auto"; - } + interaction.hover.setHovered(props.name, false); + interaction.nodeGestures.cancelNode(props.name); } // End the active drag if this node's draggability is revoked (bindings @@ -943,8 +941,9 @@ export function SceneNodeThreeObject(props: { name: string }) { React.useEffect(() => { if (!draggable && dragLayer !== null) { dragLayer.stopIfNodeIs(props.name); + interaction.nodeGestures.cancelNode(props.name); } - }, [draggable, dragLayer, props.name]); + }, [draggable, dragLayer, interaction, props.name]); // Reset hover state on true unmount, and tell DragLayer to end the // drag if it targets this node. @@ -954,20 +953,12 @@ export function SceneNodeThreeObject(props: { name: string }) { return () => { if (hoveredRef.current.isHovered) { hoveredRef.current.isHovered = false; - viewerMutable.hoveredElementsCount--; - if (viewerMutable.hoveredElementsCount === 0) { - document.body.style.cursor = "auto"; - } + interaction.hover.setHovered(props.name, false); } dragLayerRef.current?.stopIfNodeIs(props.name); + interaction.nodeGestures.cancelNode(props.name); }; - }, []); - - const dragInfo = React.useRef({ - dragging: false, - startClientX: 0, - startClientY: 0, - }); + }, [interaction, props.name]); if (objNode === undefined || unmount) { return null; @@ -988,64 +979,105 @@ export function SceneNodeThreeObject(props: { name: string }) { : (e) => { if (!isDisplayed()) return; e.stopPropagation(); - const state = dragInfo.current; - const [pointerX, pointerY] = getPointerXy( - e.clientX, - e.clientY, - ); - state.startClientX = pointerX; - state.startClientY = pointerY; - state.dragging = false; - - // Hand off to DragLayer. It no-ops if any drag is already - // active (mutex) or if no binding matches the input. - if (!draggable || dragLayer === null) return; - if (objRef.current === null) return; const buttonName = pointerButtonFromNative( e.nativeEvent.button, ); - if (buttonName === null) return; - const input: DragInput = { - button: buttonName, - modifier: keyModifierFromEvent(e), - }; - if (!anyBindingMatches(dragBindings, input)) return; - - e.nativeEvent.preventDefault(); - state.dragging = true; - dragLayer.beginDrag({ - nodeName: props.name, - // Batched handles (meshes/GLBs/axes) set - // computeClickInstanceIndexFromInstanceId; plain - // handles leave it undefined and instance_index is - // null on the wire. - instanceIndex: - computeClickInstanceIndexFromInstanceId === undefined - ? null - : computeClickInstanceIndexFromInstanceId(e.instanceId), - targetObj: objRef.current, - eventPoint: e.point, - pointerXy: [pointerX, pointerY], - pointerId: e.nativeEvent.pointerId, - input, - bindings: dragBindings, - }); + const input: DragInput | null = + buttonName === null + ? null + : { + button: buttonName, + modifier: keyModifierFromEvent(e), + }; + interaction.nodeGestures.recordPointerDown(input); + + const clickMatches = + input !== null && anyBindingMatches(clickBindings, input); + const targetObj = objRef.current; + const dragMatches = + input !== null && + draggable && + dragLayer !== null && + targetObj !== null && + anyBindingMatches(dragBindings, input); + if (!clickMatches && !dragMatches) return; + + const beginDragArgs = + dragMatches && + input !== null && + dragLayer !== null && + targetObj !== null + ? { + nodeName: props.name, + // Batched handles (meshes/GLBs/axes) set + // computeClickInstanceIndexFromInstanceId; plain + // handles leave it undefined and instance_index + // is null on the wire. + instanceIndex: + computeClickInstanceIndexFromInstanceId === + undefined + ? null + : computeClickInstanceIndexFromInstanceId( + e.instanceId, + ), + targetObj, + eventPoint: e.point, + pointerXy: getPointerXy(e.clientX, e.clientY), + pointerId: e.nativeEvent.pointerId, + input, + bindings: dragBindings, + } + : null; + + if (clickMatches || dragMatches) { + // Every interactive node runs through the + // motion-threshold candidate: a stationary press on + // a clickable node fires the click without a + // spurious drag start/end pair, and a stationary + // press on a drag-only node fires nothing at all + // (vs. the prior behavior, where dragstart fired + // immediately and dragend followed on release with + // no motion between -- a degenerate gesture user + // code had to special-case). + interaction.nodeGestures.beginCandidate({ + pointerId: e.nativeEvent.pointerId, + nodeKey: props.name, + startClientXy: [e.clientX, e.clientY], + lockCamera: dragMatches, + onPromote: + beginDragArgs === null || dragLayer === null + ? null + : () => dragLayer.beginDrag(beginDragArgs), + }); + if (dragMatches) e.nativeEvent.preventDefault(); + } } } onContextMenu={ - !draggable + !interactive ? undefined : (e) => { if (!isDisplayed()) return; - // Only suppress the browser context menu if a right-button - // binding matches the current modifier state. A node - // registered for shift+right-drag shouldn't eat plain - // right-clicks. - const input: DragInput = { - button: "right", - modifier: keyModifierFromEvent(e), - }; - if (!anyBindingMatches(dragBindings, input)) return; + // Suppress the browser context menu only when the + // gesture that fired this contextmenu would actually + // be consumed by this node. The native contextmenu + // event's button code is not reliable across browsers, + // so we consult the input recorded by the most recent + // pointerdown -- which on macOS includes the + // pointerdown that ctrl+left-click raises alongside + // the contextmenu event. + // Fallback: if no pointerdown was recorded (e.g. the + // menu was triggered by the keyboard menu key), use a + // plain right-button input -- preserves the original + // "right-click on a right-bound node" behavior. + const input: DragInput = + interaction.nodeGestures.getLastPointerDownInput() ?? { + button: "right", + modifier: keyModifierFromEvent(e), + }; + const dragMatches = anyBindingMatches(dragBindings, input); + const clickMatches = anyBindingMatches(clickBindings, input); + if (!dragMatches && !clickMatches) return; e.nativeEvent.preventDefault(); e.stopPropagation(); } @@ -1057,27 +1089,12 @@ export function SceneNodeThreeObject(props: { name: string }) { if (!isDisplayed()) return; e.stopPropagation(); - // Update pointer position for re-raycasting when mesh changes. + // Track pointer position for the useFrame hover + // recheck (mesh geometry update / visibility loss). lastPointerPos.current = { clientX: e.clientX, clientY: e.clientY, }; - - // If a drag has been initiated, DragLayer's window - // pointermove handler owns the gesture -- skip local - // click-vs-drag bookkeeping. - const state = dragInfo.current; - if (state.dragging) return; - - const [pointerX, pointerY] = getPointerXy( - e.clientX, - e.clientY, - ); - const deltaX = pointerX - state.startClientX; - const deltaY = pointerY - state.startClientY; - // Minimum motion. - if (Math.abs(deltaX) <= 3 && Math.abs(deltaY) <= 3) return; - state.dragging = true; } } onPointerUp={ @@ -1087,12 +1104,22 @@ export function SceneNodeThreeObject(props: { name: string }) { if (!isDisplayed()) return; e.stopPropagation(); - // If a drag was active, DragLayer's window pointerup - // handler ends it and sends the end message. Here we just - // need to suppress the local click path. - const state = dragInfo.current; - if (state.dragging) return; - if (!clickable) return; + // Drop the recorded pointerdown input now that the + // gesture is over. Prevents stale state from + // confusing a later keyboard-triggered contextmenu + // (Shift+F10 / Menu key). macOS ctrl+click fires + // contextmenu BEFORE pointerup, so the suppression + // path has already consumed the value by the time + // we clear it. + interaction.nodeGestures.clearLastPointerDownInput(); + // Settle any node-click-candidate. "click" means the + // press stayed stationary; "none" means it + // promoted to drag, was cancelled, or no candidate + // was ever started (for example, a drag-only node). + const outcome = interaction.nodeGestures.settlePointerUp({ + pointerId: e.nativeEvent.pointerId, + }); + if (!clickable || outcome !== "click") return; // Convert ray to viser coordinates. const ray = rayToViserCoords(viewer, e.ray); @@ -1121,6 +1148,13 @@ export function SceneNodeThreeObject(props: { name: string }) { }); } } + onPointerCancel={ + !interactive + ? undefined + : (e) => { + interaction.cancelPointer(e.nativeEvent.pointerId); + } + } onPointerOver={ !interactive ? undefined @@ -1141,12 +1175,7 @@ export function SceneNodeThreeObject(props: { name: string }) { hoveredRef.current.isHovered = true; // Store the instanceId in the hover ref. hoveredRef.current.instanceId = e.instanceId ?? null; - - // Increment global hover count and update cursor. - viewerMutable.hoveredElementsCount++; - if (viewerMutable.hoveredElementsCount === 1) { - document.body.style.cursor = "pointer"; - } + interaction.hover.setHovered(props.name, true); } } onPointerOut={ @@ -1161,12 +1190,7 @@ export function SceneNodeThreeObject(props: { name: string }) { hoveredRef.current.isHovered = false; // Clear the instanceId when no longer hovering. hoveredRef.current.instanceId = null; - - // Decrement global hover count and update cursor if needed. - viewerMutable.hoveredElementsCount--; - if (viewerMutable.hoveredElementsCount === 0) { - document.body.style.cursor = "auto"; - } + interaction.hover.setHovered(props.name, false); } } > diff --git a/src/viser/client/src/SceneTreeState.ts b/src/viser/client/src/SceneTreeState.ts index 5e751b8dd..d7dd78a3a 100644 --- a/src/viser/client/src/SceneTreeState.ts +++ b/src/viser/client/src/SceneTreeState.ts @@ -8,7 +8,11 @@ import { NodePoseDataMap } from "./ViewerContext"; export type SceneNode = { message: SceneNodeMessage; children: string[]; - clickable: boolean; + /** Per-node click bindings carried over the wire by + * `SetSceneNodeClickBindingsMessage`. Empty array means "not + * clickable for any input"; structurally identical to + * `DragBinding`. */ + clickBindings: DragBinding[]; dragBindings: DragBinding[]; labelVisible?: boolean; // Whether to show the label for this node. poseUpdateState?: "updated" | "needsUpdate" | "waitForMakeObject"; @@ -40,7 +44,7 @@ function makeRootNodeTemplate(): SceneNode { }, }, children: ["/WorldAxes"], - clickable: false, + clickBindings: [], dragBindings: [], visibility: true, effectiveVisibility: true, @@ -64,7 +68,7 @@ function makeWorldAxesNodeTemplate(): SceneNode { }, }, children: [], - clickable: false, + clickBindings: [], dragBindings: [], visibility: true, effectiveVisibility: true, @@ -98,7 +102,7 @@ function createSceneTreeActions( ...existingNode, message: message, children: existingNode?.children ?? [], - clickable: existingNode?.clickable ?? false, + clickBindings: existingNode?.clickBindings ?? [], dragBindings: existingNode?.dragBindings ?? [], labelVisible: existingNode?.labelVisible ?? false, // Default to true, will be updated when visibility is set. diff --git a/src/viser/client/src/ViewerContext.ts b/src/viser/client/src/ViewerContext.ts index 43fc2cf94..c4cb5e1c8 100644 --- a/src/viser/client/src/ViewerContext.ts +++ b/src/viser/client/src/ViewerContext.ts @@ -13,7 +13,7 @@ import { useInitialCameraState } from "./InitialCameraState"; import { useEnvironmentState } from "./EnvironmentState"; import { useDevSettingsStore } from "./DevSettingsStore"; import { GetRenderRequestMessage, Message } from "./WebsocketMessages"; -import { KeyModifier } from "./dragUtils"; +import { InteractionController } from "./pointer/interactionController"; export type NodePoseEntry = { wxyz: [number, number, number, number]; @@ -50,34 +50,6 @@ export type ViewerMutable = { getRenderRequestState: "ready" | "triggered" | "pause" | "in_progress"; getRenderRequest: null | GetRenderRequestMessage; - // Interaction state. - scenePointerInfo: { - /** Modifier-filter lists keyed by event_type. Mirrors the - * server's per-event-type set: each entry is a registered - * callback's filter (``null`` = "no modifiers held"). An - * absent/empty entry means the event_type is disabled. Used to - * gate gesture engagement and dispatch on - * ``modifierAtDown``-vs-filter match. */ - filtersByEventType: Map< - "click" | "rect-select", - (KeyModifier | null)[] - >; - dragStart: [number, number]; // First mouse position. - dragEnd: [number, number]; // Final mouse position. - isDragging: boolean; - /** Canonical ``KeyModifier`` string captured at pointerdown. - * Frozen for the gesture's lifetime -- we don't want releasing - * Shift/Cmd before mouse-up to lose the modifier match (or a - * mid-drag press to spuriously add one). Mirrors how drag - * callbacks freeze modifiers at drag-start. */ - modifierAtDown: KeyModifier | null; - /** Subset of event_types whose filter list matched - * ``modifierAtDown`` at the time of the active gesture's - * pointerdown. Drives drawing + dispatch; empty means no gesture - * was engaged. */ - activeEventTypes: Set<"click" | "rect-select">; - }; - // Skinned mesh state. skinnedMeshState: { [name: string]: { @@ -93,9 +65,6 @@ export type ViewerMutable = { // Per-node pose data. Stored outside the reactive store to avoid // triggering React re-renders on every pose update. nodePoseData: NodePoseDataMap; - - // Global hover state tracking. - hoveredElementsCount: number; }; export type ViewerContextContents = { @@ -115,6 +84,9 @@ export type ViewerContextContents = { // Single reference to all mutable state. mutable: React.MutableRefObject; + + // Per-viewer pointer/hover/camera interaction coordinator. + interaction: InteractionController; }; export const ViewerContext = React.createContext( diff --git a/src/viser/client/src/WebsocketMessages.ts b/src/viser/client/src/WebsocketMessages.ts index 94473c678..aa52bab78 100644 --- a/src/viser/client/src/WebsocketMessages.ts +++ b/src/viser/client/src/WebsocketMessages.ts @@ -1483,15 +1483,6 @@ export interface SetSceneNodeVisibilityMessage { name: string; visible: boolean; } -/** Set the clickability of a particular node in the scene. - * - * (automatically generated) - */ -export interface SetSceneNodeClickableMessage { - type: "SetSceneNodeClickableMessage"; - name: string; - clickable: boolean; -} /** Declare the drag-input combinations a scene node listens for. * * Sent as a full set; empty ``bindings`` means the node is not draggable. @@ -1521,6 +1512,35 @@ export interface SetSceneNodeDragBindingsMessage { | null; }[]; } +/** Declare the click-input combinations a scene node listens for. + * + * Sent as a full set; empty ``bindings`` means the node is not + * clickable. Mirrors :class:`SetSceneNodeDragBindingsMessage` for the + * click channel. Click and drag share the same `DragBinding` shape -- + * button + exact-match modifier. + * + * Excluded from scene serialization for the same reason as the drag + * sibling -- click callbacks live on the server. + * + * + * (automatically generated) + */ +export interface SetSceneNodeClickBindingsMessage { + type: "SetSceneNodeClickBindingsMessage"; + name: string; + bindings: { + button: "left" | "middle" | "right"; + modifier: + | "cmd/ctrl" + | "alt" + | "shift" + | "cmd/ctrl+alt" + | "cmd/ctrl+shift" + | "alt+shift" + | "cmd/ctrl+alt+shift" + | null; + }[]; +} /** Message for clicked objects. * * (automatically generated) @@ -1982,8 +2002,8 @@ export type Message = | TransformControlsDragEndMessage | BackgroundImageMessage | SetSceneNodeVisibilityMessage - | SetSceneNodeClickableMessage | SetSceneNodeDragBindingsMessage + | SetSceneNodeClickBindingsMessage | SceneNodeClickMessage | SceneNodeDragMessage | ResetGuiMessage diff --git a/src/viser/client/src/components/ComponentStyles.css.ts b/src/viser/client/src/components/ComponentStyles.css.ts index c89945adc..012959acb 100644 --- a/src/viser/client/src/components/ComponentStyles.css.ts +++ b/src/viser/client/src/components/ComponentStyles.css.ts @@ -54,6 +54,44 @@ globalStyle( }, ); +// Disabled text inputs (TextInput, Textarea, NumberInput, etc.). +// +// Mantine's default disabled style fades the value text to ~50% opacity +// against a light gray background, which is hard to read for fields the +// server uses as read-only displays (status text, gesture state, the +// acceptance-fixture diagnostics, etc.). We keep the field visually +// distinct (default cursor, muted background, softer text) but restore +// full opacity so the value stays legible. Text color is set per +// scheme below: a touch softer than the regular body text so a disabled +// field still reads as non-interactive at a glance. +globalStyle(".mantine-Input-input[data-disabled]", { + opacity: 1, + cursor: "default", +}); + +// Light mode: gray-9 text (≈ #212529, very dark gray) sits one notch +// below full black for a softer feel. Background gray-1 + border +// gray-3 differentiate the field from active inputs. +globalStyle( + '[data-mantine-color-scheme="light"] .mantine-Input-input[data-disabled]', + { + color: "var(--mantine-color-gray-7)", + backgroundColor: "var(--mantine-color-gray-1)", + borderColor: "var(--mantine-color-gray-3)", + }, +); + +// Dark mode: dark-1 (≈ #A6A7AB) is mid-light gray, clearly visible +// against the dark-6 disabled background. +globalStyle( + '[data-mantine-color-scheme="dark"] .mantine-Input-input[data-disabled]', + { + color: "var(--mantine-color-dark-1)", + backgroundColor: "var(--mantine-color-dark-6)", + borderColor: "var(--mantine-color-dark-4)", + }, +); + // Tab group: when tabs wrap onto multiple rows, the default Mantine underline // (drawn via ::before on the list) only appears below the last row. Replace // it with a per-tab strategy: each tab gets its own bottom border, plus a diff --git a/src/viser/client/src/dragLayerContext.ts b/src/viser/client/src/dragLayerContext.ts index 3b5bd45f0..a6d821fa1 100644 --- a/src/viser/client/src/dragLayerContext.ts +++ b/src/viser/client/src/dragLayerContext.ts @@ -26,7 +26,7 @@ export type BeginDragArgs = { export interface DragLayerApi { /** Attempt to start a drag on the given scene node. No-op if any drag * is already active, or if no binding matches the current input. */ - beginDrag(args: BeginDragArgs): void; + beginDrag(args: BeginDragArgs): boolean; /** End the active drag if (and only if) it targets the given node. */ stopIfNodeIs(nodeName: string): void; } diff --git a/src/viser/client/src/dragUtils.ts b/src/viser/client/src/dragUtils.ts index e19735f30..e00a92a1c 100644 --- a/src/viser/client/src/dragUtils.ts +++ b/src/viser/client/src/dragUtils.ts @@ -97,14 +97,47 @@ export function anyBindingMatches( return bindings.some((b) => matchesDragBinding(b, input)); } +/** True when the held modifier includes cmd/ctrl. Used to gate browser + * context-menu suppression: macOS raises a ``contextmenu`` event on + * ctrl+click, and we only want to suppress it when the gesture is a + * cmd/ctrl-modified press a binding would consume. */ +export function hasCmdCtrl(modifier: KeyModifier | null): boolean { + return modifier !== null && modifier.startsWith("cmd/ctrl"); +} + +/** Pixel distance, in canvas-local CSS pixels, that the pointer must + * travel between pointerdown and pointermove for the gesture to count + * as a drag rather than a stationary click. Used at every pointer + * site that disambiguates click vs drag (canvas scene-pointer in + * ``App.tsx``, per-node click in ``SceneTree.tsx``, and any future + * ``InputManager``). Single source of truth; keep in sync between + * sites by reference, not by repeated literal. */ +export const MOTION_THRESHOLD_PX = 3; + +/** ``true`` when the L∞ distance between ``start`` and ``end`` exceeds + * :data:`MOTION_THRESHOLD_PX`. Equivalent to the duplicated inline + * ``Math.abs(end[0] - start[0]) > N || Math.abs(end[1] - start[1]) > N`` + * checks at the pointer-move sites; centralizes the comparison so + * click-vs-drag classification stays consistent across canvas, node, + * and (future) coordinator paths. */ +export function motionExceedsThreshold( + start: [number, number], + end: [number, number], +): boolean { + return ( + Math.abs(end[0] - start[0]) > MOTION_THRESHOLD_PX || + Math.abs(end[1] - start[1]) > MOTION_THRESHOLD_PX + ); +} + // ============================================================================ // Active-drag state shape + batched-pose math. // ============================================================================ /** State of an in-progress drag, owned by ``DragLayer``. Refs into - * scene/three.js objects (targetObj, cameraControl) are captured at - * drag-start; mutable fields (``endPointWorld``, ``endPointerXy``) + * scene/three.js objects are captured at drag-start; mutable fields + * (``endPointWorld``, ``endPointerXy``) * are updated in place on every pointermove. */ export type ActiveDragState = { nodeName: string; @@ -135,9 +168,12 @@ export type ActiveDragState = { * pointermove. */ endPointerXy: [number, number]; input: DragInput; - /** Camera-control instance captured at drag-start, so we re-enable the - * same instance even if the viewer swaps camera types mid-drag. */ - cameraControl: ViewerContextContents["mutable"]["current"]["cameraControl"]; + /** Release for the camera-control lock held for the lifetime of + * this drag. Called in `stopActiveDrag` (and on every cancel + * path). Routing through `cameraLock` keeps a concurrent + * rect-select (which holds its own lock) from racing this drag's + * enable/disable, and reapplies on camera-type swap mid-drag. */ + releaseCameraLock: (() => void) | null; cleanup: () => void; }; diff --git a/src/viser/client/src/mesh/BatchedGlbAsset.tsx b/src/viser/client/src/mesh/BatchedGlbAsset.tsx index 5dc84bd56..36543d10d 100644 --- a/src/viser/client/src/mesh/BatchedGlbAsset.tsx +++ b/src/viser/client/src/mesh/BatchedGlbAsset.tsx @@ -22,7 +22,8 @@ export const BatchedGlbAsset = React.forwardRef< >(function BatchedGlbAsset({ children, ...message }, ref) { const viewer = React.useContext(ViewerContext)!; const clickable = - viewer.useSceneTree(message.name, (node) => node?.clickable) ?? false; + (viewer.useSceneTree(message.name, (node) => node?.clickBindings?.length) + ?? 0) > 0; const draggable = (viewer.useSceneTree( message.name, diff --git a/src/viser/client/src/mesh/BatchedMesh.tsx b/src/viser/client/src/mesh/BatchedMesh.tsx index 40e84054f..95a3458be 100644 --- a/src/viser/client/src/mesh/BatchedMesh.tsx +++ b/src/viser/client/src/mesh/BatchedMesh.tsx @@ -17,7 +17,8 @@ export const BatchedMesh = React.forwardRef< >(function BatchedMesh({ children, ...message }, ref) { const viewer = React.useContext(ViewerContext)!; const clickable = - viewer.useSceneTree(message.name, (node) => node?.clickable) ?? false; + (viewer.useSceneTree(message.name, (node) => node?.clickBindings?.length) + ?? 0) > 0; const draggable = (viewer.useSceneTree( message.name, diff --git a/src/viser/client/src/pointer/cameraLock.ts b/src/viser/client/src/pointer/cameraLock.ts new file mode 100644 index 000000000..5e12a9fd8 --- /dev/null +++ b/src/viser/client/src/pointer/cameraLock.ts @@ -0,0 +1,45 @@ +import type { CameraControls } from "@react-three/drei"; + +/** + * Per-viewer owner for `CameraControls.enabled`. + * + * Every camera-disable site gets a lease from this manager instead of + * writing `cameraControl.enabled` directly. Leases stack and release + * idempotently; the camera is enabled only when no leases remain. + */ +export class CameraLockManager { + private readonly reasons = new Map(); + + constructor(private readonly getInstance: () => CameraControls | null) {} + + /** Disable the camera until the returned release function is called. */ + acquire(reason: string): () => void { + const id = Symbol(reason); + this.reasons.set(id, reason); + this.apply(); + return () => { + if (!this.reasons.has(id)) return; + this.reasons.delete(id); + this.apply(); + }; + } + + /** Re-apply the current lease state to the current camera-control instance. */ + apply(): void { + const instance = this.getInstance(); + if (instance === null) return; + const next = this.reasons.size === 0; + if (instance.enabled !== next) instance.enabled = next; + } + + /** Dev-only inspection: list reason strings for currently-held locks. */ + reasonsForTest(): string[] { + return Array.from(this.reasons.values()); + } + + /** Reset state for tests / HMR teardown. */ + resetForTest(): void { + this.reasons.clear(); + this.apply(); + } +} diff --git a/src/viser/client/src/pointer/gestures.ts b/src/viser/client/src/pointer/gestures.ts new file mode 100644 index 000000000..051a2b78a --- /dev/null +++ b/src/viser/client/src/pointer/gestures.ts @@ -0,0 +1,397 @@ +import { + matchesModifierFilter, + motionExceedsThreshold, + pointerButtonFromNative, + type DragInput, + type KeyModifier, +} from "../dragUtils"; +import { CameraLockManager } from "./cameraLock"; +import { + HoverCursorManager, + type ScenePointerEventType, +} from "./hoverSet"; + +export type { ScenePointerEventType } from "./hoverSet"; + +export type CanvasGesture = + | { kind: "idle" } + | { + kind: "scene-pointer-candidate"; + pointerId: number; + input: DragInput; + startXy: [number, number]; + endXy: [number, number]; + moved: boolean; + eligible: Set; + } + | { + kind: "scene-rect-select"; + pointerId: number; + input: DragInput; + startXy: [number, number]; + endXy: [number, number]; + eligible: Set; + release: () => void; + }; + +export type ScenePointerOutcome = + | { kind: "none" } + | { + kind: "scene-click"; + xy: [number, number]; + modifier: KeyModifier | null; + } + | { + kind: "scene-rect-select"; + startXy: [number, number]; + endXy: [number, number]; + modifier: KeyModifier | null; + }; + +const IDLE: CanvasGesture = { kind: "idle" }; + +export class ScenePointerController { + private gesture: CanvasGesture = IDLE; + private readonly filters = new Map(); + /** Cleanup for window-level pointerup/pointercancel listeners + * installed while a gesture is engaged. Null when idle. The + * listeners catch releases that happen off the canvas -- a + * `scene-pointer-candidate` does not call `setPointerCapture` (so + * camera-controls stays responsive), so an off-canvas release would + * otherwise leak the gesture and block all subsequent pointerdowns + * until the next on-canvas release. */ + private windowListenerCleanup: (() => void) | null = null; + + constructor( + private readonly cameraLocks: CameraLockManager, + private readonly hover: HoverCursorManager, + ) {} + + /** Install window-level safety net. On-canvas releases fire the + * canvas listener first (bubble phase) and clear the gesture, so + * the window listener is a no-op on the happy path. */ + private installWindowListeners(): void { + if (this.windowListenerCleanup !== null) return; + const onPointerUp = (event: PointerEvent): void => { + if (this.gesture.kind === "idle") return; + if (this.gesture.pointerId !== event.pointerId) return; + // Off-canvas release: cancel rather than dispatch a click or + // rect-select outcome. If the user dragged off the canvas they + // didn't mean to commit the gesture against this viewport. + this.cancelPointer(event.pointerId); + }; + const onPointerCancel = (event: PointerEvent): void => { + this.cancelPointer(event.pointerId); + }; + window.addEventListener("pointerup", onPointerUp, { passive: true }); + window.addEventListener("pointercancel", onPointerCancel, { passive: true }); + this.windowListenerCleanup = () => { + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerCancel); + }; + } + + private removeWindowListeners(): void { + if (this.windowListenerCleanup === null) return; + this.windowListenerCleanup(); + this.windowListenerCleanup = null; + } + + getGesture(): CanvasGesture { + return this.gesture; + } + + applyFiltersDelta( + eventType: ScenePointerEventType, + modifiers: readonly (KeyModifier | null)[], + ): void { + if (modifiers.length === 0) this.filters.delete(eventType); + else this.filters.set(eventType, [...modifiers]); + this.hover.refresh(); + } + + getFilter( + eventType: ScenePointerEventType, + ): readonly (KeyModifier | null)[] | undefined { + return this.filters.get(eventType); + } + + anyFilterMatches(modifier: KeyModifier | null): boolean { + for (const list of this.filters.values()) { + for (const f of list) { + if (matchesModifierFilter(modifier, f)) return true; + } + } + return false; + } + + onPointerDown(args: { + pointerId: number; + button: number; + modifier: KeyModifier | null; + xy: [number, number]; + insideViewport: boolean; + }): CanvasGesture { + if (this.gesture.kind !== "idle") return this.gesture; + if (!args.insideViewport) return this.gesture; + const button = pointerButtonFromNative(args.button); + if (button === null) return this.gesture; + if (this.filters.size === 0) return this.gesture; + + const input: DragInput = { button, modifier: args.modifier }; + const eligible = new Set(); + for (const [eventType, modifiers] of this.filters) { + if ( + button === "left" && + modifiers.some((m) => matchesModifierFilter(args.modifier, m)) + ) { + eligible.add(eventType); + } + } + if (eligible.size === 0) return this.gesture; + if (eligible.has("rect-select")) { + this.gesture = { + kind: "scene-rect-select", + pointerId: args.pointerId, + input, + startXy: args.xy, + endXy: args.xy, + eligible, + release: this.cameraLocks.acquire("scene-rect-select"), + }; + this.installWindowListeners(); + return this.gesture; + } + this.gesture = { + kind: "scene-pointer-candidate", + pointerId: args.pointerId, + input, + startXy: args.xy, + endXy: args.xy, + moved: false, + eligible, + }; + this.installWindowListeners(); + return this.gesture; + } + + /** Returns true when the rect-select overlay should repaint. */ + onPointerMove(args: { pointerId: number; xy: [number, number] }): boolean { + const g = this.gesture; + if (g.kind !== "scene-pointer-candidate" && g.kind !== "scene-rect-select") { + return false; + } + if (g.pointerId !== args.pointerId) return false; + const exceeded = motionExceedsThreshold(g.startXy, args.xy); + g.endXy = args.xy; + if (g.kind === "scene-pointer-candidate") { + if (exceeded) g.moved = true; + return false; + } + if (exceeded) { + this.hover.setRectSelectActive(true); + return true; + } + return false; + } + + onPointerUp(args: { pointerId: number }): ScenePointerOutcome { + const g = this.gesture; + if (g.kind === "idle") return { kind: "none" }; + if (g.pointerId !== args.pointerId) return { kind: "none" }; + + this.hover.setRectSelectActive(false); + if (g.kind === "scene-pointer-candidate") { + this.gesture = IDLE; + this.removeWindowListeners(); + if (!g.moved && g.eligible.has("click")) { + return { + kind: "scene-click", + xy: g.startXy, + modifier: g.input.modifier, + }; + } + return { kind: "none" }; + } + + g.release(); + const moved = motionExceedsThreshold(g.startXy, g.endXy); + this.gesture = IDLE; + this.removeWindowListeners(); + if (moved) { + return { + kind: "scene-rect-select", + startXy: g.startXy, + endXy: g.endXy, + modifier: g.input.modifier, + }; + } + if (g.eligible.has("click")) { + return { + kind: "scene-click", + xy: g.startXy, + modifier: g.input.modifier, + }; + } + return { kind: "none" }; + } + + cancelPointer(pointerId: number): void { + const g = this.gesture; + if (g.kind !== "idle" && g.pointerId === pointerId) { + this.hover.setRectSelectActive(false); + if (g.kind === "scene-rect-select") g.release(); + this.gesture = IDLE; + this.removeWindowListeners(); + } + } + + cancelAny(): void { + if (this.gesture.kind === "idle") return; + this.hover.setRectSelectActive(false); + if (this.gesture.kind === "scene-rect-select") { + this.gesture.release(); + } + this.gesture = IDLE; + this.removeWindowListeners(); + } + + resetForTest(): void { + this.cancelAny(); + this.filters.clear(); + this.hover.refresh(); + } +} + +type NodeCandidate = { + pointerId: number; + nodeKey: string; + startClientXy: [number, number]; + release: (() => void) | null; + cleanup: () => void; + onPromote: (() => boolean) | null; +}; + +export class NodeGestureController { + private candidate: NodeCandidate | null = null; + private lastPointerDownInput: DragInput | null = null; + + constructor(private readonly cameraLocks: CameraLockManager) {} + + recordPointerDown(input: DragInput | null): void { + this.lastPointerDownInput = input; + } + + getLastPointerDownInput(): DragInput | null { + return this.lastPointerDownInput; + } + + clearLastPointerDownInput(): void { + this.lastPointerDownInput = null; + } + + beginCandidate(args: { + pointerId: number; + nodeKey: string; + startClientXy: [number, number]; + lockCamera: boolean; + onPromote: (() => boolean) | null; + }): void { + this.cancelCandidate(); + + const handlePointerMove = (event: PointerEvent) => { + const candidate = this.candidate; + if (candidate === null || event.pointerId !== candidate.pointerId) return; + if ( + !motionExceedsThreshold(candidate.startClientXy, [ + event.clientX, + event.clientY, + ]) + ) { + return; + } + this.promoteOrCancelCandidate(); + }; + const handlePointerUp = (event: PointerEvent) => { + const candidate = this.candidate; + if (candidate === null || event.pointerId !== candidate.pointerId) return; + this.lastPointerDownInput = null; + this.cancelCandidate(); + }; + const handlePointerCancel = (event: PointerEvent) => { + this.cancelPointer(event.pointerId); + }; + const handleBlur = () => { + this.cancelCandidate(); + this.lastPointerDownInput = null; + }; + const cleanup = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerCancel); + window.removeEventListener("blur", handleBlur); + }; + + this.candidate = { + pointerId: args.pointerId, + nodeKey: args.nodeKey, + startClientXy: args.startClientXy, + release: args.lockCamera + ? this.cameraLocks.acquire("node-click-or-drag-candidate") + : null, + cleanup, + onPromote: args.onPromote, + }; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerCancel); + window.addEventListener("blur", handleBlur); + } + + settlePointerUp(args: { pointerId: number }): "click" | "none" { + const candidate = this.candidate; + if (candidate === null || candidate.pointerId !== args.pointerId) { + return "none"; + } + this.cancelCandidate(); + return "click"; + } + + cancelPointer(pointerId: number): void { + const candidate = this.candidate; + if (candidate === null || candidate.pointerId !== pointerId) return; + this.cancelCandidate(); + this.lastPointerDownInput = null; + } + + cancelNode(nodeKey: string): void { + const candidate = this.candidate; + if (candidate === null || candidate.nodeKey !== nodeKey) return; + this.cancelCandidate(); + } + + cancelAny(): void { + this.cancelCandidate(); + this.lastPointerDownInput = null; + } + + resetForTest(): void { + this.cancelAny(); + } + + private promoteOrCancelCandidate(): void { + const candidate = this.candidate; + if (candidate === null) return; + const promote = candidate.onPromote; + this.cancelCandidate(); + if (promote !== null) promote(); + } + + private cancelCandidate(): void { + const candidate = this.candidate; + if (candidate === null) return; + this.candidate = null; + candidate.cleanup(); + if (candidate.release !== null) candidate.release(); + } +} diff --git a/src/viser/client/src/pointer/hoverSet.ts b/src/viser/client/src/pointer/hoverSet.ts new file mode 100644 index 000000000..f4241272c --- /dev/null +++ b/src/viser/client/src/pointer/hoverSet.ts @@ -0,0 +1,75 @@ +import { matchesModifierFilter, type KeyModifier } from "../dragUtils"; + +export type ScenePointerEventType = "click" | "rect-select"; + +/** + * Per-viewer single writer for `canvas.style.cursor`. + * + * Hover is stored as a set of node keys, not a refcount, so repeated + * over/out events cannot underflow the cursor state. + */ +export class HoverCursorManager { + private readonly hovered = new Set(); + private heldModifier: KeyModifier | null = null; + private rectSelectActive = false; + private lastApplied: "auto" | "pointer" | "crosshair" | null = null; + + constructor( + private readonly getCanvas: () => HTMLCanvasElement | null, + private readonly getScenePointerFilter: ( + eventType: ScenePointerEventType, + ) => readonly (KeyModifier | null)[] | undefined, + ) {} + + /** Mark a clickable node hovered or unhovered. Idempotent. */ + setHovered(key: string, on: boolean): void { + const changed = on ? !this.hovered.has(key) : this.hovered.delete(key); + if (on) this.hovered.add(key); + if (changed) this.apply(); + } + + refresh(): void { + this.apply(); + } + + setHeldModifier(modifier: KeyModifier | null): void { + if (modifier === this.heldModifier) return; + this.heldModifier = modifier; + this.apply(); + } + + setRectSelectActive(active: boolean): void { + if (active === this.rectSelectActive) return; + this.rectSelectActive = active; + this.apply(); + } + + resetForTest(): void { + this.hovered.clear(); + this.heldModifier = null; + this.rectSelectActive = false; + this.lastApplied = null; + this.apply(); + } + + private derive(): "auto" | "pointer" | "crosshair" { + if (this.rectSelectActive) return "crosshair"; + if (this.hovered.size > 0) return "pointer"; + const clickFilters = this.getScenePointerFilter("click"); + if (clickFilters !== undefined) { + for (const m of clickFilters) { + if (matchesModifierFilter(this.heldModifier, m)) return "pointer"; + } + } + return "auto"; + } + + private apply(): void { + const canvas = this.getCanvas(); + if (canvas === null) return; + const next = this.derive(); + if (next === this.lastApplied) return; + this.lastApplied = next; + canvas.style.cursor = next; + } +} diff --git a/src/viser/client/src/pointer/interactionController.ts b/src/viser/client/src/pointer/interactionController.ts new file mode 100644 index 000000000..0c68985ef --- /dev/null +++ b/src/viser/client/src/pointer/interactionController.ts @@ -0,0 +1,68 @@ +import type { CameraControls } from "@react-three/drei"; +import type { KeyModifier } from "../dragUtils"; +import { CameraLockManager } from "./cameraLock"; +import { HoverCursorManager } from "./hoverSet"; +import { + NodeGestureController, + ScenePointerController, + type CanvasGesture, +} from "./gestures"; + +export class InteractionController { + readonly cameraLocks: CameraLockManager; + readonly hover: HoverCursorManager; + readonly scenePointer: ScenePointerController; + readonly nodeGestures: NodeGestureController; + + constructor(args: { + getCameraControl: () => CameraControls | null; + getCanvas: () => HTMLCanvasElement | null; + }) { + this.cameraLocks = new CameraLockManager(args.getCameraControl); + this.hover = new HoverCursorManager(args.getCanvas, (eventType) => + this.scenePointer.getFilter(eventType), + ); + this.scenePointer = new ScenePointerController(this.cameraLocks, this.hover); + this.nodeGestures = new NodeGestureController(this.cameraLocks); + } + + cancelPointer(pointerId: number): void { + this.scenePointer.cancelPointer(pointerId); + this.nodeGestures.cancelPointer(pointerId); + } + + cancelAny(): void { + this.scenePointer.cancelAny(); + this.nodeGestures.cancelAny(); + } + + resetForTest(): void { + this.cancelAny(); + this.scenePointer.resetForTest(); + this.nodeGestures.resetForTest(); + this.cameraLocks.resetForTest(); + this.hover.resetForTest(); + } + + testApi(): ViserPointerTestApi { + return { + getGesture: () => this.scenePointer.getGesture(), + cameraLockReasons: () => this.cameraLocks.reasonsForTest(), + setHeldModifier: (modifier) => this.hover.setHeldModifier(modifier), + reset: () => this.resetForTest(), + }; + } +} + +export type ViserPointerTestApi = { + getGesture: () => CanvasGesture; + cameraLockReasons: () => string[]; + setHeldModifier: (modifier: KeyModifier | null) => void; + reset: () => void; +}; + +declare global { + interface Window { + __viserPointer?: ViserPointerTestApi; + } +} diff --git a/tests/drag_handler_tests.py b/tests/drag_handler_tests.py new file mode 100644 index 000000000..4c06a68b1 --- /dev/null +++ b/tests/drag_handler_tests.py @@ -0,0 +1,617 @@ +"""Manual stepped acceptance test for drag/click/scene-pointer behavior. + +This is a developer harness, not a pytest file -- it spins up a viser server +with a GUI stepper that walks through every edge case the drag-handler +branch fixes. Each phase sets up a scene, gives you an instruction, and +then shows what fired so you can confirm the observed behavior matches +the expectation. + +Run: + + python tests/drag_handler_tests.py + +Open the printed URL, follow the instruction at the top of the GUI, then +press "Next phase". The status log at the bottom prints every callback +firing so you can diff observed vs. expected without leaving the page. + +Edge cases covered: + + 1. Click-only node: small jitter still fires click (motion-threshold + suppression for stationary-ish gestures). + 2. Drag-only node: drag fires immediately, no click race. + 3. Click+drag node: stationary press fires click, drag fires drag. + A press+drag must NOT fire the click handler. A press without + motion must NOT fire drag start/update/end. + 4. Scene on_click: plain left-drag on empty space orbits the camera + (does NOT disable orbit at pointerdown). + 5. Scene on_click: stationary press in empty space fires the scene + click handler. + 6. Scene on_rect_select(modifier="shift"): shift-drag fires + rect-select AND suppresses orbit; plain drag still orbits. + 7. Parallel state slots: shift+drag on a draggable node engages + BOTH the node candidate AND the rect-select gesture. + 8. Modifier-filtered click does NOT block plain orbit (an + on_click(modifier="cmd/ctrl") still lets plain drag orbit). + 9. Multiple drag handlers on the same node, different modifiers, + route to the right one. + 10. Window blur mid-drag fires the "end" phase exactly once so + per-drag state can be released cleanly. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from typing import Callable + +import numpy as np + +import viser +from viser._messages import KeyModifier + +# --------------------------------------------------------------------------- +# Stepper state. +# --------------------------------------------------------------------------- + + +@dataclass +class Phase: + """One acceptance step.""" + + title: str + instructions: str + expected: str + setup: Callable[["Harness"], None] + + +@dataclass +class Harness: + server: viser.ViserServer + log_handle: viser.GuiMarkdownHandle # status log + counters: dict[str, int] = field(default_factory=dict) + log_lines: list[str] = field(default_factory=list) + log_lock: threading.Lock = field(default_factory=threading.Lock) + # Scene-level callbacks registered by the current phase. Tracked + # so teardown can remove only this phase's handlers without + # touching anything else on the scene. + scene_click_cbs: list[Callable] = field(default_factory=list) + scene_rect_select_cbs: list[Callable] = field(default_factory=list) + # Scene nodes created by the current phase, tracked by name. + phase_node_names: list[str] = field(default_factory=list) + + def log(self, line: str) -> None: + stamp = time.strftime("%H:%M:%S") + with self.log_lock: + self.log_lines.append(f"`{stamp}` {line}") + if len(self.log_lines) > 30: + self.log_lines.pop(0) + self.log_handle.content = "### Event log\n\n" + "\n\n".join( + reversed(self.log_lines) + ) + + def bump(self, key: str) -> int: + self.counters[key] = self.counters.get(key, 0) + 1 + return self.counters[key] + + def teardown(self) -> None: + self.counters.clear() + # Remove this phase's nodes only -- leaves the orbit marker + # and world axes alone so they keep providing a parallax cue. + for name in self.phase_node_names: + try: + self.server.scene.remove_by_name(name) + except Exception: # noqa: BLE001 + # Node may have been removed already (e.g. by a + # repeated reset). Quietly ignore. + pass + self.phase_node_names.clear() + # Drop this phase's scene-level callbacks. + for cb in self.scene_click_cbs: + try: + self.server.scene.remove_click_callback(cb) + except Exception: # noqa: BLE001 + pass + self.scene_click_cbs.clear() + for cb in self.scene_rect_select_cbs: + try: + self.server.scene.remove_rect_select_callback(cb) + except Exception: # noqa: BLE001 + pass + self.scene_rect_select_cbs.clear() + + def add_box( + self, + name: str, + dimensions: tuple[float, float, float], + color: tuple[int, int, int], + position: tuple[float, float, float] = (0.0, 0.0, 0.0), + ) -> viser.BoxHandle: + handle = self.server.scene.add_box( + name, dimensions=dimensions, color=color, position=position + ) + self.phase_node_names.append(name) + return handle + + def on_scene_click(self, modifier: KeyModifier | None = None) -> Callable: + def decorator(func: Callable) -> Callable: + registered = self.server.scene.on_click(modifier=modifier)(func) + self.scene_click_cbs.append(registered) + return registered + + return decorator + + def on_scene_rect_select(self, modifier: KeyModifier | None = None) -> Callable: + def decorator(func: Callable) -> Callable: + registered = self.server.scene.on_rect_select(modifier=modifier)(func) + self.scene_rect_select_cbs.append(registered) + return registered + + return decorator + + +# --------------------------------------------------------------------------- +# Phase definitions. +# --------------------------------------------------------------------------- + + +def _phase_click_only(h: Harness) -> None: + """Edge case 1: click-only node, motion threshold.""" + box = h.add_box( + "/test/click_only", + dimensions=(0.6, 0.6, 0.6), + color=(180, 220, 255), + ) + + @box.on_click + def _(event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: + n = h.bump("click_only/click") + h.log(f"click-only box: on_click #{n} (modifier={event.modifier})") + + +def _phase_drag_only(h: Harness) -> None: + """Edge case 2: drag-only node.""" + box = h.add_box( + "/test/drag_only", + dimensions=(0.6, 0.6, 0.6), + color=(255, 200, 200), + ) + + @box.on_drag("left") + def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + n = h.bump(f"drag_only/{event.phase}") + h.log(f"drag-only box: on_drag phase={event.phase} #{n}") + + +def _phase_click_and_drag(h: Harness) -> None: + """Edge case 3: tap-vs-drag gate.""" + box = h.add_box( + "/test/click_and_drag", + dimensions=(0.6, 0.6, 0.6), + color=(220, 255, 200), + ) + + @box.on_click + def _(event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: + del event + n = h.bump("click_and_drag/click") + h.log(f"click+drag box: on_click #{n}") + + @box.on_drag("left") + def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + n = h.bump(f"click_and_drag/{event.phase}") + h.log(f"click+drag box: on_drag phase={event.phase} #{n}") + + +def _phase_scene_click_does_not_disable_orbit(h: Harness) -> None: + """Edge case 4 + 5: scene.on_click() is passive at pointerdown. + + Plain drag on empty space should orbit; stationary press should + fire the click handler. + """ + + @h.on_scene_click() + def _(event: viser.SceneClickEvent) -> None: + del event + n = h.bump("scene/click") + h.log(f"scene.on_click() fired #{n}") + + +def _phase_rect_select_with_shift(h: Harness) -> None: + """Edge case 6: rect-select with shift modifier suppresses orbit + on shift+drag but plain drag still orbits.""" + + @h.on_scene_rect_select(modifier="shift") + def _(event: viser.SceneRectSelectEvent) -> None: + del event + n = h.bump("scene/rect_select_shift") + h.log(f"scene.on_rect_select(modifier='shift') fired #{n}") + + +def _phase_parallel_node_and_rect_select(h: Harness) -> None: + """Edge case 7: parallel state slots. + + A clickable+draggable node and a shift rect-select both registered. + Shift-drag starting ON the box should fire rect-select; plain drag + starting ON the box should drag the box (not orbit, not rect-select); + plain click on the box should fire on_click. + """ + box = h.add_box( + "/test/parallel", + dimensions=(0.6, 0.6, 0.6), + color=(255, 230, 150), + ) + + @box.on_click + def _(event: viser.SceneNodePointerEvent[viser.BoxHandle]) -> None: + del event + n = h.bump("parallel/click") + h.log(f"parallel box: on_click #{n}") + + @box.on_drag("left") + def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + n = h.bump(f"parallel/drag_{event.phase}") + h.log(f"parallel box: on_drag phase={event.phase} #{n}") + + @h.on_scene_rect_select(modifier="shift") + def _(event: viser.SceneRectSelectEvent) -> None: + del event + n = h.bump("scene/rect_select_shift") + h.log(f"scene.on_rect_select(modifier='shift') fired #{n}") + + +def _phase_modifier_filtered_click(h: Harness) -> None: + """Edge case 8: modifier-filtered click does not block plain orbit.""" + + @h.on_scene_click(modifier="cmd/ctrl") + def _(event: viser.SceneClickEvent) -> None: + del event + n = h.bump("scene/click_cmdctrl") + h.log(f"scene.on_click(modifier='cmd/ctrl') fired #{n}") + + +def _phase_multiple_drag_handlers(h: Harness) -> None: + """Edge case 9: multiple drag handlers, different modifiers.""" + box = h.add_box( + "/test/multi_modifier", + dimensions=(0.6, 0.6, 0.6), + color=(220, 200, 255), + ) + + @box.on_drag("left") + def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + n = h.bump(f"multi/plain_{event.phase}") + h.log(f"multi box: plain drag phase={event.phase} #{n}") + + @box.on_drag("left", modifier="cmd/ctrl") + def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + n = h.bump(f"multi/cmdctrl_{event.phase}") + h.log(f"multi box: cmd/ctrl drag phase={event.phase} #{n}") + + @box.on_drag("left", modifier="shift") + def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + n = h.bump(f"multi/shift_{event.phase}") + h.log(f"multi box: shift drag phase={event.phase} #{n}") + + +def _phase_blur_during_drag(h: Harness) -> None: + """Edge case 10: window blur mid-drag fires the 'end' phase. + + Press down on the box, then alt-tab (or click into another window) + while still holding the mouse. The drag should receive an 'end' + phase exactly once. + """ + box = h.add_box( + "/test/blur_test", + dimensions=(0.6, 0.6, 0.6), + color=(255, 200, 255), + ) + state = {"saw_start": False, "saw_end": False} + + @box.on_drag("left") + def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + n = h.bump(f"blur/{event.phase}") + h.log(f"blur box: on_drag phase={event.phase} #{n}") + if event.phase == "start": + state["saw_start"] = True + elif event.phase == "end": + state["saw_end"] = True + if state["saw_start"]: + h.log("blur box: end phase fired after start -- lifecycle complete") + + +PHASES: list[Phase] = [ + Phase( + title="1. Click-only node", + instructions=( + "A blue box appears at the origin. It has an `on_click` " + "handler but no `on_drag`.\n\n" + "**Do this:**\n" + "- Click the box once (no motion).\n" + "- Then press and drag the box ~30 px (small motion).\n" + "- Then press and drag the box across the canvas (>50 px)." + ), + expected=( + "- Stationary click: `click_only/click` increments by 1.\n" + "- Small drag (~30 px): camera orbits, `click_only/click` " + "does NOT increment (motion threshold suppresses).\n" + "- Large drag: camera orbits, no click." + ), + setup=_phase_click_only, + ), + Phase( + title="2. Drag-only node (3px motion gate)", + instructions=( + "A red box appears. It has an `on_drag` handler but no " + "`on_click`. Drag-only nodes use the same 3px motion gate " + "as click+drag nodes -- a stationary press fires nothing.\n\n" + "**Do this:**\n" + "- Press and drag the box across the canvas (>3 px).\n" + "- Press and release the box without moving.\n" + "- Press the box, jitter ~2 px, release (sub-threshold)." + ), + expected=( + "- Drag (>3 px): `drag_only/start` += 1, " + "`drag_only/update` += N (>=1), `drag_only/end` += 1.\n" + "- Stationary press: NO drag callbacks fire.\n" + "- Sub-threshold jitter (<3 px): NO drag callbacks fire." + ), + setup=_phase_drag_only, + ), + Phase( + title="3. Click + drag node (tap-vs-drag gate)", + instructions=( + "A green box appears with BOTH `on_click` and `on_drag` " + "handlers.\n\n" + "**Do this:**\n" + "- Click the box without moving.\n" + "- Press the box and drag across the canvas.\n" + "- Press the box, jitter ~2 px, release (sub-threshold)." + ), + expected=( + "- Stationary click: `click_and_drag/click` += 1. No drag " + "callbacks.\n" + "- Drag (>3 px motion): `click_and_drag/start/update/end` " + "fire. `click_and_drag/click` does NOT increment.\n" + "- Sub-threshold jitter (<3 px): treated as click -- " + "`click_and_drag/click` += 1. No drag callbacks." + ), + setup=_phase_click_and_drag, + ), + Phase( + title="4. scene.on_click() is passive at pointerdown", + instructions=( + "A `scene.on_click()` handler is registered. No nodes in the " + "scene.\n\n" + "**Do this:**\n" + "- Plain left-drag on empty space.\n" + "- Stationary click on empty space." + ), + expected=( + "- Drag on empty space: camera orbits. `scene/click` does " + "NOT increment.\n" + "- Stationary click: camera does NOT move; `scene/click` " + "+= 1." + ), + setup=_phase_scene_click_does_not_disable_orbit, + ), + Phase( + title="5. Rect-select with shift", + instructions=( + "A `scene.on_rect_select(modifier='shift')` handler is " + "registered.\n\n" + "**Do this:**\n" + "- Plain left-drag on empty space (no shift).\n" + "- Shift + left-drag on empty space." + ), + expected=( + "- Plain drag: camera orbits, no rect-select rectangle, " + "`scene/rect_select_shift` does NOT increment.\n" + "- Shift drag: rubber-band rectangle appears, camera does " + "NOT orbit, `scene/rect_select_shift` += 1 on release." + ), + setup=_phase_rect_select_with_shift, + ), + Phase( + title="6. Parallel slots: node drag + shift rect-select", + instructions=( + "A yellow draggable+clickable box AND a " + "`scene.on_rect_select(modifier='shift')` are both " + "registered.\n\n" + "**Do this:**\n" + "- Plain left-drag starting on the box (no shift).\n" + "- Shift + left-drag starting ON the box.\n" + "- Plain click on the box." + ), + expected=( + "- Plain drag on box: `parallel/drag_*` fires, box moves. " + "No rect-select.\n" + "- Shift drag starting on box: rubber-band rectangle " + "draws, `scene/rect_select_shift` += 1. The node drag " + "candidate also engaged but resolved to no-op because the " + "modifier doesn't match the plain `on_drag('left')` " + "binding.\n" + "- Click on box: `parallel/click` += 1." + ), + setup=_phase_parallel_node_and_rect_select, + ), + Phase( + title="7. Modifier-filtered click does not block plain orbit", + instructions=( + "A `scene.on_click(modifier='cmd/ctrl')` handler is " + "registered.\n\n" + "**Do this:**\n" + "- Plain left-drag on empty space (no modifier).\n" + "- Cmd/Ctrl + click on empty space." + ), + expected=( + "- Plain drag: camera orbits, `scene/click_cmdctrl` does " + "NOT increment.\n" + "- Cmd/Ctrl + click: `scene/click_cmdctrl` += 1, camera " + "doesn't move." + ), + setup=_phase_modifier_filtered_click, + ), + Phase( + title="8. Multiple drag handlers on one node", + instructions=( + "A purple box with three drag bindings: plain, cmd/ctrl, " + "shift.\n\n" + "**Do this:**\n" + "- Plain left-drag the box.\n" + "- Cmd/Ctrl + left-drag the box.\n" + "- Shift + left-drag the box." + ), + expected=( + "- Plain drag: only `multi/plain_*` fires.\n" + "- Cmd/Ctrl drag: only `multi/cmdctrl_*` fires.\n" + "- Shift drag: only `multi/shift_*` fires.\n" + "- Each gesture produces exactly one `start` and one `end`." + ), + setup=_phase_multiple_drag_handlers, + ), + Phase( + title="9. Window blur during drag fires 'end'", + instructions=( + "A pink box with a plain `on_drag`.\n\n" + "**Do this:**\n" + "- Press the box and start dragging.\n" + "- While still holding the mouse button, Alt+Tab (or " + "click into another app/window) to blur this window.\n" + "- Then release the mouse." + ), + expected=( + "- `blur/start` += 1 at press.\n" + "- `blur/update` += N during motion.\n" + "- `blur/end` += 1 *immediately* when the window loses " + "focus -- not later when the mouse is released.\n" + "- The log line `lifecycle complete` confirms start+end " + "paired." + ), + setup=_phase_blur_during_drag, + ), +] + + +# --------------------------------------------------------------------------- +# Main: build the GUI stepper. +# --------------------------------------------------------------------------- + + +def main() -> None: + server = viser.ViserServer() + server.scene.world_axes.visible = True + server.initial_camera.position = (3.0, 3.0, 3.0) + server.initial_camera.look_at = (0.0, 0.0, 0.0) + + # Spinning sphere so the camera-orbit checks have an obvious + # parallax cue. + marker = server.scene.add_icosphere( + "/orbit_marker", + radius=0.08, + color=(255, 255, 255), + position=(1.5, 0.0, 0.0), + ) + + def spin_marker() -> None: + t0 = time.time() + while True: + t = time.time() - t0 + marker.position = (1.5 * np.cos(t), 1.5 * np.sin(t), 0.0) + time.sleep(0.05) + + threading.Thread(target=spin_marker, daemon=True).start() + + # Top header. + server.gui.add_markdown( + "# Drag handler acceptance tests\n\n" + "Walk through each phase, perform the gesture, then read the " + "log at the bottom to confirm the observed counters match the " + "expectation." + ) + + phase_idx_handle = server.gui.add_number( + "Phase", + initial_value=0, + min=0, + max=len(PHASES) - 1, + step=1, + disabled=True, + ) + phase_title = server.gui.add_markdown("") + phase_instructions = server.gui.add_markdown("") + phase_expected = server.gui.add_markdown("") + + counter_handle = server.gui.add_markdown("") + log_handle = server.gui.add_markdown("### Event log\n\n_(empty)_") + + harness = Harness(server=server, log_handle=log_handle) + + def render_phase(idx: int) -> None: + harness.teardown() + phase = PHASES[idx] + phase_title.content = f"## {phase.title}" + phase_instructions.content = "**Instructions**\n\n" + phase.instructions + phase_expected.content = "**Expected**\n\n" + phase.expected + phase_idx_handle.value = idx + counter_handle.content = "### Counters\n\n_(none yet)_" + log_handle.content = "### Event log\n\n_(empty)_" + harness.log_lines.clear() + harness.log(f"--- entered phase {idx + 1}: {phase.title} ---") + phase.setup(harness) + + def refresh_counters() -> None: + with harness.log_lock: + if not harness.counters: + counter_handle.content = "### Counters\n\n_(none yet)_" + return + lines = [ + f"- `{key}`: **{val}**" for key, val in sorted(harness.counters.items()) + ] + counter_handle.content = "### Counters\n\n" + "\n".join(lines) + + # Background loop to keep the counter panel in sync with the log + # without forcing every callback to repaint it. + def counter_repaint() -> None: + while True: + refresh_counters() + time.sleep(0.2) + + threading.Thread(target=counter_repaint, daemon=True).start() + + next_button = server.gui.add_button("Next phase ▶") + prev_button = server.gui.add_button("◀ Previous phase") + reset_button = server.gui.add_button("Reset current phase") + + @next_button.on_click + def _(event: viser.GuiEvent) -> None: + del event + cur = int(phase_idx_handle.value) + nxt = (cur + 1) % len(PHASES) + render_phase(nxt) + + @prev_button.on_click + def _(event: viser.GuiEvent) -> None: + del event + cur = int(phase_idx_handle.value) + prv = (cur - 1) % len(PHASES) + render_phase(prv) + + @reset_button.on_click + def _(event: viser.GuiEvent) -> None: + del event + render_phase(int(phase_idx_handle.value)) + + # Boot into the first phase. + render_phase(0) + + print( + "\nDrag handler acceptance tests running. Open the URL above, " + "follow the GUI stepper, and Ctrl-C here to stop.\n" + ) + while True: + time.sleep(1.0) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/test_bug_hover_visibility.py b/tests/e2e/test_bug_hover_visibility.py index 34a67bf99..2cb7f2c41 100644 --- a/tests/e2e/test_bug_hover_visibility.py +++ b/tests/e2e/test_bug_hover_visibility.py @@ -1,7 +1,7 @@ -"""E2E test for hover count resetting when visibility is toggled. +"""E2E test for hover cleanup when visibility is toggled. -This test verifies that when a clickable node is hidden while hovered, -the hoveredElementsCount properly resets to 0 and the cursor returns to "auto". +When a clickable node is hidden while hovered, the per-viewer hover set +must drop it and return the canvas cursor to ``auto``. """ from __future__ import annotations @@ -17,12 +17,7 @@ def test_hover_count_resets_on_visibility_toggle( viser_server: viser.ViserServer, viser_page: Page, ) -> None: - """Test that hoveredElementsCount resets to 0 when a hovered clickable node is hidden. - - This test verifies the fix for the bug where the onPointerOut handler would return - early without decrementing hoveredElementsCount when the node is not displayed. - The fix ensures hover state is properly cleaned up when visibility changes. - """ + """Hiding a hovered clickable node must clear the pointer cursor.""" # Create a clickable box with an on_click callback. click_counter = {"count": 0} @@ -43,9 +38,9 @@ def on_click_handler( # Wait for the box to appear in the scene. wait_for_scene_node(viser_page, "/test_clickable_box") - # Wait for hoveredElementsCount to be initialized (confirms mutable state is ready). + # Wait for the canvas test surface to be initialized. viser_page.wait_for_function( - "() => window.__viserMutable && window.__viserMutable.hoveredElementsCount === 0", + "() => window.__viserMutable?.canvas != null", timeout=5_000, ) @@ -62,15 +57,9 @@ def on_click_handler( # Move mouse to the center of the canvas to hover over the box. viser_page.mouse.move(center_x, center_y) - # Poll until hoveredElementsCount becomes > 0 (hover event processed). - viser_page.wait_for_function( - "() => window.__viserMutable.hoveredElementsCount > 0", - timeout=5_000, - ) - - # Also verify cursor changed to pointer. + # Poll until hover state makes the canvas cursor a pointer. viser_page.wait_for_function( - "() => document.body.style.cursor === 'pointer'", + "() => window.__viserMutable.canvas.style.cursor === 'pointer'", timeout=5_000, ) @@ -80,14 +69,8 @@ def on_click_handler( # Wait for the visibility change to propagate to the Three.js scene. wait_for_scene_node_hidden(viser_page, "/test_clickable_box") - # Verify that hoveredElementsCount resets to 0 when the node is hidden. - viser_page.wait_for_function( - "() => window.__viserMutable.hoveredElementsCount === 0", - timeout=5_000, - ) - # Verify that cursor returns to auto. viser_page.wait_for_function( - "() => document.body.style.cursor === 'auto'", + "() => window.__viserMutable.canvas.style.cursor === 'auto'", timeout=5_000, ) diff --git a/tests/e2e/test_held_modifier_focus_gate.py b/tests/e2e/test_held_modifier_focus_gate.py new file mode 100644 index 000000000..eb5380671 --- /dev/null +++ b/tests/e2e/test_held_modifier_focus_gate.py @@ -0,0 +1,122 @@ +"""E2E coverage for the focus-aware held-modifier gate. + +The canvas cursor reflects modifier state for registered click filters. +Without filtering, a Shift press inside a focused Mantine `` +would flip the canvas cursor to "pointer" mid-typing. The keydown +listener in `App.tsx` checks both the event's target *and* +`document.activeElement` and skips the update when either is a form +control. + +These tests assert the observable behavior: `canvas.style.cursor`. +""" + +from __future__ import annotations + +from playwright.sync_api import Page + +import viser + + +def test_keydown_with_input_target_is_ignored( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """A keydown whose ``target`` is an ```` must not update the + held modifier. Without a registered click filter the cursor stays + "auto" regardless; this test asserts the filter-active case below.""" + + @viser_server.scene.on_click(modifier="shift") + def _(event: viser.SceneClickEvent) -> None: + del event + + viser_page.wait_for_function("() => window.__viserPointer != null", timeout=10_000) + out = viser_page.evaluate( + """ + () => { + window.__viserPointer.setHeldModifier(null); + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + try { + window.dispatchEvent(new KeyboardEvent("keydown", { + key: "Shift", + shiftKey: true, + bubbles: true, + target: input, + })); + return window.__viserMutable.canvas.style.cursor || "auto"; + } finally { + input.remove(); + } + } + """ + ) + # Shift press while input is focused: held modifier stays null, + # shift-only filter doesn't match, cursor stays "auto". + assert out == "auto" + + +def test_keydown_outside_form_control_updates_modifier( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """Keydowns outside form controls must update held modifier. With + a shift-filtered click registered, pressing shift flips the cursor + to "pointer".""" + + @viser_server.scene.on_click(modifier="shift") + def _(event: viser.SceneClickEvent) -> None: + del event + + viser_page.wait_for_function("() => window.__viserPointer != null", timeout=10_000) + out = viser_page.evaluate( + """ + () => { + window.__viserPointer.setHeldModifier(null); + document.body.focus(); + const canvas = window.__viserMutable.canvas; + const before = canvas.style.cursor || "auto"; + window.dispatchEvent(new KeyboardEvent("keydown", { + key: "Shift", + shiftKey: true, + bubbles: true, + })); + const after = canvas.style.cursor || "auto"; + return { before, after }; + } + """ + ) + assert out["before"] == "auto" + assert out["after"] == "pointer" + + +def test_focused_textarea_blocks_modifier_update( + viser_server: viser.ViserServer, viser_page: Page +) -> None: + """`