Files
Mujoco_WASM/python/mujoco/experimental/studio/studio_app_events.py
T
Matija Kecman 58d6910afa Refactor StepControl and GUI to return change status.
StepControl methods SetSpeed, SetNoiseParameters, and SetPauseState now return a boolean indicating whether the state was actually changed. The GUI functions NoiseGui and StepControlGui also return a boolean reflecting if any parameters were modified. Noise parameters are now directly managed by StepControl rather than being stored in UxState. The StepControlEvent message has been expanded to include all step control parameters. Keyboard event handling has been slightly refactored to better align with event-based updates.

PiperOrigin-RevId: 940656342
Change-Id: I3701623a2fdcfc4c084b5db0ae19048669040de4
2026-06-30 14:21:00 -07:00

606 lines
17 KiB
Python

# Copyright 2026 DeepMind Technologies Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Temporary event handling functions for StudioApp."""
# TODO(matijak): These free functions implement the keyboard and mouse event
# handling for Studio. They are separated from the main StudioApp class to keep
# clarify the long-term API and avoid cluttering it with a large amount of
# temporary code. When studio/platform has a proper API for registering key
# bindings and mouse behaviour, the event handling functions will delegate to
# code shared with the C++ studio application.
import mujoco
from mujoco.experimental.studio import sim
from mujoco.experimental.studio import ux
import numpy as np
from mujoco.experimental.dear_imgui import dear_imgui as imgui
def handle_vis_options_keyboard_events(
vis_options: mujoco.MjvOption,
is_freecam_wasd: bool,
) -> bool:
"""Toggles visualization flags based on keyboard shortcuts.
Args:
vis_options: The visualization options to modify.
is_freecam_wasd: If True, keys Q/E/A/D are reserved for camera movement and
will not toggle visualization flags.
Returns:
True if a key was handled, False otherwise.
"""
if imgui.GetIO().WantCaptureKeyboard:
return False
pressed = imgui.IsKeyChordPressed
# Frame and label cycling.
if pressed(imgui.Key.F6):
vis_options.frame = (vis_options.frame + 1) % mujoco.mjtFrame.mjNFRAME.value
elif pressed(imgui.Key.F7):
vis_options.label = (vis_options.label + 1) % mujoco.mjtLabel.mjNLABEL.value
# Visualization flag toggles (single-key shortcuts).
elif pressed(imgui.Key.H):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_CONVEXHULL] ^= 1
elif pressed(imgui.Key.X):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_TEXTURE] ^= 1
elif pressed(imgui.Key.J):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_JOINT] ^= 1
elif not is_freecam_wasd and pressed(imgui.Key.Q):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_CAMERA] ^= 1
elif pressed(imgui.Key.U):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_ACTUATOR] ^= 1
elif pressed(imgui.Key.Comma):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_ACTIVATION] ^= 1
elif pressed(imgui.Key.Z):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_LIGHT] ^= 1
elif pressed(imgui.Key.V):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_TENDON] ^= 1
elif pressed(imgui.Key.Y):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_RANGEFINDER] ^= 1
elif not is_freecam_wasd and pressed(imgui.Key.E):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_CONSTRAINT] ^= 1
elif pressed(imgui.Key.I):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_INERTIA] ^= 1
elif pressed(imgui.Key.Apostrophe):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_SCLINERTIA] ^= 1
elif pressed(imgui.Key.B):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_PERTFORCE] ^= 1
elif pressed(imgui.Key.O):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_PERTOBJ] ^= 1
elif pressed(imgui.Key.C):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_CONTACTPOINT] ^= 1
elif pressed(imgui.Key.N):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_ISLAND] ^= 1
elif pressed(imgui.Key.F):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_CONTACTFORCE] ^= 1
elif pressed(imgui.Key.P):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_CONTACTSPLIT] ^= 1
elif pressed(imgui.Key.T):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_TRANSPARENT] ^= 1
elif not is_freecam_wasd and pressed(imgui.Key.A):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_AUTOCONNECT] ^= 1
elif pressed(imgui.Key.M):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_COM] ^= 1
elif not is_freecam_wasd and pressed(imgui.Key.D):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_STATIC] ^= 1
elif pressed(imgui.Key.Semicolon):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_SKIN] ^= 1
elif pressed(imgui.Key.GraveAccent):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_BODYBVH] ^= 1
elif pressed(imgui.Key.Backslash):
vis_options.flags[mujoco.mjtVisFlag.mjVIS_MESHBVH] ^= 1
# Site group toggles (Shift + 0-5).
elif pressed(int(imgui.Key.N0) | int(imgui.Key.Shift)):
vis_options.sitegroup[0] ^= 1
elif pressed(int(imgui.Key.N1) | int(imgui.Key.Shift)):
vis_options.sitegroup[1] ^= 1
elif pressed(int(imgui.Key.N2) | int(imgui.Key.Shift)):
vis_options.sitegroup[2] ^= 1
elif pressed(int(imgui.Key.N3) | int(imgui.Key.Shift)):
vis_options.sitegroup[3] ^= 1
elif pressed(int(imgui.Key.N4) | int(imgui.Key.Shift)):
vis_options.sitegroup[4] ^= 1
elif pressed(int(imgui.Key.N5) | int(imgui.Key.Shift)):
vis_options.sitegroup[5] ^= 1
# Geom group toggles (0-5).
elif pressed(imgui.Key.N0):
vis_options.geomgroup[0] ^= 1
elif pressed(imgui.Key.N1):
vis_options.geomgroup[1] ^= 1
elif pressed(imgui.Key.N2):
vis_options.geomgroup[2] ^= 1
elif pressed(imgui.Key.N3):
vis_options.geomgroup[3] ^= 1
elif pressed(imgui.Key.N4):
vis_options.geomgroup[4] ^= 1
elif pressed(imgui.Key.N5):
vis_options.geomgroup[5] ^= 1
else:
return False
return True
def handle_step_control_keyboard_events(
step_control: sim.StepControl,
ux_state: ux.UxState,
) -> bool:
"""Handles keyboard shortcuts for simulation stepping control.
Args:
step_control: The simulation step control object.
ux_state: The UX state object.
Returns:
True if a key was handled, False otherwise.
"""
if imgui.GetIO().WantCaptureKeyboard:
return False
pressed = imgui.IsKeyChordPressed
if pressed(int(imgui.Key.Ctrl) | int(imgui.Key.Space)):
if step_control.get_pause_state() == sim.PauseState.VISCOUS_PAUSED:
step_control.set_pause_state(sim.PauseState.UNPAUSED)
else:
step_control.set_pause_state(sim.PauseState.VISCOUS_PAUSED)
return True
elif pressed(imgui.Key.Space):
pause = step_control.get_pause_state()
if pause in (sim.PauseState.VISCOUS_PAUSED, sim.PauseState.UNPAUSED):
step_control.set_pause_state(sim.PauseState.NORMAL_PAUSED)
else:
step_control.set_pause_state(sim.PauseState.UNPAUSED)
return True
elif pressed(imgui.Key.Minus):
ux_state.speed_index = ux.set_speed_index(
step_control, ux_state.speed_index, ux_state.speed_index + 1
)
return True
elif pressed(imgui.Key.Equal):
ux_state.speed_index = ux.set_speed_index(
step_control, ux_state.speed_index, ux_state.speed_index - 1
)
return True
return False
def handle_reset_keyboard_events(
model: mujoco.MjModel, data: mujoco.MjData
) -> bool:
"""Handles keyboard shortcuts for simulation reset."""
if imgui.IsKeyChordPressed(imgui.Key.Backspace):
if model is not None and data is not None:
mujoco.mj_resetData(model, data)
mujoco.mj_forward(model, data)
return True
return False
def handle_camera_select_keyboard_events(
model: mujoco.MjModel,
camera: mujoco.MjvCamera,
ux_state: ux.UxState,
) -> bool:
"""Handles keyboard shortcuts for camera selection.
Args:
model: The MuJoCo model.
camera: The MuJoCo camera object.
ux_state: The UX state object.
Returns:
True if a key was handled, False otherwise.
"""
if imgui.GetIO().WantCaptureKeyboard:
return False
pressed = imgui.IsKeyChordPressed
if pressed(imgui.Key.Escape):
ux_state.camera_index = ux.set_camera(model, camera, ux.TUMBLE_CAMERA_IDX)
return True
elif pressed(imgui.Key.LeftBracket):
ux_state.camera_index = ux.set_camera(
model, camera, ux_state.camera_index - 1
)
return True
elif pressed(imgui.Key.RightBracket):
ux_state.camera_index = ux.set_camera(
model, camera, ux_state.camera_index + 1
)
return True
return False
def handle_freecam_wasd_keyboard_events(
model: mujoco.MjModel,
data: mujoco.MjData,
camera: mujoco.MjvCamera,
cam_speed: float,
) -> tuple[bool, float]:
"""Handles keyboard shortcuts for free camera movement.
Args:
model: The MuJoCo model.
data: The MuJoCo data.
camera: The MuJoCo camera object.
cam_speed: The current camera speed.
Returns:
A tuple of (handled, updated_cam_speed).
"""
if imgui.GetIO().WantCaptureKeyboard:
return False, cam_speed
if (
imgui.IsKeyDown(imgui.Key.W)
or imgui.IsKeyDown(imgui.Key.S)
or imgui.IsKeyDown(imgui.Key.A)
or imgui.IsKeyDown(imgui.Key.D)
or imgui.IsKeyDown(imgui.Key.Q)
or imgui.IsKeyDown(imgui.Key.E)
):
moved = False
# Move (dolly) forward/backward using W and S keys.
if imgui.IsKeyDown(imgui.Key.W):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.TRUCK_DOLLY,
0,
cam_speed,
)
moved = True
elif imgui.IsKeyDown(imgui.Key.S):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.TRUCK_DOLLY,
0,
-cam_speed,
)
moved = True
# Strafe (truck) left/right using A and D keys.
if imgui.IsKeyDown(imgui.Key.A):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.TRUCK_DOLLY,
-cam_speed,
0,
)
moved = True
elif imgui.IsKeyDown(imgui.Key.D):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.TRUCK_DOLLY,
cam_speed,
0,
)
moved = True
# Move (pedestal) up/down using Q and E keys.
if imgui.IsKeyDown(imgui.Key.Q):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.TRUCK_PEDESTAL,
0,
cam_speed,
)
moved = True
elif imgui.IsKeyDown(imgui.Key.E):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.TRUCK_PEDESTAL,
0,
-cam_speed,
)
moved = True
if moved:
cam_speed += 0.001
max_speed = 0.1 if imgui.GetIO().KeyShift else 0.01
if cam_speed > max_speed:
cam_speed = max_speed
else:
cam_speed = 0.001
return True, cam_speed
return False, cam_speed
def handle_keyboard_events(
model: mujoco.MjModel,
data: mujoco.MjData,
camera: mujoco.MjvCamera,
vis_options: mujoco.MjvOption,
step_control: sim.StepControl,
ux_state: ux.UxState,
cam_speed: float,
) -> tuple[bool, float]:
"""Handle keyboard events according to Studio's bindings.
Args:
model: The MuJoCo model.
data: The MuJoCo data.
camera: The MuJoCo camera object.
vis_options: The MuJoCo visualization options.
step_control: The simulation step control object.
ux_state: The UX state object.
cam_speed: The current camera speed.
Returns:
A tuple of (handled, updated_cam_speed).
"""
if imgui.GetIO().WantCaptureKeyboard:
return False, cam_speed
is_freecam_wasd = ux_state.camera_index == ux.FREE_CAMERA_IDX
if handle_step_control_keyboard_events(step_control, ux_state):
return True, cam_speed
if handle_reset_keyboard_events(model, data):
return True, cam_speed
if handle_camera_select_keyboard_events(model, camera, ux_state):
return True, cam_speed
if handle_vis_options_keyboard_events(vis_options, is_freecam_wasd):
return True, cam_speed
if is_freecam_wasd:
handled, cam_speed = handle_freecam_wasd_keyboard_events(
model, data, camera, cam_speed
)
if handled:
return True, cam_speed
return False, cam_speed
def handle_camera_tracking_mouse_events(
model: mujoco.MjModel,
data: mujoco.MjData,
camera: mujoco.MjvCamera,
vis_options: mujoco.MjvOption,
ux_state: ux.UxState,
) -> None:
"""Handles mouse events for camera tracking."""
io = imgui.GetIO()
if imgui.GetIO().WantCaptureMouse:
return
if io.DisplaySize.x <= 0 or io.DisplaySize.y <= 0:
return
mouse_x = io.MousePos.x / io.DisplaySize.x
mouse_y = io.MousePos.y / io.DisplaySize.y
aspect_ratio = io.DisplaySize.x / io.DisplaySize.y
# Right double click.
if imgui.IsMouseDoubleClicked(imgui.MouseButton.Right):
picked = ux.Pick(
model,
data,
camera,
mouse_x,
mouse_y,
aspect_ratio,
vis_options,
)
if picked.body > 0 and io.KeyCtrl:
# Switch camera to tracking mode and track the selected body.
camera.type = int(mujoco.mjtCamera.mjCAMERA_TRACKING)
camera.trackbodyid = picked.body
camera.fixedcamid = -1
ux_state.camera_index = ux.TRACKING_CAMERA_IDX
def handle_mouse_events(
model: mujoco.MjModel,
data: mujoco.MjData,
camera: mujoco.MjvCamera,
vis_options: mujoco.MjvOption,
perturb: mujoco.MjvPerturb,
ux_state: ux.UxState,
) -> None:
"""Handles mouse events."""
io = imgui.GetIO()
if io.WantCaptureMouse:
return
if io.DisplaySize.x <= 0 or io.DisplaySize.y <= 0:
return
mouse_x = io.MousePos.x / io.DisplaySize.x
mouse_y = io.MousePos.y / io.DisplaySize.y
mouse_dx = io.MouseDelta.x / io.DisplaySize.x
mouse_dy = io.MouseDelta.y / io.DisplaySize.y
mouse_scroll = io.MouseWheel / 50.0
is_mouse_moving = mouse_dx != 0.0 or mouse_dy != 0.0
is_any_mouse_down = (
imgui.IsMouseDown(imgui.MouseButton.Left)
or imgui.IsMouseDown(imgui.MouseButton.Right)
or imgui.IsMouseDown(imgui.MouseButton.Middle)
)
is_mouse_dragging = is_mouse_moving and is_any_mouse_down
# If no mouse buttons are down, end any active perturbations.
if not is_any_mouse_down:
perturb.active = 0
# Handle perturbation mouse actions.
if is_mouse_dragging and io.KeyCtrl:
if perturb.select > 0:
action = int(mujoco.mjtMouse.mjMOUSE_NONE)
if imgui.IsMouseDown(imgui.MouseButton.Left):
if io.KeyAlt:
action = int(
mujoco.mjtMouse.mjMOUSE_MOVE_H
if io.KeyShift
else mujoco.mjtMouse.mjMOUSE_MOVE_V
)
else:
action = int(
mujoco.mjtMouse.mjMOUSE_ROTATE_H
if io.KeyShift
else mujoco.mjtMouse.mjMOUSE_ROTATE_V
)
elif imgui.IsMouseDown(imgui.MouseButton.Right):
action = int(
mujoco.mjtMouse.mjMOUSE_MOVE_H
if io.KeyShift
else mujoco.mjtMouse.mjMOUSE_MOVE_V
)
elif imgui.IsMouseDown(imgui.MouseButton.Middle):
action = int(mujoco.mjtMouse.mjMOUSE_ZOOM)
active = int(
mujoco.mjtPertBit.mjPERT_TRANSLATE
if action
in (
int(mujoco.mjtMouse.mjMOUSE_MOVE_V),
int(mujoco.mjtMouse.mjMOUSE_MOVE_H),
)
else mujoco.mjtPertBit.mjPERT_ROTATE
)
if active != perturb.active:
ux.InitPerturb(model, data, camera, perturb, active)
ux.MovePerturb(
model,
data,
camera,
perturb,
action,
mouse_dx,
mouse_dy,
)
elif is_mouse_dragging:
if ux_state.camera_index == ux.FREE_CAMERA_IDX:
if imgui.IsMouseDown(imgui.MouseButton.Left):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.PAN_TILT,
mouse_dx,
mouse_dy,
)
else:
if imgui.IsMouseDown(imgui.MouseButton.Left):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.ORBIT,
mouse_dx,
mouse_dy,
)
elif imgui.IsMouseDown(imgui.MouseButton.Middle):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.ZOOM,
mouse_dx,
mouse_dy,
)
# Right mouse movement is relative to the horizontal and vertical planes.
if imgui.IsMouseDown(imgui.MouseButton.Right) and io.KeyShift:
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.PLANAR_MOVE_H,
mouse_dx,
mouse_dy,
)
elif imgui.IsMouseDown(imgui.MouseButton.Right):
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.PLANAR_MOVE_V,
mouse_dx,
mouse_dy,
)
# Mouse scroll.
if mouse_scroll != 0.0 and ux_state.camera_index != ux.FREE_CAMERA_IDX:
ux.MoveCamera(
model,
data,
camera,
ux.CameraMotion.ZOOM,
0,
-mouse_scroll,
)
aspect_ratio = io.DisplaySize.x / io.DisplaySize.y
# Left double click.
if imgui.IsMouseDoubleClicked(imgui.MouseButton.Left):
picked = ux.Pick(
model,
data,
camera,
mouse_x,
mouse_y,
aspect_ratio,
vis_options,
)
if picked.body >= 0:
perturb.select = picked.body
perturb.flexselect = picked.flex
perturb.skinselect = picked.skin
# Compute the local position of the selected object in the world.
tmp = np.array(picked.point, dtype=np.float64) - data.xpos[picked.body]
xmat = np.array(data.xmat[picked.body], dtype=np.float64).reshape(3, 3)
perturb.localpos = xmat.T @ tmp
else:
perturb.select = 0
perturb.flexselect = -1
perturb.skinselect = -1
handle_camera_tracking_mouse_events(
model, data, camera, vis_options, ux_state
)