diff options
Diffstat (limited to 'src/silx/gui/plot3d/Plot3DWidget.py')
-rw-r--r-- | src/silx/gui/plot3d/Plot3DWidget.py | 463 |
1 files changed, 463 insertions, 0 deletions
diff --git a/src/silx/gui/plot3d/Plot3DWidget.py b/src/silx/gui/plot3d/Plot3DWidget.py new file mode 100644 index 0000000..a90d34c --- /dev/null +++ b/src/silx/gui/plot3d/Plot3DWidget.py @@ -0,0 +1,463 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a Qt widget embedding an OpenGL scene.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/04/2018" + + +import enum +import logging + +from silx.gui import qt +from silx.gui.colors import rgba +from . import actions + +from ...utils.enum import Enum as _Enum +from ..utils.image import convertArrayToQImage + +from .. import _glutils as glu +from .scene import interaction, primitives, transform +from . import scene + +import numpy + + +_logger = logging.getLogger(__name__) + + +class _OverviewViewport(scene.Viewport): + """A scene displaying the orientation of the data in another scene. + + :param Camera camera: The camera to track. + """ + + _SIZE = 100 + """Size in pixels of the overview square""" + + def __init__(self, camera=None): + super(_OverviewViewport, self).__init__() + self.size = self._SIZE, self._SIZE + self.background = None # Disable clear + + self.scene.transforms = [transform.Scale(2.5, 2.5, 2.5)] + + # Add a point to draw the background (in a group with depth mask) + backgroundPoint = primitives.ColorPoints( + x=0., y=0., z=0., + color=(1., 1., 1., 0.5), + size=self._SIZE) + backgroundPoint.marker = 'o' + noDepthGroup = primitives.GroupNoDepth(mask=True, notest=True) + noDepthGroup.children.append(backgroundPoint) + self.scene.children.append(noDepthGroup) + + axes = primitives.Axes() + self.scene.children.append(axes) + + if camera is not None: + camera.addListener(self._cameraChanged) + + def _cameraChanged(self, source): + """Listen to camera in other scene for transformation updates. + + Sync the overview camera to point in the same direction + but from a sphere centered on origin. + """ + position = -12. * source.extrinsic.direction + self.camera.extrinsic.position = position + + self.camera.extrinsic.setOrientation( + source.extrinsic.direction, source.extrinsic.up) + + +class Plot3DWidget(glu.OpenGLWidget): + """OpenGL widget with a 3D viewport and an overview.""" + + sigInteractiveModeChanged = qt.Signal() + """Signal emitted when the interactive mode has changed + """ + + sigStyleChanged = qt.Signal(str) + """Signal emitted when the style of the scene has changed + + It provides the updated property. + """ + + sigSceneClicked = qt.Signal(float, float) + """Signal emitted when the scene is clicked with the left mouse button. + + It provides the (x, y) clicked mouse position in logical widget pixel coordinates. + """ + + @enum.unique + class FogMode(_Enum): + """Different mode to render the scene with fog""" + + NONE = 'none' + """No fog effect""" + + LINEAR = 'linear' + """Linear fog through the whole scene""" + + def __init__(self, parent=None, f=qt.Qt.WindowFlags()): + self._firstRender = True + + super(Plot3DWidget, self).__init__( + parent, + alphaBufferSize=8, + depthBufferSize=0, + stencilBufferSize=0, + version=(2, 1), + f=f) + + self.setAutoFillBackground(False) + self.setMouseTracking(True) + + self.setFocusPolicy(qt.Qt.StrongFocus) + self._copyAction = actions.io.CopyAction(parent=self, plot3d=self) + self.addAction(self._copyAction) + + self._updating = False # True if an update is requested + + # Main viewport + self.viewport = scene.Viewport() + + self._sceneScale = transform.Scale(1., 1., 1.) + self.viewport.scene.transforms = [self._sceneScale, + transform.Translate(0., 0., 0.)] + + # Overview area + self.overview = _OverviewViewport(self.viewport.camera) + + self.setBackgroundColor((0.2, 0.2, 0.2, 1.)) + + # Window describing on screen area to render + self._window = scene.Window(mode='framebuffer') + self._window.viewports = [self.viewport, self.overview] + self._window.addListener(self._redraw) + + self.eventHandler = None + self.setInteractiveMode('rotate') + + def __clickHandler(self, *args): + """Handle interaction state machine click""" + x, y = args[0][:2] + # Convert from device pixel to logical pixel unit + devicePixelRatio = self.getDevicePixelRatio() + self.sigSceneClicked.emit(x / devicePixelRatio, y / devicePixelRatio) + + def setInteractiveMode(self, mode): + """Set the interactive mode. + + :param str mode: The interactive mode: 'rotate', 'pan' or None + """ + if mode == self.getInteractiveMode(): + return + + if mode is None: + self.eventHandler = None + + elif mode == 'rotate': + self.eventHandler = interaction.RotateCameraControl( + self.viewport, + orbitAroundCenter=False, + mode='position', + scaleTransform=self._sceneScale, + selectCB=self.__clickHandler) + + elif mode == 'pan': + self.eventHandler = interaction.PanCameraControl( + self.viewport, + orbitAroundCenter=False, + mode='position', + scaleTransform=self._sceneScale, + selectCB=self.__clickHandler) + + elif isinstance(mode, interaction.StateMachine): + self.eventHandler = mode + + else: + raise ValueError('Unsupported interactive mode %s', str(mode)) + + if (self.eventHandler is not None and + qt.QApplication.keyboardModifiers() & qt.Qt.ControlModifier): + self.eventHandler.handleEvent('keyPress', qt.Qt.Key_Control) + + self.sigInteractiveModeChanged.emit() + + def getInteractiveMode(self): + """Returns the interactive mode in use. + + :rtype: str + """ + if self.eventHandler is None: + return None + if isinstance(self.eventHandler, interaction.RotateCameraControl): + return 'rotate' + elif isinstance(self.eventHandler, interaction.PanCameraControl): + return 'pan' + else: + return None + + def setProjection(self, projection): + """Change the projection in use. + + :param str projection: In 'perspective', 'orthographic'. + """ + if projection == 'orthographic': + projection = transform.Orthographic(size=self.viewport.size) + elif projection == 'perspective': + projection = transform.Perspective(fovy=30., + size=self.viewport.size) + else: + raise RuntimeError('Unsupported projection: %s' % projection) + + self.viewport.camera.intrinsic = projection + self.viewport.resetCamera() + + def getProjection(self): + """Return the current camera projection mode as a str. + + See :meth:`setProjection` + """ + projection = self.viewport.camera.intrinsic + if isinstance(projection, transform.Orthographic): + return 'orthographic' + elif isinstance(projection, transform.Perspective): + return 'perspective' + else: + raise RuntimeError('Unknown projection in use') + + def setBackgroundColor(self, color): + """Set the background color of the OpenGL view. + + :param color: RGB color of the isosurface: name, #RRGGBB or RGB values + :type color: + QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8 + """ + color = rgba(color) + if color != self.viewport.background: + self.viewport.background = color + self.sigStyleChanged.emit('backgroundColor') + + def getBackgroundColor(self): + """Returns the RGBA background color (QColor).""" + return qt.QColor.fromRgbF(*self.viewport.background) + + def setFogMode(self, mode): + """Set the kind of fog to use for the whole scene. + + :param Union[str,FogMode] mode: The mode to use + :raise ValueError: If mode is not supported + """ + mode = self.FogMode.from_value(mode) + if mode != self.getFogMode(): + self.viewport.fog.isOn = mode is self.FogMode.LINEAR + self.sigStyleChanged.emit('fogMode') + + def getFogMode(self): + """Returns the kind of fog in use + + :return: The kind of fog in use + :rtype: FogMode + """ + if self.viewport.fog.isOn: + return self.FogMode.LINEAR + else: + return self.FogMode.NONE + + def isOrientationIndicatorVisible(self): + """Returns True if the orientation indicator is displayed. + + :rtype: bool + """ + return self.overview in self._window.viewports + + def setOrientationIndicatorVisible(self, visible): + """Set the orientation indicator visibility. + + :param bool visible: True to show + """ + visible = bool(visible) + if visible != self.isOrientationIndicatorVisible(): + if visible: + self._window.viewports = [self.viewport, self.overview] + else: + self._window.viewports = [self.viewport] + self.sigStyleChanged.emit('orientationIndicatorVisible') + + def centerScene(self): + """Position the center of the scene at the center of rotation.""" + self.viewport.resetCamera() + + def resetZoom(self, face='front'): + """Reset the camera position to a default. + + :param str face: The direction the camera is looking at: + side, front, back, top, bottom, right, left. + Default: front. + """ + self.viewport.camera.extrinsic.reset(face=face) + self.centerScene() + + def _redraw(self, source=None): + """Viewport listener to require repaint""" + if not self._updating: + self._updating = True # Mark that an update is requested + self.update() # Queued repaint (i.e., asynchronous) + + def sizeHint(self): + return qt.QSize(400, 300) + + def initializeGL(self): + pass + + def paintGL(self): + # In case paintGL is called by the system and not through _redraw, + # Mark as updating. + self._updating = True + + # Update near and far planes only if viewport needs refresh + if self.viewport.dirty: + self.viewport.adjustCameraDepthExtent() + + self._window.render(self.context(), self.getDevicePixelRatio()) + + if self._firstRender: # TODO remove this ugly hack + self._firstRender = False + self.centerScene() + self._updating = False + + def resizeGL(self, width, height): + width *= self.getDevicePixelRatio() + height *= self.getDevicePixelRatio() + self._window.size = width, height + self.viewport.size = self._window.size + overviewWidth, overviewHeight = self.overview.size + self.overview.origin = width - overviewWidth, height - overviewHeight + + def grabGL(self): + """Renders the OpenGL scene into a numpy array + + :returns: OpenGL scene RGB rasterization + :rtype: QImage + """ + if not self.isValid(): + _logger.error('OpenGL 2.1 not available, cannot save OpenGL image') + height, width = self._window.shape + image = numpy.zeros((height, width, 3), dtype=numpy.uint8) + + else: + self.makeCurrent() + image = self._window.grab(self.context()) + + return convertArrayToQImage(image) + + def wheelEvent(self, event): + if qt.BINDING == "PySide6": + x, y = event.position().x(), event.position().y() + else: + x, y = event.x(), event.y() + xpixel = x * self.getDevicePixelRatio() + ypixel = y * self.getDevicePixelRatio() + angle = event.angleDelta().y() / 8. + event.accept() + + if self.eventHandler is not None and angle != 0 and self.isValid(): + self.makeCurrent() + self.eventHandler.handleEvent('wheel', xpixel, ypixel, angle) + + def keyPressEvent(self, event): + keyCode = event.key() + # No need to accept QKeyEvent + + converter = { + qt.Qt.Key_Left: 'left', + qt.Qt.Key_Right: 'right', + qt.Qt.Key_Up: 'up', + qt.Qt.Key_Down: 'down' + } + direction = converter.get(keyCode, None) + if direction is not None: + if event.modifiers() == qt.Qt.ControlModifier: + self.viewport.camera.rotate(direction) + elif event.modifiers() == qt.Qt.ShiftModifier: + self.viewport.moveCamera(direction) + else: + self.viewport.orbitCamera(direction) + + else: + if (keyCode == qt.Qt.Key_Control and + self.eventHandler is not None and + self.isValid()): + self.eventHandler.handleEvent('keyPress', keyCode) + + # Key not handled, call base class implementation + super(Plot3DWidget, self).keyPressEvent(event) + + def keyReleaseEvent(self, event): + """Catch Ctrl key release""" + keyCode = event.key() + if (keyCode == qt.Qt.Key_Control and + self.eventHandler is not None and + self.isValid()): + self.eventHandler.handleEvent('keyRelease', keyCode) + super(Plot3DWidget, self).keyReleaseEvent(event) + + # Mouse events # + _MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'} + + def mousePressEvent(self, event): + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() + btn = self._MOUSE_BTNS[event.button()] + event.accept() + + if self.eventHandler is not None and self.isValid(): + self.makeCurrent() + self.eventHandler.handleEvent('press', xpixel, ypixel, btn) + + def mouseMoveEvent(self, event): + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() + event.accept() + + if self.eventHandler is not None and self.isValid(): + self.makeCurrent() + self.eventHandler.handleEvent('move', xpixel, ypixel) + + def mouseReleaseEvent(self, event): + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() + btn = self._MOUSE_BTNS[event.button()] + event.accept() + + if self.eventHandler is not None and self.isValid(): + self.makeCurrent() + self.eventHandler.handleEvent('release', xpixel, ypixel, btn) |