From bfa4dba15485b4192f8bbe13345e9658c97ecf76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Picca=20Fr=C3=A9d=C3=A9ric-Emmanuel?= Date: Sat, 7 Oct 2017 07:59:01 +0200 Subject: New upstream version 0.6.0+dfsg --- silx/gui/plot3d/Plot3DActions.py | 362 ---------------------------- silx/gui/plot3d/Plot3DToolBar.py | 119 --------- silx/gui/plot3d/Plot3DWidget.py | 203 +++++++++------- silx/gui/plot3d/Plot3DWindow.py | 20 +- silx/gui/plot3d/SFViewParamTree.py | 142 +++++++---- silx/gui/plot3d/ScalarFieldView.py | 270 ++++++++++----------- silx/gui/plot3d/ViewpointToolBar.py | 114 --------- silx/gui/plot3d/__init__.py | 7 +- silx/gui/plot3d/actions/Plot3DAction.py | 69 ++++++ silx/gui/plot3d/actions/__init__.py | 33 +++ silx/gui/plot3d/actions/io.py | 336 ++++++++++++++++++++++++++ silx/gui/plot3d/actions/mode.py | 126 ++++++++++ silx/gui/plot3d/scene/axes.py | 19 +- silx/gui/plot3d/scene/function.py | 128 +++++----- silx/gui/plot3d/scene/interaction.py | 29 +++ silx/gui/plot3d/scene/primitives.py | 7 +- silx/gui/plot3d/scene/viewport.py | 15 +- silx/gui/plot3d/scene/window.py | 11 +- silx/gui/plot3d/setup.py | 2 + silx/gui/plot3d/test/__init__.py | 4 + silx/gui/plot3d/test/testGL.py | 84 +++++++ silx/gui/plot3d/test/testScalarFieldView.py | 114 +++++++++ silx/gui/plot3d/tools/ViewpointTools.py | 135 +++++++++++ silx/gui/plot3d/tools/__init__.py | 32 +++ silx/gui/plot3d/tools/toolbars.py | 176 ++++++++++++++ 25 files changed, 1596 insertions(+), 961 deletions(-) delete mode 100644 silx/gui/plot3d/Plot3DActions.py delete mode 100644 silx/gui/plot3d/Plot3DToolBar.py delete mode 100644 silx/gui/plot3d/ViewpointToolBar.py create mode 100644 silx/gui/plot3d/actions/Plot3DAction.py create mode 100644 silx/gui/plot3d/actions/__init__.py create mode 100644 silx/gui/plot3d/actions/io.py create mode 100644 silx/gui/plot3d/actions/mode.py create mode 100644 silx/gui/plot3d/test/testGL.py create mode 100644 silx/gui/plot3d/test/testScalarFieldView.py create mode 100644 silx/gui/plot3d/tools/ViewpointTools.py create mode 100644 silx/gui/plot3d/tools/__init__.py create mode 100644 silx/gui/plot3d/tools/toolbars.py (limited to 'silx/gui/plot3d') diff --git a/silx/gui/plot3d/Plot3DActions.py b/silx/gui/plot3d/Plot3DActions.py deleted file mode 100644 index 2ae2750..0000000 --- a/silx/gui/plot3d/Plot3DActions.py +++ /dev/null @@ -1,362 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2017 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 QAction that can be attached to a plot3DWidget.""" - -from __future__ import absolute_import, division - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "26/01/2017" - - -import logging -import os -import weakref - -import numpy - -from silx.gui import qt -from silx.gui.plot.PlotActions import PrintAction as _PrintAction -from silx.gui.icons import getQIcon -from .utils import mng -from .._utils import convertQImageToArray - - -_logger = logging.getLogger(__name__) - - -class Plot3DAction(qt.QAction): - """QAction associated to a Plot3DWidget - - :param parent: See :class:`QAction` - :param Plot3DWidget plot3d: Plot3DWidget the action is associated with - """ - - def __init__(self, parent, plot3d=None): - super(Plot3DAction, self).__init__(parent) - self._plot3d = None - self.setPlot3DWidget(plot3d) - - def setPlot3DWidget(self, widget): - """Set the Plot3DWidget this action is associated with - - :param Plot3DWidget widget: The Plot3DWidget to use - """ - self._plot3d = None if widget is None else weakref.ref(widget) - - def getPlot3DWidget(self): - """Return the Plot3DWidget associated to this action. - - If no widget is associated, it returns None. - - :rtype: qt.QWidget - """ - return None if self._plot3d is None else self._plot3d() - - -class CopyAction(Plot3DAction): - """QAction to provide copy of a Plot3DWidget - - :param parent: See :class:`QAction` - :param Plot3DWidget plot3d: Plot3DWidget the action is associated with - """ - - def __init__(self, parent, plot3d=None): - super(CopyAction, self).__init__(parent, plot3d) - - self.setIcon(getQIcon('edit-copy')) - self.setText('Copy') - self.setToolTip('Copy a snapshot of the 3D scene to the clipboard') - self.setCheckable(False) - self.setShortcut(qt.QKeySequence.Copy) - self.setShortcutContext(qt.Qt.WidgetShortcut) - self.triggered[bool].connect(self._triggered) - - def _triggered(self, checked=False): - plot3d = self.getPlot3DWidget() - if plot3d is None: - _logger.error('Cannot copy widget, no associated Plot3DWidget') - else: - image = plot3d.grabGL() - qt.QApplication.clipboard().setImage(image) - - -class SaveAction(Plot3DAction): - """QAction to provide save snapshot of a Plot3DWidget - - :param parent: See :class:`QAction` - :param Plot3DWidget plot3d: Plot3DWidget the action is associated with - """ - - def __init__(self, parent, plot3d=None): - super(SaveAction, self).__init__(parent, plot3d) - - self.setIcon(getQIcon('document-save')) - self.setText('Save...') - self.setToolTip('Save a snapshot of the 3D scene') - self.setCheckable(False) - self.setShortcut(qt.QKeySequence.Save) - self.setShortcutContext(qt.Qt.WidgetShortcut) - self.triggered[bool].connect(self._triggered) - - def _triggered(self, checked=False): - plot3d = self.getPlot3DWidget() - if plot3d is None: - _logger.error('Cannot save widget, no associated Plot3DWidget') - else: - dialog = qt.QFileDialog(self.parent()) - dialog.setWindowTitle('Save snapshot as') - dialog.setModal(True) - dialog.setNameFilters(('Plot3D Snapshot PNG (*.png)', - 'Plot3D Snapshot JPEG (*.jpg)')) - - dialog.setFileMode(qt.QFileDialog.AnyFile) - dialog.setAcceptMode(qt.QFileDialog.AcceptSave) - - if not dialog.exec_(): - return - - nameFilter = dialog.selectedNameFilter() - filename = dialog.selectedFiles()[0] - dialog.close() - - # Forces the filename extension to match the chosen filter - extension = nameFilter.split()[-1][2:-1] - if (len(filename) <= len(extension) or - filename[-len(extension):].lower() != extension.lower()): - filename += extension - - image = plot3d.grabGL() - if not image.save(filename): - _logger.error('Failed to save image as %s', filename) - qt.QMessageBox.critical( - self.parent(), - 'Save snapshot as', - 'Failed to save snapshot') - - -class PrintAction(Plot3DAction): - """QAction to provide printing of a Plot3DWidget - - :param parent: See :class:`QAction` - :param Plot3DWidget plot3d: Plot3DWidget the action is associated with - """ - - def __init__(self, parent, plot3d=None): - super(PrintAction, self).__init__(parent, plot3d) - - self.setIcon(getQIcon('document-print')) - self.setText('Print...') - self.setToolTip('Print a snapshot of the 3D scene') - self.setCheckable(False) - self.setShortcut(qt.QKeySequence.Print) - self.setShortcutContext(qt.Qt.WidgetShortcut) - self.triggered[bool].connect(self._triggered) - - def getPrinter(self): - """Return the QPrinter instance used for printing. - - :rtype: qt.QPrinter - """ - # TODO This is a hack to sync with silx plot PrintAction - # This needs to be centralized - if _PrintAction._printer is None: - _PrintAction._printer = qt.QPrinter() - return _PrintAction._printer - - def _triggered(self, checked=False): - plot3d = self.getPlot3DWidget() - if plot3d is None: - _logger.error('Cannot print widget, no associated Plot3DWidget') - else: - printer = self.getPrinter() - dialog = qt.QPrintDialog(printer, plot3d) - dialog.setWindowTitle('Print Plot3D snapshot') - if not dialog.exec_(): - return - - image = plot3d.grabGL() - - # Draw pixmap with painter - painter = qt.QPainter() - if not painter.begin(printer): - return - - if (printer.pageRect().width() < image.width() or - printer.pageRect().height() < image.height()): - # Downscale to page - xScale = printer.pageRect().width() / image.width() - yScale = printer.pageRect().height() / image.height() - scale = min(xScale, yScale) - else: - scale = 1. - - rect = qt.QRectF(0, - 0, - scale * image.width(), - scale * image.height()) - painter.drawImage(rect, image) - painter.end() - - -class VideoAction(Plot3DAction): - """This action triggers the recording of a video of the scene. - - The scene is rotated 360 degrees around a vertical axis. - - :param parent: Action parent see :class:`QAction`. - """ - - PNG_SERIE_FILTER = 'Serie of PNG files (*.png)' - MNG_FILTER = 'Multiple-image Network Graphics file (*.mng)' - - def __init__(self, parent, plot3d=None): - super(VideoAction, self).__init__(parent, plot3d) - self.setText('Record video..') - self.setIcon(getQIcon('camera')) - self.setToolTip( - 'Record a video of a 360 degrees rotation of the 3D scene.') - self.setCheckable(False) - self.triggered[bool].connect(self._triggered) - - def _triggered(self, checked=False): - """Action triggered callback""" - plot3d = self.getPlot3DWidget() - if plot3d is None: - _logger.warning( - 'Ignoring action triggered without Plot3DWidget set') - return - - dialog = qt.QFileDialog(parent=plot3d) - dialog.setWindowTitle('Save video as...') - dialog.setModal(True) - dialog.setNameFilters([self.PNG_SERIE_FILTER, - self.MNG_FILTER]) - dialog.setFileMode(dialog.AnyFile) - dialog.setAcceptMode(dialog.AcceptSave) - - if not dialog.exec_(): - return - - nameFilter = dialog.selectedNameFilter() - filename = dialog.selectedFiles()[0] - - # Forces the filename extension to match the chosen filter - extension = nameFilter.split()[-1][2:-1] - if (len(filename) <= len(extension) or - filename[-len(extension):].lower() != extension.lower()): - filename += extension - - nbFrames = int(4. * 25) # 4 seconds, 25 fps - - if nameFilter == self.PNG_SERIE_FILTER: - self._saveAsPNGSerie(filename, nbFrames) - elif nameFilter == self.MNG_FILTER: - self._saveAsMNG(filename, nbFrames) - else: - _logger.error('Unsupported file filter: %s', nameFilter) - - def _saveAsPNGSerie(self, filename, nbFrames): - """Save video as serie of PNG files. - - It adds a counter to the provided filename before the extension. - - :param str filename: filename to use as template - :param int nbFrames: Number of frames to generate - """ - plot3d = self.getPlot3DWidget() - assert plot3d is not None - - # Define filename template - nbDigits = int(numpy.log10(nbFrames)) + 1 - indexFormat = '%%0%dd' % nbDigits - extensionIndex = filename.rfind('.') - filenameFormat = \ - filename[:extensionIndex] + indexFormat + filename[extensionIndex:] - - try: - for index, image in enumerate(self._video360(nbFrames)): - image.save(filenameFormat % index) - except GeneratorExit: - pass - - def _saveAsMNG(self, filename, nbFrames): - """Save video as MNG file. - - :param str filename: filename to use - :param int nbFrames: Number of frames to generate - """ - plot3d = self.getPlot3DWidget() - assert plot3d is not None - - frames = (convertQImageToArray(im) for im in self._video360(nbFrames)) - try: - with open(filename, 'wb') as file_: - for chunk in mng.convert(frames, nb_images=nbFrames): - file_.write(chunk) - except GeneratorExit: - os.remove(filename) # Saving aborted, delete file - - def _video360(self, nbFrames): - """Run the video and provides the images - - :param int nbFrames: The number of frames to generate for - :return: Iterator of QImage of the video sequence - """ - plot3d = self.getPlot3DWidget() - assert plot3d is not None - - angleStep = 360. / nbFrames - - # Create progress bar dialog - dialog = qt.QDialog(plot3d) - dialog.setWindowTitle('Record Video') - layout = qt.QVBoxLayout(dialog) - progress = qt.QProgressBar() - progress.setRange(0, nbFrames) - layout.addWidget(progress) - - btnBox = qt.QDialogButtonBox(qt.QDialogButtonBox.Abort) - btnBox.rejected.connect(dialog.reject) - layout.addWidget(btnBox) - - dialog.setModal(True) - dialog.show() - - qapp = qt.QApplication.instance() - - for frame in range(nbFrames): - progress.setValue(frame) - image = plot3d.grabGL() - yield image - plot3d.viewport.orbitCamera('left', angleStep) - qapp.processEvents() - if not dialog.isVisible(): - break # It as been rejected by the abort button - else: - dialog.accept() - - if dialog.result() == qt.QDialog.Rejected: - raise GeneratorExit('Aborted') diff --git a/silx/gui/plot3d/Plot3DToolBar.py b/silx/gui/plot3d/Plot3DToolBar.py deleted file mode 100644 index cf11362..0000000 --- a/silx/gui/plot3d/Plot3DToolBar.py +++ /dev/null @@ -1,119 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2017 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 toolbar with tools for a Plot3DWidget. - -It provides: - -- Copy -- Save -- Print -""" - -from __future__ import absolute_import - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "10/01/2017" - -import logging - -from silx.gui import qt - -from . import Plot3DActions - -_logger = logging.getLogger(__name__) - - -class Plot3DToolBar(qt.QToolBar): - """Toolbar providing icons to copy, save and print the OpenGL scene - - :param parent: See :class:`QWidget` - :param str title: Title of the toolbar. - """ - - def __init__(self, parent=None, title='Plot3D'): - super(Plot3DToolBar, self).__init__(title, parent) - - self._plot3d = None - - self._copyAction = Plot3DActions.CopyAction(parent=self) - self.addAction(self._copyAction) - - self._saveAction = Plot3DActions.SaveAction(parent=self) - self.addAction(self._saveAction) - - self._videoAction = Plot3DActions.VideoAction(parent=self) - self.addAction(self._videoAction) - - self._printAction = Plot3DActions.PrintAction(parent=self) - self.addAction(self._printAction) - - def setPlot3DWidget(self, widget): - """Set the Plot3DWidget this toolbar is associated with - - :param Plot3DWidget widget: The widget to copy/save/print - """ - self._plot3d = widget - self.getCopyAction().setPlot3DWidget(widget) - self.getSaveAction().setPlot3DWidget(widget) - self.getVideoRecordAction().setPlot3DWidget(widget) - self.getPrintAction().setPlot3DWidget(widget) - - def getPlot3DWidget(self): - """Return the Plot3DWidget associated to this toolbar. - - If no widget is associated, it returns None. - - :rtype: qt.QWidget - """ - return self._plot3d - - def getCopyAction(self): - """Returns the QAction performing copy to clipboard of the Plot3DWidget - - :rtype: qt.QAction - """ - return self._copyAction - - def getSaveAction(self): - """Returns the QAction performing save to file of the Plot3DWidget - - :rtype: qt.QAction - """ - return self._saveAction - - def getVideoRecordAction(self): - """Returns the QAction performing record video of the Plot3DWidget - - :rtype: qt.QAction - """ - return self._videoAction - - def getPrintAction(self): - """Returns the QAction performing printing of the Plot3DWidget - - :rtype: qt.QAction - """ - return self._printAction diff --git a/silx/gui/plot3d/Plot3DWidget.py b/silx/gui/plot3d/Plot3DWidget.py index 9c9da0c..aae3955 100644 --- a/silx/gui/plot3d/Plot3DWidget.py +++ b/silx/gui/plot3d/Plot3DWidget.py @@ -35,10 +35,10 @@ import logging from silx.gui import qt from silx.gui.plot.Colors import rgba -from silx.gui.plot3d import Plot3DActions +from . import actions from .._utils import convertArrayToQImage -from .._glutils import gl +from .. import _glutils as glu from .scene import interaction, primitives, transform from . import scene @@ -79,31 +79,29 @@ class _OverviewViewport(scene.Viewport): source.extrinsic.direction, source.extrinsic.up) -class Plot3DWidget(qt.QGLWidget): - """QGLWidget with a 3D viewport and an overview.""" +class Plot3DWidget(glu.OpenGLWidget): + """OpenGL widget with a 3D viewport and an overview.""" - def __init__(self, parent=None): - if not qt.QGLFormat.hasOpenGL(): # Check if any OpenGL is available - raise RuntimeError( - 'OpenGL is not available on this platform: 3D disabled') + sigInteractiveModeChanged = qt.Signal() + """Signal emitted when the interactive mode has changed + """ - self._devicePixelRatio = 1.0 # Store GL canvas/QWidget ratio - self._isOpenGL21 = False + def __init__(self, parent=None, f=qt.Qt.WindowFlags()): self._firstRender = True - format_ = qt.QGLFormat() - format_.setRgba(True) - format_.setDepth(False) - format_.setStencil(False) - format_.setVersion(2, 1) - format_.setDoubleBuffer(True) + super(Plot3DWidget, self).__init__( + parent, + alphaBufferSize=8, + depthBufferSize=0, + stencilBufferSize=0, + version=(2, 1), + f=f) - super(Plot3DWidget, self).__init__(format_, parent) self.setAutoFillBackground(False) self.setMouseTracking(True) self.setFocusPolicy(qt.Qt.StrongFocus) - self._copyAction = Plot3DActions.CopyAction(parent=self, plot3d=self) + self._copyAction = actions.io.CopyAction(parent=self, plot3d=self) self.addAction(self._copyAction) self._updating = False # True if an update is requested @@ -112,8 +110,8 @@ class Plot3DWidget(qt.QGLWidget): self.viewport = scene.Viewport() self.viewport.background = 0.2, 0.2, 0.2, 1. - sceneScale = transform.Scale(1., 1., 1.) - self.viewport.scene.transforms = [sceneScale, + self._sceneScale = transform.Scale(1., 1., 1.) + self.viewport.scene.transforms = [self._sceneScale, transform.Translate(0., 0., 0.)] # Overview area @@ -122,15 +120,56 @@ class Plot3DWidget(qt.QGLWidget): 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 = scene.Window(mode='framebuffer') + self._window.viewports = [self.viewport, self.overview] + self._window.addListener(self._redraw) + + self.eventHandler = None + self.setInteractiveMode('rotate') + + 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) + + elif mode == 'pan': + self.eventHandler = interaction.PanCameraControl( + self.viewport, + mode='position', + scaleTransform=self._sceneScale, + selectCB=None) + + else: + raise ValueError('Unsupported interactive mode %s', str(mode)) + + self.sigInteractiveModeChanged.emit() - self.eventHandler = interaction.CameraControl( - self.viewport, orbitAroundCenter=False, - mode='position', scaleTransform=sceneScale, - selectCB=None) + def getInteractiveMode(self): + """Returns the interactive mode in use. - self.viewport.addListener(self._redraw) + :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. @@ -176,6 +215,25 @@ class Plot3DWidget(qt.QGLWidget): """Returns the RGBA background color (QColor).""" return qt.QColor.fromRgbF(*self.viewport.background) + 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] + def centerScene(self): """Position the center of the scene at the center of rotation.""" self.viewport.resetCamera() @@ -192,7 +250,7 @@ class Plot3DWidget(qt.QGLWidget): def _redraw(self, source=None): """Viewport listener to require repaint""" - if not self._updating and self.viewport.dirty: + if not self._updating: self._updating = True # Mark that an update is requested self.update() # Queued repaint (i.e., asynchronous) @@ -200,52 +258,18 @@ class Plot3DWidget(qt.QGLWidget): return qt.QSize(400, 300) def initializeGL(self): - # Check if OpenGL2 is available - versionflags = self.format().openGLVersionFlags() - self._isOpenGL21 = bool(versionflags & qt.QGLFormat.OpenGL_Version_2_1) - if not self._isOpenGL21: - _logger.error( - '3D rendering is disabled: OpenGL 2.1 not available') - - messageBox = qt.QMessageBox(parent=self) - messageBox.setIcon(qt.QMessageBox.Critical) - messageBox.setWindowTitle('Error') - messageBox.setText('3D rendering is disabled.\n\n' - 'Reason: OpenGL 2.1 is not available.') - messageBox.addButton(qt.QMessageBox.Ok) - messageBox.setWindowModality(qt.Qt.WindowModal) - messageBox.setAttribute(qt.Qt.WA_DeleteOnClose) - messageBox.show() + pass def paintGL(self): # In case paintGL is called by the system and not through _redraw, # Mark as updating. self._updating = True - if hasattr(self, 'windowHandle'): # Qt 5 - devicePixelRatio = self.windowHandle().devicePixelRatio() - if devicePixelRatio != self._devicePixelRatio: - # Move window from one screen to another one - self._devicePixelRatio = devicePixelRatio - # Resize might not be called, so call it explicitly - self.resizeGL(int(self.width() * devicePixelRatio), - int(self.height() * devicePixelRatio)) - - if not self._isOpenGL21: - # Cannot render scene, just clear the color buffer. - ox, oy = self.viewport.origin - w, h = self.viewport.size - gl.glViewport(ox, oy, w, h) + # Update near and far planes only if viewport needs refresh + if self.viewport.dirty: + self.viewport.adjustCameraDepthExtent() - gl.glClearColor(*self.viewport.background) - gl.glClear(gl.GL_COLOR_BUFFER_BIT) - - else: - # Update near and far planes only if viewport needs refresh - if self.viewport.dirty: - self.viewport.adjustCameraDepthExtent() - - self.window.render(self.context(), self._devicePixelRatio) + self._window.render(self.context(), self.getDevicePixelRatio()) if self._firstRender: # TODO remove this ugly hack self._firstRender = False @@ -253,8 +277,10 @@ class Plot3DWidget(qt.QGLWidget): self._updating = False def resizeGL(self, width, height): - self.window.size = width, height - self.viewport.size = 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 @@ -264,27 +290,27 @@ class Plot3DWidget(qt.QGLWidget): :returns: OpenGL scene RGB rasterization :rtype: QImage """ - if not self._isOpenGL21: + if not self.isValid(): _logger.error('OpenGL 2.1 not available, cannot save OpenGL image') - height, width = self.window.shape + height, width = self._window.shape image = numpy.zeros((height, width, 3), dtype=numpy.uint8) else: self.makeCurrent() - image = self.window.grab(qt.QGLContext.currentContext()) + image = self._window.grab(self.context()) return convertArrayToQImage(image) def wheelEvent(self, event): - xpixel = event.x() * self._devicePixelRatio - ypixel = event.y() * self._devicePixelRatio + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() if hasattr(event, 'delta'): # Qt4 angle = event.delta() / 8. else: # Qt5 angle = event.angleDelta().y() / 8. event.accept() - if angle != 0: + if self.eventHandler is not None and angle != 0 and self.isValid(): self.makeCurrent() self.eventHandler.handleEvent('wheel', xpixel, ypixel, angle) @@ -315,27 +341,30 @@ class Plot3DWidget(qt.QGLWidget): _MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'} def mousePressEvent(self, event): - xpixel = event.x() * self._devicePixelRatio - ypixel = event.y() * self._devicePixelRatio + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() btn = self._MOUSE_BTNS[event.button()] event.accept() - self.makeCurrent() - self.eventHandler.handleEvent('press', xpixel, ypixel, btn) + 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._devicePixelRatio - ypixel = event.y() * self._devicePixelRatio + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() event.accept() - self.makeCurrent() - self.eventHandler.handleEvent('move', xpixel, ypixel) + 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._devicePixelRatio - ypixel = event.y() * self._devicePixelRatio + xpixel = event.x() * self.getDevicePixelRatio() + ypixel = event.y() * self.getDevicePixelRatio() btn = self._MOUSE_BTNS[event.button()] event.accept() - self.makeCurrent() - self.eventHandler.handleEvent('release', xpixel, ypixel, btn) + if self.eventHandler is not None and self.isValid(): + self.makeCurrent() + self.eventHandler.handleEvent('release', xpixel, ypixel, btn) diff --git a/silx/gui/plot3d/Plot3DWindow.py b/silx/gui/plot3d/Plot3DWindow.py index 4658d38..1bc2738 100644 --- a/silx/gui/plot3d/Plot3DWindow.py +++ b/silx/gui/plot3d/Plot3DWindow.py @@ -34,13 +34,13 @@ __date__ = "26/01/2017" from silx.gui import qt -from .Plot3DToolBar import Plot3DToolBar from .Plot3DWidget import Plot3DWidget -from .ViewpointToolBar import ViewpointToolBar +from .tools import OutputToolBar, InteractiveModeToolBar +from .tools import ViewpointToolButton class Plot3DWindow(qt.QMainWindow): - """QGLWidget with a 3D viewport and an overview.""" + """OpenGL widget with a 3D viewport and an overview.""" def __init__(self, parent=None): super(Plot3DWindow, self).__init__(parent) @@ -50,9 +50,17 @@ class Plot3DWindow(qt.QMainWindow): self._plot3D = Plot3DWidget() self.setCentralWidget(self._plot3D) - self.addToolBar( - ViewpointToolBar(parent=self, plot3D=self._plot3D)) - toolbar = Plot3DToolBar(parent=self) + + toolbar = InteractiveModeToolBar(parent=self) + toolbar.setPlot3DWidget(self._plot3D) + self.addToolBar(toolbar) + self.addActions(toolbar.actions()) + + toolbar = qt.QToolBar(self) + toolbar.addWidget(ViewpointToolButton(plot3D=self._plot3D)) + self.addToolBar(toolbar) + + toolbar = OutputToolBar(parent=self) toolbar.setPlot3DWidget(self._plot3D) self.addToolBar(toolbar) self.addActions(toolbar.actions()) diff --git a/silx/gui/plot3d/SFViewParamTree.py b/silx/gui/plot3d/SFViewParamTree.py index 38d4e37..8b144df 100644 --- a/silx/gui/plot3d/SFViewParamTree.py +++ b/silx/gui/plot3d/SFViewParamTree.py @@ -30,15 +30,18 @@ from __future__ import absolute_import __authors__ = ["D. N."] __license__ = "MIT" -__date__ = "10/01/2017" +__date__ = "02/10/2017" import logging import sys +import weakref import numpy from silx.gui import qt from silx.gui.icons import getQIcon +from silx.gui.plot.Colormap import Colormap +from silx.gui.widgets.FloatEdit import FloatEdit from .ScalarFieldView import Isosurface @@ -111,14 +114,20 @@ class SubjectItem(qt.QStandardItem): value = setValue super(SubjectItem, self).setData(value, role) - subject = property(lambda self: self.__subject) + @property + def subject(self): + """The subject this item is observing""" + return None if self.__subject is None else self.__subject() @subject.setter def subject(self, subject): if self.__subject is not None: raise ValueError('Subject already set ' ' (subject change not supported).') - self.__subject = subject + if subject is None: + self.__subject = None + else: + self.__subject = weakref.ref(subject) if subject is not None: self._init() self._connectSignals() @@ -343,6 +352,44 @@ class HighlightColorItem(ColorItem): return self.subject.getHighlightColor() +class BoundingBoxItem(SubjectItem): + """Bounding box, axes labels and grid visibility item. + + Item is checkable. + """ + itemName = 'Bounding Box' + + def _init(self): + visible = self.subject.isBoundingBoxVisible() + self.setCheckable(True) + self.setCheckState(qt.Qt.Checked if visible else qt.Qt.Unchecked) + + def leftClicked(self): + checked = (self.checkState() == qt.Qt.Checked) + if checked != self.subject.isBoundingBoxVisible(): + self.subject.setBoundingBoxVisible(checked) + + +class OrientationIndicatorItem(SubjectItem): + """Orientation indicator visibility item. + + Item is checkable. + """ + itemName = 'Axes indicator' + + def _init(self): + plot3d = self.subject.getPlot3DWidget() + visible = plot3d.isOrientationIndicatorVisible() + self.setCheckable(True) + self.setCheckState(qt.Qt.Checked if visible else qt.Qt.Unchecked) + + def leftClicked(self): + plot3d = self.subject.getPlot3DWidget() + checked = (self.checkState() == qt.Qt.Checked) + if checked != plot3d.isOrientationIndicatorVisible(): + plot3d.setOrientationIndicatorVisible(checked) + + class ViewSettingsItem(qt.QStandardItem): """Viewport settings""" @@ -352,7 +399,9 @@ class ViewSettingsItem(qt.QStandardItem): self.setEditable(False) - classes = BackgroundColorItem, ForegroundColorItem, HighlightColorItem + classes = (BackgroundColorItem, ForegroundColorItem, + HighlightColorItem, + BoundingBoxItem, OrientationIndicatorItem) for cls in classes: titleItem = qt.QStandardItem(cls.itemName) titleItem.setEditable(False) @@ -534,8 +583,8 @@ class _IsoLevelSlider(qt.QSlider): """Set slider from iso-surface level""" dataRange = self.subject.parent().getDataRange() - if dataRange is not None and None not in dataRange: - width = dataRange[1] - dataRange[0] + if dataRange is not None: + width = dataRange[-1] - dataRange[0] if width > 0: sliderWidth = self.maximum() - self.minimum() sliderPosition = sliderWidth * (level - dataRange[0]) / width @@ -548,10 +597,12 @@ class _IsoLevelSlider(qt.QSlider): def __sliderReleased(self): value = self.value() dataRange = self.subject.parent().getDataRange() - width = dataRange[1] - dataRange[0] - sliderWidth = self.maximum() - self.minimum() - level = dataRange[0] + width * value / sliderWidth - self.subject.setLevel(level) + if dataRange is not None: + min_, _, max_ = dataRange + width = max_ - min_ + sliderWidth = self.maximum() - self.minimum() + level = min_ + width * value / sliderWidth + self.subject.setLevel(level) class IsoSurfaceLevelSlider(IsoSurfaceLevelItem): @@ -771,7 +822,8 @@ class IsoSurfaceAddRemoveWidget(qt.QWidget): if dataRange is None: dataRange = [0, 1] - sfview.addIsosurface(numpy.mean(dataRange), '#0000FF') + sfview.addIsosurface( + numpy.mean((dataRange[0], dataRange[-1])), '#0000FF') def __removeClicked(self): self.sigViewTask.emit('remove_iso') @@ -867,30 +919,24 @@ class PlaneMinRangeItem(ColormapBase): self._setVMin(value) def _setVMin(self, value): - cutPlane = self.subject.getCutPlanes()[0] - colormap = cutPlane.getColormap() + colormap = self.subject.getCutPlanes()[0].getColormap() vMin = value vMax = colormap.getVMax() if vMax is not None and value > vMax: vMin = vMax vMax = value - cutPlane.setColormap(name=colormap.getName(), - norm=colormap.getNorm(), - vmin=vMin, - vmax=vMax) + colormap.setVRange(vMin, vMax) def getEditor(self, parent, option, index): - editor = qt.QLineEdit(parent) - editor.setValidator(qt.QDoubleValidator()) - return editor + return FloatEdit(parent) def setEditorData(self, editor): - editor.setText(str(self._pullData())) + editor.setValue(self._pullData()) return True def _setModelData(self, editor): - value = float(editor.text()) + value = editor.value() self._setVMin(value) return True @@ -910,29 +956,23 @@ class PlaneMaxRangeItem(ColormapBase): return self.subject.getCutPlanes()[0].getColormap().getVMax() def _setVMax(self, value): - cutPlane = self.subject.getCutPlanes()[0] - colormap = cutPlane.getColormap() + colormap = self.subject.getCutPlanes()[0].getColormap() vMin = colormap.getVMin() vMax = value if vMin is not None and value < vMin: vMax = vMin vMin = value - cutPlane.setColormap(name=colormap.getName(), - norm=colormap.getNorm(), - vmin=vMin, - vmax=vMax) + colormap.setVRange(vMin, vMax) def getEditor(self, parent, option, index): - editor = qt.QLineEdit(parent) - editor.setValidator(qt.QDoubleValidator()) - return editor + return FloatEdit(parent) def setEditorData(self, editor): editor.setText(str(self._pullData())) return True def _setModelData(self, editor): - value = float(editor.text()) + value = editor.value() self._setVMax(value) return True @@ -1028,7 +1068,8 @@ class PlaneColormapItem(ColormapBase): listValues = ['gray', 'reversed gray', 'temperature', 'red', - 'green', 'blue'] + 'green', 'blue', + 'viridis', 'magma', 'inferno', 'plasma'] def getEditor(self, parent, option, index): editor = qt.QComboBox(parent) @@ -1038,17 +1079,18 @@ class PlaneColormapItem(ColormapBase): return editor def __editorChanged(self, index): - colorMapName = self.listValues[index] - colorMap = self.subject.getCutPlanes()[0].getColormap() - self.subject.getCutPlanes()[0].setColormap(name=colorMapName, - norm=colorMap.getNorm(), - vmin=colorMap.getVMin(), - vmax=colorMap.getVMax()) + colormapName = self.listValues[index] + colormap = self.subject.getCutPlanes()[0].getColormap() + colormap.setName(colormapName) def setEditorData(self, editor): colormapName = self.subject.getCutPlanes()[0].getColormap().getName() - index = self.listValues.index(colormapName) - editor.setCurrentIndex(index) + try: + index = self.listValues.index(colormapName) + except ValueError: + _logger.error('Unsupported colormap: %s', colormapName) + else: + editor.setCurrentIndex(index) return True def _setModelData(self, editor): @@ -1078,22 +1120,18 @@ class PlaneAutoScaleItem(ColormapBase): def _setAutoScale(self, auto): view3d = self.subject - cutPlane = view3d.getCutPlanes()[0] - colormap = cutPlane.getColormap() + colormap = view3d.getCutPlanes()[0].getColormap() if auto != colormap.isAutoscale(): if auto: vMin = vMax = None else: dataRange = view3d.getDataRange() - if dataRange is None or None in dataRange: + if dataRange is None: vMin = vMax = None else: - vMin, vMax = dataRange - cutPlane.setColormap(colormap.getName(), - colormap.getNorm(), - vMin, - vMax) + vMin, vMax = dataRange[0], dataRange[-1] + colormap.setVRange(vMin, vMax) def _pullData(self): auto = self.subject.getCutPlanes()[0].getColormap().isAutoscale() @@ -1111,7 +1149,7 @@ class NormalizationNode(ColormapBase): Item is a QComboBox. """ editable = True - listValues = ['linear', 'log'] + listValues = list(Colormap.NORMALIZATIONS) def getEditor(self, parent, option, index): editor = qt.QComboBox(parent) @@ -1129,7 +1167,7 @@ class NormalizationNode(ColormapBase): vmax=colorMap.getVMax()) def setEditorData(self, editor): - normalization = self.subject.getCutPlanes()[0].getColormap().getNorm() + normalization = self.subject.getCutPlanes()[0].getColormap().getNormalization() index = self.listValues.index(normalization) editor.setCurrentIndex(index) return True @@ -1139,7 +1177,7 @@ class NormalizationNode(ColormapBase): return True def _pullData(self): - return self.subject.getCutPlanes()[0].getColormap().getNorm() + return self.subject.getCutPlanes()[0].getColormap().getNormalization() class PlaneGroup(SubjectItem): diff --git a/silx/gui/plot3d/ScalarFieldView.py b/silx/gui/plot3d/ScalarFieldView.py index 2eb54a3..6a4d9d4 100644 --- a/silx/gui/plot3d/ScalarFieldView.py +++ b/silx/gui/plot3d/ScalarFieldView.py @@ -41,15 +41,17 @@ from collections import deque import numpy -from silx.gui import qt +from silx.gui import qt, icons from silx.gui.plot.Colors import rgba +from silx.gui.plot.Colormap import Colormap from silx.math.marchingcubes import MarchingCubes +from silx.math.combo import min_max -from .scene import axes, cutplane, function, interaction, primitives, transform +from .scene import axes, cutplane, interaction, primitives, transform from . import scene from .Plot3DWindow import Plot3DWindow - +from .tools import InteractiveModeToolBar _logger = logging.getLogger(__name__) @@ -245,7 +247,7 @@ class Isosurface(qt.QObject): self._level = level self.sigLevelChanged.emit(level) - if numpy.isnan(self._level): + if not numpy.isfinite(self._level): return st = time.time() @@ -265,48 +267,6 @@ class Isosurface(qt.QObject): self._group.children = [mesh] -class Colormap(object): - """Description of a colormap - - :param str name: Name of the colormap - :param str norm: Normalization: 'linear' (default) or 'log' - :param float vmin: - Lower bound of the colormap or None for autoscale (default) - :param float vmax: - Upper bounds of the colormap or None for autoscale (default) - """ - - def __init__(self, name, norm='linear', vmin=None, vmax=None): - assert name in function.Colormap.COLORMAPS - self._name = str(name) - - assert norm in ('linear', 'log') - self._norm = str(norm) - - self._vmin = float(vmin) if vmin is not None else None - self._vmax = float(vmax) if vmax is not None else None - - def isAutoscale(self): - """True if both min and max are in autoscale mode""" - return self._vmin is None or self._vmax is None - - def getName(self): - """Return the name of the colormap (str)""" - return self._name - - def getNorm(self): - """Return the normalization of the colormap (str)""" - return self._norm - - def getVMin(self): - """Return the lower bound of the colormap or None""" - return self._vmin - - def getVMax(self): - """Return the upper bounds of the colormap or None""" - return self._vmax - - class SelectedRegion(object): """Selection of a 3D region aligned with the axis. @@ -391,7 +351,7 @@ class CutPlane(qt.QObject): sigPlaneChanged = qt.Signal() """Signal emitted when the cut plane has moved""" - sigColormapChanged = qt.Signal(object) + sigColormapChanged = qt.Signal(Colormap) """Signal emitted when the colormap has changed This signal provides the new colormap. @@ -406,11 +366,7 @@ class CutPlane(qt.QObject): def __init__(self, sfView): super(CutPlane, self).__init__(parent=sfView) - self._colormap = Colormap( - name='gray', norm='linear', vmin=None, vmax=None) - self._dataRange = None - self._positiveMin = None self._plane = cutplane.CutPlane(normal=(0, 1, 0)) self._plane.alpha = 1. @@ -418,6 +374,11 @@ class CutPlane(qt.QObject): self._plane.addListener(self._planeChanged) self._plane.plane.addListener(self._planePositionChanged) + self._colormap = Colormap( + name='gray', normalization='linear', vmin=None, vmax=None) + self.getColormap().sigChanged.connect(self._colormapChanged) + self._updateSceneColormap() + sfView.sigDataChanged.connect(self._sfViewDataChanged) def _get3DPrimitive(self): @@ -427,13 +388,15 @@ class CutPlane(qt.QObject): def _sfViewDataChanged(self): """Handle data change in the ScalarFieldView this plane belongs to""" self._plane.setData(self.sender().getData(), copy=False) + + # Store data range info as 3-tuple of values self._dataRange = self.sender().getDataRange() - self._positiveMin = None + self.sigDataChanged.emit() # Update colormap range when autoscale if self.getColormap().isAutoscale(): - self._updateColormapRange() + self._updateSceneColormap() def _planeChanged(self, source, *args, **kwargs): """Handle events from the plane primitive""" @@ -565,7 +528,7 @@ class CutPlane(qt.QObject): # self._plane.alpha = alpha def getColormap(self): - """Returns the colormap set by :meth:`getColormap`. + """Returns the colormap set by :meth:`setColormap`. :return: The colormap :rtype: Colormap @@ -574,25 +537,38 @@ class CutPlane(qt.QObject): def setColormap(self, name='gray', - norm='linear', + norm=None, vmin=None, vmax=None): """Set the colormap to use. - :param str name: Name of the colormap in + By either providing a :class:`Colormap` object or + its name, normalization and range. + + :param name: Name of the colormap in 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'. + Or Colormap object. + :type name: str or Colormap :param str norm: Colormap mapping: 'linear' or 'log'. :param float vmin: The minimum value of the range or None for autoscale :param float vmax: The maximum value of the range or None for autoscale """ _logger.debug('setColormap %s %s (%s, %s)', - name, norm, str(vmin), str(vmax)) + name, str(norm), str(vmin), str(vmax)) - self._colormap = Colormap( - name=name, norm=norm, vmin=vmin, vmax=vmax) + self._colormap.sigChanged.disconnect(self._colormapChanged) - self._updateColormapRange() - self.sigColormapChanged.emit(self.getColormap()) + if isinstance(name, Colormap): # Use it as it is + assert (norm, vmin, vmax) == (None, None, None) + self._colormap = name + else: + if norm is None: + norm = 'linear' + self._colormap = Colormap( + name=name, normalization=norm, vmin=vmin, vmax=vmax) + + self._colormap.sigChanged.connect(self._colormapChanged) + self._colormapChanged() def getColormapEffectiveRange(self): """Returns the currently used range of the colormap. @@ -604,35 +580,29 @@ class CutPlane(qt.QObject): """ return self._plane.colormap.range_ - def _updateColormapRange(self): - """Update the colormap range""" + def _updateSceneColormap(self): + """Synchronizes scene's colormap with Colormap object""" colormap = self.getColormap() - - self._plane.colormap.name = colormap.getName() - if colormap.isAutoscale(): - range_ = self._dataRange - if range_ is None: # No data, use a default range - range_ = 1., 10. - else: - range_ = colormap.getVMin(), colormap.getVMax() - - if colormap.getNorm() == 'linear': - self._plane.colormap.norm = 'linear' - self._plane.colormap.range_ = range_ - - else: # Log - # Make sure range is strictly positive - if range_[0] <= 0.: - data = self._plane.getData(copy=False) - if data is not None: - if self._positiveMin is None: - # TODO compute this with the range as a combo operation - self._positiveMin = numpy.min(data[data > 0.]) - range_ = (self._positiveMin, - max(range_[1], self._positiveMin)) - - self._plane.colormap.range_ = range_ - self._plane.colormap.norm = colormap.getNorm() + sceneCMap = self._plane.colormap + + indices = numpy.linspace(0., 1., 256) + colormapDisp = Colormap(name=colormap.getName(), + normalization=Colormap.LINEAR, + vmin=None, + vmax=None, + colors=colormap.getColormapLUT()) + colors = colormapDisp.applyToData(indices) + sceneCMap.colormap = colors + + sceneCMap.norm = colormap.getNormalization() + range_ = colormap.getColormapRange(data=self._dataRange) + sceneCMap.range_ = range_ + + def _colormapChanged(self): + """Handle update of Colormap object""" + self._updateSceneColormap() + # Forward colormap changed event + self.sigColormapChanged.emit(self.getColormap()) class _CutPlaneImage(object): @@ -766,7 +736,7 @@ class ScalarFieldView(Plot3DWindow): def __init__(self, parent=None): super(ScalarFieldView, self).__init__(parent) self._colormap = Colormap( - name='gray', norm='linear', vmin=None, vmax=None) + name='gray', normalization='linear', vmin=None, vmax=None) self._selectedRange = None # Store iso-surfaces @@ -815,7 +785,7 @@ class ScalarFieldView(Plot3DWindow): self._bbox.children = [self._group] self.getPlot3DWidget().viewport.scene.children.append(self._bbox) - self._initInteractionToolBar() + self._initPanPlaneAction() self._updateColors() @@ -958,81 +928,71 @@ class ScalarFieldView(Plot3DWindow): raise ValueError('Unknown entry tag {0}.' ''.format(itemId)) - def _initInteractionToolBar(self): - self._interactionToolbar = qt.QToolBar() - self._interactionToolbar.setEnabled(False) - - group = qt.QActionGroup(self._interactionToolbar) - group.setExclusive(True) - - self._cameraAction = qt.QAction(None) - self._cameraAction.setText('camera') - self._cameraAction.setCheckable(True) - self._cameraAction.setToolTip('Control camera') - self._cameraAction.setChecked(True) - group.addAction(self._cameraAction) - - self._planeAction = qt.QAction(None) - self._planeAction.setText('plane') - self._planeAction.setCheckable(True) - self._planeAction.setToolTip('Control cutting plane') - group.addAction(self._planeAction) - group.triggered.connect(self._interactionChanged) - - self._interactionToolbar.addActions(group.actions()) - self.addToolBar(self._interactionToolbar) + def _initPanPlaneAction(self): + """Creates and init the pan plane action""" + self._panPlaneAction = qt.QAction(self) + self._panPlaneAction.setIcon(icons.getQIcon('3d-plane-pan')) + self._panPlaneAction.setText('plane') + self._panPlaneAction.setCheckable(True) + self._panPlaneAction.setToolTip('pan the cutting plane') + self._panPlaneAction.setEnabled(False) + + self._panPlaneAction.triggered[bool].connect(self._planeActionTriggered) + self.getPlot3DWidget().sigInteractiveModeChanged.connect( + self._interactiveModeChanged) + + toolbar = self.findChild(InteractiveModeToolBar) + if toolbar is not None: + toolbar.addAction(self._panPlaneAction) + + def _planeActionTriggered(self, checked=False): + self._panPlaneAction.setChecked(True) + self.setInteractiveMode('plane') + + def _interactiveModeChanged(self): + self._panPlaneAction.setChecked(self.getInteractiveMode() == 'plane') + self._updateColors() def _planeVisibilityChanged(self, visible): """Handle visibility events from the plane""" - if visible != self._interactionToolbar.isEnabled(): + if visible != self._panPlaneAction.isEnabled(): + self._panPlaneAction.setEnabled(visible) if visible: - self._interactionToolbar.setEnabled(True) self.setInteractiveMode('plane') - else: - self._interactionToolbar.setEnabled(False) - self.setInteractiveMode('camera') - - def _interactionChanged(self, action): - self.setInteractiveMode(action.text()) + elif self._panPlaneAction.isChecked(): + self.setInteractiveMode('rotate') def setInteractiveMode(self, mode): """Choose the current interaction. - :param str mode: Either plane or camera + :param str mode: Either rotate, pan or plane """ if mode == self.getInteractiveMode(): return sceneScale = self.getPlot3DWidget().viewport.scene.transforms[0] if mode == 'plane': + self.getPlot3DWidget().setInteractiveMode(None) + self.getPlot3DWidget().eventHandler = \ - interaction.PanPlaneRotateCameraControl( + interaction.PanPlaneZoomOnWheelControl( self.getPlot3DWidget().viewport, self._cutPlane._get3DPrimitive(), mode='position', scaleTransform=sceneScale) - self._planeAction.setChecked(True) - elif mode == 'camera': - self.getPlot3DWidget().eventHandler = interaction.CameraControl( - self.getPlot3DWidget().viewport, orbitAroundCenter=False, - mode='position', scaleTransform=sceneScale, - selectCB=None) - self._cameraAction.setChecked(True) else: - raise ValueError('Unsupported interactive mode %s', str(mode)) + self.getPlot3DWidget().setInteractiveMode(mode) self._updateColors() def getInteractiveMode(self): """Returns the current interaction mode, see :meth:`setInteractiveMode` """ - if isinstance(self.getPlot3DWidget().eventHandler, - interaction.PanPlaneRotateCameraControl): + if (isinstance(self.getPlot3DWidget().eventHandler, + interaction.PanPlaneZoomOnWheelControl) or + self.getPlot3DWidget().eventHandler is None): return 'plane' - elif isinstance(self.getPlot3DWidget().eventHandler, - interaction.CameraControl): - return 'camera' else: - raise RuntimeError('Unknown interactive mode') + return self.getPlot3DWidget().getInteractiveMode() # Handle scalar field @@ -1063,7 +1023,18 @@ class ScalarFieldView(Plot3DWindow): previousSelectedRegion = self.getSelectedRegion() self._data = data - self._dataRange = self._data.min(), self._data.max() + + # Store data range info + dataRange = min_max(self._data, min_positive=True, finite=True) + if dataRange.minimum is None: # Only non-finite data + dataRange = None + + if dataRange is not None: + min_positive = dataRange.min_positive + if min_positive is None: + min_positive = float('nan') + dataRange = dataRange.minimum, min_positive, dataRange.maximum + self._dataRange = dataRange if previousSelectedRegion is not None: # Update selected region to ensure it is clipped to array range @@ -1094,7 +1065,12 @@ class ScalarFieldView(Plot3DWindow): return numpy.array(self._data, copy=copy) def getDataRange(self): - """Return the range of the data as a 2-tuple (min, max)""" + """Return the range of the data as a 3-tuple of values. + + positive min is NaN if no data is positive. + + :return: (min, positive min, max) or None. + """ return self._dataRange # Transformations @@ -1135,6 +1111,20 @@ class ScalarFieldView(Plot3DWindow): # Axes labels + def isBoundingBoxVisible(self): + """Returns axes labels, grid and bounding box visibility. + + :rtype: bool + """ + return self._bbox.boxVisible + + def setBoundingBoxVisible(self, visible): + """Set axes labels, grid and bounding box visibility. + + :param bool visible: True to show axes, False to hide + """ + self._bbox.boxVisible = bool(visible) + def setAxesLabels(self, xlabel=None, ylabel=None, zlabel=None): """Set the text labels of the axes. diff --git a/silx/gui/plot3d/ViewpointToolBar.py b/silx/gui/plot3d/ViewpointToolBar.py deleted file mode 100644 index d062c1b..0000000 --- a/silx/gui/plot3d/ViewpointToolBar.py +++ /dev/null @@ -1,114 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-2017 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 toolbar to control Plot3DWidget viewpoint.""" - -from __future__ import absolute_import - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "15/09/2016" - - -from silx.gui import qt -from silx.gui.icons import getQIcon - - -class ViewpointActionGroup(qt.QActionGroup): - """ActionGroup of actions to reset the viewpoint. - - As for QActionGroup, add group's actions to the widget with: - `widget.addActions(actionGroup.actions())` - - :param Plot3DWidget plot3D: The widget for which to control the viewpoint - :param parent: See :class:`QActionGroup` - """ - - # Action information: icon name, text, tooltip - _RESET_CAMERA_ACTIONS = ( - ('cube-front', 'Front', 'View along the -Z axis'), - ('cube-back', 'Back', 'View along the +Z axis'), - ('cube-top', 'Top', 'View along the -Y'), - ('cube-bottom', 'Bottom', 'View along the +Y'), - ('cube-right', 'Right', 'View along the -X'), - ('cube-left', 'Left', 'View along the +X'), - ('cube', 'Side', 'Side view') - ) - - def __init__(self, plot3D, parent=None): - super(ViewpointActionGroup, self).__init__(parent) - self.setExclusive(False) - - self._plot3D = plot3D - - for actionInfo in self._RESET_CAMERA_ACTIONS: - iconname, text, tooltip = actionInfo - - action = qt.QAction(getQIcon(iconname), text, None) - action.setCheckable(False) - action.setToolTip(tooltip) - self.addAction(action) - - self.triggered[qt.QAction].connect(self._actionGroupTriggered) - - def _actionGroupTriggered(self, action): - actionname = action.text().lower() - - self._plot3D.viewport.camera.extrinsic.reset(face=actionname) - self._plot3D.centerScene() - - -class ViewpointToolBar(qt.QToolBar): - """A toolbar providing icons to reset the viewpoint. - - :param parent: See :class:`QToolBar` - :param Plot3DWidget plot3D: The widget to control - :param str title: Title of the toolbar - """ - - def __init__(self, parent=None, plot3D=None, title='Viewpoint control'): - super(ViewpointToolBar, self).__init__(title, parent) - - self._actionGroup = ViewpointActionGroup(plot3D) - assert plot3D is not None - self._plot3D = plot3D - self.addActions(self._actionGroup.actions()) - - # Choosing projection disabled for now - # Add projection combo box - # comboBoxProjection = qt.QComboBox() - # comboBoxProjection.addItem('Perspective') - # comboBoxProjection.addItem('Parallel') - # comboBoxProjection.setToolTip( - # 'Choose the projection:' - # ' perspective or parallel (i.e., orthographic)') - # comboBoxProjection.currentIndexChanged[(str)].connect( - # self._comboBoxProjectionCurrentIndexChanged) - # self.addWidget(qt.QLabel('Projection:')) - # self.addWidget(comboBoxProjection) - - # def _comboBoxProjectionCurrentIndexChanged(self, text): - # """Projection combo box listener""" - # self._plot3D.setProjection( - # 'perspective' if text == 'Perspective' else 'orthographic') diff --git a/silx/gui/plot3d/__init__.py b/silx/gui/plot3d/__init__.py index ad45424..af74613 100644 --- a/silx/gui/plot3d/__init__.py +++ b/silx/gui/plot3d/__init__.py @@ -25,7 +25,7 @@ """ This package provides widgets displaying 3D content based on OpenGL. -It depends on PyOpenGL and QtOpenGL. +It depends on PyOpenGL and PyQtx.QtOpenGL or PyQt>=5.4. """ from __future__ import absolute_import @@ -34,11 +34,6 @@ __license__ = "MIT" __date__ = "18/01/2017" -from .. import qt as _qt - -if not _qt.HAS_OPENGL: - raise ImportError('Qt.QtOpenGL is not available') - try: import OpenGL as _OpenGL except ImportError: diff --git a/silx/gui/plot3d/actions/Plot3DAction.py b/silx/gui/plot3d/actions/Plot3DAction.py new file mode 100644 index 0000000..a1faaea --- /dev/null +++ b/silx/gui/plot3d/actions/Plot3DAction.py @@ -0,0 +1,69 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ +"""Base class for QAction attached to a Plot3DWidget.""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + + +import logging +import weakref + +from silx.gui import qt + + +_logger = logging.getLogger(__name__) + + +class Plot3DAction(qt.QAction): + """QAction associated to a Plot3DWidget + + :param parent: See :class:`QAction` + :param Plot3DWidget plot3d: Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(Plot3DAction, self).__init__(parent) + self._plot3d = None + self.setPlot3DWidget(plot3d) + + def setPlot3DWidget(self, widget): + """Set the Plot3DWidget this action is associated with + + :param Plot3DWidget widget: The Plot3DWidget to use + """ + self._plot3d = None if widget is None else weakref.ref(widget) + + def getPlot3DWidget(self): + """Return the Plot3DWidget associated to this action. + + If no widget is associated, it returns None. + + :rtype: qt.QWidget + """ + return None if self._plot3d is None else self._plot3d() diff --git a/silx/gui/plot3d/actions/__init__.py b/silx/gui/plot3d/actions/__init__.py new file mode 100644 index 0000000..ebc57d2 --- /dev/null +++ b/silx/gui/plot3d/actions/__init__.py @@ -0,0 +1,33 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 QAction that can be attached to a plot3DWidget.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + +from .Plot3DAction import Plot3DAction +from . import io +from . import mode diff --git a/silx/gui/plot3d/actions/io.py b/silx/gui/plot3d/actions/io.py new file mode 100644 index 0000000..18f91b4 --- /dev/null +++ b/silx/gui/plot3d/actions/io.py @@ -0,0 +1,336 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 Plot3DAction related to input/output. + +It provides QAction to copy, save (snapshot and video), print a Plot3DWidget. +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + + +import logging +import os + +import numpy + +from silx.gui import qt +from silx.gui.plot.actions.io import PrintAction as _PrintAction +from silx.gui.icons import getQIcon +from .Plot3DAction import Plot3DAction +from ..utils import mng +from ..._utils import convertQImageToArray + + +_logger = logging.getLogger(__name__) + + +class CopyAction(Plot3DAction): + """QAction to provide copy of a Plot3DWidget + + :param parent: See :class:`QAction` + :param Plot3DWidget plot3d: Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(CopyAction, self).__init__(parent, plot3d) + + self.setIcon(getQIcon('edit-copy')) + self.setText('Copy') + self.setToolTip('Copy a snapshot of the 3D scene to the clipboard') + self.setCheckable(False) + self.setShortcut(qt.QKeySequence.Copy) + self.setShortcutContext(qt.Qt.WidgetShortcut) + self.triggered[bool].connect(self._triggered) + + def _triggered(self, checked=False): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error('Cannot copy widget, no associated Plot3DWidget') + else: + image = plot3d.grabGL() + qt.QApplication.clipboard().setImage(image) + + +class SaveAction(Plot3DAction): + """QAction to provide save snapshot of a Plot3DWidget + + :param parent: See :class:`QAction` + :param Plot3DWidget plot3d: Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(SaveAction, self).__init__(parent, plot3d) + + self.setIcon(getQIcon('document-save')) + self.setText('Save...') + self.setToolTip('Save a snapshot of the 3D scene') + self.setCheckable(False) + self.setShortcut(qt.QKeySequence.Save) + self.setShortcutContext(qt.Qt.WidgetShortcut) + self.triggered[bool].connect(self._triggered) + + def _triggered(self, checked=False): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error('Cannot save widget, no associated Plot3DWidget') + else: + dialog = qt.QFileDialog(self.parent()) + dialog.setWindowTitle('Save snapshot as') + dialog.setModal(True) + dialog.setNameFilters(('Plot3D Snapshot PNG (*.png)', + 'Plot3D Snapshot JPEG (*.jpg)')) + + dialog.setFileMode(qt.QFileDialog.AnyFile) + dialog.setAcceptMode(qt.QFileDialog.AcceptSave) + + if not dialog.exec_(): + return + + nameFilter = dialog.selectedNameFilter() + filename = dialog.selectedFiles()[0] + dialog.close() + + # Forces the filename extension to match the chosen filter + extension = nameFilter.split()[-1][2:-1] + if (len(filename) <= len(extension) or + filename[-len(extension):].lower() != extension.lower()): + filename += extension + + image = plot3d.grabGL() + if not image.save(filename): + _logger.error('Failed to save image as %s', filename) + qt.QMessageBox.critical( + self.parent(), + 'Save snapshot as', + 'Failed to save snapshot') + + +class PrintAction(Plot3DAction): + """QAction to provide printing of a Plot3DWidget + + :param parent: See :class:`QAction` + :param Plot3DWidget plot3d: Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(PrintAction, self).__init__(parent, plot3d) + + self.setIcon(getQIcon('document-print')) + self.setText('Print...') + self.setToolTip('Print a snapshot of the 3D scene') + self.setCheckable(False) + self.setShortcut(qt.QKeySequence.Print) + self.setShortcutContext(qt.Qt.WidgetShortcut) + self.triggered[bool].connect(self._triggered) + + def getPrinter(self): + """Return the QPrinter instance used for printing. + + :rtype: qt.QPrinter + """ + # TODO This is a hack to sync with silx plot PrintAction + # This needs to be centralized + if _PrintAction._printer is None: + _PrintAction._printer = qt.QPrinter() + return _PrintAction._printer + + def _triggered(self, checked=False): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error('Cannot print widget, no associated Plot3DWidget') + else: + printer = self.getPrinter() + dialog = qt.QPrintDialog(printer, plot3d) + dialog.setWindowTitle('Print Plot3D snapshot') + if not dialog.exec_(): + return + + image = plot3d.grabGL() + + # Draw pixmap with painter + painter = qt.QPainter() + if not painter.begin(printer): + return + + if (printer.pageRect().width() < image.width() or + printer.pageRect().height() < image.height()): + # Downscale to page + xScale = printer.pageRect().width() / image.width() + yScale = printer.pageRect().height() / image.height() + scale = min(xScale, yScale) + else: + scale = 1. + + rect = qt.QRectF(0, + 0, + scale * image.width(), + scale * image.height()) + painter.drawImage(rect, image) + painter.end() + + +class VideoAction(Plot3DAction): + """This action triggers the recording of a video of the scene. + + The scene is rotated 360 degrees around a vertical axis. + + :param parent: Action parent see :class:`QAction`. + """ + + PNG_SERIE_FILTER = 'Serie of PNG files (*.png)' + MNG_FILTER = 'Multiple-image Network Graphics file (*.mng)' + + def __init__(self, parent, plot3d=None): + super(VideoAction, self).__init__(parent, plot3d) + self.setText('Record video..') + self.setIcon(getQIcon('camera')) + self.setToolTip( + 'Record a video of a 360 degrees rotation of the 3D scene.') + self.setCheckable(False) + self.triggered[bool].connect(self._triggered) + + def _triggered(self, checked=False): + """Action triggered callback""" + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.warning( + 'Ignoring action triggered without Plot3DWidget set') + return + + dialog = qt.QFileDialog(parent=plot3d) + dialog.setWindowTitle('Save video as...') + dialog.setModal(True) + dialog.setNameFilters([self.PNG_SERIE_FILTER, + self.MNG_FILTER]) + dialog.setFileMode(dialog.AnyFile) + dialog.setAcceptMode(dialog.AcceptSave) + + if not dialog.exec_(): + return + + nameFilter = dialog.selectedNameFilter() + filename = dialog.selectedFiles()[0] + + # Forces the filename extension to match the chosen filter + extension = nameFilter.split()[-1][2:-1] + if (len(filename) <= len(extension) or + filename[-len(extension):].lower() != extension.lower()): + filename += extension + + nbFrames = int(4. * 25) # 4 seconds, 25 fps + + if nameFilter == self.PNG_SERIE_FILTER: + self._saveAsPNGSerie(filename, nbFrames) + elif nameFilter == self.MNG_FILTER: + self._saveAsMNG(filename, nbFrames) + else: + _logger.error('Unsupported file filter: %s', nameFilter) + + def _saveAsPNGSerie(self, filename, nbFrames): + """Save video as serie of PNG files. + + It adds a counter to the provided filename before the extension. + + :param str filename: filename to use as template + :param int nbFrames: Number of frames to generate + """ + plot3d = self.getPlot3DWidget() + assert plot3d is not None + + # Define filename template + nbDigits = int(numpy.log10(nbFrames)) + 1 + indexFormat = '%%0%dd' % nbDigits + extensionIndex = filename.rfind('.') + filenameFormat = \ + filename[:extensionIndex] + indexFormat + filename[extensionIndex:] + + try: + for index, image in enumerate(self._video360(nbFrames)): + image.save(filenameFormat % index) + except GeneratorExit: + pass + + def _saveAsMNG(self, filename, nbFrames): + """Save video as MNG file. + + :param str filename: filename to use + :param int nbFrames: Number of frames to generate + """ + plot3d = self.getPlot3DWidget() + assert plot3d is not None + + frames = (convertQImageToArray(im) for im in self._video360(nbFrames)) + try: + with open(filename, 'wb') as file_: + for chunk in mng.convert(frames, nb_images=nbFrames): + file_.write(chunk) + except GeneratorExit: + os.remove(filename) # Saving aborted, delete file + + def _video360(self, nbFrames): + """Run the video and provides the images + + :param int nbFrames: The number of frames to generate for + :return: Iterator of QImage of the video sequence + """ + plot3d = self.getPlot3DWidget() + assert plot3d is not None + + angleStep = 360. / nbFrames + + # Create progress bar dialog + dialog = qt.QDialog(plot3d) + dialog.setWindowTitle('Record Video') + layout = qt.QVBoxLayout(dialog) + progress = qt.QProgressBar() + progress.setRange(0, nbFrames) + layout.addWidget(progress) + + btnBox = qt.QDialogButtonBox(qt.QDialogButtonBox.Abort) + btnBox.rejected.connect(dialog.reject) + layout.addWidget(btnBox) + + dialog.setModal(True) + dialog.show() + + qapp = qt.QApplication.instance() + + for frame in range(nbFrames): + progress.setValue(frame) + image = plot3d.grabGL() + yield image + plot3d.viewport.orbitCamera('left', angleStep) + qapp.processEvents() + if not dialog.isVisible(): + break # It as been rejected by the abort button + else: + dialog.accept() + + if dialog.result() == qt.QDialog.Rejected: + raise GeneratorExit('Aborted') diff --git a/silx/gui/plot3d/actions/mode.py b/silx/gui/plot3d/actions/mode.py new file mode 100644 index 0000000..a06b9a8 --- /dev/null +++ b/silx/gui/plot3d/actions/mode.py @@ -0,0 +1,126 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 Plot3DAction related to interaction modes. + +It provides QAction to rotate or pan a Plot3DWidget. +""" + +from __future__ import absolute_import, division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + + +import logging + +from silx.gui.icons import getQIcon +from .Plot3DAction import Plot3DAction + + +_logger = logging.getLogger(__name__) + + +class InteractiveModeAction(Plot3DAction): + """Base class for QAction changing interactive mode of a Plot3DWidget + + :param parent: See :class:`QAction` + :param str interaction: The interactive mode this action controls + :param Plot3DWidget plot3d: Plot3DWidget the action is associated with + """ + + def __init__(self, parent, interaction, plot3d=None): + self._interaction = interaction + + super(InteractiveModeAction, self).__init__(parent, plot3d) + self.setCheckable(True) + self.triggered[bool].connect(self._triggered) + + def _triggered(self, checked=False): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error( + 'Cannot set %s interaction, no associated Plot3DWidget' % + self._interaction) + else: + plot3d.setInteractiveMode(self._interaction) + self.setChecked(True) + + def setPlot3DWidget(self, widget): + # Disconnect from previous Plot3DWidget + plot3d = self.getPlot3DWidget() + if plot3d is not None: + plot3d.sigInteractiveModeChanged.disconnect( + self._interactiveModeChanged) + + super(InteractiveModeAction, self).setPlot3DWidget(widget) + + # Connect to new Plot3DWidget + if widget is None: + self.setChecked(False) + else: + self.setChecked(widget.getInteractiveMode() == self._interaction) + widget.sigInteractiveModeChanged.connect( + self._interactiveModeChanged) + + # Reuse docstring from super class + setPlot3DWidget.__doc__ = Plot3DAction.setPlot3DWidget.__doc__ + + def _interactiveModeChanged(self): + plot3d = self.getPlot3DWidget() + if plot3d is None: + _logger.error('Received a signal while there is no widget') + else: + self.setChecked(plot3d.getInteractiveMode() == self._interaction) + + +class RotateArcballAction(InteractiveModeAction): + """QAction to set arcball rotation interaction on a Plot3DWidget + + :param parent: See :class:`QAction` + :param Plot3DWidget plot3d: Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(RotateArcballAction, self).__init__(parent, 'rotate', plot3d) + + self.setIcon(getQIcon('rotate-3d')) + self.setText('Rotate') + self.setToolTip('Rotate the view') + + +class PanAction(InteractiveModeAction): + """QAction to set pan interaction on a Plot3DWidget + + :param parent: See :class:`QAction` + :param Plot3DWidget plot3d: Plot3DWidget the action is associated with + """ + + def __init__(self, parent, plot3d=None): + super(PanAction, self).__init__(parent, 'pan', plot3d) + + self.setIcon(getQIcon('pan')) + self.setText('Pan') + self.setToolTip('Pan the view') diff --git a/silx/gui/plot3d/scene/axes.py b/silx/gui/plot3d/scene/axes.py index 528e4f7..520ef3e 100644 --- a/silx/gui/plot3d/scene/axes.py +++ b/silx/gui/plot3d/scene/axes.py @@ -52,6 +52,8 @@ class LabelledAxes(primitives.GroupBBox): self._font = text.Font() + self._boxVisibility = True + # TODO offset labels from anchor in pixels self._xlabel = text.Text2D(font=self._font) @@ -145,6 +147,21 @@ class LabelledAxes(primitives.GroupBBox): def zlabel(self, text): self._zlabel.text = text + @property + def boxVisible(self): + """Returns bounding box, axes labels and grid visibility.""" + return self._boxVisibility + + @boxVisible.setter + def boxVisible(self, visible): + self._boxVisibility = bool(visible) + for child in self._children: + if child == self._tickLines: + if self._ticksForBounds is not None: + child.visible = self._boxVisibility + elif child != self._group: + child.visible = self._boxVisibility + def _updateTicks(self): """Check if ticks need update and update them if needed.""" bounds = self._group.bounds(transformed=False, dataBounds=True) @@ -187,7 +204,7 @@ class LabelledAxes(primitives.GroupBBox): zcoords[:, 3, 1] += ticklength[1] # Z ticks on YZ plane self._tickLines.setPositions(coords.reshape(-1, 3)) - self._tickLines.visible = True + self._tickLines.visible = self._boxVisibility # Update labels color = self.tickColor diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py index 80ac820..73cdb72 100644 --- a/silx/gui/plot3d/scene/function.py +++ b/silx/gui/plot3d/scene/function.py @@ -35,6 +35,7 @@ import contextlib import logging import numpy +from ... import _glutils from ..._glutils import gl from . import event @@ -296,18 +297,10 @@ class DirectionalLight(event.Notifier, ProgramFunction): class Colormap(event.Notifier, ProgramFunction): # TODO use colors for out-of-bound values, for <=0 with log, for nan - # TODO texture-based colormap decl = """ - #define CMAP_GRAY 0 - #define CMAP_R_GRAY 1 - #define CMAP_RED 2 - #define CMAP_GREEN 3 - #define CMAP_BLUE 4 - #define CMAP_TEMP 5 - uniform struct { - int id; + sampler2D texture; bool isLog; float min; float oneOverRange; @@ -328,60 +321,24 @@ class Colormap(event.Notifier, ProgramFunction): value = clamp(cmap.oneOverRange * (value - cmap.min), 0.0, 1.0); } - if (cmap.id == CMAP_GRAY) { - return vec4(value, value, value, 1.0); - } - else if (cmap.id == CMAP_R_GRAY) { - float invValue = 1.0 - value; - return vec4(invValue, invValue, invValue, 1.0); - } - else if (cmap.id == CMAP_RED) { - return vec4(value, 0.0, 0.0, 1.0); - } - else if (cmap.id == CMAP_GREEN) { - return vec4(0.0, value, 0.0, 1.0); - } - else if (cmap.id == CMAP_BLUE) { - return vec4(0.0, 0.0, value, 1.0); - } - else if (cmap.id == CMAP_TEMP) { - //red: 0.5->0.75: 0->1 - //green: 0.->0.25: 0->1; 0.75->1.: 1->0 - //blue: 0.25->0.5: 1->0 - return vec4( - clamp(4.0 * value - 2.0, 0.0, 1.0), - 1.0 - clamp(4.0 * abs(value - 0.5) - 1.0, 0.0, 1.0), - 1.0 - clamp(4.0 * value - 1.0, 0.0, 1.0), - 1.0); - } - else { - /* Unknown colormap */ - return vec4(0.0, 0.0, 0.0, 1.0); - } + vec4 color = texture2D(cmap.texture, vec2(value, 0.5)); + return color; } """ call = "colormap" - _COLORMAPS = { - 'gray': 0, - 'reversed gray': 1, - 'red': 2, - 'green': 3, - 'blue': 4, - 'temperature': 5 - } - - COLORMAPS = tuple(_COLORMAPS.keys()) - """Tuple of supported colormap names.""" - NORMS = 'linear', 'log' """Tuple of supported normalizations.""" - def __init__(self, name='gray', norm='linear', range_=(1., 10.)): + _COLORMAP_TEXTURE_UNIT = 1 + """Texture unit to use for storing the colormap""" + + def __init__(self, colormap=None, norm='linear', range_=(1., 10.)): """Shader function to apply a colormap to a value. - :param str name: Name of the colormap. + :param colormap: RGB(A) color look-up table (default: gray) + :param colormap: numpy.ndarray of numpy.uint8 of dimension Nx3 or Nx4 :param str norm: Normalization to apply: 'linear' (default) or 'log'. :param range_: Range of value to map to the colormap. :type range_: 2-tuple of float (begin, end). @@ -389,24 +346,35 @@ class Colormap(event.Notifier, ProgramFunction): super(Colormap, self).__init__() # Init privates to default - self._name, self._norm, self._range = 'gray', 'linear', (1., 10.) + self._colormap, self._norm, self._range = None, 'linear', (1., 10.) + + self._texture = None + self._update_texture = True + + if colormap is None: + # default colormap + colormap = numpy.empty((256, 3), dtype=numpy.uint8) + colormap[:] = numpy.arange(256, + dtype=numpy.uint8)[:, numpy.newaxis] # Set to param values through properties to go through asserts - self.name = name + self.colormap = colormap self.norm = norm self.range_ = range_ @property - def name(self): - """Name of the colormap in use.""" - return self._name - - @name.setter - def name(self, name): - if name != self._name: - assert name in self.COLORMAPS - self._name = name - self.notify() + def colormap(self): + """Color look-up table to use.""" + return numpy.array(self._colormap, copy=True) + + @colormap.setter + def colormap(self, colormap): + colormap = numpy.array(colormap, copy=True) + assert colormap.ndim == 2 + assert colormap.shape[1] in (3, 4) + self._colormap = colormap + self._update_texture = True + self.notify() @property def norm(self): @@ -459,7 +427,15 @@ class Colormap(event.Notifier, ProgramFunction): :param GLProgram program: The program to set-up. It MUST be in use and using this function. """ - gl.glUniform1i(program.uniforms['cmap.id'], self._COLORMAPS[self.name]) + self.prepareGL2(context) # TODO see how to handle + + if self._texture is None: # No colormap + return + + self._texture.bind() + + gl.glUniform1i(program.uniforms['cmap.texture'], + self._texture.texUnit) gl.glUniform1i(program.uniforms['cmap.isLog'], self._norm == 'log') min_, max_ = self.range_ @@ -469,3 +445,23 @@ class Colormap(event.Notifier, ProgramFunction): gl.glUniform1f(program.uniforms['cmap.min'], min_) gl.glUniform1f(program.uniforms['cmap.oneOverRange'], (1. / (max_ - min_)) if max_ != min_ else 0.) + + def prepareGL2(self, context): + if self._texture is None or self._update_texture: + if self._texture is not None: + self._texture.discard() + + colormap = numpy.empty( + (16, self._colormap.shape[0], self._colormap.shape[1]), + dtype=self._colormap.dtype) + colormap[:] = self._colormap + + format_ = gl.GL_RGBA if colormap.shape[-1] == 4 else gl.GL_RGB + + self._texture = _glutils.Texture( + format_, colormap, format_, + texUnit=self._COLORMAP_TEXTURE_UNIT, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=gl.GL_CLAMP_TO_EDGE) + self._update_texture = False diff --git a/silx/gui/plot3d/scene/interaction.py b/silx/gui/plot3d/scene/interaction.py index 68bfc13..2911b2c 100644 --- a/silx/gui/plot3d/scene/interaction.py +++ b/silx/gui/plot3d/scene/interaction.py @@ -440,6 +440,26 @@ class FocusManager(StateMachine): # CameraControl ############################################################### +class RotateCameraControl(FocusManager): + """Combine wheel and rotate state machine.""" + def __init__(self, viewport, + orbitAroundCenter=False, + mode='center', scaleTransform=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + CameraRotate(viewport, orbitAroundCenter, LEFT_BTN)) + super(RotateCameraControl, self).__init__(handlers) + + +class PanCameraControl(FocusManager): + """Combine wheel, selectPan and rotate state machine.""" + def __init__(self, viewport, + mode='center', scaleTransform=None, + selectCB=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + CameraSelectPan(viewport, LEFT_BTN, selectCB)) + super(PanCameraControl, self).__init__(handlers) + + class CameraControl(FocusManager): """Combine wheel, selectPan and rotate state machine.""" def __init__(self, viewport, @@ -650,3 +670,12 @@ class PanPlaneRotateCameraControl(FocusManager): orbitAroundCenter=False, button=RIGHT_BTN)) super(PanPlaneRotateCameraControl, self).__init__(handlers) + + +class PanPlaneZoomOnWheelControl(FocusManager): + """Combine zoom on wheel and pan plane state machines.""" + def __init__(self, viewport, plane, + mode='center', scaleTransform=None): + handlers = (CameraWheel(viewport, mode, scaleTransform), + PlanePan(viewport, plane, LEFT_BTN)) + super(PanPlaneZoomOnWheelControl, self).__init__(handlers) diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py index ca2616a..fc38e09 100644 --- a/silx/gui/plot3d/scene/primitives.py +++ b/silx/gui/plot3d/scene/primitives.py @@ -292,8 +292,11 @@ class Geometry(core.Elem): self.__bounds = numpy.zeros((2, 3), dtype=numpy.float32) # Support vertex with to 2 to 4 coordinates positions = self._attributes['position'] - self.__bounds[0, :positions.shape[1]] = positions.min(axis=0)[:3] - self.__bounds[1, :positions.shape[1]] = positions.max(axis=0)[:3] + self.__bounds[0, :positions.shape[1]] = \ + numpy.nanmin(positions, axis=0)[:3] + self.__bounds[1, :positions.shape[1]] = \ + numpy.nanmax(positions, axis=0)[:3] + self.__bounds[numpy.isnan(self.__bounds)] = 0. # Avoid NaNs return self.__bounds.copy() def prepareGL2(self, ctx): diff --git a/silx/gui/plot3d/scene/viewport.py b/silx/gui/plot3d/scene/viewport.py index 83cda43..72e1ea3 100644 --- a/silx/gui/plot3d/scene/viewport.py +++ b/silx/gui/plot3d/scene/viewport.py @@ -314,6 +314,9 @@ class Viewport(event.Notifier): (e.g., if spanning behind the viewpoint with perspective projection). """ bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) bounds = self.camera.extrinsic.transformBounds(bounds) if isinstance(self.camera.intrinsic, transform.Perspective): @@ -337,7 +340,11 @@ class Viewport(event.Notifier): It updates the camera position and depth extent. Camera sight direction and up are not affected. """ - self.camera.resetCamera(self.scene.bounds(transformed=True)) + bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) + self.camera.resetCamera(bounds) def orbitCamera(self, direction, angle=1.): """Rotate the camera around center of the scene. @@ -347,6 +354,9 @@ class Viewport(event.Notifier): :param float angle: he angle in degrees of the rotation. """ bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) center = 0.5 * (bounds[0] + bounds[1]) self.camera.orbit(direction, center, angle) @@ -359,6 +369,9 @@ class Viewport(event.Notifier): :param float step: The ratio of data to step for each pan. """ bounds = self.scene.bounds(transformed=True) + if bounds is None: + bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)), + dtype=numpy.float32) bounds = self.camera.extrinsic.transformBounds(bounds) center = 0.5 * (bounds[0] + bounds[1]) ndcCenter = self.camera.intrinsic.transformPoint( diff --git a/silx/gui/plot3d/scene/window.py b/silx/gui/plot3d/scene/window.py index ad7e6e5..3c63c7a 100644 --- a/silx/gui/plot3d/scene/window.py +++ b/silx/gui/plot3d/scene/window.py @@ -244,6 +244,7 @@ class Window(event.Notifier): void main(void) { gl_FragColor = texture2D(texture, textureCoord); + gl_FragColor.a = 1.0; } """) @@ -304,12 +305,11 @@ class Window(event.Notifier): self._viewports.removeListener(self._updated) self._viewports = event.NotifierList(iterable) self._viewports.addListener(self._updated) - self._dirty = True + self._updated(self) def _updated(self, source, *args, **kwargs): - if source is not self: - self._dirty = True - self.notify(*args, **kwargs) + self._dirty = True + self.notify(*args, **kwargs) framebufferid = property(lambda self: self._framebufferid, doc="Framebuffer ID used to perform rendering") @@ -323,11 +323,12 @@ class Window(event.Notifier): height, width = self.shape image = numpy.empty((height, width, 3), dtype=numpy.uint8) + previousFramebuffer = gl.glGetInteger(gl.GL_FRAMEBUFFER_BINDING) gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.framebufferid) gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) gl.glReadPixels( 0, 0, width, height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE, image) - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, previousFramebuffer) # glReadPixels gives bottom to top, # while images are stored as top to bottom diff --git a/silx/gui/plot3d/setup.py b/silx/gui/plot3d/setup.py index b9d626f..bb6eaa5 100644 --- a/silx/gui/plot3d/setup.py +++ b/silx/gui/plot3d/setup.py @@ -32,7 +32,9 @@ from numpy.distutils.misc_util import Configuration def configuration(parent_package='', top_path=None): config = Configuration('plot3d', parent_package, top_path) + config.add_subpackage('actions') config.add_subpackage('scene') + config.add_subpackage('tools') config.add_subpackage('test') config.add_subpackage('utils') return config diff --git a/silx/gui/plot3d/test/__init__.py b/silx/gui/plot3d/test/__init__.py index 66a2f62..2e8c9f4 100644 --- a/silx/gui/plot3d/test/__init__.py +++ b/silx/gui/plot3d/test/__init__.py @@ -56,7 +56,11 @@ def suite(): # Import here to avoid loading modules if tests are disabled from ..scene import test as test_scene + from .testGL import suite as testGLSuite + from .testScalarFieldView import suite as testScalarFieldViewSuite test_suite = unittest.TestSuite() + test_suite.addTest(testGLSuite()) test_suite.addTest(test_scene.suite()) + test_suite.addTest(testScalarFieldViewSuite()) return test_suite diff --git a/silx/gui/plot3d/test/testGL.py b/silx/gui/plot3d/test/testGL.py new file mode 100644 index 0000000..70f197f --- /dev/null +++ b/silx/gui/plot3d/test/testGL.py @@ -0,0 +1,84 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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. +# ###########################################################################*/ +"""Test OpenGL""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "10/08/2017" + + +import logging +import unittest + +from silx.gui._glutils import gl, OpenGLWidget +from silx.gui.test.utils import TestCaseQt +from silx.gui import qt + + +_logger = logging.getLogger(__name__) + + +class TestOpenGL(TestCaseQt): + """Tests of OpenGL widget.""" + + class OpenGLWidgetLogger(OpenGLWidget): + """Widget logging information of available OpenGL version""" + + def __init__(self): + self._dump = False + super(TestOpenGL.OpenGLWidgetLogger, self).__init__(version=(1, 0)) + + def paintOpenGL(self): + """Perform the rendering and logging""" + if not self._dump: + self._dump = True + _logger.info('OpenGL info:') + _logger.info('\tQt OpenGL context version: %d.%d', *self.getOpenGLVersion()) + _logger.info('\tGL_VERSION: %s' % gl.glGetString(gl.GL_VERSION)) + _logger.info('\tGL_SHADING_LANGUAGE_VERSION: %s' % + gl.glGetString(gl.GL_SHADING_LANGUAGE_VERSION)) + _logger.debug('\tGL_EXTENSIONS: %s' % gl.glGetString(gl.GL_EXTENSIONS)) + + gl.glClearColor(1., 1., 1., 1.) + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + + def testOpenGL(self): + """Log OpenGL version using an OpenGLWidget""" + super(TestOpenGL, self).setUp() + widget = self.OpenGLWidgetLogger() + widget.show() + widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.qWaitForWindowExposed(widget) + widget.close() + + +def suite(): + test_suite = unittest.TestSuite() + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTests(TestOpenGL)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/plot3d/test/testScalarFieldView.py b/silx/gui/plot3d/test/testScalarFieldView.py new file mode 100644 index 0000000..5ad4051 --- /dev/null +++ b/silx/gui/plot3d/test/testScalarFieldView.py @@ -0,0 +1,114 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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. +# ###########################################################################*/ +"""Test ScalarFieldView widget""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "11/07/2017" + + +import logging +import unittest + +import numpy + +from silx.test.utils import ParametricTestCase +from silx.gui.test.utils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.ScalarFieldView import ScalarFieldView +from silx.gui.plot3d.SFViewParamTree import TreeView + + +_logger = logging.getLogger(__name__) + + +class TestScalarFieldView(TestCaseQt, ParametricTestCase): + """Tests of ScalarFieldView widget.""" + + def setUp(self): + super(TestScalarFieldView, self).setUp() + self.widget = ScalarFieldView() + self.widget.show() + + # Commented as it slows down the tests + # self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.qapp.processEvents() + self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.widget.close() + del self.widget + super(TestScalarFieldView, self).tearDown() + + @staticmethod + def _buildData(size): + """Make a 3D dataset""" + coords = numpy.linspace(-10, 10, size) + z = coords.reshape(-1, 1, 1) + y = coords.reshape(1, -1, 1) + x = coords.reshape(1, 1, -1) + return numpy.sin(x * y * z) / (x * y * z) + + def testSimple(self): + """Set the data and an isosurface""" + data = self._buildData(size=32) + + self.widget.setData(data) + self.widget.addIsosurface(0.5, (1., 0., 0., 0.5)) + self.widget.addIsosurface(0.7, qt.QColor('green')) + self.qapp.processEvents() + + def testNotFinite(self): + """Test with NaN and inf in data set""" + + # Some NaNs and inf + data = self._buildData(size=32) + data[8, :, :] = numpy.nan + data[16, :, :] = numpy.inf + data[24, :, :] = - numpy.inf + + self.widget.addIsosurface(0.5, 'red') + self.widget.setData(data, copy=True) + self.qapp.processEvents() + self.widget.setData(None) + + # All NaNs or inf + data = numpy.empty((4, 4, 4), dtype=numpy.float32) + for value in (numpy.nan, numpy.inf): + with self.subTest(value=str(value)): + data[:] = value + self.widget.setData(data, copy=True) + self.qapp.processEvents() + + +def suite(): + test_suite = unittest.TestSuite() + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTests(TestScalarFieldView)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/plot3d/tools/ViewpointTools.py b/silx/gui/plot3d/tools/ViewpointTools.py new file mode 100644 index 0000000..1346c1c --- /dev/null +++ b/silx/gui/plot3d/tools/ViewpointTools.py @@ -0,0 +1,135 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 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 toolbar to control Plot3DWidget viewpoint.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/09/2017" + + +from silx.gui import qt +from silx.gui.icons import getQIcon + + +class _ViewpointActionGroup(qt.QActionGroup): + """ActionGroup of actions to reset the viewpoint. + + As for QActionGroup, add group's actions to the widget with: + `widget.addActions(actionGroup.actions())` + + :param Plot3DWidget plot3D: The widget for which to control the viewpoint + :param parent: See :class:`QActionGroup` + """ + + # Action information: icon name, text, tooltip + _RESET_CAMERA_ACTIONS = ( + ('cube-front', 'Front', 'View along the -Z axis'), + ('cube-back', 'Back', 'View along the +Z axis'), + ('cube-top', 'Top', 'View along the -Y'), + ('cube-bottom', 'Bottom', 'View along the +Y'), + ('cube-right', 'Right', 'View along the -X'), + ('cube-left', 'Left', 'View along the +X'), + ('cube', 'Side', 'Side view') + ) + + def __init__(self, plot3D, parent=None): + super(_ViewpointActionGroup, self).__init__(parent) + self.setExclusive(False) + + self._plot3D = plot3D + + for actionInfo in self._RESET_CAMERA_ACTIONS: + iconname, text, tooltip = actionInfo + + action = qt.QAction(getQIcon(iconname), text, None) + action.setIconVisibleInMenu(True) + action.setCheckable(False) + action.setToolTip(tooltip) + self.addAction(action) + + self.triggered[qt.QAction].connect(self._actionGroupTriggered) + + def _actionGroupTriggered(self, action): + actionname = action.text().lower() + + self._plot3D.viewport.camera.extrinsic.reset(face=actionname) + self._plot3D.centerScene() + + +class ViewpointToolBar(qt.QToolBar): + """A toolbar providing icons to reset the viewpoint. + + :param parent: See :class:`QToolBar` + :param Plot3DWidget plot3D: The widget to control + :param str title: Title of the toolbar + """ + + def __init__(self, parent=None, plot3D=None, title='Viewpoint control'): + super(ViewpointToolBar, self).__init__(title, parent) + + self._actionGroup = _ViewpointActionGroup(plot3D) + assert plot3D is not None + self._plot3D = plot3D + self.addActions(self._actionGroup.actions()) + + # Choosing projection disabled for now + # Add projection combo box + # comboBoxProjection = qt.QComboBox() + # comboBoxProjection.addItem('Perspective') + # comboBoxProjection.addItem('Parallel') + # comboBoxProjection.setToolTip( + # 'Choose the projection:' + # ' perspective or parallel (i.e., orthographic)') + # comboBoxProjection.currentIndexChanged[(str)].connect( + # self._comboBoxProjectionCurrentIndexChanged) + # self.addWidget(qt.QLabel('Projection:')) + # self.addWidget(comboBoxProjection) + + # def _comboBoxProjectionCurrentIndexChanged(self, text): + # """Projection combo box listener""" + # self._plot3D.setProjection( + # 'perspective' if text == 'Perspective' else 'orthographic') + + +class ViewpointToolButton(qt.QToolButton): + """A toolbutton with a drop-down list of ways to reset the viewpoint. + + :param parent: See :class:`QToolButton` + :param Plot3DWiddget plot3D: The widget to control + """ + + def __init__(self, parent=None, plot3D=None): + super(ViewpointToolButton, self).__init__(parent) + + self._actionGroup = _ViewpointActionGroup(plot3D) + + menu = qt.QMenu(self) + menu.addActions(self._actionGroup.actions()) + self.setMenu(menu) + self.setPopupMode(qt.QToolButton.InstantPopup) + self.setIcon(getQIcon('cube')) + self.setToolTip('Reset the viewpoint to a defined position') diff --git a/silx/gui/plot3d/tools/__init__.py b/silx/gui/plot3d/tools/__init__.py new file mode 100644 index 0000000..e14f604 --- /dev/null +++ b/silx/gui/plot3d/tools/__init__.py @@ -0,0 +1,32 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017 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 tool widgets that can be attached to a plot3DWidget.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/09/2017" + +from .toolbars import InteractiveModeToolBar, OutputToolBar +from .ViewpointTools import ViewpointToolBar, ViewpointToolButton diff --git a/silx/gui/plot3d/tools/toolbars.py b/silx/gui/plot3d/tools/toolbars.py new file mode 100644 index 0000000..c8be226 --- /dev/null +++ b/silx/gui/plot3d/tools/toolbars.py @@ -0,0 +1,176 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2017 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 toolbars with tools for a Plot3DWidget. + +It provides the following toolbars: + +- :class:`InteractiveModeToolBar` with: + - Set interactive mode to rotation + - Set interactive mode to pan + +- :class:`OutputToolBar` with: + - Copy + - Save + - Video + - Print +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "06/09/2017" + +import logging + +from silx.gui import qt + +from .. import actions + +_logger = logging.getLogger(__name__) + + +class InteractiveModeToolBar(qt.QToolBar): + """Toolbar providing icons to change the interaction mode + + :param parent: See :class:`QWidget` + :param str title: Title of the toolbar. + """ + + def __init__(self, parent=None, title='Plot3D Interaction'): + super(InteractiveModeToolBar, self).__init__(title, parent) + + self._plot3d = None + + self._rotateAction = actions.mode.RotateArcballAction(parent=self) + self.addAction(self._rotateAction) + + self._panAction = actions.mode.PanAction(parent=self) + self.addAction(self._panAction) + + def setPlot3DWidget(self, widget): + """Set the Plot3DWidget this toolbar is associated with + + :param Plot3DWidget widget: The widget to copy/save/print + """ + self._plot3d = widget + self.getRotateAction().setPlot3DWidget(widget) + self.getPanAction().setPlot3DWidget(widget) + + def getPlot3DWidget(self): + """Return the Plot3DWidget associated to this toolbar. + + If no widget is associated, it returns None. + + :rtype: qt.QWidget + """ + return self._plot3d + + def getRotateAction(self): + """Returns the QAction setting rotate interaction of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._rotateAction + + def getPanAction(self): + """Returns the QAction setting pan interaction of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._panAction + + +class OutputToolBar(qt.QToolBar): + """Toolbar providing icons to copy, save and print the OpenGL scene + + :param parent: See :class:`QWidget` + :param str title: Title of the toolbar. + """ + + def __init__(self, parent=None, title='Plot3D Output'): + super(OutputToolBar, self).__init__(title, parent) + + self._plot3d = None + + self._copyAction = actions.io.CopyAction(parent=self) + self.addAction(self._copyAction) + + self._saveAction = actions.io.SaveAction(parent=self) + self.addAction(self._saveAction) + + self._videoAction = actions.io.VideoAction(parent=self) + self.addAction(self._videoAction) + + self._printAction = actions.io.PrintAction(parent=self) + self.addAction(self._printAction) + + def setPlot3DWidget(self, widget): + """Set the Plot3DWidget this toolbar is associated with + + :param Plot3DWidget widget: The widget to copy/save/print + """ + self._plot3d = widget + self.getCopyAction().setPlot3DWidget(widget) + self.getSaveAction().setPlot3DWidget(widget) + self.getVideoRecordAction().setPlot3DWidget(widget) + self.getPrintAction().setPlot3DWidget(widget) + + def getPlot3DWidget(self): + """Return the Plot3DWidget associated to this toolbar. + + If no widget is associated, it returns None. + + :rtype: qt.QWidget + """ + return self._plot3d + + def getCopyAction(self): + """Returns the QAction performing copy to clipboard of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._copyAction + + def getSaveAction(self): + """Returns the QAction performing save to file of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._saveAction + + def getVideoRecordAction(self): + """Returns the QAction performing record video of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._videoAction + + def getPrintAction(self): + """Returns the QAction performing printing of the Plot3DWidget + + :rtype: qt.QAction + """ + return self._printAction -- cgit v1.2.3