Source code for silx.gui.plot3d.Plot3DWidget

# /*##########################################################################
#
# Copyright (c) 2015-2022 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."""

__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.0, y=0.0, z=0.0, color=(1.0, 1.0, 1.0, 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.0 * source.extrinsic.direction
        self.camera.extrinsic.position = position

        self.camera.extrinsic.setOrientation(
            source.extrinsic.direction, source.extrinsic.up
        )


[docs] 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. """
[docs] @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.Widget): 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.0, 1.0, 1.0) self.viewport.scene.transforms = [ self._sceneScale, transform.Translate(0.0, 0.0, 0.0), ] # Overview area self.overview = _OverviewViewport(self.viewport.camera) self.setBackgroundColor((0.2, 0.2, 0.2, 1.0)) # 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)
[docs] 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()
[docs] 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
[docs] 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.0, size=self.viewport.size) else: raise RuntimeError("Unsupported projection: %s" % projection) self.viewport.camera.intrinsic = projection self.viewport.resetCamera()
[docs] 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")
[docs] 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")
[docs] def getBackgroundColor(self): """Returns the RGBA background color (QColor).""" return qt.QColor.fromRgbF(*self.viewport.background)
[docs] 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")
[docs] 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
[docs] def isOrientationIndicatorVisible(self): """Returns True if the orientation indicator is displayed. :rtype: bool """ return self.overview in self._window.viewports
[docs] 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")
[docs] def centerScene(self): """Position the center of the scene at the center of rotation.""" self.viewport.resetCamera()
[docs] 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)
[docs] def sizeHint(self): return qt.QSize(400, 300)
[docs] def initializeGL(self): pass
[docs] 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.getDotsPerInch(), self.getDevicePixelRatio() ) if self._firstRender: # TODO remove this ugly hack self._firstRender = False self.centerScene() self._updating = False
[docs] 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
[docs] 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)
[docs] def wheelEvent(self, event): x, y = qt.getMouseEventPosition(event) xpixel = x * self.getDevicePixelRatio() ypixel = y * self.getDevicePixelRatio() angle = event.angleDelta().y() / 8.0 event.accept() if self.eventHandler is not None and angle != 0 and self.isValid(): self.makeCurrent() self.eventHandler.handleEvent("wheel", xpixel, ypixel, angle)
[docs] 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)
[docs] 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 = { qt.Qt.LeftButton: "left", qt.Qt.RightButton: "right", qt.Qt.MiddleButton: "middle", }
[docs] def mousePressEvent(self, event): x, y = qt.getMouseEventPosition(event) xpixel = x * self.getDevicePixelRatio() ypixel = 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)
[docs] def mouseMoveEvent(self, event): x, y = qt.getMouseEventPosition(event) xpixel = x * self.getDevicePixelRatio() ypixel = y * self.getDevicePixelRatio() event.accept() if self.eventHandler is not None and self.isValid(): self.makeCurrent() self.eventHandler.handleEvent("move", xpixel, ypixel)
[docs] def mouseReleaseEvent(self, event): x, y = qt.getMouseEventPosition(event) xpixel = x * self.getDevicePixelRatio() ypixel = 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)