| 1 | # vim: set expandtab shiftwidth=4 softtabstop=4:
|
|---|
| 2 |
|
|---|
| 3 | # === UCSF ChimeraX Copyright ===
|
|---|
| 4 | # Copyright 2016 Regents of the University of California.
|
|---|
| 5 | # All rights reserved. This software provided pursuant to a
|
|---|
| 6 | # license agreement containing restrictions on its disclosure,
|
|---|
| 7 | # duplication and use. For details see:
|
|---|
| 8 | # https://www.rbvi.ucsf.edu/chimerax/docs/licensing.html
|
|---|
| 9 | # This notice must be embedded in or attached to all copies,
|
|---|
| 10 | # including partial copies, of the software or any revisions
|
|---|
| 11 | # or derivations thereof.
|
|---|
| 12 | # === UCSF ChimeraX Copyright ===
|
|---|
| 13 |
|
|---|
| 14 | # -----------------------------------------------------------------------------
|
|---|
| 15 | # Routines to setup OpenXR 3D screens such as Sony Spatial Reality
|
|---|
| 16 | # or Acer SpatialLabs to handle the coordinate systems for these displays
|
|---|
| 17 | # and mouse events and keyboard input.
|
|---|
| 18 | #
|
|---|
| 19 | def setup_openxr_screen(openxr_system_name, openxr_camera):
|
|---|
| 20 | if openxr_system_name == 'SonySRD System':
|
|---|
| 21 | _sony_spatial_reality_setup(openxr_camera)
|
|---|
| 22 | elif openxr_system_name == 'SpatialLabs Display Driver':
|
|---|
| 23 | _acer_spatial_labs_setup(openxr_camera)
|
|---|
| 24 |
|
|---|
| 25 | def _sony_spatial_reality_setup(openxr_camera):
|
|---|
| 26 | # Flatpanel Sony Spatial Reality display with eye tracking.
|
|---|
| 27 | # 15.6" screen, 34 x 19 cm, tilted at 45 degree angle.
|
|---|
| 28 | # TODO: Distinguish 27" from 15.6" display. Might use OpenXR vendorId
|
|---|
| 29 | from math import sqrt
|
|---|
| 30 | s2 = 1/sqrt(2)
|
|---|
| 31 | w,h = 0.34, 0.19 # Screen size meters
|
|---|
| 32 | from numpy import array
|
|---|
| 33 | screen_center = array((0, s2*h/2, -s2*h/2))
|
|---|
| 34 | from chimerax.geometry import rotation
|
|---|
| 35 | screen_orientation = rotation((1,0,0), -45) # View direction 45 degree down.
|
|---|
| 36 |
|
|---|
| 37 | # Room size and center for view_all() positioning.
|
|---|
| 38 | c = openxr_camera
|
|---|
| 39 | c._initial_room_scene_size = h # meters
|
|---|
| 40 | c._initial_room_center = screen_center
|
|---|
| 41 |
|
|---|
| 42 | # Make mouse zoom always perpendicular at screen center.
|
|---|
| 43 | # Sony rendered camera positions always are perpendicular
|
|---|
| 44 | # to screen but offset based on eye-tracking head position.
|
|---|
| 45 | # That leads to confusing skewed mouse zooming.
|
|---|
| 46 | c._desktop_view_point = screen_center
|
|---|
| 47 |
|
|---|
| 48 | # When leaving XR keep the same camera view point in the graphics window.
|
|---|
| 49 | c.keep_position = True
|
|---|
| 50 |
|
|---|
| 51 | # Set camera position and room to scene transform preserving
|
|---|
| 52 | # current camera view direction.
|
|---|
| 53 | v = c._session.main_view
|
|---|
| 54 | c.fit_view_to_room(room_width = w,
|
|---|
| 55 | room_center = screen_center,
|
|---|
| 56 | room_center_distance = 0.40,
|
|---|
| 57 | screen_orientation = screen_orientation,
|
|---|
| 58 | scene_center = v.center_of_rotation,
|
|---|
| 59 | scene_camera = v.camera)
|
|---|
| 60 |
|
|---|
| 61 | _enable_xr_mouse_modes(c._session, openxr_window_captures_events = True)
|
|---|
| 62 |
|
|---|
| 63 | def _acer_spatial_labs_setup(openxr_camera):
|
|---|
| 64 | # Flatpanel Acer SpatialLabs 27" display with eye tracking.
|
|---|
| 65 | w,h = 0.60, 0.34 # Screen size meters
|
|---|
| 66 | from numpy import array
|
|---|
| 67 | screen_center = array((0, 0, 0))
|
|---|
| 68 | from chimerax.geometry import identity
|
|---|
| 69 | screen_orientation = identity()
|
|---|
| 70 |
|
|---|
| 71 | # Room size and center for view_all() positioning.
|
|---|
| 72 | c = openxr_camera
|
|---|
| 73 | c._initial_room_scene_size = 0.7*h # meters
|
|---|
| 74 | c._initial_room_center = screen_center
|
|---|
| 75 |
|
|---|
| 76 | # Make mouse zoom always perpendicular at screen center.
|
|---|
| 77 | # Sony rendered camera positions always are perpendicular
|
|---|
| 78 | # to screen but offset based on eye-tracking head position.
|
|---|
| 79 | # That leads to confusing skewed mouse zooming.
|
|---|
| 80 | c._desktop_view_point = screen_center
|
|---|
| 81 |
|
|---|
| 82 | # When leaving XR keep the same camera view point in the graphics window.
|
|---|
| 83 | c.keep_position = True
|
|---|
| 84 |
|
|---|
| 85 | # Set camera position and room to scene transform preserving
|
|---|
| 86 | # current camera view direction.
|
|---|
| 87 | v = c._session.main_view
|
|---|
| 88 | c.fit_view_to_room(room_width = w,
|
|---|
| 89 | room_center = screen_center,
|
|---|
| 90 | room_center_distance = 0.40,
|
|---|
| 91 | screen_orientation = screen_orientation,
|
|---|
| 92 | scene_center = v.center_of_rotation,
|
|---|
| 93 | scene_camera = v.camera)
|
|---|
| 94 |
|
|---|
| 95 | _enable_xr_mouse_modes(c._session)
|
|---|
| 96 |
|
|---|
| 97 | def _enable_xr_mouse_modes(session, screen_model_name = None,
|
|---|
| 98 | openxr_window_captures_events = False):
|
|---|
| 99 | '''
|
|---|
| 100 | Allow mouse modes to work with mouse on Acer or Sony 3D displays.
|
|---|
| 101 | Both these displays create a fullscreen window. This mouse mode support
|
|---|
| 102 | works by creating a backing full-screen Qt window which receives the
|
|---|
| 103 | mouse events.
|
|---|
| 104 | '''
|
|---|
| 105 | screen = find_xr_screen(session, screen_model_name)
|
|---|
| 106 | if screen is None:
|
|---|
| 107 | return False
|
|---|
| 108 | XRBackingWindow(session, screen, in_front = openxr_window_captures_events)
|
|---|
| 109 | return True
|
|---|
| 110 |
|
|---|
| 111 | xr_screen_model_names = ['ASV27-2P', '1ASV27-2P', 'DS1_156', 'SR Display', 'SR Display GB']
|
|---|
| 112 | def find_xr_screen(session, screen_model_name = None):
|
|---|
| 113 | model_names = [screen_model_name] if screen_model_name else xr_screen_model_names
|
|---|
| 114 | screens = session.ui.screens()
|
|---|
| 115 | for screen in screens:
|
|---|
| 116 | if screen.model() in model_names:
|
|---|
| 117 | return screen
|
|---|
| 118 | found_names = [screen.model() for screen in screens]
|
|---|
| 119 | msg = f'Could not find OpenXR screen {", ".join(model_names)} , only found {", ".join(found_names)}'
|
|---|
| 120 | session.logger.warning(msg)
|
|---|
| 121 | return None
|
|---|
| 122 |
|
|---|
| 123 | class XRBackingWindow:
|
|---|
| 124 | '''
|
|---|
| 125 | Backing window for OpenXR autostereo 3D displays such as Acer SpatialLabs
|
|---|
| 126 | and Sony Spatial Reality to capture mouse and keyboard events when
|
|---|
| 127 | mouse is on the 3D display.
|
|---|
| 128 | '''
|
|---|
| 129 | def __init__(self, session, screen, in_front = False, hover_text = True):
|
|---|
| 130 | self._session = session
|
|---|
| 131 | self._screen = screen
|
|---|
| 132 |
|
|---|
| 133 | # Create fullscreen backing Qt window on openxr screen.
|
|---|
| 134 | from Qt.QtWidgets import QWidget
|
|---|
| 135 | self._widget = w = QWidget()
|
|---|
| 136 |
|
|---|
| 137 | if in_front:
|
|---|
| 138 | self._make_transparent_in_front(w)
|
|---|
| 139 |
|
|---|
| 140 | w.move(screen.geometry().topLeft())
|
|---|
| 141 | w.showFullScreen()
|
|---|
| 142 |
|
|---|
| 143 | self._register_mouse_handlers()
|
|---|
| 144 |
|
|---|
| 145 | # Forward key press events
|
|---|
| 146 | w.keyPressEvent = session.ui.forward_keystroke
|
|---|
| 147 |
|
|---|
| 148 | # Remove backing window when openxr is turned off.
|
|---|
| 149 | session.triggers.add_handler('vr stopped', self._xr_quit)
|
|---|
| 150 |
|
|---|
| 151 | # Show text labels for atoms and residues when mouse pauses.
|
|---|
| 152 | if hover_text:
|
|---|
| 153 | session.triggers.add_handler('graphics update',
|
|---|
| 154 | self._check_for_mouse_hover)
|
|---|
| 155 |
|
|---|
| 156 | def _make_transparent_in_front(self, w):
|
|---|
| 157 | # On Sony Spatial Reality displays the full screen
|
|---|
| 158 | # window made by Sony OpenXR captures mouse events
|
|---|
| 159 | # so we instead put a transparent Qt window in front (July 2025).
|
|---|
| 160 | from Qt.QtCore import Qt
|
|---|
| 161 | w.setAttribute(Qt.WA_TranslucentBackground)
|
|---|
| 162 | w.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
|---|
| 163 |
|
|---|
| 164 | # Unforunately the top level Qt translucent frameless window
|
|---|
| 165 | # also does not capture mouse events unless we add a frame
|
|---|
| 166 | # that has a tiny bit of opacity.
|
|---|
| 167 | from Qt.QtWidgets import QFrame, QVBoxLayout
|
|---|
| 168 | self._f = f = QFrame(w)
|
|---|
| 169 | f.setStyleSheet("background: rgba(2, 2, 2, 2);")
|
|---|
| 170 |
|
|---|
| 171 | # Make frame fill the entire parent window.
|
|---|
| 172 | layout = QVBoxLayout(w)
|
|---|
| 173 | w.setLayout(layout)
|
|---|
| 174 | layout.addWidget(f)
|
|---|
| 175 |
|
|---|
| 176 | # The following settings did not avoid the need to make
|
|---|
| 177 | # a child QFrame.
|
|---|
| 178 | # w.setWindowFlags(Qt.FramelessWindowHint)
|
|---|
| 179 | # w.setAttribute(Qt.WA_AlwaysStackOnTop)
|
|---|
| 180 | # w.setAttribute(Qt.WA_TransparentForMouseEvents, False)
|
|---|
| 181 | # w.setStyleSheet("background:transparent;")
|
|---|
| 182 | # w.setStyleSheet("background:green;")
|
|---|
| 183 |
|
|---|
| 184 | def _register_mouse_handlers(self):
|
|---|
| 185 | w = self._widget
|
|---|
| 186 | w.mousePressEvent = self._mouse_down
|
|---|
| 187 | w.mouseMoveEvent = self._mouse_drag
|
|---|
| 188 | w.mouseReleaseEvent = self._mouse_up
|
|---|
| 189 | w.mouseDoubleClickEvent = self._mouse_double_click
|
|---|
| 190 | w.wheelEvent = self._wheel
|
|---|
| 191 |
|
|---|
| 192 | def _mouse_down(self, event):
|
|---|
| 193 | self._dispatch_mouse_event(event, "mouse_down")
|
|---|
| 194 | def _mouse_drag(self, event):
|
|---|
| 195 | self._dispatch_mouse_event(event, "mouse_drag")
|
|---|
| 196 | def _mouse_up(self, event):
|
|---|
| 197 | self._dispatch_mouse_event(event, "mouse_up")
|
|---|
| 198 | def _mouse_double_click(self, event):
|
|---|
| 199 | self._dispatch_mouse_event(event, "mouse_double_click")
|
|---|
| 200 | def _wheel(self, event):
|
|---|
| 201 | self._dispatch_wheel_event(event)
|
|---|
| 202 |
|
|---|
| 203 | def _dispatch_mouse_event(self, event, action):
|
|---|
| 204 | '''
|
|---|
| 205 | Convert a mouse event from 3D screen coordinates to
|
|---|
| 206 | graphics pane coordinates and dispatch it.
|
|---|
| 207 | '''
|
|---|
| 208 | p = event.position()
|
|---|
| 209 | gx, gy = self._backing_to_graphics_coordinates(p.x(), p.y())
|
|---|
| 210 | e = self._repositioned_event(event, gx, gy)
|
|---|
| 211 | mm = self._session.ui.mouse_modes
|
|---|
| 212 | mm._dispatch_mouse_event(e, action)
|
|---|
| 213 |
|
|---|
| 214 | def _dispatch_wheel_event(self, event):
|
|---|
| 215 | '''
|
|---|
| 216 | Convert a wheel event from 3D screen coordinates to
|
|---|
| 217 | graphics pane coordinates and dispatch it.
|
|---|
| 218 | '''
|
|---|
| 219 | p = event.position()
|
|---|
| 220 | gx, gy = self._backing_to_graphics_coordinates(p.x(), p.y())
|
|---|
| 221 | e = self._repositioned_event(event, gx, gy)
|
|---|
| 222 | mm = self._session.ui.mouse_modes
|
|---|
| 223 | mm._wheel_event(e)
|
|---|
| 224 |
|
|---|
| 225 | def _backing_to_graphics_coordinates(self, x, y):
|
|---|
| 226 | '''
|
|---|
| 227 | Convert backing window x,y pixel coordinates to main
|
|---|
| 228 | graphics window coordinates. Handle different aspect ratio
|
|---|
| 229 | of backing and graphics windows. Graphics window has cropped
|
|---|
| 230 | version of openxr window image.
|
|---|
| 231 | '''
|
|---|
| 232 | w3d = self._widget
|
|---|
| 233 | w, h = w3d.width(), w3d.height()
|
|---|
| 234 | gw, gh = self._session.main_view.window_size
|
|---|
| 235 | if w == 0 or h == 0 or gw == 0 or gh == 0:
|
|---|
| 236 | return x, y
|
|---|
| 237 | fx,fy = x/w, y/h
|
|---|
| 238 | af = w*gh/(h*gw)
|
|---|
| 239 | if af > 1:
|
|---|
| 240 | afx = 0.5 + af * (fx - 0.5)
|
|---|
| 241 | afy = fy
|
|---|
| 242 | else:
|
|---|
| 243 | afx = fx
|
|---|
| 244 | afy = 0.5 + (1/af) * (fy - 0.5)
|
|---|
| 245 | gx, gy = afx * gw, afy * gh
|
|---|
| 246 | return gx, gy
|
|---|
| 247 |
|
|---|
| 248 | def _repositioned_event(self, event, x, y):
|
|---|
| 249 | from Qt.QtGui import QMouseEvent, QWheelEvent
|
|---|
| 250 | from Qt.QtCore import QPointF
|
|---|
| 251 | pos = QPointF(x, y)
|
|---|
| 252 | if isinstance(event, QMouseEvent):
|
|---|
| 253 | e = QMouseEvent(event.type(), pos, event.globalPosition(), event.button(), event.buttons(), event.modifiers(), event.device())
|
|---|
| 254 | elif isinstance(event, QWheelEvent):
|
|---|
| 255 | e = QWheelEvent(pos, event.globalPosition(), event.pixelDelta(), event.angleDelta(), event.buttons(), event.modifiers(), event.phase(), event.inverted(), device = event.device())
|
|---|
| 256 | else:
|
|---|
| 257 | raise RuntimeError(f'Event type is not mouse or wheel event {event}')
|
|---|
| 258 | return e
|
|---|
| 259 |
|
|---|
| 260 | def _graphics_cursor_position(self):
|
|---|
| 261 | from Qt.QtGui import QCursor
|
|---|
| 262 | cp = QCursor.pos()
|
|---|
| 263 | if self._session.ui.topLevelAt(cp) == self._widget:
|
|---|
| 264 | p = self._widget.mapFromGlobal(cp)
|
|---|
| 265 | x,y = self._map_event_coordinates(p.x(), p.y())
|
|---|
| 266 | return (int(x), int(y))
|
|---|
| 267 | else:
|
|---|
| 268 | mm = self._session.ui.mouse_modes
|
|---|
| 269 | return mm._graphics_cursor_position_original()
|
|---|
| 270 | return None
|
|---|
| 271 |
|
|---|
| 272 | def _check_for_mouse_hover(self, *args):
|
|---|
| 273 | if self._widget is None:
|
|---|
| 274 | return 'delete handler'
|
|---|
| 275 | from Qt.QtGui import QCursor
|
|---|
| 276 | cp = QCursor.pos()
|
|---|
| 277 | if self._session.ui.topLevelAt(cp) != self._widget:
|
|---|
| 278 | return
|
|---|
| 279 |
|
|---|
| 280 | bp = self._widget.mapFromGlobal(cp)
|
|---|
| 281 | x,y = self._backing_to_graphics_coordinates(bp.x(), bp.y())
|
|---|
| 282 | mm = self._session.ui.mouse_modes
|
|---|
| 283 | paused, unpaused = mm.mouse_paused((int(x),int(y)))
|
|---|
| 284 | if not paused and not unpaused:
|
|---|
| 285 | return
|
|---|
| 286 |
|
|---|
| 287 | if paused:
|
|---|
| 288 | pick, object, label_type = self._hover_pick(x, y)
|
|---|
| 289 | if pick is None:
|
|---|
| 290 | self._hide_hover_label()
|
|---|
| 291 | else:
|
|---|
| 292 | self._show_hover_label(pick, object, label_type)
|
|---|
| 293 | if unpaused:
|
|---|
| 294 | self._hide_hover_label()
|
|---|
| 295 |
|
|---|
| 296 | def _show_hover_label(self, pick, object, label_type):
|
|---|
| 297 | text = pick.description()
|
|---|
| 298 | from chimerax.label.label3d import label
|
|---|
| 299 | label(self._session, object, label_type,
|
|---|
| 300 | text = text, bg_color = (0,0,0,255))
|
|---|
| 301 | self._hover_label_object = object, label_type
|
|---|
| 302 |
|
|---|
| 303 | def _hide_hover_label(self):
|
|---|
| 304 | if hasattr(self, '_hover_label_object'):
|
|---|
| 305 | object, label_type = self._hover_label_object
|
|---|
| 306 | if object:
|
|---|
| 307 | from chimerax.label.label3d import label_delete
|
|---|
| 308 | label_delete(self._session, object, label_type)
|
|---|
| 309 | self._hover_label_object = None, None
|
|---|
| 310 |
|
|---|
| 311 | def _hover_pick(self, x, y):
|
|---|
| 312 | pick = self._session.main_view.picked_object(x, y)
|
|---|
| 313 |
|
|---|
| 314 | from chimerax.atomic import PickedAtom, PickedResidue, PickedBond
|
|---|
| 315 | from chimerax.core.objects import Objects
|
|---|
| 316 | if isinstance(pick, PickedAtom):
|
|---|
| 317 | from chimerax.atomic import Atoms
|
|---|
| 318 | object = Objects(atoms = Atoms([pick.atom]))
|
|---|
| 319 | label_type = 'atoms'
|
|---|
| 320 | elif isinstance(pick, PickedResidue):
|
|---|
| 321 | object = Objects(atoms = pick.residue.atoms)
|
|---|
| 322 | label_type = 'residues'
|
|---|
| 323 | elif isinstance(pick, PickedBond):
|
|---|
| 324 | from chimerax.atomic import Bonds
|
|---|
| 325 | object = Objects(bonds = Bonds([pick.bond]))
|
|---|
| 326 | label_type = 'bonds'
|
|---|
| 327 | else:
|
|---|
| 328 | pick = object = label_type = None
|
|---|
| 329 | return pick, object, label_type
|
|---|
| 330 |
|
|---|
| 331 | def _xr_quit(self, *args):
|
|---|
| 332 | self._hide_hover_label()
|
|---|
| 333 | # Delete the backing window
|
|---|
| 334 | self._widget.deleteLater()
|
|---|
| 335 | self._widget = None
|
|---|
| 336 | return 'delete handler'
|
|---|
| 337 |
|
|---|
| 338 | def _openxr_window(window_name = None):
|
|---|
| 339 | if window_name is None:
|
|---|
| 340 | window_name = 'Preview window Composited' # For Sony SR 16" display
|
|---|
| 341 | handles = _find_window_handles_by_title(window_name)
|
|---|
| 342 | if len(handles) == 1:
|
|---|
| 343 | from Qt.QtGui import QWindow
|
|---|
| 344 | w = QWindow.fromWinId(handles[0])
|
|---|
| 345 | return w
|
|---|
| 346 | return None
|
|---|
| 347 |
|
|---|
| 348 | def _find_window_handles_by_title(window_name):
|
|---|
| 349 | from win32 import win32gui
|
|---|
| 350 |
|
|---|
| 351 | def callback(hwnd, window_handles):
|
|---|
| 352 | if win32gui.GetWindowText(hwnd) == window_name:
|
|---|
| 353 | window_handles.append(hwnd)
|
|---|
| 354 |
|
|---|
| 355 | window_handles = []
|
|---|
| 356 | win32gui.EnumWindows(callback, window_handles)
|
|---|
| 357 | return window_handles
|
|---|