summaryrefslogtreecommitdiff
path: root/silx/gui/plot3d
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot3d')
-rw-r--r--silx/gui/plot3d/Plot3DWidget.py203
-rw-r--r--silx/gui/plot3d/Plot3DWindow.py20
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py142
-rw-r--r--silx/gui/plot3d/ScalarFieldView.py270
-rw-r--r--silx/gui/plot3d/__init__.py7
-rw-r--r--silx/gui/plot3d/actions/Plot3DAction.py69
-rw-r--r--silx/gui/plot3d/actions/__init__.py33
-rw-r--r--silx/gui/plot3d/actions/io.py (renamed from silx/gui/plot3d/Plot3DActions.py)44
-rw-r--r--silx/gui/plot3d/actions/mode.py126
-rw-r--r--silx/gui/plot3d/scene/axes.py19
-rw-r--r--silx/gui/plot3d/scene/function.py128
-rw-r--r--silx/gui/plot3d/scene/interaction.py29
-rw-r--r--silx/gui/plot3d/scene/primitives.py7
-rw-r--r--silx/gui/plot3d/scene/viewport.py15
-rw-r--r--silx/gui/plot3d/scene/window.py11
-rw-r--r--silx/gui/plot3d/setup.py2
-rw-r--r--silx/gui/plot3d/test/__init__.py4
-rw-r--r--silx/gui/plot3d/test/testGL.py84
-rw-r--r--silx/gui/plot3d/test/testScalarFieldView.py114
-rw-r--r--silx/gui/plot3d/tools/ViewpointTools.py (renamed from silx/gui/plot3d/ViewpointToolBar.py)29
-rw-r--r--silx/gui/plot3d/tools/__init__.py32
-rw-r--r--silx/gui/plot3d/tools/toolbars.py (renamed from silx/gui/plot3d/Plot3DToolBar.py)85
22 files changed, 1054 insertions, 419 deletions
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/__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/Plot3DActions.py b/silx/gui/plot3d/actions/io.py
index 2ae2750..18f91b4 100644
--- a/silx/gui/plot3d/Plot3DActions.py
+++ b/silx/gui/plot3d/actions/io.py
@@ -22,60 +22,34 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This module provides QAction that can be attached to a plot3DWidget."""
+"""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__ = "26/01/2017"
+__date__ = "06/09/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.plot.actions.io import PrintAction as _PrintAction
from silx.gui.icons import getQIcon
-from .utils import mng
-from .._utils import convertQImageToArray
+from .Plot3DAction import Plot3DAction
+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
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/ViewpointToolBar.py b/silx/gui/plot3d/tools/ViewpointTools.py
index d062c1b..1346c1c 100644
--- a/silx/gui/plot3d/ViewpointToolBar.py
+++ b/silx/gui/plot3d/tools/ViewpointTools.py
@@ -28,14 +28,14 @@ from __future__ import absolute_import
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "15/09/2016"
+__date__ = "08/09/2017"
from silx.gui import qt
from silx.gui.icons import getQIcon
-class ViewpointActionGroup(qt.QActionGroup):
+class _ViewpointActionGroup(qt.QActionGroup):
"""ActionGroup of actions to reset the viewpoint.
As for QActionGroup, add group's actions to the widget with:
@@ -57,7 +57,7 @@ class ViewpointActionGroup(qt.QActionGroup):
)
def __init__(self, plot3D, parent=None):
- super(ViewpointActionGroup, self).__init__(parent)
+ super(_ViewpointActionGroup, self).__init__(parent)
self.setExclusive(False)
self._plot3D = plot3D
@@ -66,6 +66,7 @@ class ViewpointActionGroup(qt.QActionGroup):
iconname, text, tooltip = actionInfo
action = qt.QAction(getQIcon(iconname), text, None)
+ action.setIconVisibleInMenu(True)
action.setCheckable(False)
action.setToolTip(tooltip)
self.addAction(action)
@@ -90,7 +91,7 @@ class ViewpointToolBar(qt.QToolBar):
def __init__(self, parent=None, plot3D=None, title='Viewpoint control'):
super(ViewpointToolBar, self).__init__(title, parent)
- self._actionGroup = ViewpointActionGroup(plot3D)
+ self._actionGroup = _ViewpointActionGroup(plot3D)
assert plot3D is not None
self._plot3D = plot3D
self.addActions(self._actionGroup.actions())
@@ -112,3 +113,23 @@ class ViewpointToolBar(qt.QToolBar):
# """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/Plot3DToolBar.py b/silx/gui/plot3d/tools/toolbars.py
index cf11362..c8be226 100644
--- a/silx/gui/plot3d/Plot3DToolBar.py
+++ b/silx/gui/plot3d/tools/toolbars.py
@@ -22,52 +22,109 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This module provides a toolbar with tools for a Plot3DWidget.
+"""This module provides toolbars with tools for a Plot3DWidget.
-It provides:
+It provides the following toolbars:
-- Copy
-- Save
-- Print
+- :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__ = "10/01/2017"
+__date__ = "06/09/2017"
import logging
from silx.gui import qt
-from . import Plot3DActions
+from .. import actions
_logger = logging.getLogger(__name__)
-class Plot3DToolBar(qt.QToolBar):
+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'):
- super(Plot3DToolBar, self).__init__(title, parent)
+ def __init__(self, parent=None, title='Plot3D Output'):
+ super(OutputToolBar, self).__init__(title, parent)
self._plot3d = None
- self._copyAction = Plot3DActions.CopyAction(parent=self)
+ self._copyAction = actions.io.CopyAction(parent=self)
self.addAction(self._copyAction)
- self._saveAction = Plot3DActions.SaveAction(parent=self)
+ self._saveAction = actions.io.SaveAction(parent=self)
self.addAction(self._saveAction)
- self._videoAction = Plot3DActions.VideoAction(parent=self)
+ self._videoAction = actions.io.VideoAction(parent=self)
self.addAction(self._videoAction)
- self._printAction = Plot3DActions.PrintAction(parent=self)
+ self._printAction = actions.io.PrintAction(parent=self)
self.addAction(self._printAction)
def setPlot3DWidget(self, widget):