Ticket #19718: xr_screens_with_name_and_size.py

File xr_screens_with_name_and_size.py, 13.9 KB (added by Eric Pettersen, 4 days ago)
Line 
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#
19def 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
25def _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.58*9, 0.33*9 # 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
63def _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
97def _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
111xr_screen_model_names = ['ASV27-2P', '1ASV27-2P', 'DS1_156', 'SR Display', 'SR Display GB']
112def 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
123class 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
338def _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
348def _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