summaryrefslogtreecommitdiff
path: root/silx/gui/plot3d
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot3d')
-rw-r--r--silx/gui/plot3d/ParamTreeView.py541
-rw-r--r--silx/gui/plot3d/Plot3DWidget.py60
-rw-r--r--silx/gui/plot3d/Plot3DWindow.py23
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py270
-rw-r--r--silx/gui/plot3d/ScalarFieldView.py430
-rw-r--r--silx/gui/plot3d/SceneWidget.py646
-rw-r--r--silx/gui/plot3d/SceneWindow.py192
-rw-r--r--silx/gui/plot3d/_model/__init__.py35
-rw-r--r--silx/gui/plot3d/_model/core.py372
-rw-r--r--silx/gui/plot3d/_model/items.py1388
-rw-r--r--silx/gui/plot3d/_model/model.py184
-rw-r--r--silx/gui/plot3d/actions/Plot3DAction.py10
-rw-r--r--silx/gui/plot3d/actions/io.py15
-rw-r--r--silx/gui/plot3d/actions/mode.py15
-rw-r--r--silx/gui/plot3d/actions/viewpoint.py141
-rw-r--r--silx/gui/plot3d/items/__init__.py43
-rw-r--r--silx/gui/plot3d/items/clipplane.py50
-rw-r--r--silx/gui/plot3d/items/core.py622
-rw-r--r--silx/gui/plot3d/items/image.py126
-rw-r--r--silx/gui/plot3d/items/mesh.py145
-rw-r--r--silx/gui/plot3d/items/mixins.py302
-rw-r--r--silx/gui/plot3d/items/scatter.py474
-rw-r--r--silx/gui/plot3d/items/volume.py463
-rw-r--r--silx/gui/plot3d/scene/__init__.py2
-rw-r--r--silx/gui/plot3d/scene/axes.py19
-rw-r--r--silx/gui/plot3d/scene/camera.py7
-rw-r--r--silx/gui/plot3d/scene/cutplane.py46
-rw-r--r--silx/gui/plot3d/scene/function.py36
-rw-r--r--silx/gui/plot3d/scene/interaction.py58
-rw-r--r--silx/gui/plot3d/scene/primitives.py1241
-rw-r--r--silx/gui/plot3d/scene/test/test_utils.py4
-rw-r--r--silx/gui/plot3d/scene/transform.py24
-rw-r--r--silx/gui/plot3d/scene/utils.py42
-rw-r--r--silx/gui/plot3d/scene/viewport.py59
-rw-r--r--silx/gui/plot3d/scene/window.py27
-rw-r--r--silx/gui/plot3d/setup.py2
-rw-r--r--silx/gui/plot3d/test/__init__.py12
-rw-r--r--silx/gui/plot3d/test/testScalarFieldView.py4
-rw-r--r--silx/gui/plot3d/tools/GroupPropertiesWidget.py200
-rw-r--r--silx/gui/plot3d/tools/ViewpointTools.py119
-rw-r--r--silx/gui/plot3d/tools/__init__.py6
-rw-r--r--silx/gui/plot3d/tools/toolbars.py111
42 files changed, 7907 insertions, 659 deletions
diff --git a/silx/gui/plot3d/ParamTreeView.py b/silx/gui/plot3d/ParamTreeView.py
new file mode 100644
index 0000000..a352627
--- /dev/null
+++ b/silx/gui/plot3d/ParamTreeView.py
@@ -0,0 +1,541 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 :class:`QTreeView` dedicated to display plot3d models.
+
+This module contains:
+- :class:`ParamTreeView`: A QTreeView specific for plot3d parameters and scene.
+- :class:`ParameterTreeDelegate`: The delegate for :class:`ParamTreeView`.
+- A set of specific editors used by :class:`ParameterTreeDelegate`:
+ :class:`FloatEditor`, :class:`Vector3DEditor`,
+ :class:`Vector4DEditor`, :class:`IntSliderEditor`, :class:`BooleanEditor`
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2017"
+
+
+import sys
+
+from silx.third_party import six
+
+from .. import qt
+from ..widgets.FloatEdit import FloatEdit as _FloatEdit
+from ._model import visitQAbstractItemModel
+
+
+class FloatEditor(_FloatEdit):
+ """Editor widget for float.
+
+ :param parent: The widget's parent
+ :param float value: The initial editor value
+ """
+
+ valueChanged = qt.Signal(float)
+ """Signal emitted when the float value has changed"""
+
+ def __init__(self, parent=None, value=None):
+ super(FloatEditor, self).__init__(parent, value)
+ self.setAlignment(qt.Qt.AlignLeft)
+ self.editingFinished.connect(self._emit)
+
+ def _emit(self):
+ self.valueChanged.emit(self.value)
+
+ value = qt.Property(float,
+ fget=_FloatEdit.value,
+ fset=_FloatEdit.setValue,
+ user=True,
+ notify=valueChanged)
+ """Qt user property of the float value this widget edits"""
+
+
+class Vector3DEditor(qt.QWidget):
+ """Editor widget for QVector3D.
+
+ :param parent: The widget's parent
+ :param flags: The widgets's flags
+ """
+
+ valueChanged = qt.Signal(qt.QVector3D)
+ """Signal emitted when the QVector3D value has changed"""
+
+ def __init__(self, parent=None, flags=qt.Qt.Widget):
+ super(Vector3DEditor, self).__init__(parent, flags)
+ layout = qt.QHBoxLayout(self)
+ # layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(layout)
+ self._xEdit = _FloatEdit(parent=self, value=0.)
+ self._xEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._xEdit.editingFinished.connect(self._emit)
+ self._yEdit = _FloatEdit(parent=self, value=0.)
+ self._yEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._yEdit.editingFinished.connect(self._emit)
+ self._zEdit = _FloatEdit(parent=self, value=0.)
+ self._zEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._zEdit.editingFinished.connect(self._emit)
+ layout.addWidget(qt.QLabel('x:'))
+ layout.addWidget(self._xEdit)
+ layout.addWidget(qt.QLabel('y:'))
+ layout.addWidget(self._yEdit)
+ layout.addWidget(qt.QLabel('z:'))
+ layout.addWidget(self._zEdit)
+ layout.addStretch(1)
+
+ def _emit(self):
+ vector = self.value
+ self.valueChanged.emit(vector)
+
+ def getValue(self):
+ """Returns the QVector3D value of this widget
+
+ :rtype: QVector3D
+ """
+ return qt.QVector3D(
+ self._xEdit.value(), self._yEdit.value(), self._zEdit.value())
+
+ def setValue(self, value):
+ """Set the QVector3D value
+
+ :param QVector3D value: The new value
+ """
+ self._xEdit.setValue(value.x())
+ self._yEdit.setValue(value.y())
+ self._zEdit.setValue(value.z())
+ self.valueChanged.emit(value)
+
+ value = qt.Property(qt.QVector3D,
+ fget=getValue,
+ fset=setValue,
+ user=True,
+ notify=valueChanged)
+ """Qt user property of the QVector3D value this widget edits"""
+
+
+class Vector4DEditor(qt.QWidget):
+ """Editor widget for QVector4D.
+
+ :param parent: The widget's parent
+ :param flags: The widgets's flags
+ """
+
+ valueChanged = qt.Signal(qt.QVector4D)
+ """Signal emitted when the QVector4D value has changed"""
+
+ def __init__(self, parent=None, flags=qt.Qt.Widget):
+ super(Vector4DEditor, self).__init__(parent, flags)
+ layout = qt.QHBoxLayout(self)
+ # layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(layout)
+ self._xEdit = _FloatEdit(parent=self, value=0.)
+ self._xEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._xEdit.editingFinished.connect(self._emit)
+ self._yEdit = _FloatEdit(parent=self, value=0.)
+ self._yEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._yEdit.editingFinished.connect(self._emit)
+ self._zEdit = _FloatEdit(parent=self, value=0.)
+ self._zEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._zEdit.editingFinished.connect(self._emit)
+ self._wEdit = _FloatEdit(parent=self, value=0.)
+ self._wEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._wEdit.editingFinished.connect(self._emit)
+ layout.addWidget(qt.QLabel('x:'))
+ layout.addWidget(self._xEdit)
+ layout.addWidget(qt.QLabel('y:'))
+ layout.addWidget(self._yEdit)
+ layout.addWidget(qt.QLabel('z:'))
+ layout.addWidget(self._zEdit)
+ layout.addWidget(qt.QLabel('w:'))
+ layout.addWidget(self._wEdit)
+ layout.addStretch(1)
+
+ def _emit(self):
+ vector = self.value
+ self.valueChanged.emit(vector)
+
+ def getValue(self):
+ """Returns the QVector4D value of this widget
+
+ :rtype: QVector4D
+ """
+ return qt.QVector4D(self._xEdit.value(), self._yEdit.value(),
+ self._zEdit.value(), self._wEdit.value())
+
+ def setValue(self, value):
+ """Set the QVector4D value
+
+ :param QVector4D value: The new value
+ """
+ self._xEdit.setValue(value.x())
+ self._yEdit.setValue(value.y())
+ self._zEdit.setValue(value.z())
+ self._wEdit.setValue(value.w())
+ self.valueChanged.emit(value)
+
+ value = qt.Property(qt.QVector4D,
+ fget=getValue,
+ fset=setValue,
+ user=True,
+ notify=valueChanged)
+ """Qt user property of the QVector4D value this widget edits"""
+
+
+class IntSliderEditor(qt.QSlider):
+ """Slider editor widget for integer.
+
+ Note: Tracking is disabled.
+
+ :param parent: The widget's parent
+ """
+
+ def __init__(self, parent=None):
+ super(IntSliderEditor, self).__init__(parent)
+ self.setOrientation(qt.Qt.Horizontal)
+ self.setSingleStep(1)
+ self.setRange(0, 255)
+ self.setValue(0)
+
+
+class BooleanEditor(qt.QCheckBox):
+ """Checkbox editor for bool.
+
+ This is a QCheckBox with white background.
+
+ :param parent: The widget's parent
+ """
+
+ def __init__(self, parent=None):
+ super(BooleanEditor, self).__init__(parent)
+ self.setStyleSheet("background: white;")
+
+
+class ParameterTreeDelegate(qt.QStyledItemDelegate):
+ """TreeView delegate specific to plot3d scene and object parameter tree.
+
+ It provides additional editors.
+
+ :param parent: Delegate's parent
+ """
+
+ EDITORS = {
+ bool: BooleanEditor,
+ float: FloatEditor,
+ qt.QVector3D: Vector3DEditor,
+ qt.QVector4D: Vector4DEditor,
+ }
+ """Specific editors for different type of data"""
+
+ def __init__(self, parent=None):
+ super(ParameterTreeDelegate, self).__init__(parent)
+
+ def _fixVariant(self, data):
+ """Fix PyQt4 zero vectors being stored as QPyNullVariant.
+
+ :param data: Data retrieved from the model
+ :return: Corresponding object
+ """
+ if qt.BINDING == 'PyQt4' and isinstance(data, qt.QPyNullVariant):
+ typeName = data.typeName()
+ if typeName == 'QVector3D':
+ data = qt.QVector3D()
+ elif typeName == 'QVector4D':
+ data = qt.QVector4D()
+ return data
+
+ def paint(self, painter, option, index):
+ """See :meth:`QStyledItemDelegate.paint`"""
+ data = index.data(qt.Qt.DisplayRole)
+ data = self._fixVariant(data)
+
+ if isinstance(data, (qt.QVector3D, qt.QVector4D)):
+ if isinstance(data, qt.QVector3D):
+ text = '(x: %g; y: %g; z: %g)' % (data.x(), data.y(), data.z())
+ elif isinstance(data, qt.QVector4D):
+ text = '(%g; %g; %g; %g)' % (data.x(), data.y(), data.z(), data.w())
+ else:
+ text = ''
+
+ painter.save()
+ painter.setRenderHint(qt.QPainter.Antialiasing, True)
+
+ # Select palette color group
+ colorGroup = qt.QPalette.Inactive
+ if option.state & qt.QStyle.State_Active:
+ colorGroup = qt.QPalette.Active
+ if not option.state & qt.QStyle.State_Enabled:
+ colorGroup = qt.QPalette.Disabled
+
+ # Draw background if selected
+ if option.state & qt.QStyle.State_Selected:
+ brush = option.palette.brush(colorGroup,
+ qt.QPalette.Highlight)
+ painter.fillRect(option.rect, brush)
+
+ # Draw text
+ if option.state & qt.QStyle.State_Selected:
+ colorRole = qt.QPalette.HighlightedText
+ else:
+ colorRole = qt.QPalette.WindowText
+ color = option.palette.color(colorGroup, colorRole)
+ painter.setPen(qt.QPen(color))
+ painter.drawText(option.rect, qt.Qt.AlignLeft, text)
+
+ painter.restore()
+
+ # The following commented code does the same as QPainter based code
+ # but it does not work with PySide
+ # self.initStyleOption(option, index)
+ # option.text = text
+ # widget = option.widget
+ # style = qt.QApplication.style() if not widget else widget.style()
+ # style.drawControl(qt.QStyle.CE_ItemViewItem, option, painter, widget)
+
+ else:
+ super(ParameterTreeDelegate, self).paint(painter, option, index)
+
+ def _commit(self, *args):
+ """Commit data to the model from editors"""
+ sender = self.sender()
+ self.commitData.emit(sender)
+
+ def editorEvent(self, event, model, option, index):
+ """See :meth:`QStyledItemDelegate.editorEvent`"""
+ if (event.type() == qt.QEvent.MouseButtonPress and
+ isinstance(index.data(qt.Qt.EditRole), qt.QColor)):
+ initialColor = index.data(qt.Qt.EditRole)
+
+ def callback(color):
+ theModel = index.model()
+ theModel.setData(index, color, qt.Qt.EditRole)
+
+ dialog = qt.QColorDialog(self.parent())
+ # dialog.setOption(qt.QColorDialog.ShowAlphaChannel, True)
+ if sys.platform == 'darwin':
+ # Use of native color dialog on macos might cause problems
+ dialog.setOption(qt.QColorDialog.DontUseNativeDialog, True)
+ dialog.setCurrentColor(initialColor)
+ dialog.currentColorChanged.connect(callback)
+ if dialog.exec_() == qt.QDialog.Rejected:
+ # Reset color
+ dialog.setCurrentColor(initialColor)
+
+ return True
+ else:
+ return super(ParameterTreeDelegate, self).editorEvent(
+ event, model, option, index)
+
+ def createEditor(self, parent, option, index):
+ """See :meth:`QStyledItemDelegate.createEditor`"""
+ data = index.data(qt.Qt.EditRole)
+ data = self._fixVariant(data)
+ editorHint = index.data(qt.Qt.UserRole)
+
+ if callable(editorHint):
+ editor = editorHint()
+ assert isinstance(editor, qt.QWidget)
+ editor.setParent(parent)
+
+ elif isinstance(data, (int, float)) and editorHint is not None:
+ # Use a slider
+ editor = IntSliderEditor(parent)
+ range_ = editorHint
+ editor.setRange(*range_)
+ editor.sliderReleased.connect(self._commit)
+
+ elif isinstance(data, six.string_types) and editorHint is not None:
+ # Use a combo box
+ editor = qt.QComboBox(parent)
+ if data not in editorHint:
+ editor.addItem(data)
+ editor.addItems(editorHint)
+
+ index = editor.findText(data)
+ editor.setCurrentIndex(index)
+
+ editor.currentIndexChanged.connect(self._commit)
+
+ else:
+ # Handle overridden editors from Python
+ # Mimic Qt C++ implementation
+ for type_, editorClass in self.EDITORS.items():
+ if isinstance(data, type_):
+ editor = editorClass(parent)
+ metaObject = editor.metaObject()
+ userProperty = metaObject.userProperty()
+ if userProperty.isValid() and userProperty.hasNotifySignal():
+ notifySignal = userProperty.notifySignal()
+ if hasattr(notifySignal, 'signature'): # Qt4
+ signature = notifySignal.signature()
+ else:
+ signature = bytes(notifySignal.methodSignature())
+
+ if hasattr(signature, 'decode'): # For PySide with python3
+ signature = signature.decode('ascii')
+ signalName = signature.split('(')[0]
+
+ signal = getattr(editor, signalName)
+ signal.connect(self._commit)
+ break
+
+ else: # Default handling for default types
+ return super(ParameterTreeDelegate, self).createEditor(
+ parent, option, index)
+
+ editor.setAutoFillBackground(True)
+ return editor
+
+ def setModelData(self, editor, model, index):
+ """See :meth:`QStyledItemDelegate.setModelData`"""
+ if isinstance(editor, tuple(self.EDITORS.values())):
+ # Special handling of Python classes
+ # Translation of QStyledItemDelegate::setModelData to Python
+ # To make it work with Python QVariant wrapping/unwrapping
+ name = editor.metaObject().userProperty().name()
+ if not name:
+ pass # TODO handle the case of missing user property
+ if name:
+ if hasattr(editor, name):
+ value = getattr(editor, name)
+ else:
+ value = editor.property(name)
+ model.setData(index, value, qt.Qt.EditRole)
+
+ else:
+ super(ParameterTreeDelegate, self).setModelData(editor, model, index)
+
+
+class ParamTreeView(qt.QTreeView):
+ """QTreeView specific to handle plot3d scene and object parameters.
+
+ It provides additional editors and specific creation of persistent editors.
+
+ :param parent: The widget's parent.
+ """
+
+ def __init__(self, parent=None):
+ super(ParamTreeView, self).__init__(parent)
+
+ header = self.header()
+ header.setMinimumSectionSize(128) # For colormap pixmaps
+ if hasattr(header, 'setSectionResizeMode'): # Qt5
+ header.setSectionResizeMode(qt.QHeaderView.ResizeToContents)
+ else: # Qt4
+ header.setResizeMode(qt.QHeaderView.ResizeToContents)
+
+ delegate = ParameterTreeDelegate()
+ self.setItemDelegate(delegate)
+
+ self.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
+ self.setSelectionMode(qt.QAbstractItemView.SingleSelection)
+
+ self.expanded.connect(self._expanded)
+
+ self.setEditTriggers(qt.QAbstractItemView.CurrentChanged |
+ qt.QAbstractItemView.DoubleClicked)
+
+ self.__persistentEditors = set()
+
+ def _openEditorForIndex(self, index):
+ """Check if it has to open a persistent editor for a specific cell.
+
+ :param QModelIndex index: The cell index
+ """
+ if index.flags() & qt.Qt.ItemIsEditable:
+ data = index.data(qt.Qt.EditRole)
+ editorHint = index.data(qt.Qt.UserRole)
+ if (isinstance(data, bool) or
+ callable(editorHint) or
+ (isinstance(data, (float, int)) and editorHint)):
+ self.openPersistentEditor(index)
+ self.__persistentEditors.add(index)
+
+ def _openEditors(self, parent=qt.QModelIndex()):
+ """Open persistent editors in a subtree starting at parent.
+
+ :param QModelIndex parent: The root of the subtree to process.
+ """
+ model = self.model()
+ if model is not None:
+ for index in visitQAbstractItemModel(model, parent):
+ self._openEditorForIndex(index)
+
+ def setModel(self, model):
+ """Set the model this TreeView is displaying
+
+ :param QAbstractItemModel model:
+ """
+ super(ParamTreeView, self).setModel(model)
+ self._openEditors()
+
+ def rowsInserted(self, parent, start, end):
+ """See :meth:`QTreeView.rowsInserted`"""
+ super(ParamTreeView, self).rowsInserted(parent, start, end)
+ model = self.model()
+ if model is not None:
+ for row in range(start, end+1):
+ self._openEditorForIndex(model.index(row, 1, parent))
+ self._openEditors(model.index(row, 0, parent))
+
+ def _expanded(self, index):
+ """Handle QTreeView expanded signal"""
+ name = index.data(qt.Qt.DisplayRole)
+ if name == 'Transform':
+ rotateIndex = self.model().index(1, 0, index)
+ self.setExpanded(rotateIndex, True)
+
+ def dataChanged(self, topLeft, bottomRight, roles=()):
+ """Handle model dataChanged signal eventually closing editors"""
+ if roles: # Qt 5
+ super(ParamTreeView, self).dataChanged(topLeft, bottomRight, roles)
+ else: # Qt4 compatibility
+ super(ParamTreeView, self).dataChanged(topLeft, bottomRight)
+ if not roles or qt.Qt.UserRole in roles: # Check editorHint update
+ for row in range(topLeft.row(), bottomRight.row() + 1):
+ for column in range(topLeft.column(), bottomRight.column() + 1):
+ index = topLeft.sibling(row, column)
+ if index.isValid():
+ if self._isPersistentEditorOpen(index):
+ self.closePersistentEditor(index)
+ self._openEditorForIndex(index)
+
+ def _isPersistentEditorOpen(self, index):
+ """Returns True if a persistent editor is opened for index
+
+ :param QModelIndex index:
+ :rtype: bool
+ """
+ return index in self.__persistentEditors
+
+ def selectionCommand(self, index, event=None):
+ """Filter out selection of not selectable items"""
+ if index.flags() & qt.Qt.ItemIsSelectable:
+ return super(ParamTreeView, self).selectionCommand(index, event)
+ else:
+ return qt.QItemSelectionModel.NoUpdate
diff --git a/silx/gui/plot3d/Plot3DWidget.py b/silx/gui/plot3d/Plot3DWidget.py
index aae3955..15e2356 100644
--- a/silx/gui/plot3d/Plot3DWidget.py
+++ b/silx/gui/plot3d/Plot3DWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -54,12 +54,26 @@ class _OverviewViewport(scene.Viewport):
:param Camera camera: The camera to track.
"""
+ _SIZE = 100
+ """Size in pixels of the overview square"""
+
def __init__(self, camera=None):
super(_OverviewViewport, self).__init__()
- self.size = 100, 100
+ self.size = self._SIZE, self._SIZE
+ self.background = None # Disable clear
self.scene.transforms = [transform.Scale(2.5, 2.5, 2.5)]
+ # Add a point to draw the background (in a group with depth mask)
+ backgroundPoint = primitives.ColorPoints(
+ x=0., y=0., z=0.,
+ color=(1., 1., 1., 0.5),
+ size=self._SIZE)
+ backgroundPoint.marker = 'o'
+ noDepthGroup = primitives.GroupNoDepth(mask=True, notest=True)
+ noDepthGroup.children.append(backgroundPoint)
+ self.scene.children.append(noDepthGroup)
+
axes = primitives.Axes()
self.scene.children.append(axes)
@@ -86,6 +100,12 @@ class Plot3DWidget(glu.OpenGLWidget):
"""Signal emitted when the interactive mode has changed
"""
+ sigStyleChanged = qt.Signal(str)
+ """Signal emitted when the style of the scene has changed
+
+ It provides the updated property.
+ """
+
def __init__(self, parent=None, f=qt.Qt.WindowFlags()):
self._firstRender = True
@@ -108,7 +128,6 @@ class Plot3DWidget(glu.OpenGLWidget):
# Main viewport
self.viewport = scene.Viewport()
- self.viewport.background = 0.2, 0.2, 0.2, 1.
self._sceneScale = transform.Scale(1., 1., 1.)
self.viewport.scene.transforms = [self._sceneScale,
@@ -143,18 +162,27 @@ class Plot3DWidget(glu.OpenGLWidget):
self.viewport,
orbitAroundCenter=False,
mode='position',
- scaleTransform=self._sceneScale)
+ scaleTransform=self._sceneScale,
+ selectCB=None)
elif mode == 'pan':
self.eventHandler = interaction.PanCameraControl(
self.viewport,
+ orbitAroundCenter=False,
mode='position',
scaleTransform=self._sceneScale,
selectCB=None)
+ elif isinstance(mode, interaction.StateMachine):
+ self.eventHandler = mode
+
else:
raise ValueError('Unsupported interactive mode %s', str(mode))
+ if (self.eventHandler is not None and
+ qt.QApplication.keyboardModifiers() & qt.Qt.ControlModifier):
+ self.eventHandler.handleEvent('keyPress', qt.Qt.Key_Control)
+
self.sigInteractiveModeChanged.emit()
def getInteractiveMode(self):
@@ -208,8 +236,9 @@ class Plot3DWidget(glu.OpenGLWidget):
QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
"""
color = rgba(color)
- self.viewport.background = color
- self.overview.background = color[0]*0.5, color[1]*0.5, color[2]*0.5, 1.
+ if color != self.viewport.background:
+ self.viewport.background = color
+ self.sigStyleChanged.emit('backgroundColor')
def getBackgroundColor(self):
"""Returns the RGBA background color (QColor)."""
@@ -233,6 +262,7 @@ class Plot3DWidget(glu.OpenGLWidget):
self._window.viewports = [self.viewport, self.overview]
else:
self._window.viewports = [self.viewport]
+ self.sigStyleChanged.emit('orientationIndicatorVisible')
def centerScene(self):
"""Position the center of the scene at the center of rotation."""
@@ -315,7 +345,7 @@ class Plot3DWidget(glu.OpenGLWidget):
self.eventHandler.handleEvent('wheel', xpixel, ypixel, angle)
def keyPressEvent(self, event):
- keycode = event.key()
+ keyCode = event.key()
# No need to accept QKeyEvent
converter = {
@@ -324,7 +354,7 @@ class Plot3DWidget(glu.OpenGLWidget):
qt.Qt.Key_Up: 'up',
qt.Qt.Key_Down: 'down'
}
- direction = converter.get(keycode, None)
+ direction = converter.get(keyCode, None)
if direction is not None:
if event.modifiers() == qt.Qt.ControlModifier:
self.viewport.camera.rotate(direction)
@@ -334,9 +364,23 @@ class Plot3DWidget(glu.OpenGLWidget):
self.viewport.orbitCamera(direction)
else:
+ if (keyCode == qt.Qt.Key_Control and
+ self.eventHandler is not None and
+ self.isValid()):
+ self.eventHandler.handleEvent('keyPress', keyCode)
+
# Key not handled, call base class implementation
super(Plot3DWidget, self).keyPressEvent(event)
+ def keyReleaseEvent(self, event):
+ """Catch Ctrl key release"""
+ keyCode = event.key()
+ if (keyCode == qt.Qt.Key_Control and
+ self.eventHandler is not None and
+ self.isValid()):
+ self.eventHandler.handleEvent('keyRelease', keyCode)
+ super(Plot3DWidget, self).keyReleaseEvent(event)
+
# Mouse events #
_MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'}
diff --git a/silx/gui/plot3d/Plot3DWindow.py b/silx/gui/plot3d/Plot3DWindow.py
index d8c393e..331eca2 100644
--- a/silx/gui/plot3d/Plot3DWindow.py
+++ b/silx/gui/plot3d/Plot3DWindow.py
@@ -35,9 +35,7 @@ __date__ = "26/01/2017"
from silx.gui import qt
from .Plot3DWidget import Plot3DWidget
-from .actions.viewpoint import RotateViewport
-from .tools import OutputToolBar, InteractiveModeToolBar
-from .tools import ViewpointToolButton
+from .tools import OutputToolBar, InteractiveModeToolBar, ViewpointToolBar
class Plot3DWindow(qt.QMainWindow):
@@ -52,20 +50,11 @@ class Plot3DWindow(qt.QMainWindow):
self._plot3D = Plot3DWidget()
self.setCentralWidget(self._plot3D)
- 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))
- toolbar.addAction(RotateViewport(parent=toolbar, plot3d=self._plot3D))
- self.addToolBar(toolbar)
-
- toolbar = OutputToolBar(parent=self)
- toolbar.setPlot3DWidget(self._plot3D)
- self.addToolBar(toolbar)
- self.addActions(toolbar.actions())
+ for klass in (InteractiveModeToolBar, ViewpointToolBar, OutputToolBar):
+ toolbar = klass(parent=self)
+ toolbar.setPlot3DWidget(self._plot3D)
+ self.addToolBar(toolbar)
+ self.addActions(toolbar.actions())
def getPlot3DWidget(self):
"""Get the :class:`Plot3DWidget` of this window"""
diff --git a/silx/gui/plot3d/SFViewParamTree.py b/silx/gui/plot3d/SFViewParamTree.py
index e67c17e..314e5a1 100644
--- a/silx/gui/plot3d/SFViewParamTree.py
+++ b/silx/gui/plot3d/SFViewParamTree.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -102,7 +102,7 @@ class SubjectItem(qt.QStandardItem):
Overloaded method from QStandardItem. The pushData keyword tells
the item to push data to the subject if the role is equal to EditRole.
This is useful to let this method know if the setData method was called
- internaly or from the view.
+ internally or from the view.
:param value: the value ti set to data
:param role: role in the item
@@ -355,6 +355,183 @@ class HighlightColorItem(ColorItem):
return self.subject.getHighlightColor()
+class _LightDirectionAngleBaseItem(SubjectItem):
+ """Base class for directional light angle item."""
+ editable = True
+ persistent = True
+
+ def _init(self):
+ pass
+
+ def getSignals(self):
+ """Override to provide signals to listen"""
+ raise NotImplementedError("MUST be implemented in subclass")
+
+ def _pullData(self):
+ """Override in subclass to get current angle"""
+ raise NotImplementedError("MUST be implemented in subclass")
+
+ def _pushData(self, value, role=qt.Qt.UserRole):
+ """Override in subclass to set the angle"""
+ raise NotImplementedError("MUST be implemented in subclass")
+
+ def getEditor(self, parent, option, index):
+ editor = qt.QSlider(parent)
+ editor.setOrientation(qt.Qt.Horizontal)
+ editor.setMinimum(-90)
+ editor.setMaximum(90)
+ editor.setValue(self._pullData())
+
+ # Wrapping call in lambda is a workaround for PySide with Python 3
+ editor.valueChanged.connect(
+ lambda value: self._pushData(value))
+
+ return editor
+
+ def setEditorData(self, editor):
+ editor.setValue(self._pullData())
+ return True
+
+ def _setModelData(self, editor):
+ value = editor.value()
+ self._pushData(value)
+ return True
+
+
+class LightAzimuthAngleItem(_LightDirectionAngleBaseItem):
+ """Light direction azimuth angle item."""
+
+ def getSignals(self):
+ return self.subject.sigAzimuthAngleChanged
+
+ def _pullData(self):
+ return self.subject.getAzimuthAngle()
+
+ def _pushData(self, value, role=qt.Qt.UserRole):
+ self.subject.setAzimuthAngle(value)
+
+
+class LightAltitudeAngleItem(_LightDirectionAngleBaseItem):
+ """Light direction altitude angle item."""
+
+ def getSignals(self):
+ return self.subject.sigAltitudeAngleChanged
+
+ def _pullData(self):
+ return self.subject.getAltitudeAngle()
+
+ def _pushData(self, value, role=qt.Qt.UserRole):
+ self.subject.setAltitudeAngle(value)
+
+
+class _DirectionalLightProxy(qt.QObject):
+ """Proxy to handle directional light with angles rather than vector.
+ """
+
+ sigAzimuthAngleChanged = qt.Signal()
+ """Signal sent when the azimuth angle has changed."""
+
+ sigAltitudeAngleChanged = qt.Signal()
+ """Signal sent when altitude angle has changed."""
+
+ def __init__(self, light):
+ super(_DirectionalLightProxy, self).__init__()
+ self._light = light
+ light.addListener(self._directionUpdated)
+ self._azimuth = 0.
+ self._altitude = 0.
+
+ def getAzimuthAngle(self):
+ """Returns the signed angle in the horizontal plane.
+
+ Unit: degrees.
+ The 0 angle corresponds to the axis perpendicular to the screen.
+
+ :rtype: float
+ """
+ return self._azimuth
+
+ def getAltitudeAngle(self):
+ """Returns the signed vertical angle from the horizontal plane.
+
+ Unit: degrees.
+ Range: [-90, +90]
+
+ :rtype: float
+ """
+ return self._altitude
+
+ def setAzimuthAngle(self, angle):
+ """Set the horizontal angle.
+
+ :param float angle: Angle from -z axis in zx plane in degrees.
+ """
+ if angle != self._azimuth:
+ self._azimuth = angle
+ self._updateLight()
+ self.sigAzimuthAngleChanged.emit()
+
+ def setAltitudeAngle(self, angle):
+ """Set the horizontal angle.
+
+ :param float angle: Angle from -z axis in zy plane in degrees.
+ """
+ if angle != self._altitude:
+ self._altitude = angle
+ self._updateLight()
+ self.sigAltitudeAngleChanged.emit()
+
+ def _directionUpdated(self, *args, **kwargs):
+ """Handle light direction update in the scene"""
+ # Invert direction to manipulate the 'source' pointing to
+ # the center of the viewport
+ x, y, z = - self._light.direction
+
+ # Horizontal plane is plane xz
+ azimuth = numpy.degrees(numpy.arctan2(x, z))
+ altitude = numpy.degrees(numpy.pi/2. - numpy.arccos(y))
+
+ if (abs(azimuth - self.getAzimuthAngle()) > 0.01 and
+ abs(abs(altitude) - 90.) >= 0.001): # Do not update when at zenith
+ self.setAzimuthAngle(azimuth)
+
+ if abs(altitude - self.getAltitudeAngle()) > 0.01:
+ self.setAltitudeAngle(altitude)
+
+ def _updateLight(self):
+ """Update light direction in the scene"""
+ azimuth = numpy.radians(self._azimuth)
+ delta = numpy.pi/2. - numpy.radians(self._altitude)
+ z = - numpy.sin(delta) * numpy.cos(azimuth)
+ x = - numpy.sin(delta) * numpy.sin(azimuth)
+ y = - numpy.cos(delta)
+ self._light.direction = x, y, z
+
+
+class DirectionalLightGroup(SubjectItem):
+ """
+ Root Item for the directional light
+ """
+
+ def __init__(self,subject, *args):
+ self._light = _DirectionalLightProxy(
+ subject.getPlot3DWidget().viewport.light)
+
+ super(DirectionalLightGroup, self).__init__(subject, *args)
+
+ def _init(self):
+
+ nameItem = qt.QStandardItem('Azimuth')
+ nameItem.setEditable(False)
+ valueItem = LightAzimuthAngleItem(self._light)
+ self.appendRow([nameItem, valueItem])
+
+ nameItem = qt.QStandardItem('Altitude')
+ nameItem.setEditable(False)
+ valueItem = LightAltitudeAngleItem(self._light)
+ self.appendRow([nameItem, valueItem])
+
+
class BoundingBoxItem(SubjectItem):
"""Bounding box, axes labels and grid visibility item.
@@ -402,14 +579,20 @@ class ViewSettingsItem(qt.QStandardItem):
self.setEditable(False)
- classes = (BackgroundColorItem, ForegroundColorItem,
+ classes = (BackgroundColorItem,
+ ForegroundColorItem,
HighlightColorItem,
- BoundingBoxItem, OrientationIndicatorItem)
+ BoundingBoxItem,
+ OrientationIndicatorItem)
for cls in classes:
titleItem = qt.QStandardItem(cls.itemName)
titleItem.setEditable(False)
self.appendRow([titleItem, cls(subject)])
+ nameItem = DirectionalLightGroup(subject, 'Light Direction')
+ valueItem = qt.QStandardItem()
+ self.appendRow([nameItem, valueItem])
+
# Data information ############################################################
@@ -421,7 +604,7 @@ class DataChangedItem(SubjectItem):
def getSignals(self):
subject = self.subject
if subject:
- return subject.sigDataChanged
+ return subject.sigDataChanged, subject.sigTransformChanged
return None
def _init(self):
@@ -463,6 +646,17 @@ class ScaleItem(DataChangedItem):
return ((scale is not None) and str(scale)) or 'N/A'
+class MatrixItem(DataChangedItem):
+
+ def __init__(self, subject, row, *args):
+ self.__row = row
+ super(MatrixItem, self).__init__(subject, *args)
+
+ def _pullData(self):
+ matrix = self.subject.getTransformMatrix()
+ return str(matrix[self.__row])
+
+
class DataSetItem(qt.QStandardItem):
def __init__(self, subject, *args):
@@ -471,12 +665,27 @@ class DataSetItem(qt.QStandardItem):
self.setEditable(False)
- klasses = [DataTypeItem, DataShapeItem, OffsetItem, ScaleItem]
+ klasses = [DataTypeItem, DataShapeItem, OffsetItem]
for klass in klasses:
titleItem = qt.QStandardItem(klass.itemName)
titleItem.setEditable(False)
self.appendRow([titleItem, klass(subject)])
+ matrixItem = qt.QStandardItem('matrix')
+ matrixItem.setEditable(False)
+ valueItem = qt.QStandardItem()
+ self.appendRow([matrixItem, valueItem])
+
+ for row in range(3):
+ titleItem = qt.QStandardItem()
+ titleItem.setEditable(False)
+ valueItem = MatrixItem(subject, row)
+ matrixItem.appendRow([titleItem, valueItem])
+
+ titleItem = qt.QStandardItem(ScaleItem.itemName)
+ titleItem.setEditable(False)
+ self.appendRow([titleItem, ScaleItem(subject)])
+
# Isosurface ##################################################################
@@ -559,9 +768,17 @@ class IsoSurfaceLevelItem(SubjectItem):
return [subject.sigLevelChanged,
subject.sigVisibilityChanged]
+ def getEditor(self, parent, option, index):
+ return FloatEdit(parent)
+
def setEditorData(self, editor):
+ editor.setValue(self._pullData())
return False
+ def _setModelData(self, editor):
+ self._pushData(editor.value())
+ return True
+
def _pullData(self):
return self.subject.getLevel()
@@ -1004,9 +1221,10 @@ class PlaneOrientationItem(SubjectItem):
return [self.subject.getCutPlanes()[0].sigPlaneChanged]
def _pullData(self):
- currentNormal = self.subject.getCutPlanes()[0].getNormal()
+ currentNormal = self.subject.getCutPlanes()[0].getNormal(
+ coordinates='scene')
for _, text, _, normal in self._PLANE_ACTIONS:
- if numpy.array_equal(normal, currentNormal):
+ if numpy.allclose(normal, currentNormal):
return text
return ''
@@ -1023,7 +1241,7 @@ class PlaneOrientationItem(SubjectItem):
def __editorChanged(self, index):
normal = self._PLANE_ACTIONS[index][3]
plane = self.subject.getCutPlanes()[0]
- plane.setNormal(normal)
+ plane.setNormal(normal, coordinates='scene')
plane.moveToCenter()
def setEditorData(self, editor):
@@ -1069,6 +1287,35 @@ class PlaneInterpolationItem(SubjectItem):
self.subject.getCutPlanes()[0].setInterpolation(interpolation)
+class PlaneDisplayBelowMinItem(SubjectItem):
+ """Toggle whether to display or not values <= colormap min of the cut plane
+
+ Item is checkable
+ """
+
+ def _init(self):
+ display = self.subject.getCutPlanes()[0].getDisplayValuesBelowMin()
+ self.setCheckable(True)
+ self.setCheckState(
+ qt.Qt.Checked if display else qt.Qt.Unchecked)
+ self.setData(self._pullData(), role=qt.Qt.DisplayRole, pushData=False)
+
+ def getSignals(self):
+ return [self.subject.getCutPlanes()[0].sigTransparencyChanged]
+
+ def leftClicked(self):
+ checked = self.checkState() == qt.Qt.Checked
+ self._setDisplayValuesBelowMin(checked)
+
+ def _pullData(self):
+ display = self.subject.getCutPlanes()[0].getDisplayValuesBelowMin()
+ self._setDisplayValuesBelowMin(display)
+ return "Displayed" if display else "Hidden"
+
+ def _setDisplayValuesBelowMin(self, display):
+ self.subject.getCutPlanes()[0].setDisplayValuesBelowMin(display)
+
+
class PlaneColormapItem(ColormapBase):
"""
colormap name item.
@@ -1241,6 +1488,11 @@ class PlaneGroup(SubjectItem):
valueItem = PlaneMaxRangeItem(self.subject)
self.appendRow([nameItem, valueItem])
+ nameItem = qt.QStandardItem('Values<=Min')
+ nameItem.setEditable(False)
+ valueItem = PlaneDisplayBelowMinItem(self.subject)
+ self.appendRow([nameItem, valueItem])
+
class PlaneVisibleItem(SubjectItem):
"""
diff --git a/silx/gui/plot3d/ScalarFieldView.py b/silx/gui/plot3d/ScalarFieldView.py
index 6a4d9d4..a41999b 100644
--- a/silx/gui/plot3d/ScalarFieldView.py
+++ b/silx/gui/plot3d/ScalarFieldView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -56,48 +56,6 @@ from .tools import InteractiveModeToolBar
_logger = logging.getLogger(__name__)
-class _BoundedGroup(scene.Group):
- """Group with data bounds"""
-
- _shape = None # To provide a default value without overriding __init__
-
- @property
- def shape(self):
- """Data shape (depth, height, width) of this group or None"""
- return self._shape
-
- @shape.setter
- def shape(self, shape):
- if shape is None:
- self._shape = None
- else:
- depth, height, width = shape
- self._shape = float(depth), float(height), float(width)
-
- @property
- def size(self):
- """Data size (width, height, depth) of this group or None"""
- shape = self.shape
- if shape is None:
- return None
- else:
- return shape[2], shape[1], shape[0]
-
- @size.setter
- def size(self, size):
- if size is None:
- self.shape = None
- else:
- self.shape = size[2], size[1], size[0]
-
- def _bounds(self, dataBounds=False):
- if dataBounds and self.size is not None:
- return numpy.array(((0., 0., 0.), self.size),
- dtype=numpy.float32)
- else:
- return super(_BoundedGroup, self)._bounds(dataBounds)
-
-
class Isosurface(qt.QObject):
"""Class representing an iso-surface
@@ -272,24 +230,26 @@ class SelectedRegion(object):
:param arrayRange: Range of the selection in the array
((zmin, zmax), (ymin, ymax), (xmin, xmax))
+ :param dataBBox: Bounding box of the selection in data coordinates
+ ((xmin, xmax), (ymin, ymax), (zmin, zmax))
:param translation: Offset from array to data coordinates (ox, oy, oz)
:param scale: Scale from array to data coordinates (sx, sy, sz)
"""
- def __init__(self, arrayRange,
+ def __init__(self, arrayRange, dataBBox,
translation=(0., 0., 0.),
scale=(1., 1., 1.)):
self._arrayRange = numpy.array(arrayRange, copy=True, dtype=numpy.int)
assert self._arrayRange.shape == (3, 2)
assert numpy.all(self._arrayRange[:, 1] >= self._arrayRange[:, 0])
+
+ self._dataRange = dataBBox
+
self._translation = numpy.array(translation, dtype=numpy.float32)
assert self._translation.shape == (3,)
self._scale = numpy.array(scale, dtype=numpy.float32)
assert self._scale.shape == (3,)
- self._dataRange = (self._translation.reshape(3, -1) +
- self._arrayRange[::-1] * self._scale.reshape(3, -1))
-
def getArrayRange(self):
"""Returns array ranges of the selection: 3x2 array of int
@@ -311,6 +271,10 @@ class SelectedRegion(object):
def getDataRange(self):
"""Range in the data coordinates of the selection: 3x2 array of float
+ When the transform matrix is not the identity matrix
+ (e.g., rotation, skew) the returned range is the one of the selected
+ region bounding box in data coordinates.
+
:return: A numpy array with ((xmin, xmax), (ymin, ymax), (zmin, zmax))
:rtype: numpy.ndarray
"""
@@ -336,7 +300,8 @@ class SelectedRegion(object):
class CutPlane(qt.QObject):
"""Class representing a cutting plane
- :param ScalarFieldView sfView: Widget in which the cut plane is applied.
+ :param ~silx.gui.plot3d.ScalarFieldView.ScalarFieldView sfView:
+ Widget in which the cut plane is applied.
"""
sigVisibilityChanged = qt.Signal(bool)
@@ -357,6 +322,12 @@ class CutPlane(qt.QObject):
This signal provides the new colormap.
"""
+ sigTransparencyChanged = qt.Signal()
+ """Signal emitted when the transparency of the plane has changed.
+
+ This signal is emitted when calling :meth:`setDisplayValuesBelowMin`.
+ """
+
sigInterpolationChanged = qt.Signal(str)
"""Signal emitted when the cut plane interpolation has changed
@@ -367,12 +338,22 @@ class CutPlane(qt.QObject):
super(CutPlane, self).__init__(parent=sfView)
self._dataRange = None
+ self._visible = False
+
+ self.__syncPlane = True
- self._plane = cutplane.CutPlane(normal=(0, 1, 0))
- self._plane.alpha = 1.
- self._plane.visible = self._visible = False
- self._plane.addListener(self._planeChanged)
- self._plane.plane.addListener(self._planePositionChanged)
+ # Plane stroke on the outer bounding box
+ self._planeStroke = primitives.PlaneInGroup(normal=(0, 1, 0))
+ self._planeStroke.visible = self._visible
+ self._planeStroke.addListener(self._planeChanged)
+ self._planeStroke.plane.addListener(self._planePositionChanged)
+
+ # Plane with texture on the data bounding box
+ self._dataPlane = cutplane.CutPlane(normal=(0, 1, 0))
+ self._dataPlane.strokeVisible = False
+ self._dataPlane.alpha = 1.
+ self._dataPlane.visible = self._visible
+ self._dataPlane.plane.addListener(self._planePositionChanged)
self._colormap = Colormap(
name='gray', normalization='linear', vmin=None, vmax=None)
@@ -380,14 +361,40 @@ class CutPlane(qt.QObject):
self._updateSceneColormap()
sfView.sigDataChanged.connect(self._sfViewDataChanged)
+ sfView.sigTransformChanged.connect(self._sfViewTransformChanged)
+
+ def _get3DPrimitives(self):
+ """Return the cut plane scene node."""
+ return self._planeStroke, self._dataPlane
+
+ def _keepPlaneInBBox(self):
+ """Makes sure the plane intersect its parent bounding box if any"""
+ bounds = self._planeStroke.parent.bounds(dataBounds=True)
+ if bounds is not None:
+ self._planeStroke.plane.point = numpy.clip(
+ self._planeStroke.plane.point,
+ a_min=bounds[0], a_max=bounds[1])
+
+ @staticmethod
+ def _syncPlanes(master, slave):
+ """Move slave PlaneInGroup so that it is coplanar with master.
+
+ :param PlaneInGroup master: Reference PlaneInGroup
+ :param PlaneInGroup slave: PlaneInGroup to align
+ """
+ masterToSlave = transform.StaticTransformList([
+ slave.objectToSceneTransform.inverse(),
+ master.objectToSceneTransform])
- def _get3DPrimitive(self):
- """Return the cut plane scene node"""
- return self._plane
+ point = masterToSlave.transformPoint(
+ master.plane.point)
+ normal = masterToSlave.transformNormal(
+ master.plane.normal)
+ slave.plane.setPlane(point, normal)
def _sfViewDataChanged(self):
"""Handle data change in the ScalarFieldView this plane belongs to"""
- self._plane.setData(self.sender().getData(), copy=False)
+ self._dataPlane.setData(self.sender().getData(), copy=False)
# Store data range info as 3-tuple of values
self._dataRange = self.sender().getDataRange()
@@ -398,6 +405,15 @@ class CutPlane(qt.QObject):
if self.getColormap().isAutoscale():
self._updateSceneColormap()
+ self._keepPlaneInBBox()
+
+ def _sfViewTransformChanged(self):
+ """Handle transform changed in the ScalarFieldView"""
+ self._keepPlaneInBBox()
+ self._syncPlanes(master=self._planeStroke,
+ slave=self._dataPlane)
+ self.sigPlaneChanged.emit()
+
def _planeChanged(self, source, *args, **kwargs):
"""Handle events from the plane primitive"""
# Using _visible for now, until scene as more info in events
@@ -407,68 +423,144 @@ class CutPlane(qt.QObject):
def _planePositionChanged(self, source, *args, **kwargs):
"""Handle update of cut plane position and normal"""
- if self._plane.visible:
- self.sigPlaneChanged.emit()
+ if self.__syncPlane:
+ self.__syncPlane = False
+ if source is self._planeStroke.plane:
+ self._syncPlanes(master=self._planeStroke,
+ slave=self._dataPlane)
+ elif source is self._dataPlane.plane:
+ self._syncPlanes(master=self._dataPlane,
+ slave=self._planeStroke)
+ else:
+ _logger.error('Received an unknown object %s',
+ str(source))
+
+ if self._planeStroke.visible or self._dataPlane.visible:
+ self.sigPlaneChanged.emit()
+
+ self.__syncPlane = True
# Plane position
def moveToCenter(self):
"""Move cut plane to center of data set"""
- self._plane.moveToCenter()
+ self._planeStroke.moveToCenter()
def isValid(self):
"""Returns whether the cut plane is defined or not (bool)"""
- return self._plane.isValid
+ return self._planeStroke.isValid
+
+ def _plane(self, coordinates='array'):
+ """Returns the scene plane to set.
+
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
+ :rtype: Plane
+ :raise ValueError: If coordinates is not correct
+ """
+ if coordinates == 'scene':
+ return self._planeStroke.plane
+ elif coordinates == 'array':
+ return self._dataPlane.plane
+ else:
+ raise ValueError(
+ 'Unsupported coordinates: %s' % str(coordinates))
- def getNormal(self):
+ def getNormal(self, coordinates='array'):
"""Returns the normal of the plane (as a unit vector)
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
:return: Normal (nx, ny, nz), vector is 0 if no plane is defined
:rtype: numpy.ndarray
+ :raise ValueError: If coordinates is not correct
"""
- return self._plane.plane.normal
+ return self._plane(coordinates).normal
- def setNormal(self, normal):
- """Set the normal of the plane
+ def setNormal(self, normal, coordinates='array'):
+ """Set the normal of the plane.
:param normal: 3-tuple of float: nx, ny, nz
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
+ :raise ValueError: If coordinates is not correct
"""
- self._plane.plane.normal = normal
+ self._plane(coordinates).normal = normal
- def getPoint(self):
- """Returns a point on the plane
+ def getPoint(self, coordinates='array'):
+ """Returns a point on the plane.
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
:return: (x, y, z)
:rtype: numpy.ndarray
+ :raise ValueError: If coordinates is not correct
+ """
+ return self._plane(coordinates).point
+
+ def setPoint(self, point, constraint=True, coordinates='array'):
+ """Set a point contained in the plane.
+
+ Warning: The plane might not intersect the bounding box of the data.
+
+ :param point: (x, y, z) position
+ :type point: 3-tuple of float
+ :param bool constraint:
+ True (default) to make sure the plane intersect data bounding box,
+ False to set the plane without any constraint.
+ :raise ValueError: If coordinates is not correc
"""
- return self._plane.plane.point
+ self._plane(coordinates).point = point
+ if constraint:
+ self._keepPlaneInBBox()
- def getParameters(self):
+ def getParameters(self, coordinates='array'):
"""Returns the plane equation parameters: a*x + b*y + c*z + d = 0
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
:return: Plane equation parameters: (a, b, c, d)
:rtype: numpy.ndarray
+ :raise ValueError: If coordinates is not correct
+ """
+ return self._plane(coordinates).parameters
+
+ def setParameters(self, parameters, constraint=True, coordinates='array'):
+ """Set the plane equation parameters: a*x + b*y + c*z + d = 0
+
+ Warning: The plane might not intersect the bounding box of the data.
+
+ :param parameters: (a, b, c, d) plane equation parameters.
+ :type parameters: 4-tuple of float
+ :param bool constraint:
+ True (default) to make sure the plane intersect data bounding box,
+ False to set the plane without any constraint.
+ :raise ValueError: If coordinates is not correc
"""
- return self._plane.plane.parameters
+ self._plane(coordinates).parameters = parameters
+ if constraint:
+ self._keepPlaneInBBox()
# Visibility
def isVisible(self):
"""Returns True if the plane is visible, False otherwise"""
- return self._plane.visible
+ return self._planeStroke.visible
def setVisible(self, visible):
"""Set the visibility of the plane
:param bool visible: True to make plane visible
"""
- self._plane.visible = visible
+ visible = bool(visible)
+ self._planeStroke.visible = visible
+ self._dataPlane.visible = visible
# Border stroke
def getStrokeColor(self):
"""Returns the color of the plane border (QColor)"""
- return qt.QColor.fromRgbF(*self._plane.color)
+ return qt.QColor.fromRgbF(*self._planeStroke.color)
def setStrokeColor(self, color):
"""Set the color of the plane border.
@@ -477,7 +569,9 @@ class CutPlane(qt.QObject):
:type color:
QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
"""
- self._plane.color = rgba(color)
+ color = rgba(color)
+ self._planeStroke.color = color
+ self._dataPlane.color = color
# Data
@@ -501,7 +595,7 @@ class CutPlane(qt.QObject):
:return: 'nearest' or 'linear'
:rtype: str
"""
- return self._plane.interpolation
+ return self._dataPlane.interpolation
def setInterpolation(self, interpolation):
"""Set the interpolation used to display to cut plane
@@ -511,7 +605,7 @@ class CutPlane(qt.QObject):
:param str interpolation: 'nearest' or 'linear'
"""
if interpolation != self.getInterpolation():
- self._plane.interpolation = interpolation
+ self._dataPlane.interpolation = interpolation
self.sigInterpolationChanged.emit(interpolation)
# Colormap
@@ -527,11 +621,29 @@ class CutPlane(qt.QObject):
# """
# self._plane.alpha = alpha
+ def getDisplayValuesBelowMin(self):
+ """Return whether values <= colormap min are displayed or not.
+
+ :rtype: bool
+ """
+ return self._dataPlane.colormap.displayValuesBelowMin
+
+ def setDisplayValuesBelowMin(self, display):
+ """Set whether to display values <= colormap min.
+
+ :param bool display: True to show values below min,
+ False to discard them
+ """
+ display = bool(display)
+ if display != self.getDisplayValuesBelowMin():
+ self._dataPlane.colormap.displayValuesBelowMin = display
+ self.sigTransparencyChanged.emit()
+
def getColormap(self):
"""Returns the colormap set by :meth:`setColormap`.
:return: The colormap
- :rtype: Colormap
+ :rtype: ~silx.gui.plot.Colormap.Colormap
"""
return self._colormap
@@ -548,7 +660,7 @@ class CutPlane(qt.QObject):
:param name: Name of the colormap in
'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
Or Colormap object.
- :type name: str or Colormap
+ :type name: str or ~silx.gui.plot.Colormap.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
@@ -578,21 +690,14 @@ class CutPlane(qt.QObject):
:return: 2-tuple of float
"""
- return self._plane.colormap.range_
+ return self._dataPlane.colormap.range_
def _updateSceneColormap(self):
"""Synchronizes scene's colormap with Colormap object"""
colormap = self.getColormap()
- sceneCMap = self._plane.colormap
+ sceneCMap = self._dataPlane.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.colormap = colormap.getNColors()
sceneCMap.norm = colormap.getNormalization()
range_ = colormap.getColormapRange(data=self._dataRange)
@@ -614,14 +719,14 @@ class _CutPlaneImage(object):
def __init__(self, cutPlane):
# Init attributes with default values
self._isValid = False
- self._data = numpy.array([])
+ self._data = numpy.zeros((0, 0), dtype=numpy.float32)
+ self._index = 0
self._xLabel = ''
self._yLabel = ''
self._normalLabel = ''
- self._scale = 1., 1.
- self._translation = 0., 0.
- self._index = 0
- self._position = 0.
+ self._scale = float('nan'), float('nan')
+ self._translation = float('nan'), float('nan')
+ self._position = float('nan')
sfView = cutPlane.parent()
if not sfView or not cutPlane.isValid():
@@ -633,19 +738,30 @@ class _CutPlaneImage(object):
_logger.info("No data available")
return
- normal = cutPlane.getNormal()
- point = numpy.array(cutPlane.getPoint(), dtype=numpy.int)
+ normal = cutPlane.getNormal(coordinates='array')
+ point = cutPlane.getPoint(coordinates='array')
- if numpy.all(numpy.equal(normal, (1., 0., 0.))):
- index = max(0, min(point[0], data.shape[2] - 1))
+ if numpy.linalg.norm(numpy.cross(normal, (1., 0., 0.))) < 0.0017:
+ if not 0 <= point[0] <= data.shape[2]:
+ _logger.info("Plane outside dataset")
+ return
+ index = max(0, min(int(point[0]), data.shape[2] - 1))
slice_ = data[:, :, index]
xAxisIndex, yAxisIndex, normalAxisIndex = 1, 2, 0 # y, z, x
- elif numpy.all(numpy.equal(normal, (0., 1., 0.))):
- index = max(0, min(point[1], data.shape[1] - 1))
+
+ elif numpy.linalg.norm(numpy.cross(normal, (0., 1., 0.))) < 0.0017:
+ if not 0 <= point[1] <= data.shape[1]:
+ _logger.info("Plane outside dataset")
+ return
+ index = max(0, min(int(point[1]), data.shape[1] - 1))
slice_ = numpy.transpose(data[:, index, :])
xAxisIndex, yAxisIndex, normalAxisIndex = 2, 0, 1 # z, x, y
- elif numpy.all(numpy.equal(normal, (0., 0., 1.))):
- index = max(0, min(point[2], data.shape[0] - 1))
+
+ elif numpy.linalg.norm(numpy.cross(normal, (0., 0., 1.))) < 0.0017:
+ if not 0 <= point[2] <= data.shape[0]:
+ _logger.info("Plane outside dataset")
+ return
+ index = max(0, min(int(point[2]), data.shape[0] - 1))
slice_ = data[index, :, :]
xAxisIndex, yAxisIndex, normalAxisIndex = 0, 1, 2 # x, y, z
else:
@@ -657,21 +773,25 @@ class _CutPlaneImage(object):
self._isValid = True
self._data = numpy.array(slice_, copy=True)
+ self._index = index
- labels = sfView.getAxesLabels()
- scale = sfView.getScale()
- translation = sfView.getTranslation()
+ # Only store extra information when no transform matrix is set
+ # Otherwise this information can be meaningless
+ if numpy.all(numpy.equal(sfView.getTransformMatrix(),
+ numpy.identity(3, dtype=numpy.float32))):
+ labels = sfView.getAxesLabels()
+ self._xLabel = labels[xAxisIndex]
+ self._yLabel = labels[yAxisIndex]
+ self._normalLabel = labels[normalAxisIndex]
- self._xLabel = labels[xAxisIndex]
- self._yLabel = labels[yAxisIndex]
- self._normalLabel = labels[normalAxisIndex]
+ scale = sfView.getScale()
+ self._scale = scale[xAxisIndex], scale[yAxisIndex]
- self._scale = scale[xAxisIndex], scale[yAxisIndex]
- self._translation = translation[xAxisIndex], translation[yAxisIndex]
+ translation = sfView.getTranslation()
+ self._translation = translation[xAxisIndex], translation[yAxisIndex]
- self._index = index
- self._position = float(index * scale[normalAxisIndex] +
- translation[normalAxisIndex])
+ self._position = float(index * scale[normalAxisIndex] +
+ translation[normalAxisIndex])
def isValid(self):
"""Returns True if the cut plane image is defined (bool)"""
@@ -727,6 +847,13 @@ class ScalarFieldView(Plot3DWindow):
sigDataChanged = qt.Signal()
"""Signal emitted when the scalar data field has changed."""
+ sigTransformChanged = qt.Signal()
+ """Signal emitted when the transformation has changed.
+
+ It is emitted by :meth:`setTranslation`, :meth:`setTransformMatrix`,
+ :meth:`setScale`.
+ """
+
sigSelectedRegionChanged = qt.Signal(object)
"""Signal emitted when the selected region has changed.
@@ -745,6 +872,7 @@ class ScalarFieldView(Plot3DWindow):
# Transformations
self._dataScale = transform.Scale()
self._dataTranslate = transform.Translate()
+ self._dataTransform = transform.Matrix() # default to identity
self._foregroundColor = 1., 1., 1., 1.
self._highlightColor = 0.7, 0.7, 0., 1.
@@ -752,8 +880,13 @@ class ScalarFieldView(Plot3DWindow):
self._data = None
self._dataRange = None
- self._group = _BoundedGroup()
- self._group.transforms = [self._dataTranslate, self._dataScale]
+ self._group = primitives.BoundedGroup()
+ self._group.transforms = [
+ self._dataTranslate, self._dataTransform, self._dataScale]
+
+ self._bbox = axes.LabelledAxes()
+ self._bbox.children = [self._group]
+ self.getPlot3DWidget().viewport.scene.children.append(self._bbox)
self._selectionBox = primitives.Box()
self._selectionBox.strokeSmooth = False
@@ -766,7 +899,9 @@ class ScalarFieldView(Plot3DWindow):
self._cutPlane = CutPlane(sfView=self)
self._cutPlane.sigVisibilityChanged.connect(
self._planeVisibilityChanged)
- self._group.children.append(self._cutPlane._get3DPrimitive())
+ planeStroke, dataPlane = self._cutPlane._get3DPrimitives()
+ self._bbox.children.append(planeStroke)
+ self._group.children.append(dataPlane)
self._isogroup = primitives.GroupDepthOffset()
self._isogroup.transforms = [
@@ -781,10 +916,6 @@ class ScalarFieldView(Plot3DWindow):
]
self._group.children.append(self._isogroup)
- self._bbox = axes.LabelledAxes()
- self._bbox.children = [self._group]
- self.getPlot3DWidget().viewport.scene.children.append(self._bbox)
-
self._initPanPlaneAction()
self._updateColors()
@@ -932,9 +1063,10 @@ class ScalarFieldView(Plot3DWindow):
"""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.setText('Pan plane')
self._panPlaneAction.setCheckable(True)
- self._panPlaneAction.setToolTip('pan the cutting plane')
+ self._panPlaneAction.setToolTip(
+ 'Pan the cutting plane. Press <b>Ctrl</b> to rotate the scene.')
self._panPlaneAction.setEnabled(False)
self._panPlaneAction.triggered[bool].connect(self._planeActionTriggered)
@@ -972,24 +1104,21 @@ class ScalarFieldView(Plot3DWindow):
sceneScale = self.getPlot3DWidget().viewport.scene.transforms[0]
if mode == 'plane':
- self.getPlot3DWidget().setInteractiveMode(None)
-
- self.getPlot3DWidget().eventHandler = \
- interaction.PanPlaneZoomOnWheelControl(
- self.getPlot3DWidget().viewport,
- self._cutPlane._get3DPrimitive(),
- mode='position',
- scaleTransform=sceneScale)
- else:
- self.getPlot3DWidget().setInteractiveMode(mode)
+ mode = interaction.PanPlaneZoomOnWheelControl(
+ self.getPlot3DWidget().viewport,
+ self._cutPlane._get3DPrimitives()[0],
+ mode='position',
+ orbitAroundCenter=False,
+ scaleTransform=sceneScale)
+
+ self.getPlot3DWidget().setInteractiveMode(mode)
self._updateColors()
def getInteractiveMode(self):
"""Returns the current interaction mode, see :meth:`setInteractiveMode`
"""
- if (isinstance(self.getPlot3DWidget().eventHandler,
- interaction.PanPlaneZoomOnWheelControl) or
- self.getPlot3DWidget().eventHandler is None):
+ if isinstance(self.getPlot3DWidget().eventHandler,
+ interaction.PanPlaneZoomOnWheelControl):
return 'plane'
else:
return self.getPlot3DWidget().getInteractiveMode()
@@ -1085,6 +1214,7 @@ class ScalarFieldView(Plot3DWindow):
scale = numpy.array((sx, sy, sz), dtype=numpy.float32)
if not numpy.all(numpy.equal(scale, self.getScale())):
self._dataScale.scale = scale
+ self.sigTransformChanged.emit()
self.centerScene() # Reset viewpoint
def getScale(self):
@@ -1102,6 +1232,7 @@ class ScalarFieldView(Plot3DWindow):
translation = numpy.array((x, y, z), dtype=numpy.float32)
if not numpy.all(numpy.equal(translation, self.getTranslation())):
self._dataTranslate.translation = translation
+ self.sigTransformChanged.emit()
self.centerScene() # Reset viewpoint
def getTranslation(self):
@@ -1109,6 +1240,28 @@ class ScalarFieldView(Plot3DWindow):
"""
return self._dataTranslate.translation
+ def setTransformMatrix(self, matrix3x3):
+ """Set the transform matrix applied to the data.
+
+ :param numpy.ndarray matrix: 3x3 transform matrix
+ """
+ matrix3x3 = numpy.array(matrix3x3, copy=True, dtype=numpy.float32)
+ if not numpy.all(numpy.equal(matrix3x3, self.getTransformMatrix())):
+ matrix = numpy.identity(4, dtype=numpy.float32)
+ matrix[:3, :3] = matrix3x3
+ self._dataTransform.setMatrix(matrix)
+ self.sigTransformChanged.emit()
+ self.centerScene() # Reset viewpoint
+
+ def getTransformMatrix(self):
+ """Returns the transform matrix applied to the data.
+
+ See :meth:`setTransformMatrix`.
+
+ :rtype: numpy.ndarray
+ """
+ return self._dataTransform.getMatrix()[:3, :3]
+
# Axes labels
def isBoundingBoxVisible(self):
@@ -1123,7 +1276,8 @@ class ScalarFieldView(Plot3DWindow):
:param bool visible: True to show axes, False to hide
"""
- self._bbox.boxVisible = bool(visible)
+ visible = bool(visible)
+ self._bbox.boxVisible = visible
def setAxesLabels(self, xlabel=None, ylabel=None, zlabel=None):
"""Set the text labels of the axes.
@@ -1297,7 +1451,9 @@ class ScalarFieldView(Plot3DWindow):
if self._selectedRange is None:
return None
else:
- return SelectedRegion(self._selectedRange,
+ dataBBox = self._group.transforms.transformBounds(
+ self._selectedRange[::-1].T).T
+ return SelectedRegion(self._selectedRange, dataBBox,
translation=self.getTranslation(),
scale=self.getScale())
diff --git a/silx/gui/plot3d/SceneWidget.py b/silx/gui/plot3d/SceneWidget.py
new file mode 100644
index 0000000..4e75515
--- /dev/null
+++ b/silx/gui/plot3d/SceneWidget.py
@@ -0,0 +1,646 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 widget to view data sets in 3D."""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "26/10/2017"
+
+import numpy
+import weakref
+
+from silx.third_party import enum
+from .. import qt
+from ..plot.Colors import rgba
+
+from .Plot3DWidget import Plot3DWidget
+from . import items
+from .scene import interaction
+from ._model import SceneModel, visitQAbstractItemModel
+from ._model.items import Item3DRow
+
+
+__all__ = ['items', 'SceneWidget']
+
+
+class _SceneSelectionHighlightManager(object):
+ """Class controlling the highlight of the selection in a SceneWidget
+
+ :param ~silx.gui.plot3d.SceneWidget.SceneSelection:
+ """
+
+ def __init__(self, selection):
+ assert isinstance(selection, SceneSelection)
+ self._sceneWidget = weakref.ref(selection.parent())
+
+ self._enabled = True
+ self._previousBBoxState = None
+
+ self.__selectItem(selection.getCurrentItem())
+ selection.sigCurrentChanged.connect(self.__currentChanged)
+
+ def isEnabled(self):
+ """Returns True if highlight of selection in enabled.
+
+ :rtype: bool
+ """
+ return self._enabled
+
+ def setEnabled(self, enabled=True):
+ """Activate/deactivate selection highlighting
+
+ :param bool enabled: True (default) to enable selection highlighting
+ """
+ enabled = bool(enabled)
+ if enabled != self._enabled:
+ self._enabled = enabled
+
+ sceneWidget = self.getSceneWidget()
+ if sceneWidget is not None:
+ selection = sceneWidget.selection()
+ current = selection.getCurrentItem()
+
+ if enabled:
+ self.__selectItem(current)
+ selection.sigCurrentChanged.connect(self.__currentChanged)
+
+ else: # disabled
+ self.__unselectItem(current)
+ selection.sigCurrentChanged.disconnect(
+ self.__currentChanged)
+
+ def getSceneWidget(self):
+ """Returns the SceneWidget this class controls highlight for.
+
+ :rtype: ~silx.gui.plot3d.SceneWidget.SceneWidget
+ """
+ return self._sceneWidget()
+
+ def __selectItem(self, current):
+ """Highlight given item.
+
+ :param ~silx.gui.plot3d.items.Item3D current: New current or None
+ """
+ if current is None:
+ return
+
+ sceneWidget = self.getSceneWidget()
+ if sceneWidget is None:
+ return
+
+ if isinstance(current, items.DataItem3D):
+ self._previousBBoxState = current.isBoundingBoxVisible()
+ current.setBoundingBoxVisible(True)
+ current._setForegroundColor(sceneWidget.getHighlightColor())
+ current.sigItemChanged.connect(self.__selectedChanged)
+
+ def __unselectItem(self, current):
+ """Remove highlight of given item.
+
+ :param ~silx.gui.plot3d.items.Item3D current:
+ Currently highlighted item
+ """
+ if current is None:
+ return
+
+ sceneWidget = self.getSceneWidget()
+ if sceneWidget is None:
+ return
+
+ # Restore bbox visibility and color
+ current.sigItemChanged.disconnect(self.__selectedChanged)
+ if (self._previousBBoxState is not None and
+ isinstance(current, items.DataItem3D)):
+ current.setBoundingBoxVisible(self._previousBBoxState)
+ current._setForegroundColor(sceneWidget.getForegroundColor())
+
+ def __currentChanged(self, current, previous):
+ """Handle change of current item in the selection
+
+ :param ~silx.gui.plot3d.items.Item3D current: New current or None
+ :param ~silx.gui.plot3d.items.Item3D previous: Previous current or None
+ """
+ self.__unselectItem(previous)
+ self.__selectItem(current)
+
+ def __selectedChanged(self, event):
+ """Handle updates of selected item bbox.
+
+ If bbox gets changed while selected, do not restore state.
+
+ :param event:
+ """
+ if event == items.Item3DChangedType.BOUNDING_BOX_VISIBLE:
+ self._previousBBoxState = None
+
+
+@enum.unique
+class HighlightMode(enum.Enum):
+ """:class:`SceneSelection` highlight modes"""
+
+ NONE = 'noHighlight'
+ """Do not highlight selected item"""
+
+ BOUNDING_BOX = 'boundingBox'
+ """Highlight selected item bounding box"""
+
+
+class SceneSelection(qt.QObject):
+ """Object managing a :class:`SceneWidget` selection
+
+ :param SceneWidget parent:
+ """
+
+ NO_SELECTION = 0
+ """Flag for no item selected"""
+
+ sigCurrentChanged = qt.Signal(object, object)
+ """This signal is emitted whenever the current item changes.
+
+ It provides the current and previous items.
+ Either of those can be :attr:`NO_SELECTION`.
+ """
+
+ def __init__(self, parent=None):
+ super(SceneSelection, self).__init__(parent)
+ self.__current = None # Store weakref to current item
+ self.__selectionModel = None # Store sync selection model
+ self.__syncInProgress = False # True during model synchronization
+
+ self.__highlightManager = _SceneSelectionHighlightManager(self)
+
+ def getHighlightMode(self):
+ """Returns current selection highlight mode.
+
+ Either NONE or BOUNDING_BOX.
+
+ :rtype: HighlightMode
+ """
+ if self.__highlightManager.isEnabled():
+ return HighlightMode.BOUNDING_BOX
+ else:
+ return HighlightMode.NONE
+
+ def setHighlightMode(self, mode):
+ """Set selection highlighting mode
+
+ :param HighlightMode mode: The mode to use
+ """
+ assert isinstance(mode, HighlightMode)
+ self.__highlightManager.setEnabled(mode == HighlightMode.BOUNDING_BOX)
+
+ def getCurrentItem(self):
+ """Returns the current item in the scene or None.
+
+ :rtype: Union[~silx.gui.plot3d.items.Item3D, None]
+ """
+ return None if self.__current is None else self.__current()
+
+ def setCurrentItem(self, item):
+ """Set the current item in the scene.
+
+ :param Union[Item3D, None] item:
+ The new item to select or None to clear the selection.
+ :raise ValueError: If the item is not the widget's scene
+ """
+ previous = self.getCurrentItem()
+ if previous is not None:
+ previous.sigItemChanged.disconnect(self.__currentChanged)
+
+ if item is None:
+ self.__current = None
+
+ elif isinstance(item, items.Item3D):
+ parent = self.parent()
+ assert isinstance(parent, SceneWidget)
+
+ sceneGroup = parent.getSceneGroup()
+ if item is sceneGroup or item.root() is sceneGroup:
+ item.sigItemChanged.connect(self.__currentChanged)
+ self.__current = weakref.ref(item)
+ else:
+ raise ValueError(
+ 'Item is not in this SceneWidget: %s' % str(item))
+
+ else:
+ raise ValueError(
+ 'Not an Item3D: %s' % str(item))
+
+ current = self.getCurrentItem()
+ if current is not previous:
+ self.sigCurrentChanged.emit(current, previous)
+ self.__updateSelectionModel()
+
+ def __currentChanged(self, event):
+ """Handle updates of the selected item"""
+ if event == items.Item3DChangedType.ROOT_ITEM:
+ item = self.sender()
+ if item.root() != self.getSceneGroup():
+ self.setSelectedItem(None)
+
+ # Synchronization with QItemSelectionModel
+
+ def _getSyncSelectionModel(self):
+ """Returns the QItemSelectionModel this selection is synchronized with.
+
+ :rtype: Union[QItemSelectionModel, None]
+ """
+ return self.__selectionModel
+
+ def _setSyncSelectionModel(self, selectionModel):
+ """Synchronizes this selection object with a selection model.
+
+ :param Union[QItemSelectionModel, None] selectionModel:
+ :raise ValueError: If the selection model does not correspond
+ to the same :class:`SceneWidget`
+ """
+ if (not isinstance(selectionModel, qt.QItemSelectionModel) or
+ not isinstance(selectionModel.model(), SceneModel) or
+ selectionModel.model().sceneWidget() is not self.parent()):
+ raise ValueError("Expecting a QItemSelectionModel "
+ "attached to the same SceneWidget")
+
+ # Disconnect from previous selection model
+ previousSelectionModel = self._getSyncSelectionModel()
+ if previousSelectionModel is not None:
+ previousSelectionModel.selectionChanged.disconnect(
+ self.__selectionModelSelectionChanged)
+
+ self.__selectionModel = selectionModel
+
+ if selectionModel is not None:
+ # Connect to new selection model
+ selectionModel.selectionChanged.connect(
+ self.__selectionModelSelectionChanged)
+ self.__updateSelectionModel()
+
+ def __selectionModelSelectionChanged(self, selected, deselected):
+ """Handle QItemSelectionModel selection updates.
+
+ :param QItemSelection selected:
+ :param QItemSelection deselected:
+ """
+ if self.__syncInProgress:
+ return
+
+ indices = selected.indexes()
+ if not indices:
+ item = None
+
+ else: # Select the first selected item
+ index = indices[0]
+ itemRow = index.internalPointer()
+ if isinstance(itemRow, Item3DRow):
+ item = itemRow.item()
+ else:
+ item = None
+
+ self.setCurrentItem(item)
+
+ def __updateSelectionModel(self):
+ """Sync selection model when current item has been updated"""
+ selectionModel = self._getSyncSelectionModel()
+ if selectionModel is None:
+ return
+
+ currentItem = self.getCurrentItem()
+
+ if currentItem is None:
+ selectionModel.clear()
+
+ else:
+ # visit the model to find selectable index corresponding to item
+ model = selectionModel.model()
+ for index in visitQAbstractItemModel(model):
+ itemRow = index.internalPointer()
+ if (isinstance(itemRow, Item3DRow) and
+ itemRow.item() is currentItem and
+ index.flags() & qt.Qt.ItemIsSelectable):
+ # This is the item we are looking for: select it in the model
+ self.__syncInProgress = True
+ selectionModel.select(
+ index, qt.QItemSelectionModel.Clear |
+ qt.QItemSelectionModel.Select |
+ qt.QItemSelectionModel.Current)
+ self.__syncInProgress = False
+ break
+
+
+class SceneWidget(Plot3DWidget):
+ """Widget displaying data sets in 3D"""
+
+ def __init__(self, parent=None):
+ super(SceneWidget, self).__init__(parent)
+ self._model = None # Store lazy-loaded model
+ self._selection = None # Store lazy-loaded SceneSelection
+ self._items = []
+
+ self._textColor = 1., 1., 1., 1.
+ self._foregroundColor = 1., 1., 1., 1.
+ self._highlightColor = 0.7, 0.7, 0., 1.
+
+ self._sceneGroup = items.GroupWithAxesItem(parent=self)
+ self._sceneGroup.setLabel('Data')
+
+ self.viewport.scene.children.append(self._sceneGroup._getScenePrimitive())
+
+ def model(self):
+ """Returns the model corresponding the scene of this widget
+
+ :rtype: SceneModel
+ """
+ if self._model is None:
+ # Lazy-loading of the model
+ self._model = SceneModel(parent=self)
+ return self._model
+
+ def selection(self):
+ """Returns the object managing selection in the scene
+
+ :rtype: SceneSelection
+ """
+ if self._selection is None:
+ # Lazy-loading of the SceneSelection
+ self._selection = SceneSelection(parent=self)
+ return self._selection
+
+ def getSceneGroup(self):
+ """Returns the root group of the scene
+
+ :rtype: GroupItem
+ """
+ return self._sceneGroup
+
+ # Interactive modes
+
+ def _handleSelectionChanged(self, current, previous):
+ """Handle change of selection to update interactive mode"""
+ if self.getInteractiveMode() == 'panSelectedPlane':
+ if isinstance(current, items.PlaneMixIn):
+ # Update pan plane to use new selected plane
+ self.setInteractiveMode('panSelectedPlane')
+
+ else: # Switch to rotate scene if new selection is not a plane
+ self.setInteractiveMode('rotate')
+
+ def setInteractiveMode(self, mode):
+ """Set the interactive mode.
+
+ 'panSelectedPlane' mode set plane panning if a plane is selected,
+ otherwise it fall backs to 'rotate'.
+
+ :param str mode:
+ The interactive mode: 'rotate', 'pan', 'panSelectedPlane' or None
+ """
+ if self.getInteractiveMode() == 'panSelectedPlane':
+ self.selection().sigCurrentChanged.disconnect(
+ self._handleSelectionChanged)
+
+ if mode == 'panSelectedPlane':
+ selected = self.selection().getCurrentItem()
+
+ if isinstance(selected, items.PlaneMixIn):
+ mode = interaction.PanPlaneZoomOnWheelControl(
+ self.viewport,
+ selected._getPlane(),
+ mode='position',
+ orbitAroundCenter=False,
+ scaleTransform=self._sceneScale)
+
+ self.selection().sigCurrentChanged.connect(
+ self._handleSelectionChanged)
+
+ else: # No selected plane, fallback to rotate scene
+ mode = 'rotate'
+
+ super(SceneWidget, self).setInteractiveMode(mode)
+
+ def getInteractiveMode(self):
+ """Returns the interactive mode in use.
+
+ :rtype: str
+ """
+ if isinstance(self.eventHandler, interaction.PanPlaneZoomOnWheelControl):
+ return 'panSelectedPlane'
+ else:
+ return super(SceneWidget, self).getInteractiveMode()
+
+ # Add/remove items
+
+ def add3DScalarField(self, data, copy=True, index=None):
+ """Add 3D scalar data volume to :class:`SceneWidget` content.
+
+ Dataset order is zyx (i.e., first dimension is z).
+
+ :param data: 3D array
+ :type data: 3D numpy.ndarray of float32 with shape at least (2, 2, 2)
+ :param bool copy:
+ True (default) to make a copy,
+ False to avoid copy (DO NOT MODIFY data afterwards)
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :return: The newly created scalar volume item
+ :rtype: items.ScalarField3D
+ """
+ volume = items.ScalarField3D()
+ volume.setData(data, copy=copy)
+ self.addItem(volume, index)
+ return volume
+
+ def add3DScatter(self, x, y, z, value, copy=True, index=None):
+ """Add 3D scatter data to :class:`SceneWidget` content.
+
+ :param numpy.ndarray x: Array of X coordinates (single value not accepted)
+ :param y: Points Y coordinate (array-like or single value)
+ :param z: Points Z coordinate (array-like or single value)
+ :param value: Points values (array-like or single value)
+ :param bool copy:
+ True (default) to copy the data,
+ False to use provided data (do not modify!)
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :return: The newly created 3D scatter item
+ :rtype: items.Scatter3D
+ """
+ scatter3d = items.Scatter3D()
+ scatter3d.setData(x=x, y=y, z=z, value=value, copy=copy)
+ self.addItem(scatter3d, index)
+ return scatter3d
+
+ def add2DScatter(self, x, y, value, copy=True, index=None):
+ """Add 2D scatter data to :class:`SceneWidget` content.
+
+ Provided arrays must have the same length.
+
+ :param numpy.ndarray x: X coordinates (array-like)
+ :param numpy.ndarray y: Y coordinates (array-like)
+ :param value: Points value: array-like or single scalar
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :return: The newly created 2D scatter item
+ :rtype: items.Scatter2D
+ """
+ scatter2d = items.Scatter2D()
+ scatter2d.setData(x=x, y=y, value=value, copy=copy)
+ self.addItem(scatter2d, index)
+ return scatter2d
+
+ def addImage(self, data, copy=True, index=None):
+ """Add a 2D data or RGB(A) image to :class:`SceneWidget` content.
+
+ 2D data is casted to float32.
+ RGBA supported formats are: float32 in [0, 1] and uint8.
+
+ :param numpy.ndarray data: Image as a 2D data array or
+ RGBA image as a 3D array (height, width, channels)
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :return: The newly created image item
+ :rtype: items.ImageData or items.ImageRgba
+ :raise ValueError: For arrays of unsupported dimensions
+ """
+ data = numpy.array(data, copy=False)
+ if data.ndim == 2:
+ image = items.ImageData()
+ elif data.ndim == 3:
+ image = items.ImageRgba()
+ else:
+ raise ValueError("Unsupported array dimensions: %d" % data.ndim)
+ image.setData(data, copy=copy)
+ self.addItem(image, index)
+ return image
+
+ def addItem(self, item, index=None):
+ """Add an item to :class:`SceneWidget` content
+
+ :param Item3D item: The item to add
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :raise ValueError: If the item is already in the :class:`SceneWidget`.
+ """
+ return self.getSceneGroup().addItem(item, index)
+
+ def removeItem(self, item):
+ """Remove an item from :class:`SceneWidget` content.
+
+ :param Item3D item: The item to remove from the scene
+ :raises ValueError: If the item does not belong to the group
+ """
+ return self.getSceneGroup().removeItem(item)
+
+ def getItems(self):
+ """Returns the list of :class:`SceneWidget` items.
+
+ Only items in the top-level group are returned.
+
+ :rtype: tuple
+ """
+ return self.getSceneGroup().getItems()
+
+ def clearItems(self):
+ """Remove all item from :class:`SceneWidget`."""
+ return self.getSceneGroup().clear()
+
+ # Colors
+
+ def getTextColor(self):
+ """Return color used for text
+
+ :rtype: QColor"""
+ return qt.QColor.fromRgbF(*self._textColor)
+
+ def setTextColor(self, color):
+ """Set the text color.
+
+ :param color: RGB color: name, #RRGGBB or RGB values
+ :type color:
+ QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
+ """
+ color = rgba(color)
+ if color != self._textColor:
+ self._textColor = color
+
+ # Update text color
+ # TODO make entry point in Item3D for this
+ bbox = self._sceneGroup._getScenePrimitive()
+ bbox.tickColor = color
+
+ self.sigStyleChanged.emit('textColor')
+
+ def getForegroundColor(self):
+ """Return color used for bounding box
+
+ :rtype: QColor
+ """
+ return qt.QColor.fromRgbF(*self._foregroundColor)
+
+ def setForegroundColor(self, color):
+ """Set the foreground color.
+
+ :param color: RGB color: name, #RRGGBB or RGB values
+ :type color:
+ QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
+ """
+ color = rgba(color)
+ if color != self._foregroundColor:
+ self._foregroundColor = color
+
+ # Update scene items
+ selected = self.selection().getCurrentItem()
+ for item in self.getSceneGroup().visit(included=True):
+ if item is not selected:
+ item._setForegroundColor(color)
+
+ self.sigStyleChanged.emit('foregroundColor')
+
+ def getHighlightColor(self):
+ """Return color used for highlighted item bounding box
+
+ :rtype: QColor
+ """
+ return qt.QColor.fromRgbF(*self._highlightColor)
+
+ def setHighlightColor(self, color):
+ """Set highlighted item color.
+
+ :param color: RGB color: name, #RRGGBB or RGB values
+ :type color:
+ QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
+ """
+ color = rgba(color)
+ if color != self._highlightColor:
+ self._highlightColor = color
+
+ selected = self.selection().getCurrentItem()
+ if selected is not None:
+ selected._setForegroundColor(color)
+
+ self.sigStyleChanged.emit('highlightColor')
diff --git a/silx/gui/plot3d/SceneWindow.py b/silx/gui/plot3d/SceneWindow.py
new file mode 100644
index 0000000..5121a17
--- /dev/null
+++ b/silx/gui/plot3d/SceneWindow.py
@@ -0,0 +1,192 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 QMainWindow with a 3D SceneWidget and toolbars.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "29/11/2017"
+
+
+from ...gui import qt, icons
+
+from .actions.mode import InteractiveModeAction
+from .SceneWidget import SceneWidget
+from .tools import OutputToolBar, InteractiveModeToolBar, ViewpointToolBar
+from .tools.GroupPropertiesWidget import GroupPropertiesWidget
+
+from .ParamTreeView import ParamTreeView
+
+# Imported here for convenience
+from . import items # noqa
+
+
+__all__ = ['items', 'SceneWidget', 'SceneWindow']
+
+
+class _PanPlaneAction(InteractiveModeAction):
+ """QAction to set plane pan interaction on a Plot3DWidget
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(_PanPlaneAction, self).__init__(
+ parent, 'panSelectedPlane', plot3d)
+ self.setIcon(icons.getQIcon('3d-plane-pan'))
+ self.setText('Pan plane')
+ self.setCheckable(True)
+ self.setToolTip(
+ 'Pan selected plane. Press <b>Ctrl</b> to rotate the scene.')
+
+ def _planeChanged(self, event):
+ """Handle plane updates"""
+ if event in (items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.POSITION):
+ plane = self.sender()
+
+ isPlaneInteractive = \
+ plane._getPlane().plane.isPlane and plane.isVisible()
+
+ if isPlaneInteractive != self.isEnabled():
+ self.setEnabled(isPlaneInteractive)
+ mode = 'panSelectedPlane' if isPlaneInteractive else 'rotate'
+ self.getPlot3DWidget().setInteractiveMode(mode)
+
+ def _selectionChanged(self, current, previous):
+ """Handle selected object change"""
+ if isinstance(previous, items.PlaneMixIn):
+ previous.sigItemChanged.disconnect(self._planeChanged)
+
+ if isinstance(current, items.PlaneMixIn):
+ current.sigItemChanged.connect(self._planeChanged)
+ self.setEnabled(True)
+ self.getPlot3DWidget().setInteractiveMode('panSelectedPlane')
+ else:
+ self.setEnabled(False)
+
+ def setPlot3DWidget(self, widget):
+ previous = self.getPlot3DWidget()
+ if isinstance(previous, SceneWidget):
+ previous.selection().sigCurrentChanged.disconnect(
+ self._selectionChanged)
+ self._selectionChanged(
+ None, previous.selection().getCurrentItem())
+
+ super(_PanPlaneAction, self).setPlot3DWidget(widget)
+
+ if isinstance(widget, SceneWidget):
+ self._selectionChanged(widget.selection().getCurrentItem(), None)
+ widget.selection().sigCurrentChanged.connect(
+ self._selectionChanged)
+
+
+class SceneWindow(qt.QMainWindow):
+ """OpenGL 3D scene widget with toolbars."""
+
+ def __init__(self, parent=None):
+ super(SceneWindow, self).__init__(parent)
+ if parent is not None:
+ # behave as a widget
+ self.setWindowFlags(qt.Qt.Widget)
+
+ self._sceneWidget = SceneWidget()
+ self.setCentralWidget(self._sceneWidget)
+
+ self._interactiveModeToolBar = InteractiveModeToolBar(parent=self)
+ panPlaneAction = _PanPlaneAction(self, plot3d=self._sceneWidget)
+ self._interactiveModeToolBar.addAction(panPlaneAction)
+
+ self._viewpointToolBar = ViewpointToolBar(parent=self)
+ self._outputToolBar = OutputToolBar(parent=self)
+
+ for toolbar in (self._interactiveModeToolBar,
+ self._viewpointToolBar,
+ self._outputToolBar):
+ toolbar.setPlot3DWidget(self._sceneWidget)
+ self.addToolBar(toolbar)
+ self.addActions(toolbar.actions())
+
+ self._paramTreeView = ParamTreeView()
+ self._paramTreeView.setModel(self._sceneWidget.model())
+
+ selectionModel = self._paramTreeView.selectionModel()
+ self._sceneWidget.selection()._setSyncSelectionModel(
+ selectionModel)
+
+ paramDock = qt.QDockWidget()
+ paramDock.setWindowTitle('Object parameters')
+ paramDock.setWidget(self._paramTreeView)
+ self.addDockWidget(qt.Qt.RightDockWidgetArea, paramDock)
+
+ self._sceneGroupResetWidget = GroupPropertiesWidget()
+ self._sceneGroupResetWidget.setGroup(
+ self._sceneWidget.getSceneGroup())
+
+ resetDock = qt.QDockWidget()
+ resetDock.setWindowTitle('Global parameters')
+ resetDock.setWidget(self._sceneGroupResetWidget)
+ self.addDockWidget(qt.Qt.RightDockWidgetArea, resetDock)
+ self.tabifyDockWidget(paramDock, resetDock)
+
+ paramDock.raise_()
+
+ def getSceneWidget(self):
+ """Returns the SceneWidget of this window.
+
+ :rtype: ~silx.gui.plot3d.SceneWidget.SceneWidget
+ """
+ return self._sceneWidget
+
+ def getParamTreeView(self):
+ """Returns the :class:`ParamTreeView` of this window.
+
+ :rtype: ParamTreeView
+ """
+ return self._paramTreeView
+
+ def getInteractiveModeToolBar(self):
+ """Returns the interactive mode toolbar.
+
+ :rtype: InteractiveModeToolBar
+ """
+ return self._interactiveModeToolBar
+
+ def getViewpointToolBar(self):
+ """Returns the viewpoint toolbar.
+
+ :rtype: ViewpointToolBar
+ """
+ return self._viewpointToolBar
+
+ def getOutputToolBar(self):
+ """Returns the output toolbar.
+
+ :rtype: OutputToolBar
+ """
+ return self._outputToolBar
diff --git a/silx/gui/plot3d/_model/__init__.py b/silx/gui/plot3d/_model/__init__.py
new file mode 100644
index 0000000..4b16e32
--- /dev/null
+++ b/silx/gui/plot3d/_model/__init__.py
@@ -0,0 +1,35 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 package provides :class:`SceneWidget` content and parameters model.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+from .model import SceneModel, visitQAbstractItemModel # noqa
diff --git a/silx/gui/plot3d/_model/core.py b/silx/gui/plot3d/_model/core.py
new file mode 100644
index 0000000..e8e0820
--- /dev/null
+++ b/silx/gui/plot3d/_model/core.py
@@ -0,0 +1,372 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 base classes to implement models for 3D scene content.
+"""
+
+from __future__ import absolute_import, division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+
+import collections
+import weakref
+
+from ....utils.weakref import WeakMethodProxy
+from ... import qt
+
+
+class BaseRow(qt.QObject):
+ """Base class for rows of the tree model.
+
+ The root node parent MUST be set to the QAbstractItemModel it belongs to.
+ By default item is enabled.
+
+ :param children: Iterable of BaseRow to start with (not signaled)
+ """
+
+ def __init__(self, children=()):
+ self.__modelRef = None
+ self.__parentRef = None
+ super(BaseRow, self).__init__()
+ self.__children = []
+ for row in children:
+ assert isinstance(row, BaseRow)
+ row.setParent(self)
+ self.__children.append(row)
+ self.__flags = collections.defaultdict(lambda: qt.Qt.ItemIsEnabled)
+ self.__tooltip = None
+
+ def setParent(self, parent):
+ """Override :meth:`QObject.setParent` to cache model and parent"""
+ self.__parentRef = None if parent is None else weakref.ref(parent)
+
+ if isinstance(parent, qt.QAbstractItemModel):
+ model = parent
+ elif isinstance(parent, BaseRow):
+ model = parent.model()
+ else:
+ model = None
+
+ self._updateModel(model)
+
+ super(BaseRow, self).setParent(parent)
+
+ def parent(self):
+ """Override :meth:`QObject.setParent` to use cached parent
+
+ :rtype: Union[QObject, None]"""
+ return self.__parentRef() if self.__parentRef is not None else None
+
+ def _updateModel(self, model):
+ """Update the model this row belongs to"""
+ if model != self.model():
+ self.__modelRef = weakref.ref(model) if model is not None else None
+ for child in self.children():
+ child._updateModel(model)
+
+ def model(self):
+ """Return the model this node belongs to or None if not in a model.
+
+ :rtype: Union[QAbstractItemModel, None]
+ """
+ return self.__modelRef() if self.__modelRef is not None else None
+
+ def index(self, column=0):
+ """Return corresponding index in the model or None if not in a model.
+
+ :param int column: The column to make the index for
+ :rtype: Union[QModelIndex, None]
+ """
+ parent = self.parent()
+ model = self.model()
+
+ if model is None: # Not in a model
+ return None
+ elif parent is model: # Root node
+ return qt.QModelIndex()
+ else:
+ index = parent.index()
+ row = parent.children().index(self)
+ return model.index(row, column, index)
+
+ def columnCount(self):
+ """Returns number of columns (default: 2)
+
+ :rtype: int
+ """
+ return 2
+
+ def children(self):
+ """Returns the list of children nodes
+
+ :rtype: tuple of Node
+ """
+ return tuple(self.__children)
+
+ def rowCount(self):
+ """Returns number of rows
+
+ :rtype: int
+ """
+ return len(self.__children)
+
+ def addRow(self, row, index=None):
+ """Add a node to the children
+
+ :param BaseRow row: The node to add
+ :param int index: The index at which to insert it or
+ None to append
+ """
+ if index is None:
+ index = self.rowCount()
+ assert index <= self.rowCount()
+
+ model = self.model()
+
+ if model is not None:
+ parent = self.index()
+ model.beginInsertRows(parent, index, index)
+
+ self.__children.insert(index, row)
+ row.setParent(self)
+
+ if model is not None:
+ model.endInsertRows()
+
+ def removeRow(self, row):
+ """Remove a row from the children list.
+
+ It removes either a node or a row index.
+
+ :param row: BaseRow object or index of row to remove
+ :type row: Union[BaseRow, int]
+ """
+ if isinstance(row, BaseRow):
+ row = self.__children.index(row)
+ else:
+ row = int(row)
+ assert row < self.rowCount()
+
+ model = self.model()
+
+ if model is not None:
+ index = self.index()
+ model.beginRemoveRows(index, row, row)
+
+ node = self.__children.pop(row)
+ node.setParent(None)
+
+ if model is not None:
+ model.endRemoveRows()
+
+ def data(self, column, role):
+ """Returns data for given column and role
+
+ :param int column: Column index for this row
+ :param int role: The role to get
+ :return: Corresponding data (Default: None)
+ """
+ if role == qt.Qt.ToolTipRole and self.__tooltip is not None:
+ return self.__tooltip
+ else:
+ return None
+
+ def setData(self, column, value, role):
+ """Set data for given column and role
+
+ :param int column: Column index for this row
+ :param value: The data to set
+ :param int role: The role to set
+ :return: True on success, False on failure
+ :rtype: bool
+ """
+ return False
+
+ def setToolTip(self, tooltip):
+ """Set the tooltip of the whole row.
+
+ If None there is no tooltip.
+
+ :param Union[str, None] tooltip:
+ """
+ self.__tooltip = tooltip
+
+ def setFlags(self, flags, column=None):
+ """Set the static flags to return.
+
+ Default is ItemIsEnabled for all columns.
+
+ :param int column: The column for which to set the flags
+ :param flags: Item flags
+ """
+ if column is None:
+ self.__flags = collections.defaultdict(lambda: flags)
+ else:
+ self.__flags[column] = flags
+
+ def flags(self, column):
+ """Returns flags for given column
+
+ :rtype: int
+ """
+ return self.__flags[column]
+
+
+class StaticRow(BaseRow):
+ """Row with static data.
+
+ :param tuple display: List of data for DisplayRole for each column
+ :param dict roles: Optional mapping of roles to list of data.
+ :param children: Iterable of BaseRow to start with (not signaled)
+ """
+
+ def __init__(self, display=('', None), roles=None, children=()):
+ super(StaticRow, self).__init__(children)
+ self._dataByRoles = {} if roles is None else roles
+ self._dataByRoles[qt.Qt.DisplayRole] = display
+
+ def data(self, column, role):
+ if role in self._dataByRoles:
+ data = self._dataByRoles[role]
+ if column < len(data):
+ return data[column]
+ return super(StaticRow, self).data(column, role)
+
+ def columnCount(self):
+ return len(self._dataByRoles[qt.Qt.DisplayRole])
+
+
+class ProxyRow(BaseRow):
+ """Provides a node to proxy a data accessible through functions.
+
+ Warning: Only weak reference are kept on fget and fset.
+
+ :param str name: The name of this node
+ :param callable fget: A callable returning the data
+ :param callable fset:
+ An optional callable setting the data with data as a single argument.
+ :param notify:
+ An optional signal emitted when data has changed.
+ :param callable toModelData:
+ An optional callable to convert from fget
+ callable to data returned by the model.
+ :param callable fromModelData:
+ An optional callable converting data provided to the model to
+ data for fset.
+ :param editorHint: Data to provide as UserRole for editor selection/setup
+ """
+
+ def __init__(self,
+ name='',
+ fget=None,
+ fset=None,
+ notify=None,
+ toModelData=None,
+ fromModelData=None,
+ editorHint=None):
+
+ super(ProxyRow, self).__init__()
+ self.__name = name
+ self.__editorHint = editorHint
+
+ assert fget is not None
+ self._fget = WeakMethodProxy(fget)
+ self._fset = WeakMethodProxy(fset) if fset is not None else None
+ if fset is not None:
+ self.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsEditable, 1)
+ self._toModelData = toModelData
+ self._fromModelData = fromModelData
+
+ if notify is not None:
+ notify.connect(self._notified) # TODO support sigItemChanged flags
+
+ def _notified(self, *args, **kwargs):
+ """Send update to the model upon signal notifications"""
+ index = self.index(column=1)
+ model = self.model()
+ if model is not None:
+ model.dataChanged.emit(index, index)
+
+ def data(self, column, role):
+ if column == 0:
+ if role == qt.Qt.DisplayRole:
+ return self.__name
+
+ elif column == 1:
+ if role == qt.Qt.UserRole: # EditorHint
+ return self.__editorHint
+ elif role == qt.Qt.DisplayRole or (role == qt.Qt.EditRole and
+ self._fset is not None):
+ data = self._fget()
+ if self._toModelData is not None:
+ data = self._toModelData(data)
+ return data
+
+ return super(ProxyRow, self).data(column, role)
+
+ def setData(self, column, value, role):
+ if role == qt.Qt.EditRole and self._fset is not None:
+ if self._fromModelData is not None:
+ value = self._fromModelData(value)
+ self._fset(value)
+ return True
+
+ return super(ProxyRow, self).setData(column, value, role)
+
+
+class ColorProxyRow(ProxyRow):
+ """Provides a proxy to a QColor property.
+
+ The color is returned through the decorative role.
+
+ See :class:`ProxyRow`
+ """
+
+ def data(self, column, role):
+ if column == 1: # Show color as decoration, not text
+ if role == qt.Qt.DisplayRole:
+ return None
+ if role == qt.Qt.DecorationRole:
+ role = qt.Qt.DisplayRole
+ return super(ColorProxyRow, self).data(column, role)
+
+
+class AngleDegreeRow(ProxyRow):
+ """ProxyRow patching display of column 1 to add degree symbol
+
+ See :class:`ProxyRow`
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(AngleDegreeRow, self).__init__(*args, **kwargs)
+
+ def data(self, column, role):
+ if column == 1 and role == qt.Qt.DisplayRole:
+ return u'%g°' % super(AngleDegreeRow, self).data(column, role)
+ else:
+ return super(AngleDegreeRow, self).data(column, role)
diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py
new file mode 100644
index 0000000..7009ea1
--- /dev/null
+++ b/silx/gui/plot3d/_model/items.py
@@ -0,0 +1,1388 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 base classes to implement models for 3D scene content
+"""
+
+from __future__ import absolute_import, division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+
+import functools
+import weakref
+
+import numpy
+
+from silx.third_party import six
+
+from ..._utils import convertArrayToQImage
+from ...plot.Colormap import preferredColormaps
+from ... import qt, icons
+from .. import items
+from ..items.volume import Isosurface, CutPlane
+
+
+from .core import AngleDegreeRow, BaseRow, ColorProxyRow, ProxyRow, StaticRow
+
+
+class _DirectionalLightProxy(qt.QObject):
+ """Proxy to handle directional light with angles rather than vector.
+ """
+
+ sigAzimuthAngleChanged = qt.Signal()
+ """Signal sent when the azimuth angle has changed."""
+
+ sigAltitudeAngleChanged = qt.Signal()
+ """Signal sent when altitude angle has changed."""
+
+ def __init__(self, light):
+ super(_DirectionalLightProxy, self).__init__()
+ self._light = light
+ light.addListener(self._directionUpdated)
+ self._azimuth = 0.
+ self._altitude = 0.
+
+ def getAzimuthAngle(self):
+ """Returns the signed angle in the horizontal plane.
+
+ Unit: degrees.
+ The 0 angle corresponds to the axis perpendicular to the screen.
+
+ :rtype: float
+ """
+ return self._azimuth
+
+ def getAltitudeAngle(self):
+ """Returns the signed vertical angle from the horizontal plane.
+
+ Unit: degrees.
+ Range: [-90, +90]
+
+ :rtype: float
+ """
+ return self._altitude
+
+ def setAzimuthAngle(self, angle):
+ """Set the horizontal angle.
+
+ :param float angle: Angle from -z axis in zx plane in degrees.
+ """
+ if angle != self._azimuth:
+ self._azimuth = angle
+ self._updateLight()
+ self.sigAzimuthAngleChanged.emit()
+
+ def setAltitudeAngle(self, angle):
+ """Set the horizontal angle.
+
+ :param float angle: Angle from -z axis in zy plane in degrees.
+ """
+ if angle != self._altitude:
+ self._altitude = angle
+ self._updateLight()
+ self.sigAltitudeAngleChanged.emit()
+
+ def _directionUpdated(self, *args, **kwargs):
+ """Handle light direction update in the scene"""
+ # Invert direction to manipulate the 'source' pointing to
+ # the center of the viewport
+ x, y, z = - self._light.direction
+
+ # Horizontal plane is plane xz
+ azimuth = numpy.degrees(numpy.arctan2(x, z))
+ altitude = numpy.degrees(numpy.pi/2. - numpy.arccos(y))
+
+ if (abs(azimuth - self.getAzimuthAngle()) > 0.01 and
+ abs(abs(altitude) - 90.) >= 0.001): # Do not update when at zenith
+ self.setAzimuthAngle(azimuth)
+
+ if abs(altitude - self.getAltitudeAngle()) > 0.01:
+ self.setAltitudeAngle(altitude)
+
+ def _updateLight(self):
+ """Update light direction in the scene"""
+ azimuth = numpy.radians(self._azimuth)
+ delta = numpy.pi/2. - numpy.radians(self._altitude)
+ z = - numpy.sin(delta) * numpy.cos(azimuth)
+ x = - numpy.sin(delta) * numpy.sin(azimuth)
+ y = - numpy.cos(delta)
+ self._light.direction = x, y, z
+
+
+class Settings(StaticRow):
+ """Subtree for :class:`SceneWidget` style parameters.
+
+ :param SceneWidget sceneWidget: The widget to control
+ """
+
+ def __init__(self, sceneWidget):
+ background = ColorProxyRow(
+ name='Background',
+ fget=sceneWidget.getBackgroundColor,
+ fset=sceneWidget.setBackgroundColor,
+ notify=sceneWidget.sigStyleChanged)
+
+ foreground = ColorProxyRow(
+ name='Foreground',
+ fget=sceneWidget.getForegroundColor,
+ fset=sceneWidget.setForegroundColor,
+ notify=sceneWidget.sigStyleChanged)
+
+ text = ColorProxyRow(
+ name='Text',
+ fget=sceneWidget.getTextColor,
+ fset=sceneWidget.setTextColor,
+ notify=sceneWidget.sigStyleChanged)
+
+ highlight = ColorProxyRow(
+ name='Highlight',
+ fget=sceneWidget.getHighlightColor,
+ fset=sceneWidget.setHighlightColor,
+ notify=sceneWidget.sigStyleChanged)
+
+ axesIndicator = ProxyRow(
+ name='Axes Indicator',
+ fget=sceneWidget.isOrientationIndicatorVisible,
+ fset=sceneWidget.setOrientationIndicatorVisible,
+ notify=sceneWidget.sigStyleChanged)
+
+ # Light direction
+
+ self._lightProxy = _DirectionalLightProxy(sceneWidget.viewport.light)
+
+ azimuthNode = ProxyRow(
+ name='Azimuth',
+ fget=self._lightProxy.getAzimuthAngle,
+ fset=self._lightProxy.setAzimuthAngle,
+ notify=self._lightProxy.sigAzimuthAngleChanged,
+ editorHint=(-90, 90))
+
+ altitudeNode = ProxyRow(
+ name='Altitude',
+ fget=self._lightProxy.getAltitudeAngle,
+ fset=self._lightProxy.setAltitudeAngle,
+ notify=self._lightProxy.sigAltitudeAngleChanged,
+ editorHint=(-90, 90))
+
+ lightDirection = StaticRow(('Light Direction', None),
+ children=(azimuthNode, altitudeNode))
+
+ # Settings row
+ children = (background, foreground, text, highlight,
+ axesIndicator, lightDirection)
+ super(Settings, self).__init__(('Settings', None), children=children)
+
+
+class Item3DRow(StaticRow):
+ """Represents an :class:`Item3D` with checkable visibility
+
+ :param Item3D item: The scene item to represent.
+ :param str name: The optional name of the item
+ """
+
+ def __init__(self, item, name=None):
+ if name is None:
+ name = item.getLabel()
+ super(Item3DRow, self).__init__((name, None))
+
+ self.setFlags(
+ self.flags(0) | qt.Qt.ItemIsUserCheckable | qt.Qt.ItemIsSelectable,
+ 0)
+ self.setFlags(self.flags(1) | qt.Qt.ItemIsSelectable, 1)
+
+ self._item = weakref.ref(item)
+ item.sigItemChanged.connect(self._itemChanged)
+
+ def _itemChanged(self, event):
+ """Handle visibility change"""
+ if event == items.ItemChangedType.VISIBLE:
+ model = self.model()
+ if model is not None:
+ index = self.index(column=1)
+ model.dataChanged.emit(index, index)
+
+ def item(self):
+ """Returns the :class:`Item3D` item or None"""
+ return self._item()
+
+ def data(self, column, role):
+ if column == 0 and role == qt.Qt.CheckStateRole:
+ item = self.item()
+ if item is not None and item.isVisible():
+ return qt.Qt.Checked
+ else:
+ return qt.Qt.Unchecked
+ elif column == 0 and role == qt.Qt.DecorationRole:
+ return icons.getQIcon('item-3dim')
+ else:
+ return super(Item3DRow, self).data(column, role)
+
+ def setData(self, column, value, role):
+ if column == 0 and role == qt.Qt.CheckStateRole:
+ item = self.item()
+ if item is not None:
+ item.setVisible(value == qt.Qt.Checked)
+ return True
+ else:
+ return False
+ return super(Item3DRow, self).setData(column, value, role)
+
+
+class DataItem3DBoundingBoxRow(ProxyRow):
+ """Represents :class:`DataItem3D` bounding box visibility
+
+ :param DataItem3D item: The item for which to display/control bounding box
+ """
+
+ def __init__(self, item):
+ super(DataItem3DBoundingBoxRow, self).__init__(
+ name='Bounding box',
+ fget=item.isBoundingBoxVisible,
+ fset=item.setBoundingBoxVisible,
+ notify=item.sigItemChanged)
+
+
+class MatrixProxyRow(ProxyRow):
+ """Proxy for a row of a DataItem3D 3x3 matrix transform
+
+ :param DataItem3D item:
+ :param int index: Matrix row index
+ """
+
+ def __init__(self, item, index):
+ self._item = weakref.ref(item)
+ self._index = index
+
+ super(MatrixProxyRow, self).__init__(
+ name='',
+ fget=self._getMatrixRow,
+ fset=self._setMatrixRow,
+ notify=item.sigItemChanged)
+
+ def _getMatrixRow(self):
+ """Returns the matrix row.
+
+ :rtype: QVector3D
+ """
+ item = self._item()
+ if item is not None:
+ matrix = item.getMatrix()
+ return qt.QVector3D(*matrix[self._index, :])
+ else:
+ return None
+
+ def _setMatrixRow(self, row):
+ """Set the row of the matrix
+
+ :param QVector3D row: Row values to set
+ """
+ item = self._item()
+ if item is not None:
+ matrix = item.getMatrix()
+ matrix[self._index, :] = row.x(), row.y(), row.z()
+ item.setMatrix(matrix)
+
+ def data(self, column, role):
+ data = super(MatrixProxyRow, self).data(column, role)
+
+ if column == 1 and role == qt.Qt.DisplayRole:
+ # Convert QVector3D to text
+ data = "%g; %g; %g" % (data.x(), data.y(), data.z())
+
+ return data
+
+
+class DataItem3DTransformRow(StaticRow):
+ """Represents :class:`DataItem3D` transform parameters
+
+ :param DataItem3D item: The item for which to display/control transform
+ """
+
+ _ROTATION_CENTER_OPTIONS = 'Origin', 'Lower', 'Center', 'Upper'
+
+ def __init__(self, item):
+ super(DataItem3DTransformRow, self).__init__(('Transform', None))
+ self._item = weakref.ref(item)
+
+ translation = ProxyRow(name='Translation',
+ fget=item.getTranslation,
+ fset=self._setTranslation,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: qt.QVector3D(*data))
+ self.addRow(translation)
+
+ # Here to keep a reference
+ self._xSetCenter = functools.partial(self._setCenter, index=0)
+ self._ySetCenter = functools.partial(self._setCenter, index=1)
+ self._zSetCenter = functools.partial(self._setCenter, index=2)
+
+ rotateCenter = StaticRow(
+ ('Center', None),
+ children=(
+ ProxyRow(name='X axis',
+ fget=item.getRotationCenter,
+ fset=self._xSetCenter,
+ notify=item.sigItemChanged,
+ toModelData=functools.partial(
+ self._centerToModelData, index=0),
+ editorHint=self._ROTATION_CENTER_OPTIONS),
+ ProxyRow(name='Y axis',
+ fget=item.getRotationCenter,
+ fset=self._ySetCenter,
+ notify=item.sigItemChanged,
+ toModelData=functools.partial(
+ self._centerToModelData, index=1),
+ editorHint=self._ROTATION_CENTER_OPTIONS),
+ ProxyRow(name='Z axis',
+ fget=item.getRotationCenter,
+ fset=self._zSetCenter,
+ notify=item.sigItemChanged,
+ toModelData=functools.partial(
+ self._centerToModelData, index=2),
+ editorHint=self._ROTATION_CENTER_OPTIONS),
+ ))
+
+ rotate = StaticRow(
+ ('Rotation', None),
+ children=(
+ AngleDegreeRow(name='Angle',
+ fget=item.getRotation,
+ fset=self._setAngle,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: data[0]),
+ ProxyRow(name='Axis',
+ fget=item.getRotation,
+ fset=self._setAxis,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: qt.QVector3D(*data[1])),
+ rotateCenter
+ ))
+ self.addRow(rotate)
+
+ scale = ProxyRow(name='Scale',
+ fget=item.getScale,
+ fset=self._setScale,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: qt.QVector3D(*data))
+ self.addRow(scale)
+
+ matrix = StaticRow(
+ ('Matrix', None),
+ children=(MatrixProxyRow(item, 0),
+ MatrixProxyRow(item, 1),
+ MatrixProxyRow(item, 2)))
+ self.addRow(matrix)
+
+ def item(self):
+ """Returns the :class:`Item3D` item or None"""
+ return self._item()
+
+ @staticmethod
+ def _centerToModelData(center, index):
+ """Convert rotation center information from scene to model.
+
+ :param center: The center info from the scene
+ :param int index: dimension to convert
+ """
+ value = center[index]
+ if isinstance(value, six.string_types):
+ return value.title()
+ elif value == 0.:
+ return 'Origin'
+ else:
+ return six.text_type(value)
+
+ def _setCenter(self, value, index):
+ """Set one dimension of the rotation center.
+
+ :param value: Value received through the model.
+ :param int index: dimension to set
+ """
+ item = self.item()
+ if item is not None:
+ if value == 'Origin':
+ value = 0.
+ elif value not in self._ROTATION_CENTER_OPTIONS:
+ value = float(value)
+ else:
+ value = value.lower()
+
+ center = list(item.getRotationCenter())
+ center[index] = value
+ item.setRotationCenter(*center)
+
+ def _setAngle(self, angle):
+ """Set rotation angle.
+
+ :param float angle:
+ """
+ item = self.item()
+ if item is not None:
+ _, axis = item.getRotation()
+ item.setRotation(angle, axis)
+
+ def _setAxis(self, axis):
+ """Set rotation axis.
+
+ :param QVector3D axis:
+ """
+ item = self.item()
+ if item is not None:
+ angle, _ = item.getRotation()
+ item.setRotation(angle, (axis.x(), axis.y(), axis.z()))
+
+ def _setTranslation(self, translation):
+ """Set translation transform.
+
+ :param QVector3D translation:
+ """
+ item = self.item()
+ if item is not None:
+ item.setTranslation(translation.x(), translation.y(), translation.z())
+
+ def _setScale(self, scale):
+ """Set scale transform.
+
+ :param QVector3D scale:
+ """
+ item = self.item()
+ if item is not None:
+ item.setScale(scale.x(), scale.y(), scale.z())
+
+
+class GroupItemRow(Item3DRow):
+ """Represents a :class:`GroupItem` with transforms and children
+
+ :param GroupItem item: The scene group to represent.
+ :param str name: The optional name of the group
+ """
+
+ _CHILDREN_ROW_OFFSET = 2
+ """Number of rows for group parameters. Children are added after"""
+
+ def __init__(self, item, name=None):
+ super(GroupItemRow, self).__init__(item, name)
+ self.addRow(DataItem3DBoundingBoxRow(item))
+ self.addRow(DataItem3DTransformRow(item))
+
+ item.sigItemAdded.connect(self._itemAdded)
+ item.sigItemRemoved.connect(self._itemRemoved)
+
+ for child in item.getItems():
+ self.addRow(nodeFromItem(child))
+
+ def _itemAdded(self, item):
+ """Handle item addition to the group and add it to the model.
+
+ :param Item3D item: added item
+ """
+ group = self.item()
+ if group is None:
+ return
+
+ row = group.getItems().index(item)
+ self.addRow(nodeFromItem(item), row + self._CHILDREN_ROW_OFFSET)
+
+ def _itemRemoved(self, item):
+ """Handle item removal from the group and remove it from the model.
+
+ :param Item3D item: removed item
+ """
+ group = self.item()
+ if group is None:
+ return
+
+ # Find item
+ for row in self.children():
+ if row.item() is item:
+ self.removeRow(row)
+ break # Got it
+ else:
+ raise RuntimeError("Model does not correspond to scene content")
+
+
+class InterpolationRow(ProxyRow):
+ """Represents :class:`InterpolationMixIn` property.
+
+ :param Item3D item: Scene item with interpolation property
+ """
+
+ def __init__(self, item):
+ modes = [mode.title() for mode in item.INTERPOLATION_MODES]
+ super(InterpolationRow, self).__init__(
+ name='Interpolation',
+ fget=item.getInterpolation,
+ fset=item.setInterpolation,
+ notify=item.sigItemChanged,
+ toModelData=lambda mode: mode.title(),
+ fromModelData=lambda mode: mode.lower(),
+ editorHint=modes)
+
+
+class _ColormapBaseProxyRow(ProxyRow):
+ """Base class for colormap model row
+
+ This class handle synchronization and signals from the item and the colormap
+ """
+
+ _sigColormapChanged = qt.Signal()
+ """Signal used internally to notify colormap (or data) update"""
+
+ def __init__(self, item, *args, **kwargs):
+ self._dataRange = None
+ self._item = weakref.ref(item)
+ self._colormap = item.getColormap()
+
+ ProxyRow.__init__(self, *args, **kwargs)
+
+ self._colormap.sigChanged.connect(self._colormapChanged)
+ item.sigItemChanged.connect(self._itemChanged)
+ self._sigColormapChanged.connect(self._modelUpdated)
+
+ def item(self):
+ """Returns the :class:`ColormapMixIn` item or None"""
+ return self._item()
+
+ def _getColormapRange(self):
+ """Returns the range of the colormap for the current data.
+
+ :return: Colormap range (min, max)
+ """
+ if self._dataRange is None:
+ item = self.item()
+ if item is not None and self._colormap is not None:
+ if hasattr(item, 'getDataRange'):
+ data = item.getDataRange()
+ else:
+ data = item.getData(copy=False)
+
+ self._dataRange = self._colormap.getColormapRange(data)
+
+ else: # Fallback
+ self._dataRange = 1, 100
+ return self._dataRange
+
+ def _modelUpdated(self, *args, **kwargs):
+ """Emit dataChanged in the model"""
+ topLeft = self.index(column=0)
+ bottomRight = self.index(column=1)
+ model = self.model()
+ if model is not None:
+ model.dataChanged.emit(topLeft, bottomRight)
+
+ def _colormapChanged(self):
+ self._sigColormapChanged.emit()
+
+ def _itemChanged(self, event):
+ """Handle change of colormap or data in the item.
+
+ :param ItemChangedType event:
+ """
+ if event == items.ItemChangedType.COLORMAP:
+ self._sigColormapChanged.emit()
+ if self._colormap is not None:
+ self._colormap.sigChanged.disconnect(self._colormapChanged)
+
+ item = self.item()
+ if item is not None:
+ self._colormap = item.getColormap()
+ self._colormap.sigChanged.connect(self._colormapChanged)
+ else:
+ self._colormap = None
+
+ elif event == items.ItemChangedType.DATA:
+ self._dataRange = None
+ self._sigColormapChanged.emit()
+
+
+class _ColormapBoundRow(_ColormapBaseProxyRow):
+ """ProxyRow for colormap min or max
+
+ :param ColormapMixIn item: The item to handle
+ :param str name: Name of the raw
+ :param int index: 0 for Min and 1 of Max
+ """
+
+ def __init__(self, item, name, index):
+ self._index = index
+ _ColormapBaseProxyRow.__init__(
+ self,
+ item,
+ name=name,
+ fget=self._getBound,
+ fset=self._setBound)
+
+ self.setToolTip('Colormap %s bound:\n'
+ 'Check to set bound manually, '
+ 'uncheck for autoscale' % name.lower())
+
+ def _getRawBound(self):
+ """Proxy to get raw colormap bound
+
+ :rtype: float or None
+ """
+ if self._colormap is None:
+ return None
+ elif self._index == 0:
+ return self._colormap.getVMin()
+ else: # self._index == 1
+ return self._colormap.getVMax()
+
+ def _getBound(self):
+ """Proxy to get colormap effective bound value
+
+ :rtype: float
+ """
+ if self._colormap is not None:
+ bound = self._getRawBound()
+
+ if bound is None:
+ bound = self._getColormapRange()[self._index]
+ return bound
+ else:
+ return 1. # Fallback
+
+ def _setBound(self, value):
+ """Proxy to set colormap bound.
+
+ :param float value:
+ """
+ if self._colormap is not None:
+ if self._index == 0:
+ min_ = value
+ max_ = self._colormap.getVMax()
+ else: # self._index == 1
+ min_ = self._colormap.getVMin()
+ max_ = value
+
+ if max_ is not None and min_ is not None and min_ > max_:
+ min_, max_ = max_, min_
+ self._colormap.setVRange(min_, max_)
+
+ def flags(self, column):
+ if column == 0:
+ return qt.Qt.ItemIsEnabled | qt.Qt.ItemIsUserCheckable
+
+ elif column == 1:
+ if self._getRawBound() is not None:
+ flags = qt.Qt.ItemIsEditable | qt.Qt.ItemIsEnabled
+ else:
+ flags = qt.Qt.NoItemFlags # Disabled if autoscale
+ return flags
+
+ else: # Never event
+ return super(_ColormapBoundRow, self).flags(column)
+
+ def data(self, column, role):
+ if column == 0 and role == qt.Qt.CheckStateRole:
+ if self._getRawBound() is None:
+ return qt.Qt.Unchecked
+ else:
+ return qt.Qt.Checked
+
+ else:
+ return super(_ColormapBoundRow, self).data(column, role)
+
+ def setData(self, column, value, role):
+ if column == 0 and role == qt.Qt.CheckStateRole:
+ if self._colormap is not None:
+ bound = self._getBound() if value == qt.Qt.Checked else None
+ self._setBound(bound)
+ return True
+ else:
+ return False
+
+ return super(_ColormapBoundRow, self).setData(column, value, role)
+
+
+class ColormapRow(_ColormapBaseProxyRow):
+ """Represents :class:`ColormapMixIn` property.
+
+ :param Item3D item: Scene item with colormap property
+ """
+
+ def __init__(self, item):
+ super(ColormapRow, self).__init__(
+ item,
+ name='Colormap',
+ fget=self._get)
+
+ self._colormapImage = None
+
+ self._colormapsMapping = {}
+ for cmap in preferredColormaps():
+ self._colormapsMapping[cmap.title()] = cmap
+
+ self.addRow(ProxyRow(
+ name='Name',
+ fget=self._getName,
+ fset=self._setName,
+ notify=self._sigColormapChanged,
+ editorHint=list(self._colormapsMapping.keys())))
+
+ norms = [norm.title() for norm in self._colormap.NORMALIZATIONS]
+ self.addRow(ProxyRow(
+ name='Normalization',
+ fget=self._getNormalization,
+ fset=self._setNormalization,
+ notify=self._sigColormapChanged,
+ editorHint=norms))
+
+ self.addRow(_ColormapBoundRow(item, name='Min.', index=0))
+ self.addRow(_ColormapBoundRow(item, name='Max.', index=1))
+
+ self._sigColormapChanged.connect(self._updateColormapImage)
+
+ def _get(self):
+ """Getter for ProxyRow subclass"""
+ return None
+
+ def _getName(self):
+ """Proxy for :meth:`Colormap.getName`"""
+ if self._colormap is not None:
+ return self._colormap.getName().title()
+ else:
+ return ''
+
+ def _setName(self, name):
+ """Proxy for :meth:`Colormap.setName`"""
+ # Convert back from titled to name if possible
+ if self._colormap is not None:
+ name = self._colormapsMapping.get(name, name)
+ self._colormap.setName(name)
+
+ def _getNormalization(self):
+ """Proxy for :meth:`Colormap.getNormalization`"""
+ if self._colormap is not None:
+ return self._colormap.getNormalization().title()
+ else:
+ return ''
+
+ def _setNormalization(self, normalization):
+ """Proxy for :meth:`Colormap.setNormalization`"""
+ if self._colormap is not None:
+ return self._colormap.setNormalization(normalization.lower())
+
+ def _updateColormapImage(self, *args, **kwargs):
+ """Notify colormap update to update the image in the tree"""
+ if self._colormapImage is not None:
+ self._colormapImage = None
+ model = self.model()
+ if model is not None:
+ index = self.index(column=1)
+ model.dataChanged.emit(index, index)
+
+ def data(self, column, role):
+ if column == 1 and role == qt.Qt.DecorationRole:
+ if self._colormapImage is None:
+ image = numpy.zeros((16, 130, 3), dtype=numpy.uint8)
+ image[1:-1, 1:-1] = self._colormap.getNColors(image.shape[1] - 2)[:, :3]
+ self._colormapImage = convertArrayToQImage(image)
+ return self._colormapImage
+
+ return super(ColormapRow, self).data(column, role)
+
+
+class SymbolRow(ProxyRow):
+ """Represents :class:`SymbolMixIn` symbol property.
+
+ :param Item3D item: Scene item with symbol property
+ """
+
+ def __init__(self, item):
+ names = [item.getSymbolName(s) for s in item.getSupportedSymbols()]
+ super(SymbolRow, self).__init__(
+ name='Marker',
+ fget=item.getSymbolName,
+ fset=item.setSymbol,
+ notify=item.sigItemChanged,
+ editorHint=names)
+
+
+class SymbolSizeRow(ProxyRow):
+ """Represents :class:`SymbolMixIn` symbol size property.
+
+ :param Item3D item: Scene item with symbol size property
+ """
+
+ def __init__(self, item):
+ super(SymbolSizeRow, self).__init__(
+ name='Marker size',
+ fget=item.getSymbolSize,
+ fset=item.setSymbolSize,
+ notify=item.sigItemChanged,
+ editorHint=(1, 20)) # TODO link with OpenGL max point size
+
+
+class PlaneRow(ProxyRow):
+ """Represents :class:`PlaneMixIn` property.
+
+ :param Item3D item: Scene item with plane equation property
+ """
+
+ def __init__(self, item):
+ super(PlaneRow, self).__init__(
+ name='Equation',
+ fget=item.getParameters,
+ fset=item.setParameters,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: qt.QVector4D(*data),
+ fromModelData=lambda data: (data.x(), data.y(), data.z(), data.w()))
+ self._item = weakref.ref(item)
+
+ def data(self, column, role):
+ if column == 1 and role == qt.Qt.DisplayRole:
+ item = self._item()
+ if item is not None:
+ params = item.getParameters()
+ return ('%gx %+gy %+gz %+g = 0' %
+ (params[0], params[1], params[2], params[3]))
+ return super(PlaneRow, self).data(column, role)
+
+
+class RemoveIsosurfaceRow(BaseRow):
+ """Class for Isosurface Delete button
+
+ :param Isosurface isosurface: The isosurface item to attach the button to.
+ """
+
+ def __init__(self, isosurface):
+ super(RemoveIsosurfaceRow, self).__init__()
+ self._isosurface = weakref.ref(isosurface)
+
+ def createEditor(self):
+ """Specific editor factory provided to the model"""
+ editor = qt.QWidget()
+ layout = qt.QHBoxLayout(editor)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ removeBtn = qt.QToolButton()
+ removeBtn.setText('Delete')
+ removeBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly)
+ layout.addWidget(removeBtn)
+ removeBtn.clicked.connect(self._removeClicked)
+
+ layout.addStretch(1)
+ return editor
+
+ def isosurface(self):
+ """Returns the controlled isosurface
+
+ :rtype: Isosurface
+ """
+ return self._isosurface()
+
+ def data(self, column, role):
+ if column == 0 and role == qt.Qt.UserRole: # editor hint
+ return self.createEditor
+
+ return super(RemoveIsosurfaceRow, self).data(column, role)
+
+ def flags(self, column):
+ flags = super(RemoveIsosurfaceRow, self).flags(column)
+ if column == 0:
+ flags |= qt.Qt.ItemIsEditable
+ return flags
+
+ def _removeClicked(self):
+ """Handle Delete button clicked"""
+ isosurface = self.isosurface()
+ if isosurface is not None:
+ scalarField3D = isosurface.parent()
+ if scalarField3D is not None:
+ scalarField3D.removeIsosurface(isosurface)
+
+
+class IsosurfaceRow(Item3DRow):
+ """Represents an :class:`Isosurface` item.
+
+ :param Isosurface item: Isosurface item
+ """
+
+ _LEVEL_SLIDER_RANGE = 0, 1000
+ """Range given as editor hint"""
+
+ def __init__(self, item):
+ super(IsosurfaceRow, self).__init__(item, name=item.getLevel())
+
+ self.setFlags(self.flags(1) | qt.Qt.ItemIsEditable, 1)
+
+ item.sigItemChanged.connect(self._levelChanged)
+
+ self.addRow(ProxyRow(
+ name='Level',
+ fget=self._getValueForLevelSlider,
+ fset=self._setLevelFromSliderValue,
+ notify=item.sigItemChanged,
+ editorHint=self._LEVEL_SLIDER_RANGE))
+
+ self.addRow(ColorProxyRow(
+ name='Color',
+ fget=self._rgbColor,
+ fset=self._setRgbColor,
+ notify=item.sigItemChanged))
+
+ self.addRow(ProxyRow(
+ name='Opacity',
+ fget=self._opacity,
+ fset=self._setOpacity,
+ notify=item.sigItemChanged,
+ editorHint=(0, 255)))
+
+ self.addRow(RemoveIsosurfaceRow(item))
+
+ def _getValueForLevelSlider(self):
+ """Convert iso level to slider value.
+
+ :rtype: int
+ """
+ item = self.item()
+ if item is not None:
+ scalarField3D = item.parent()
+ if scalarField3D is not None:
+ dataRange = scalarField3D.getDataRange()
+ if dataRange is not None:
+ dataMin, dataMax = dataRange[0], dataRange[-1]
+ offset = (item.getLevel() - dataMin) / (dataMax - dataMin)
+
+ sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE
+ value = sliderMin + (sliderMax - sliderMin) * offset
+ return value
+ return 0
+
+ def _setLevelFromSliderValue(self, value):
+ """Convert slider value to isolevel.
+
+ :param int value:
+ """
+ item = self.item()
+ if item is not None:
+ scalarField3D = item.parent()
+ if scalarField3D is not None:
+ dataRange = scalarField3D.getDataRange()
+ if dataRange is not None:
+ sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE
+ offset = (value - sliderMin) / (sliderMax - sliderMin)
+
+ dataMin, dataMax = dataRange[0], dataRange[-1]
+ level = dataMin + (dataMax - dataMin) * offset
+ item.setLevel(level)
+
+ def _rgbColor(self):
+ """Proxy to get the isosurface's RGB color without transparency
+
+ :rtype: QColor
+ """
+ item = self.item()
+ if item is None:
+ return None
+ else:
+ color = item.getColor()
+ color.setAlpha(255)
+ return color
+
+ def _setRgbColor(self, color):
+ """Proxy to set the isosurface's RGB color without transparency
+
+ :param QColor color:
+ """
+ item = self.item()
+ if item is not None:
+ color.setAlpha(item.getColor().alpha())
+ item.setColor(color)
+
+ def _opacity(self):
+ """Proxy to get the isosurface's transparency
+
+ :rtype: int
+ """
+ item = self.item()
+ return 255 if item is None else item.getColor().alpha()
+
+ def _setOpacity(self, opacity):
+ """Proxy to set the isosurface's transparency.
+
+ :param int opacity:
+ """
+ item = self.item()
+ if item is not None:
+ color = item.getColor()
+ color.setAlpha(opacity)
+ item.setColor(color)
+
+ def _levelChanged(self, event):
+ """Handle isosurface level changed and notify model
+
+ :param ItemChangedType event:
+ """
+ if event == items.Item3DChangedType.ISO_LEVEL:
+ model = self.model()
+ if model is not None:
+ index = self.index(column=1)
+ model.dataChanged.emit(index, index)
+
+ def data(self, column, role):
+ if column == 0: # Show color as decoration, not text
+ if role == qt.Qt.DisplayRole:
+ return None
+ elif role == qt.Qt.DecorationRole:
+ return self._rgbColor()
+
+ elif column == 1 and role in (qt.Qt.DisplayRole, qt.Qt.EditRole):
+ item = self.item()
+ return None if item is None else item.getLevel()
+
+ return super(IsosurfaceRow, self).data(column, role)
+
+ def setData(self, column, value, role):
+ if column == 1 and role == qt.Qt.EditRole:
+ item = self.item()
+ if item is not None:
+ item.setLevel(value)
+ return True
+
+ return super(IsosurfaceRow, self).setData(column, value, role)
+
+
+class AddIsosurfaceRow(BaseRow):
+ """Class for Isosurface create button
+
+ :param ScalarField3D scalarField3D:
+ The ScalarField3D item to attach the button to.
+ """
+
+ def __init__(self, scalarField3D):
+ super(AddIsosurfaceRow, self).__init__()
+ self._scalarField3D = weakref.ref(scalarField3D)
+
+ def createEditor(self):
+ """Specific editor factory provided to the model"""
+ editor = qt.QWidget()
+ layout = qt.QHBoxLayout(editor)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ addBtn = qt.QToolButton()
+ addBtn.setText('+')
+ addBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly)
+ layout.addWidget(addBtn)
+ addBtn.clicked.connect(self._addClicked)
+
+ layout.addStretch(1)
+ return editor
+
+ def scalarField3D(self):
+ """Returns the controlled ScalarField3D
+
+ :rtype: ScalarField3D
+ """
+ return self._scalarField3D()
+
+ def data(self, column, role):
+ if column == 0 and role == qt.Qt.UserRole: # editor hint
+ return self.createEditor
+
+ return super(AddIsosurfaceRow, self).data(column, role)
+
+ def flags(self, column):
+ flags = super(AddIsosurfaceRow, self).flags(column)
+ if column == 0:
+ flags |= qt.Qt.ItemIsEditable
+ return flags
+
+ def _addClicked(self):
+ """Handle Delete button clicked"""
+ scalarField3D = self.scalarField3D()
+ if scalarField3D is not None:
+ dataRange = scalarField3D.getDataRange()
+ if dataRange is None:
+ dataRange = 0., 1.
+
+ scalarField3D.addIsosurface(
+ numpy.mean((dataRange[0], dataRange[-1])),
+ '#0000FF')
+
+
+class ScalarField3DIsoSurfacesRow(StaticRow):
+ """Represents :class:`ScalarFieldView`'s isosurfaces
+
+ :param ScalarFieldView scalarField3D: ScalarFieldView to control
+ """
+
+ def __init__(self, scalarField3D):
+ super(ScalarField3DIsoSurfacesRow, self).__init__(
+ ('Isosurfaces', None))
+ self._scalarField3D = weakref.ref(scalarField3D)
+
+ scalarField3D.sigIsosurfaceAdded.connect(self._isosurfaceAdded)
+ scalarField3D.sigIsosurfaceRemoved.connect(self._isosurfaceRemoved)
+
+ for item in scalarField3D.getIsosurfaces():
+ self.addRow(nodeFromItem(item))
+
+ self.addRow(AddIsosurfaceRow(scalarField3D))
+
+ def scalarField3D(self):
+ """Returns the controlled ScalarField3D
+
+ :rtype: ScalarField3D
+ """
+ return self._scalarField3D()
+
+ def _isosurfaceAdded(self, item):
+ """Handle isosurface addition
+
+ :param Isosurface item: added isosurface
+ """
+ scalarField3D = self.scalarField3D()
+ if scalarField3D is None:
+ return
+
+ row = scalarField3D.getIsosurfaces().index(item)
+ self.addRow(nodeFromItem(item), row)
+
+ def _isosurfaceRemoved(self, item):
+ """Handle isosurface removal
+
+ :param Isosurface item: removed isosurface
+ """
+ scalarField3D = self.scalarField3D()
+ if scalarField3D is None:
+ return
+
+ # Find item
+ for row in self.children():
+ if row.item() is item:
+ self.removeRow(row)
+ break # Got it
+ else:
+ raise RuntimeError("Model does not correspond to scene content")
+
+
+class Scatter2DPropertyMixInRow(object):
+ """Mix-in class that enable/disable row according to Scatter2D mode.
+
+ :param Scatter2D item:
+ :param str propertyName: Name of the Scatter2D property of this row
+ """
+
+ def __init__(self, item, propertyName):
+ assert propertyName in ('lineWidth', 'symbol', 'symbolSize')
+ self.__propertyName = propertyName
+
+ self.__isEnabled = item.isPropertyEnabled(propertyName)
+ self.__updateFlags()
+
+ item.sigItemChanged.connect(self.__itemChanged)
+
+ def data(self, column, role):
+ if column == 1 and not self.__isEnabled:
+ # Discard data and editorHint if disabled
+ return None
+ else:
+ return super(Scatter2DPropertyMixInRow, self).data(column, role)
+
+ def __updateFlags(self):
+ """Update model flags"""
+ if self.__isEnabled:
+ self.setFlags(qt.Qt.ItemIsEnabled, 0)
+ self.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsEditable, 1)
+ else:
+ self.setFlags(qt.Qt.NoItemFlags)
+
+ def __itemChanged(self, event):
+ """Set flags to enable/disable the row"""
+ if event == items.ItemChangedType.VISUALIZATION_MODE:
+ item = self.sender()
+ if item is not None: # This occurs with PySide/python2.7
+ self.__isEnabled = item.isPropertyEnabled(self.__propertyName)
+ self.__updateFlags()
+
+ # Notify model
+ model = self.model()
+ if model is not None:
+ begin = self.index(column=0)
+ end = self.index(column=1)
+ model.dataChanged.emit(begin, end)
+
+
+class Scatter2DSymbolRow(Scatter2DPropertyMixInRow, SymbolRow):
+ """Specific class for Scatter2D symbol.
+
+ It is enabled/disabled according to visualization mode.
+
+ :param Scatter2D item:
+ """
+
+ def __init__(self, item):
+ SymbolRow.__init__(self, item)
+ Scatter2DPropertyMixInRow.__init__(self, item, 'symbol')
+
+
+class Scatter2DSymbolSizeRow(Scatter2DPropertyMixInRow, SymbolSizeRow):
+ """Specific class for Scatter2D symbol size.
+
+ It is enabled/disabled according to visualization mode.
+
+ :param Scatter2D item:
+ """
+
+ def __init__(self, item):
+ SymbolSizeRow.__init__(self, item)
+ Scatter2DPropertyMixInRow.__init__(self, item, 'symbolSize')
+
+
+class Scatter2DLineWidth(Scatter2DPropertyMixInRow, ProxyRow):
+ """Specific class for Scatter2D symbol size.
+
+ It is enabled/disabled according to visualization mode.
+
+ :param Scatter2D item:
+ """
+
+ def __init__(self, item):
+ # TODO link editorHint with OpenGL max line width
+ ProxyRow.__init__(self,
+ name='Line width',
+ fget=item.getLineWidth,
+ fset=item.setLineWidth,
+ notify=item.sigItemChanged,
+ editorHint=(1, 10))
+ Scatter2DPropertyMixInRow.__init__(self, item, 'lineWidth')
+
+
+def initScatter2DNode(node, item):
+ """Specific node init for Scatter2D to set order of parameters
+
+ :param Item3DRow node: The model node to setup
+ :param Scatter2D item: The Scatter2D the node is representing
+ """
+ node.addRow(ProxyRow(
+ name='Mode',
+ fget=item.getVisualization,
+ fset=item.setVisualization,
+ notify=item.sigItemChanged,
+ editorHint=[m.title() for m in item.supportedVisualizations()],
+ toModelData=lambda data: data.title(),
+ fromModelData=lambda data: data.lower()))
+
+ node.addRow(ProxyRow(
+ name='Height map',
+ fget=item.isHeightMap,
+ fset=item.setHeightMap,
+ notify=item.sigItemChanged))
+
+ node.addRow(ColormapRow(item))
+
+ node.addRow(Scatter2DSymbolRow(item))
+ node.addRow(Scatter2DSymbolSizeRow(item))
+
+ node.addRow(Scatter2DLineWidth(item))
+
+
+def initScalarField3DNode(node, item):
+ """Specific node init for ScalarField3D
+
+ :param Item3DRow node: The model node to setup
+ :param ScalarField3D item: The ScalarField3D the node is representing
+ """
+ node.addRow(nodeFromItem(item.getCutPlanes()[0])) # Add cut plane
+ node.addRow(ScalarField3DIsoSurfacesRow(item))
+
+
+def initScalarField3DCutPlaneNode(node, item):
+ """Specific node init for ScalarField3D CutPlane
+
+ :param Item3DRow node: The model node to setup
+ :param CutPlane item: The CutPlane the node is representing
+ """
+ node.addRow(PlaneRow(item))
+
+ node.addRow(ColormapRow(item))
+
+ node.addRow(ProxyRow(
+ name='Values<=Min',
+ fget=item.getDisplayValuesBelowMin,
+ fset=item.setDisplayValuesBelowMin,
+ notify=item.sigItemChanged))
+
+ node.addRow(InterpolationRow(item))
+
+
+NODE_SPECIFIC_INIT = [ # class, init(node, item)
+ (items.Scatter2D, initScatter2DNode),
+ (items.ScalarField3D, initScalarField3DNode),
+ (CutPlane, initScalarField3DCutPlaneNode),
+]
+"""List of specific node init for different item class"""
+
+
+def nodeFromItem(item):
+ """Create :class:`Item3DRow` subclass corresponding to item
+
+ :param Item3D item: The item fow which to create the node
+ :rtype: Item3DRow
+ """
+ assert isinstance(item, items.Item3D)
+
+ # Item with specific model row class
+ if isinstance(item, (items.GroupItem, items.GroupWithAxesItem)):
+ return GroupItemRow(item)
+ elif isinstance(item, Isosurface):
+ return IsosurfaceRow(item)
+
+ # Create Item3DRow and populate it
+ node = Item3DRow(item)
+
+ if isinstance(item, items.DataItem3D):
+ node.addRow(DataItem3DBoundingBoxRow(item))
+ node.addRow(DataItem3DTransformRow(item))
+
+ # Specific extra init
+ for cls, specificInit in NODE_SPECIFIC_INIT:
+ if isinstance(item, cls):
+ specificInit(node, item)
+ break
+
+ else: # Generic case: handle mixins
+ for cls in item.__class__.__mro__:
+ if cls is items.ColormapMixIn:
+ node.addRow(ColormapRow(item))
+
+ elif cls is items.InterpolationMixIn:
+ node.addRow(InterpolationRow(item))
+
+ elif cls is items.SymbolMixIn:
+ node.addRow(SymbolRow(item))
+ node.addRow(SymbolSizeRow(item))
+
+ elif cls is items.PlaneMixIn:
+ node.addRow(PlaneRow(item))
+
+ return node
diff --git a/silx/gui/plot3d/_model/model.py b/silx/gui/plot3d/_model/model.py
new file mode 100644
index 0000000..186838f
--- /dev/null
+++ b/silx/gui/plot3d/_model/model.py
@@ -0,0 +1,184 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 the :class:`SceneWidget` content and parameters model.
+"""
+
+from __future__ import absolute_import, division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+
+import weakref
+
+from ... import qt
+
+from .core import BaseRow
+from .items import Settings, nodeFromItem
+
+
+def visitQAbstractItemModel(model, parent=qt.QModelIndex()):
+ """Iterate over indices in the model starting from parent
+
+ It iterates column by column and row by row
+ (i.e., from left to right and from top to bottom).
+ Parent are returned before their children.
+ It only iterates through the children for the first column of a row.
+
+ :param QAbstractItemModel model: The model to visit
+ :param QModelIndex parent:
+ Index from which to start visiting the model.
+ Default: start from the root
+ """
+ assert isinstance(model, qt.QAbstractItemModel)
+ assert isinstance(parent, qt.QModelIndex)
+ assert parent.model() is model or not parent.isValid()
+
+ for row in range(model.rowCount(parent)):
+ for column in range(model.columnCount(parent)):
+ index = model.index(row, column, parent)
+ yield index
+
+ index = model.index(row, 0, parent)
+ for index in visitQAbstractItemModel(model, index):
+ yield index
+
+
+class Root(BaseRow):
+ """Root node of :class:`SceneWidget` parameters.
+
+ It has two children:
+ - Settings
+ - Scene group
+ """
+
+ def __init__(self, model, sceneWidget):
+ super(Root, self).__init__()
+ self._sceneWidget = weakref.ref(sceneWidget)
+ self.setParent(model) # Needed for Root
+
+ def children(self):
+ sceneWidget = self._sceneWidget()
+ if sceneWidget is None:
+ return ()
+ else:
+ return super(Root, self).children()
+
+
+class SceneModel(qt.QAbstractItemModel):
+ """Model of a :class:`SceneWidget`.
+
+ :param SceneWidget parent: The SceneWidget this model represents.
+ """
+
+ def __init__(self, parent):
+ self._sceneWidget = weakref.ref(parent)
+
+ super(SceneModel, self).__init__(parent)
+ self._root = Root(self, parent)
+ self._root.addRow(Settings(parent))
+ self._root.addRow(nodeFromItem(parent.getSceneGroup()))
+
+ def sceneWidget(self):
+ """Returns the :class:`SceneWidget` this model represents.
+
+ In case the widget has already been deleted, it returns None
+
+ :rtype: SceneWidget
+ """
+ return self._sceneWidget()
+
+ def _itemFromIndex(self, index):
+ """Returns the corresponding :class:`Node` or :class:`Item3D`.
+
+ :param QModelIndex index:
+ :rtype: Node or Item3D
+ """
+ return index.internalPointer() if index.isValid() else self._root
+
+ def index(self, row, column, parent=qt.QModelIndex()):
+ """See :meth:`QAbstractItemModel.index`"""
+ if column >= self.columnCount(parent) or row >= self.rowCount(parent):
+ return qt.QModelIndex()
+
+ item = self._itemFromIndex(parent)
+ return self.createIndex(row, column, item.children()[row])
+
+ def parent(self, index):
+ """See :meth:`QAbstractItemModel.parent`"""
+ if not index.isValid():
+ return qt.QModelIndex()
+
+ item = self._itemFromIndex(index)
+ parent = item.parent()
+
+ ancestor = parent.parent()
+
+ if ancestor is not self: # root node
+ children = ancestor.children()
+ row = children.index(parent)
+ return self.createIndex(row, 0, parent)
+
+ return qt.QModelIndex()
+
+ def rowCount(self, parent=qt.QModelIndex()):
+ """See :meth:`QAbstractItemModel.rowCount`"""
+ item = self._itemFromIndex(parent)
+ return item.rowCount()
+
+ def columnCount(self, parent=qt.QModelIndex()):
+ """See :meth:`QAbstractItemModel.columnCount`"""
+ item = self._itemFromIndex(parent)
+ return item.columnCount()
+
+ def data(self, index, role=qt.Qt.DisplayRole):
+ """See :meth:`QAbstractItemModel.data`"""
+ item = self._itemFromIndex(index)
+ column = index.column()
+ return item.data(column, role)
+
+ def setData(self, index, value, role=qt.Qt.EditRole):
+ """See :meth:`QAbstractItemModel.setData`"""
+ item = self._itemFromIndex(index)
+ column = index.column()
+ if item.setData(column, value, role):
+ self.dataChanged.emit(index, index)
+ return True
+ return False
+
+ def flags(self, index):
+ """See :meth:`QAbstractItemModel.flags`"""
+ item = self._itemFromIndex(index)
+ column = index.column()
+ return item.flags(column)
+
+ def headerData(self, section, orientation, role=qt.Qt.DisplayRole):
+ """See :meth:`QAbstractItemModel.headerData`"""
+ if orientation == qt.Qt.Horizontal and role == qt.Qt.DisplayRole:
+ return 'Item' if section == 0 else 'Value'
+ else:
+ return None
diff --git a/silx/gui/plot3d/actions/Plot3DAction.py b/silx/gui/plot3d/actions/Plot3DAction.py
index a1faaea..94b9572 100644
--- a/silx/gui/plot3d/actions/Plot3DAction.py
+++ b/silx/gui/plot3d/actions/Plot3DAction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 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
@@ -44,7 +44,8 @@ class Plot3DAction(qt.QAction):
"""QAction associated to a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -55,7 +56,8 @@ class Plot3DAction(qt.QAction):
def setPlot3DWidget(self, widget):
"""Set the Plot3DWidget this action is associated with
- :param Plot3DWidget widget: The Plot3DWidget to use
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget widget:
+ The Plot3DWidget to use
"""
self._plot3d = None if widget is None else weakref.ref(widget)
@@ -64,6 +66,6 @@ class Plot3DAction(qt.QAction):
If no widget is associated, it returns None.
- :rtype: qt.QWidget
+ :rtype: QWidget
"""
return None if self._plot3d is None else self._plot3d()
diff --git a/silx/gui/plot3d/actions/io.py b/silx/gui/plot3d/actions/io.py
index 18f91b4..5126000 100644
--- a/silx/gui/plot3d/actions/io.py
+++ b/silx/gui/plot3d/actions/io.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 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
@@ -54,7 +54,8 @@ class CopyAction(Plot3DAction):
"""QAction to provide copy of a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -81,7 +82,8 @@ 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
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -135,7 +137,8 @@ class PrintAction(Plot3DAction):
"""QAction to provide printing of a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -152,7 +155,7 @@ class PrintAction(Plot3DAction):
def getPrinter(self):
"""Return the QPrinter instance used for printing.
- :rtype: qt.QPrinter
+ :rtype: QPrinter
"""
# TODO This is a hack to sync with silx plot PrintAction
# This needs to be centralized
@@ -201,6 +204,8 @@ class VideoAction(Plot3DAction):
The scene is rotated 360 degrees around a vertical axis.
:param parent: Action parent see :class:`QAction`.
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
PNG_SERIE_FILTER = 'Serie of PNG files (*.png)'
diff --git a/silx/gui/plot3d/actions/mode.py b/silx/gui/plot3d/actions/mode.py
index a06b9a8..b591290 100644
--- a/silx/gui/plot3d/actions/mode.py
+++ b/silx/gui/plot3d/actions/mode.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -48,7 +48,8 @@ class InteractiveModeAction(Plot3DAction):
:param parent: See :class:`QAction`
:param str interaction: The interactive mode this action controls
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, interaction, plot3d=None):
@@ -100,7 +101,8 @@ 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
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -108,14 +110,15 @@ class RotateArcballAction(InteractiveModeAction):
self.setIcon(getQIcon('rotate-3d'))
self.setText('Rotate')
- self.setToolTip('Rotate the view')
+ self.setToolTip('Rotate the view. Press <b>Ctrl</b> to pan.')
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
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -123,4 +126,4 @@ class PanAction(InteractiveModeAction):
self.setIcon(getQIcon('pan'))
self.setText('Pan')
- self.setToolTip('Pan the view')
+ self.setToolTip('Pan the view. Press <b>Ctrl</b> to rotate.')
diff --git a/silx/gui/plot3d/actions/viewpoint.py b/silx/gui/plot3d/actions/viewpoint.py
index 6aa7400..d764c40 100644
--- a/silx/gui/plot3d/actions/viewpoint.py
+++ b/silx/gui/plot3d/actions/viewpoint.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -45,11 +45,144 @@ from .Plot3DAction import Plot3DAction
_logger = logging.getLogger(__name__)
-class RotateViewport(Plot3DAction):
+class _SetViewpointAction(Plot3DAction):
+ """Base class for actions setting a Plot3DWidget viewpoint
+
+ :param parent: See :class:`QAction`
+ :param str face: The name of the predefined viewpoint
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, face, plot3d=None):
+ super(_SetViewpointAction, self).__init__(parent, plot3d)
+ assert face in ('side', 'front', 'back', 'left', 'right', 'top', 'bottom')
+ self._face = face
+
+ self.setIconVisibleInMenu(True)
+ self.setCheckable(False)
+ self.triggered[bool].connect(self._triggered)
+
+ def _triggered(self, checked=False):
+ plot3d = self.getPlot3DWidget()
+ if plot3d is None:
+ _logger.error(
+ 'Cannot start/stop rotation, no associated Plot3DWidget')
+ else:
+ plot3d.viewport.camera.extrinsic.reset(face=self._face)
+ plot3d.centerScene()
+
+
+class FrontViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the front
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(FrontViewpointAction, self).__init__(parent, 'front', plot3d)
+
+ self.setIcon(getQIcon('cube-front'))
+ self.setText('Front')
+ self.setToolTip('View along the -Z axis')
+
+
+class BackViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the back
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(BackViewpointAction, self).__init__(parent, 'back', plot3d)
+
+ self.setIcon(getQIcon('cube-back'))
+ self.setText('Back')
+ self.setToolTip('View along the +Z axis')
+
+
+class LeftViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the left
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(LeftViewpointAction, self).__init__(parent, 'left', plot3d)
+
+ self.setIcon(getQIcon('cube-left'))
+ self.setText('Left')
+ self.setToolTip('View along the +X axis')
+
+
+class RightViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the right
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(RightViewpointAction, self).__init__(parent, 'right', plot3d)
+
+ self.setIcon(getQIcon('cube-right'))
+ self.setText('Right')
+ self.setToolTip('View along the -X axis')
+
+
+class TopViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the top
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(TopViewpointAction, self).__init__(parent, 'top', plot3d)
+
+ self.setIcon(getQIcon('cube-top'))
+ self.setText('Top')
+ self.setToolTip('View along the -Y axis')
+
+
+class BottomViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the bottom
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(BottomViewpointAction, self).__init__(parent, 'bottom', plot3d)
+
+ self.setIcon(getQIcon('cube-bottom'))
+ self.setText('Bottom')
+ self.setToolTip('View along the +Y axis')
+
+
+class SideViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the side
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(SideViewpointAction, self).__init__(parent, 'side', plot3d)
+
+ self.setIcon(getQIcon('cube'))
+ self.setText('Side')
+ self.setToolTip('Side view')
+
+
+class RotateViewpoint(Plot3DAction):
"""QAction to rotate the scene of a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
_TIMEOUT_MS = 50
@@ -59,7 +192,7 @@ class RotateViewport(Plot3DAction):
"""Rotation speed of the animation"""
def __init__(self, parent, plot3d=None):
- super(RotateViewport, self).__init__(parent, plot3d)
+ super(RotateViewpoint, self).__init__(parent, plot3d)
self._previousTime = None
diff --git a/silx/gui/plot3d/items/__init__.py b/silx/gui/plot3d/items/__init__.py
new file mode 100644
index 0000000..b50ea5a
--- /dev/null
+++ b/silx/gui/plot3d/items/__init__.py
@@ -0,0 +1,43 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 package provides classes that describes :class:`.SceneWidget` content.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+
+from .core import DataItem3D, Item3D, GroupItem, GroupWithAxesItem # noqa
+from .core import ItemChangedType, Item3DChangedType # noqa
+from .mixins import (ColormapMixIn, InterpolationMixIn, # noqa
+ PlaneMixIn, SymbolMixIn) # noqa
+from .clipplane import ClipPlane # noqa
+from .image import ImageData, ImageRgba # noqa
+from .mesh import Mesh # noqa
+from .scatter import Scatter2D, Scatter3D # noqa
+from .volume import ScalarField3D # noqa
diff --git a/silx/gui/plot3d/items/clipplane.py b/silx/gui/plot3d/items/clipplane.py
new file mode 100644
index 0000000..a5ba0e6
--- /dev/null
+++ b/silx/gui/plot3d/items/clipplane.py
@@ -0,0 +1,50 @@
+# 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 a scene clip plane class.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+
+from ..scene import primitives
+
+from .core import Item3D
+from .mixins import PlaneMixIn
+
+
+class ClipPlane(Item3D, PlaneMixIn):
+ """Represents a clipping plane that clips following items within the group.
+
+ For now only on clip plane is allowed at once in a scene.
+ """
+
+ def __init__(self, parent=None):
+ plane = primitives.ClipPlane()
+ Item3D.__init__(self, parent=parent, primitive=plane)
+ PlaneMixIn.__init__(self, plane=plane)
diff --git a/silx/gui/plot3d/items/core.py b/silx/gui/plot3d/items/core.py
new file mode 100644
index 0000000..e549e59
--- /dev/null
+++ b/silx/gui/plot3d/items/core.py
@@ -0,0 +1,622 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 the base class for items of the :class:`.SceneWidget`.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+from collections import defaultdict
+
+import numpy
+
+from silx.third_party import enum, six
+
+from ... import qt
+from ...plot.items import ItemChangedType
+from .. import scene
+from ..scene import axes, primitives, transform
+
+
+@enum.unique
+class Item3DChangedType(enum.Enum):
+ """Type of modification provided by :attr:`Item3D.sigItemChanged` signal."""
+
+ INTERPOLATION = 'interpolationChanged'
+ """Item3D image interpolation changed flag."""
+
+ TRANSFORM = 'transformChanged'
+ """Item3D transform changed flag."""
+
+ HEIGHT_MAP = 'heightMapChanged'
+ """Item3D height map changed flag."""
+
+ ISO_LEVEL = 'isoLevelChanged'
+ """Isosurface level changed flag."""
+
+ LABEL = 'labelChanged'
+ """Item's label changed flag."""
+
+ BOUNDING_BOX_VISIBLE = 'boundingBoxVisibleChanged'
+ """Item's bounding box visibility changed"""
+
+ ROOT_ITEM = 'rootItemChanged'
+ """Item's root changed flag."""
+
+
+class Item3D(qt.QObject):
+ """Base class representing an item in the scene.
+
+ :param parent: The View widget this item belongs to.
+ :param primitive: An optional primitive to use as scene primitive
+ """
+
+ _LABEL_INDICES = defaultdict(int)
+ """Store per class label indices"""
+
+ sigItemChanged = qt.Signal(object)
+ """Signal emitted when an item's property has changed.
+
+ It provides a flag describing which property of the item has changed.
+ See :class:`ItemChangedType` and :class:`Item3DChangedType`
+ for flags description.
+ """
+
+ def __init__(self, parent, primitive=None):
+ qt.QObject.__init__(self, parent)
+
+ if primitive is None:
+ primitive = scene.Group()
+
+ self._primitive = primitive
+
+ self.__syncForegroundColor()
+
+ labelIndex = self._LABEL_INDICES[self.__class__]
+ self._label = six.text_type(self.__class__.__name__)
+ if labelIndex != 0:
+ self._label += u' %d' % labelIndex
+ self._LABEL_INDICES[self.__class__] += 1
+
+ if isinstance(parent, Item3D):
+ parent.sigItemChanged.connect(self.__parentItemChanged)
+
+ def setParent(self, parent):
+ """Override set parent to handle root item change"""
+ previousParent = self.parent()
+ if isinstance(previousParent, Item3D):
+ previousParent.sigItemChanged.disconnect(self.__parentItemChanged)
+
+ super(Item3D, self).setParent(parent)
+
+ if isinstance(parent, Item3D):
+ parent.sigItemChanged.connect(self.__parentItemChanged)
+
+ self._updated(Item3DChangedType.ROOT_ITEM)
+
+ def __parentItemChanged(self, event):
+ """Handle updates of the parent if it is an Item3D
+
+ :param Item3DChangedType event:
+ """
+ if event == Item3DChangedType.ROOT_ITEM:
+ self._updated(Item3DChangedType.ROOT_ITEM)
+
+ def root(self):
+ """Returns the root of the scene this item belongs to.
+
+ The root is the up-most Item3D in the scene tree hierarchy.
+
+ :rtype: Union[Item3D, None]
+ """
+ root = None
+ ancestor = self.parent()
+ while isinstance(ancestor, Item3D):
+ root = ancestor
+ ancestor = ancestor.parent()
+
+ return root
+
+ def _getScenePrimitive(self):
+ """Return the group containing the item rendering"""
+ return self._primitive
+
+ def _updated(self, event=None):
+ """Handle MixIn class updates.
+
+ :param event: The event to send to :attr:`sigItemChanged` signal.
+ """
+ if event == Item3DChangedType.ROOT_ITEM:
+ self.__syncForegroundColor()
+
+ if event is not None:
+ self.sigItemChanged.emit(event)
+
+ # Label
+
+ def getLabel(self):
+ """Returns the label associated to this item.
+
+ :rtype: str
+ """
+ return self._label
+
+ def setLabel(self, label):
+ """Set the label associated to this item.
+
+ :param str label:
+ """
+ label = six.text_type(label)
+ if label != self._label:
+ self._label = label
+ self._updated(Item3DChangedType.LABEL)
+
+ # Visibility
+
+ def isVisible(self):
+ """Returns True if item is visible, else False
+
+ :rtype: bool
+ """
+ return self._getScenePrimitive().visible
+
+ def setVisible(self, visible=True):
+ """Set the visibility of the item in the scene.
+
+ :param bool visible: True (default) to show the item, False to hide
+ """
+ visible = bool(visible)
+ primitive = self._getScenePrimitive()
+ if visible != primitive.visible:
+ primitive.visible = visible
+ self._updated(ItemChangedType.VISIBLE)
+
+ # Foreground color
+
+ def _setForegroundColor(self, color):
+ """Set the foreground color of the item.
+
+ The default implementation does nothing, override it in subclass.
+
+ :param color: RGBA color
+ :type color: tuple of 4 float in [0., 1.]
+ """
+ if hasattr(super(Item3D, self), '_setForegroundColor'):
+ super(Item3D, self)._setForegroundColor(color)
+
+ def __syncForegroundColor(self):
+ """Retrieve foreground color from parent and update this item"""
+ # Look-up for SceneWidget to get its foreground color
+ root = self.root()
+ if root is not None:
+ widget = root.parent()
+ if isinstance(widget, qt.QWidget):
+ self._setForegroundColor(
+ widget.getForegroundColor().getRgbF())
+
+
+class DataItem3D(Item3D):
+ """Base class representing a data item with transform in the scene.
+
+ :param parent: The View widget this item belongs to.
+ :param Union[GroupBBox, None] group:
+ The scene group to use for rendering
+ """
+
+ def __init__(self, parent, group=None):
+ if group is None:
+ group = primitives.GroupBBox()
+
+ # Set-up bounding box
+ group.boxVisible = False
+ group.axesVisible = False
+ else:
+ assert isinstance(group, primitives.GroupBBox)
+
+ Item3D.__init__(self, parent=parent, primitive=group)
+
+ # Transformations
+ self._translate = transform.Translate()
+ self._rotateForwardTranslation = transform.Translate()
+ self._rotate = transform.Rotate()
+ self._rotateBackwardTranslation = transform.Translate()
+ self._translateFromRotationCenter = transform.Translate()
+ self._matrix = transform.Matrix()
+ self._scale = transform.Scale()
+ # Group transforms to do to data before rotation
+ # This is useful to handle rotation center relative to bbox
+ self._transformObjectToRotate = transform.TransformList(
+ [self._matrix, self._scale])
+ self._transformObjectToRotate.addListener(self._updateRotationCenter)
+
+ self._rotationCenter = 0., 0., 0.
+
+ self._getScenePrimitive().transforms = [
+ self._translate,
+ self._rotateForwardTranslation,
+ self._rotate,
+ self._rotateBackwardTranslation,
+ self._transformObjectToRotate]
+
+ def _updated(self, event=None):
+ """Handle MixIn class updates.
+
+ :param event: The event to send to :attr:`sigItemChanged` signal.
+ """
+ if event == ItemChangedType.DATA:
+ self._updateRotationCenter()
+ super(DataItem3D, self)._updated(event)
+
+ # Transformations
+
+ def setScale(self, sx=1., sy=1., sz=1.):
+ """Set the scale of the item in the scene.
+
+ :param float sx: Scale factor along the X axis
+ :param float sy: Scale factor along the Y axis
+ :param float sz: Scale factor along the Z axis
+ """
+ scale = numpy.array((sx, sy, sz), dtype=numpy.float32)
+ if not numpy.all(numpy.equal(scale, self.getScale())):
+ self._scale.scale = scale
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def getScale(self):
+ """Returns the scales provided by :meth:`setScale`.
+
+ :rtype: numpy.ndarray
+ """
+ return self._scale.scale
+
+ def setTranslation(self, x=0., y=0., z=0.):
+ """Set the translation of the origin of the item in the scene.
+
+ :param float x: Offset of the data origin on the X axis
+ :param float y: Offset of the data origin on the Y axis
+ :param float z: Offset of the data origin on the Z axis
+ """
+ translation = numpy.array((x, y, z), dtype=numpy.float32)
+ if not numpy.all(numpy.equal(translation, self.getTranslation())):
+ self._translate.translation = translation
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def getTranslation(self):
+ """Returns the offset set by :meth:`setTranslation`.
+
+ :rtype: numpy.ndarray
+ """
+ return self._translate.translation
+
+ _ROTATION_CENTER_TAGS = 'lower', 'center', 'upper'
+
+ def _updateRotationCenter(self, *args, **kwargs):
+ """Update rotation center relative to bounding box"""
+ center = []
+ for index, position in enumerate(self.getRotationCenter()):
+ # Patch position relative to bounding box
+ if position in self._ROTATION_CENTER_TAGS:
+ bounds = self._getScenePrimitive().bounds(
+ transformed=False, dataBounds=True)
+ bounds = self._transformObjectToRotate.transformBounds(bounds)
+
+ if bounds is None:
+ position = 0.
+ elif position == 'lower':
+ position = bounds[0, index]
+ elif position == 'center':
+ position = 0.5 * (bounds[0, index] + bounds[1, index])
+ elif position == 'upper':
+ position = bounds[1, index]
+
+ center.append(position)
+
+ if not numpy.all(numpy.equal(
+ center, self._rotateForwardTranslation.translation)):
+ self._rotateForwardTranslation.translation = center
+ self._rotateBackwardTranslation.translation = \
+ - self._rotateForwardTranslation.translation
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def setRotationCenter(self, x=0., y=0., z=0.):
+ """Set the center of rotation of the item.
+
+ Position of the rotation center is either a float
+ for an absolute position or one of the following
+ string to define a position relative to the item's bounding box:
+ 'lower', 'center', 'upper'
+
+ :param x: rotation center position on the X axis
+ :rtype: float or str
+ :param y: rotation center position on the Y axis
+ :rtype: float or str
+ :param z: rotation center position on the Z axis
+ :rtype: float or str
+ """
+ center = []
+ for position in (x, y, z):
+ if isinstance(position, six.string_types):
+ assert position in self._ROTATION_CENTER_TAGS
+ else:
+ position = float(position)
+ center.append(position)
+ center = tuple(center)
+
+ if center != self._rotationCenter:
+ self._rotationCenter = center
+ self._updateRotationCenter()
+
+ def getRotationCenter(self):
+ """Returns the rotation center set by :meth:`setRotationCenter`.
+
+ :rtype: 3-tuple of float or str
+ """
+ return self._rotationCenter
+
+ def setRotation(self, angle=0., axis=(0., 0., 1.)):
+ """Set the rotation of the item in the scene
+
+ :param float angle: The rotation angle in degrees.
+ :param axis: The (x, y, z) coordinates of the rotation axis.
+ """
+ axis = numpy.array(axis, dtype=numpy.float32)
+ assert axis.ndim == 1
+ assert axis.size == 3
+ if (self._rotate.angle != angle or
+ not numpy.all(numpy.equal(axis, self._rotate.axis))):
+ self._rotate.setAngleAxis(angle, axis)
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def getRotation(self):
+ """Returns the rotation set by :meth:`setRotation`.
+
+ :return: (angle, axis)
+ :rtype: 2-tuple (float, numpy.ndarray)
+ """
+ return self._rotate.angle, self._rotate.axis
+
+ def setMatrix(self, matrix=None):
+ """Set the transform matrix
+
+ :param numpy.ndarray matrix: 3x3 transform matrix
+ """
+ matrix4x4 = numpy.identity(4, dtype=numpy.float32)
+
+ if matrix is not None:
+ matrix = numpy.array(matrix, dtype=numpy.float32)
+ assert matrix.shape == (3, 3)
+ matrix4x4[:3, :3] = matrix
+
+ if not numpy.all(numpy.equal(matrix4x4, self._matrix.getMatrix())):
+ self._matrix.setMatrix(matrix4x4)
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def getMatrix(self):
+ """Returns the matrix set by :meth:`setMatrix`
+
+ :return: 3x3 matrix
+ :rtype: numpy.ndarray"""
+ return self._matrix.getMatrix(copy=True)[:3, :3]
+
+ # Bounding box
+
+ def _setForegroundColor(self, color):
+ """Set the color of the bounding box
+
+ :param color: RGBA color as 4 floats in [0, 1]
+ """
+ self._getScenePrimitive().color = color
+ super(DataItem3D, self)._setForegroundColor(color)
+
+ def isBoundingBoxVisible(self):
+ """Returns item's bounding box visibility.
+
+ :rtype: bool
+ """
+ return self._getScenePrimitive().boxVisible
+
+ def setBoundingBoxVisible(self, visible):
+ """Set item's bounding box visibility.
+
+ :param bool visible:
+ True to show the bounding box, False (default) to hide it
+ """
+ visible = bool(visible)
+ primitive = self._getScenePrimitive()
+ if visible != primitive.boxVisible:
+ primitive.boxVisible = visible
+ self._updated(Item3DChangedType.BOUNDING_BOX_VISIBLE)
+
+
+class _BaseGroupItem(DataItem3D):
+ """Base class for group of items sharing a common transform."""
+
+ sigItemAdded = qt.Signal(object)
+ """Signal emitted when a new item is added to the group.
+
+ The newly added item is provided by this signal
+ """
+
+ sigItemRemoved = qt.Signal(object)
+ """Signal emitted when an item is removed from the group.
+
+ The removed item is provided by this signal.
+ """
+
+ def __init__(self, parent=None, group=None):
+ """Base class representing a group of items in the scene.
+
+ :param parent: The View widget this item belongs to.
+ :param Union[GroupBBox, None] group:
+ The scene group to use for rendering
+ """
+ DataItem3D.__init__(self, parent=parent, group=group)
+ self._items = []
+
+ def addItem(self, item, index=None):
+ """Add an item to the group
+
+ :param Item3D item: The item to add
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :raise ValueError: If the item is already in the group.
+ """
+ assert isinstance(item, Item3D)
+ assert item.parent() in (None, self)
+
+ if item in self.getItems():
+ raise ValueError("Item3D already in group: %s" % item)
+
+ item.setParent(self)
+ if index is None:
+ self._getScenePrimitive().children.append(
+ item._getScenePrimitive())
+ self._items.append(item)
+ else:
+ self._getScenePrimitive().children.insert(
+ index, item._getScenePrimitive())
+ self._items.insert(index, item)
+ self.sigItemAdded.emit(item)
+
+ def getItems(self):
+ """Returns the list of items currently present in the group.
+
+ :rtype: tuple
+ """
+ return tuple(self._items)
+
+ def removeItem(self, item):
+ """Remove an item from the scene.
+
+ :param Item3D item: The item to remove from the scene
+ :raises ValueError: If the item does not belong to the group
+ """
+ if item not in self.getItems():
+ raise ValueError("Item3D not in group: %s" % str(item))
+
+ self._getScenePrimitive().children.remove(item._getScenePrimitive())
+ self._items.remove(item)
+ item.setParent(None)
+ self.sigItemRemoved.emit(item)
+
+ def clearItems(self):
+ """Remove all item from the group."""
+ for item in self.getItems():
+ self.removeItem(item)
+
+ def visit(self, included=True):
+ """Generator visiting the group content.
+
+ It traverses the group sub-tree in a top-down left-to-right way.
+
+ :param bool included: True (default) to include self in visit
+ """
+ if included:
+ yield self
+ for child in self.getItems():
+ yield child
+ if hasattr(child, 'visit'):
+ for item in child.visit(included=False):
+ yield item
+
+
+class GroupItem(_BaseGroupItem):
+ """Group of items sharing a common transform."""
+
+ def __init__(self, parent=None):
+ super(GroupItem, self).__init__(parent=parent)
+
+
+class GroupWithAxesItem(_BaseGroupItem):
+ """
+ Group of items sharing a common transform surrounded with labelled axes.
+ """
+
+ def __init__(self, parent=None):
+ """Class representing a group of items in the scene with labelled axes.
+
+ :param parent: The View widget this item belongs to.
+ """
+ super(GroupWithAxesItem, self).__init__(parent=parent,
+ group=axes.LabelledAxes())
+
+ # Axes labels
+
+ def setAxesLabels(self, xlabel=None, ylabel=None, zlabel=None):
+ """Set the text labels of the axes.
+
+ :param str xlabel: Label of the X axis, None to leave unchanged.
+ :param str ylabel: Label of the Y axis, None to leave unchanged.
+ :param str zlabel: Label of the Z axis, None to leave unchanged.
+ """
+ labelledAxes = self._getScenePrimitive()
+ if xlabel is not None:
+ labelledAxes.xlabel = xlabel
+
+ if ylabel is not None:
+ labelledAxes.ylabel = ylabel
+
+ if zlabel is not None:
+ labelledAxes.zlabel = zlabel
+
+ class _Labels(tuple):
+ """Return type of :meth:`getAxesLabels`"""
+
+ def getXLabel(self):
+ """Label of the X axis (str)"""
+ return self[0]
+
+ def getYLabel(self):
+ """Label of the Y axis (str)"""
+ return self[1]
+
+ def getZLabel(self):
+ """Label of the Z axis (str)"""
+ return self[2]
+
+ def getAxesLabels(self):
+ """Returns the text labels of the axes
+
+ >>> group = GroupWithAxesItem()
+ >>> group.setAxesLabels(xlabel='X')
+
+ You can get the labels either as a 3-tuple:
+
+ >>> xlabel, ylabel, zlabel = group.getAxesLabels()
+
+ Or as an object with methods getXLabel, getYLabel and getZLabel:
+
+ >>> labels = group.getAxesLabels()
+ >>> labels.getXLabel()
+ ... 'X'
+
+ :return: object describing the labels
+ """
+ labelledAxes = self._getScenePrimitive()
+ return self._Labels((labelledAxes.xlabel,
+ labelledAxes.ylabel,
+ labelledAxes.zlabel))
diff --git a/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py
new file mode 100644
index 0000000..9e8bf1e
--- /dev/null
+++ b/silx/gui/plot3d/items/image.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 2D data and RGB(A) image item class.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+import numpy
+
+from ..scene import primitives
+from .core import DataItem3D, ItemChangedType
+from .mixins import ColormapMixIn, InterpolationMixIn
+
+
+class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn):
+ """Description of a 2D image data.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+ InterpolationMixIn.__init__(self)
+
+ self._data = numpy.zeros((0, 0), dtype=numpy.float32)
+
+ self._image = primitives.ImageData(self._data)
+ self._getScenePrimitive().children.append(self._image)
+
+ # Connect scene primitive to mix-in class
+ ColormapMixIn._setSceneColormap(self, self._image.colormap)
+ InterpolationMixIn._setPrimitive(self, self._image)
+
+ def setData(self, data, copy=True):
+ """Set the image data to display.
+
+ The data will be casted to float32.
+
+ :param numpy.ndarray data: The image data
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ """
+ self._image.setData(data, copy=copy)
+ ColormapMixIn._setRangeFromData(self, self.getData(copy=False))
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Get the image data.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :rtype: numpy.ndarray
+ :return: The image data
+ """
+ return self._image.getData(copy=copy)
+
+
+class ImageRgba(DataItem3D, InterpolationMixIn):
+ """Description of a 2D data RGB(A) image.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ InterpolationMixIn.__init__(self)
+
+ self._data = numpy.zeros((0, 0, 3), dtype=numpy.float32)
+
+ self._image = primitives.ImageRgba(self._data)
+ self._getScenePrimitive().children.append(self._image)
+
+ # Connect scene primitive to mix-in class
+ InterpolationMixIn._setPrimitive(self, self._image)
+
+ def setData(self, data, copy=True):
+ """Set the RGB(A) image data to display.
+
+ Supported array format: float32 in [0, 1], uint8.
+
+ :param numpy.ndarray data:
+ The RGBA image data as an array of shape (H, W, Channels)
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ """
+ self._image.setData(data, copy=copy)
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Get the image data.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :rtype: numpy.ndarray
+ :return: The image data
+ """
+ return self._image.getData(copy=copy)
diff --git a/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py
new file mode 100644
index 0000000..8535728
--- /dev/null
+++ b/silx/gui/plot3d/items/mesh.py
@@ -0,0 +1,145 @@
+# 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 regular mesh item class.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+import numpy
+
+from ..scene import primitives
+from .core import DataItem3D, ItemChangedType
+
+
+class Mesh(DataItem3D):
+ """Description of mesh.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ self._mesh = None
+
+ def setData(self,
+ position,
+ color,
+ normal=None,
+ mode='triangles',
+ copy=True):
+ """Set mesh geometry data.
+
+ Supported drawing modes are:
+
+ - For points: 'points'
+ - For lines: 'lines', 'line_strip', 'loop'
+ - For triangles: 'triangles', 'triangle_strip', 'fan'
+
+ :param numpy.ndarray position:
+ Position (x, y, z) of each vertex as a (N, 3) array
+ :param numpy.ndarray color: Colors for each point or a single color
+ :param numpy.ndarray normal: Normals for each point or None (default)
+ :param str mode: The drawing mode.
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ """
+ self._getScenePrimitive().children = [] # Remove any previous mesh
+
+ if position is None or len(position) == 0:
+ self._mesh = 0
+ else:
+ self._mesh = primitives.Mesh3D(
+ position, color, normal, mode=mode, copy=copy)
+ self._getScenePrimitive().children.append(self._mesh)
+
+ self.sigItemChanged.emit(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Get the mesh geometry.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :return: The positions, colors, normals and mode
+ :rtype: tuple of numpy.ndarray
+ """
+ return (self.getPositionData(copy=copy),
+ self.getColorData(copy=copy),
+ self.getNormalData(copy=copy),
+ self.getDrawMode())
+
+ def getPositionData(self, copy=True):
+ """Get the mesh vertex positions.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :return: The (x, y, z) positions as a (N, 3) array
+ :rtype: numpy.ndarray
+ """
+ if self._mesh is None:
+ return numpy.empty((0, 3), dtype=numpy.float32)
+ else:
+ return self._mesh.getAttribute('position', copy=copy)
+
+ def getColorData(self, copy=True):
+ """Get the mesh vertex colors.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :return: The RGBA colors as a (N, 4) array or a single color
+ :rtype: numpy.ndarray
+ """
+ if self._mesh is None:
+ return numpy.empty((0, 4), dtype=numpy.float32)
+ else:
+ return self._mesh.getAttribute('color', copy=copy)
+
+ def getNormalData(self, copy=True):
+ """Get the mesh vertex normals.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :return: The normals as a (N, 3) array, a single normal or None
+ :rtype: numpy.ndarray or None
+ """
+ if self._mesh is None:
+ return None
+ else:
+ return self._mesh.getAttribute('normal', copy=copy)
+
+ def getDrawMode(self):
+ """Get mesh rendering mode.
+
+ :return: The drawing mode of this primitive
+ :rtype: str
+ """
+ return self._mesh.drawMode
diff --git a/silx/gui/plot3d/items/mixins.py b/silx/gui/plot3d/items/mixins.py
new file mode 100644
index 0000000..41ad3c3
--- /dev/null
+++ b/silx/gui/plot3d/items/mixins.py
@@ -0,0 +1,302 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 mix-in classes for :class:`Item3D`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+
+import collections
+import numpy
+
+from silx.math.combo import min_max
+
+from ...plot.items.core import ItemMixInBase
+from ...plot.items.core import ColormapMixIn as _ColormapMixIn
+from ...plot.items.core import SymbolMixIn as _SymbolMixIn
+from ...plot.Colors import rgba
+
+from ..scene import primitives
+from .core import Item3DChangedType, ItemChangedType
+
+
+class InterpolationMixIn(ItemMixInBase):
+ """Mix-in class for image interpolation mode
+
+ :param str mode: 'linear' (default) or 'nearest'
+ :param primitive:
+ scene object for which to sync interpolation mode.
+ This object MUST have an interpolation property that is updated.
+ """
+
+ NEAREST_INTERPOLATION = 'nearest'
+ """Nearest interpolation mode (see :meth:`setInterpolation`)"""
+
+ LINEAR_INTERPOLATION = 'linear'
+ """Linear interpolation mode (see :meth:`setInterpolation`)"""
+
+ INTERPOLATION_MODES = NEAREST_INTERPOLATION, LINEAR_INTERPOLATION
+ """Supported interpolation modes for :meth:`setInterpolation`"""
+
+ def __init__(self, mode=NEAREST_INTERPOLATION, primitive=None):
+ self.__primitive = primitive
+ self._syncPrimitiveInterpolation()
+
+ self.__interpolationMode = None
+ self.setInterpolation(mode)
+
+ def _setPrimitive(self, primitive):
+
+ """Set the scene object for which to sync interpolation"""
+ self.__primitive = primitive
+ self._syncPrimitiveInterpolation()
+
+ def _syncPrimitiveInterpolation(self):
+ """Synchronize scene object's interpolation"""
+ if self.__primitive is not None:
+ self.__primitive.interpolation = self.getInterpolation()
+
+ def setInterpolation(self, mode):
+ """Set image interpolation mode
+
+ :param str mode: 'nearest' or 'linear'
+ """
+ mode = str(mode)
+ assert mode in self.INTERPOLATION_MODES
+ if mode != self.__interpolationMode:
+ self.__interpolationMode = mode
+ self._syncPrimitiveInterpolation()
+ self._updated(Item3DChangedType.INTERPOLATION)
+
+ def getInterpolation(self):
+ """Returns the interpolation mode set by :meth:`setInterpolation`
+
+ :rtype: str
+ """
+ return self.__interpolationMode
+
+
+class ColormapMixIn(_ColormapMixIn):
+ """Mix-in class for Item3D object with a colormap
+
+ :param sceneColormap:
+ The plot3d scene colormap to sync with Colormap object.
+ """
+
+ def __init__(self, sceneColormap=None):
+ super(ColormapMixIn, self).__init__()
+
+ self._dataRange = None
+ self.__sceneColormap = sceneColormap
+ self._syncSceneColormap()
+
+ self.sigItemChanged.connect(self.__colormapUpdated)
+
+ def __colormapUpdated(self, event):
+ """Handle colormap updates"""
+ if event == ItemChangedType.COLORMAP:
+ self._syncSceneColormap()
+
+ def _setRangeFromData(self, data=None):
+ """Compute the data range the colormap should use from provided data.
+
+ :param data: Data set from which to compute the range or None
+ """
+ if data is None or len(data) == 0:
+ dataRange = None
+ else:
+ dataRange = min_max(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 self.getColormap().isAutoscale():
+ self._syncSceneColormap()
+
+ def _setSceneColormap(self, sceneColormap):
+ """Set the scene colormap to sync with Colormap object.
+
+ :param sceneColormap:
+ The plot3d scene colormap to sync with Colormap object.
+ """
+ self.__sceneColormap = sceneColormap
+ self._syncSceneColormap()
+
+ def _getSceneColormap(self):
+ """Returns scene colormap that is sync"""
+ return self.__sceneColormap
+
+ def _syncSceneColormap(self):
+ """Synchronizes scene's colormap with Colormap object"""
+ if self.__sceneColormap is not None:
+ colormap = self.getColormap()
+
+ self.__sceneColormap.colormap = colormap.getNColors()
+ self.__sceneColormap.norm = colormap.getNormalization()
+ range_ = colormap.getColormapRange(data=self._dataRange)
+ self.__sceneColormap.range_ = range_
+
+
+class SymbolMixIn(_SymbolMixIn):
+ """Mix-in class for symbol and symbolSize properties for Item3D"""
+
+ _DEFAULT_SYMBOL = 'o'
+ _DEFAULT_SYMBOL_SIZE = 7.0
+ _SUPPORTED_SYMBOLS = collections.OrderedDict((
+ ('o', 'Circle'),
+ ('d', 'Diamond'),
+ ('s', 'Square'),
+ ('+', 'Plus'),
+ ('x', 'Cross'),
+ ('*', 'Star'),
+ ('|', 'Vertical Line'),
+ ('_', 'Horizontal Line'),
+ ('.', 'Point'),
+ (',', 'Pixel')))
+
+ def _getSceneSymbol(self):
+ """Returns a symbol name and size suitable for scene primitives.
+
+ :return: (symbol, size)
+ """
+ symbol = self.getSymbol()
+ size = self.getSymbolSize()
+ if symbol == ',': # pixel
+ return 's', 1.
+ elif symbol == '.': # point
+ # Size as in plot OpenGL backend, mimic matplotlib
+ return 'o', numpy.ceil(0.5 * size) + 1.
+ else:
+ return symbol, size
+
+
+class PlaneMixIn(ItemMixInBase):
+ """Mix-in class for plane items (based on PlaneInGroup primitive)"""
+
+ def __init__(self, plane):
+ assert isinstance(plane, primitives.PlaneInGroup)
+ self.__plane = plane
+ self.__plane.alpha = 1.
+ self.__plane.addListener(self._planeChanged)
+ self.__plane.plane.addListener(self._planePositionChanged)
+
+ def _getPlane(self):
+ """Returns plane primitive
+
+ :rtype: primitives.PlaneInGroup
+ """
+ return self.__plane
+
+ def _planeChanged(self, source, *args, **kwargs):
+ """Handle events from the plane primitive"""
+ # Sync visibility
+ if source.visible != self.isVisible():
+ self.setVisible(source.visible)
+
+ def _planePositionChanged(self, source, *args, **kwargs):
+ """Handle update of cut plane position and normal"""
+ if self.__plane.visible: # TODO send even if hidden? or send also when showing if moved while hidden
+ self._updated(ItemChangedType.POSITION)
+
+ # Plane position
+
+ def moveToCenter(self):
+ """Move cut plane to center of data set"""
+ self.__plane.moveToCenter()
+
+ def isValid(self):
+ """Returns whether the cut plane is defined or not (bool)"""
+ return self.__plane.isValid
+
+ def getNormal(self):
+ """Returns the normal of the plane (as a unit vector)
+
+ :return: Normal (nx, ny, nz), vector is 0 if no plane is defined
+ :rtype: numpy.ndarray
+ """
+ return self.__plane.plane.normal
+
+ def setNormal(self, normal):
+ """Set the normal of the plane
+
+ :param normal: 3-tuple of float: nx, ny, nz
+ """
+ self.__plane.plane.normal = normal
+
+ def getPoint(self):
+ """Returns a point on the plane
+
+ :return: (x, y, z)
+ :rtype: numpy.ndarray
+ """
+ return self.__plane.plane.point
+
+ def setPoint(self, point):
+ """Set a point contained in the plane.
+
+ Warning: The plane might not intersect the bounding box of the data.
+
+ :param point: (x, y, z) position
+ :type point: 3-tuple of float
+ """
+ self.__plane.plane.point = point # TODO rework according to PR #1303
+
+ def getParameters(self):
+ """Returns the plane equation parameters: a*x + b*y + c*z + d = 0
+
+ :return: Plane equation parameters: (a, b, c, d)
+ :rtype: numpy.ndarray
+ """
+ return self.__plane.plane.parameters
+
+ def setParameters(self, parameters):
+ """Set the plane equation parameters: a*x + b*y + c*z + d = 0
+
+ Warning: The plane might not intersect the bounding box of the data.
+ The given parameters will be normalized.
+
+ :param parameters: (a, b, c, d) equation parameters
+ """
+ self.__plane.plane.parameters = parameters
+
+ # Border stroke
+
+ def _setForegroundColor(self, color):
+ """Set the color of the plane border.
+
+ :param color: RGBA color as 4 floats in [0, 1]
+ """
+ self.__plane.color = rgba(color)
+ if hasattr(super(PlaneMixIn, self), '_setForegroundColor'):
+ super(PlaneMixIn, self)._setForegroundColor(color)
diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py
new file mode 100644
index 0000000..5eea455
--- /dev/null
+++ b/silx/gui/plot3d/items/scatter.py
@@ -0,0 +1,474 @@
+# 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 2D and 3D scatter data item class.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+import collections
+import logging
+import sys
+import numpy
+
+from ..scene import function, primitives, utils
+
+from .core import DataItem3D, Item3DChangedType, ItemChangedType
+from .mixins import ColormapMixIn, SymbolMixIn
+
+
+_logger = logging.getLevelName(__name__)
+
+
+class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn):
+ """Description of a 3D scatter plot.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ # TODO supports different size for each point
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+ SymbolMixIn.__init__(self)
+
+ noData = numpy.zeros((0, 1), dtype=numpy.float32)
+ symbol, size = self._getSceneSymbol()
+ self._scatter = primitives.Points(
+ x=noData, y=noData, z=noData, value=noData, size=size)
+ self._scatter.marker = symbol
+ self._getScenePrimitive().children.append(self._scatter)
+
+ # Connect scene primitive to mix-in class
+ ColormapMixIn._setSceneColormap(self, self._scatter.colormap)
+
+ def _updated(self, event=None):
+ """Handle mix-in class updates"""
+ if event in (ItemChangedType.SYMBOL, ItemChangedType.SYMBOL_SIZE):
+ symbol, size = self._getSceneSymbol()
+ self._scatter.marker = symbol
+ self._scatter.setAttribute('size', size, copy=True)
+
+ super(Scatter3D, self)._updated(event)
+
+ def setData(self, x, y, z, value, copy=True):
+ """Set the data of the scatter plot
+
+ :param numpy.ndarray x: Array of X coordinates (single value not accepted)
+ :param y: Points Y coordinate (array-like or single value)
+ :param z: Points Z coordinate (array-like or single value)
+ :param value: Points values (array-like or single value)
+ :param bool copy:
+ True (default) to copy the data,
+ False to use provided data (do not modify!)
+ """
+ self._scatter.setAttribute('x', x, copy=copy)
+ self._scatter.setAttribute('y', y, copy=copy)
+ self._scatter.setAttribute('z', z, copy=copy)
+ self._scatter.setAttribute('value', value, copy=copy)
+
+ ColormapMixIn._setRangeFromData(self, self.getValues(copy=False))
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Returns data as provided to :meth:`setData`.
+
+ :param bool copy: True to get a copy,
+ False to return internal data (do not modify!)
+ :return: (x, y, z, value)
+ """
+ return (self.getXData(copy),
+ self.getYData(copy),
+ self.getZData(copy),
+ self.getValues(copy))
+
+ def getXData(self, copy=True):
+ """Returns X data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: X coordinates
+ :rtype: numpy.ndarray
+ """
+ return self._scatter.getAttribute('x', copy=copy)
+
+ def getYData(self, copy=True):
+ """Returns Y data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: Y coordinates
+ :rtype: numpy.ndarray
+ """
+ return self._scatter.getAttribute('y', copy=copy)
+
+ def getZData(self, copy=True):
+ """Returns Z data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: Z coordinates
+ :rtype: numpy.ndarray
+ """
+ return self._scatter.getAttribute('z', copy=copy)
+
+ def getValues(self, copy=True):
+ """Returns data values.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: data values
+ :rtype: numpy.ndarray
+ """
+ return self._scatter.getAttribute('value', copy=copy)
+
+
+class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
+ """2D scatter data with settable visualization mode.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ _VISUALIZATION_PROPERTIES = {
+ 'points': ('symbol', 'symbolSize'),
+ 'lines': ('lineWidth',),
+ 'solid': (),
+ }
+ """Dict {visualization mode: property names used in this mode}"""
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+ SymbolMixIn.__init__(self)
+
+ self._visualizationMode = 'points'
+ self._heightMap = False
+ self._lineWidth = 1.
+
+ self._x = numpy.zeros((0,), dtype=numpy.float32)
+ self._y = numpy.zeros((0,), dtype=numpy.float32)
+ self._value = numpy.zeros((0,), dtype=numpy.float32)
+
+ self._cachedLinesIndices = None
+ self._cachedTrianglesIndices = None
+
+ # Connect scene primitive to mix-in class
+ ColormapMixIn._setSceneColormap(self, function.Colormap())
+
+ def _updated(self, event=None):
+ """Handle mix-in class updates"""
+ if event in (ItemChangedType.SYMBOL, ItemChangedType.SYMBOL_SIZE):
+ symbol, size = self._getSceneSymbol()
+ for child in self._getScenePrimitive().children:
+ if isinstance(child, primitives.Points):
+ child.marker = symbol
+ child.setAttribute('size', size, copy=True)
+
+ elif event == ItemChangedType.VISIBLE:
+ # TODO smart update?, need dirty flags
+ self._updateScene()
+
+ super(Scatter2D, self)._updated(event)
+
+ def supportedVisualizations(self):
+ """Returns the list of supported visualization modes.
+
+ See :meth:`setVisualizationModes`
+
+ :rtype: tuple of str
+ """
+ return tuple(self._VISUALIZATION_PROPERTIES.keys())
+
+ def setVisualization(self, mode):
+ """Set the visualization mode of the data.
+
+ Supported visualization modes are:
+
+ - 'points': For scatter plot representation
+ - 'lines': For Delaunay tesselation-based wireframe representation
+ - 'solid': For Delaunay tesselation-based solid surface representation
+
+ :param str mode: Mode of representation to use
+ """
+ mode = str(mode)
+ assert mode in self.supportedVisualizations()
+
+ if mode != self.getVisualization():
+ self._visualizationMode = mode
+ self._updateScene()
+ self._updated(ItemChangedType.VISUALIZATION_MODE)
+
+ def getVisualization(self):
+ """Returns the current visualization mode.
+
+ See :meth:`setVisualization`
+
+ :rtype: str
+ """
+ return self._visualizationMode
+
+ def isPropertyEnabled(self, name, visualization=None):
+ """Returns true if the property is used with visualization mode.
+
+ :param str name: The name of the property to check, in:
+ 'lineWidth', 'symbol', 'symbolSize'
+ :param str visualization:
+ The visualization mode for which to get the info.
+ By default, it is the current visualization mode.
+ :return:
+ """
+ assert name in ('lineWidth', 'symbol', 'symbolSize')
+ if visualization is None:
+ visualization = self.getVisualization()
+ assert visualization in self.supportedVisualizations()
+ return name in self._VISUALIZATION_PROPERTIES[visualization]
+
+ def setHeightMap(self, heightMap):
+ """Set whether to display the data has a height map or not.
+
+ When displayed as a height map, the data values are used as
+ z coordinates.
+
+ :param bool heightMap:
+ True to display a height map,
+ False to display as 2D data with z=0
+ """
+ heightMap = bool(heightMap)
+ if heightMap != self.isHeightMap():
+ self._heightMap = heightMap
+ self._updateScene()
+ self._updated(Item3DChangedType.HEIGHT_MAP)
+
+ def isHeightMap(self):
+ """Returns True if data is displayed as a height map.
+
+ :rtype: bool
+ """
+ return self._heightMap
+
+ def getLineWidth(self):
+ """Return the curve line width in pixels (float)"""
+ return self._lineWidth
+
+ def setLineWidth(self, width):
+ """Set the width in pixel of the curve line
+
+ See :meth:`getLineWidth`.
+
+ :param float width: Width in pixels
+ """
+ width = float(width)
+ assert width >= 1.
+ if width != self._lineWidth:
+ self._lineWidth = width
+ for child in self._getScenePrimitive().children:
+ if hasattr(child, 'lineWidth'):
+ child.lineWidth = width
+ self._updated(ItemChangedType.LINE_WIDTH)
+
+ def setData(self, x, y, value, copy=True):
+ """Set the data represented by this item.
+
+ Provided arrays must have the same length.
+
+ :param numpy.ndarray x: X coordinates (array-like)
+ :param numpy.ndarray y: Y coordinates (array-like)
+ :param value: Points value: array-like or single scalar
+ :param bool copy:
+ True (default) to make a copy of the data,
+ False to avoid copy if possible (do not modify the arrays).
+ """
+ x = numpy.array(
+ x, copy=copy, dtype=numpy.float32, order='C').reshape(-1)
+ y = numpy.array(
+ y, copy=copy, dtype=numpy.float32, order='C').reshape(-1)
+ assert len(x) == len(y)
+
+ if isinstance(value, collections.Iterable):
+ value = numpy.array(
+ value, copy=copy, dtype=numpy.float32, order='C').reshape(-1)
+ assert len(value) == len(x)
+ else: # Single scalar
+ value = numpy.array((float(value),), dtype=numpy.float32)
+
+ self._x = x
+ self._y = y
+ self._value = value
+
+ # Reset cache
+ self._cachedLinesIndices = None
+ self._cachedTrianglesIndices = None
+
+ # Store data range info
+ ColormapMixIn._setRangeFromData(self, self.getValues(copy=False))
+
+ self._updateScene()
+
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Returns data as provided to :meth:`setData`.
+
+ :param bool copy: True to get a copy,
+ False to return internal data (do not modify!)
+ :return: (x, y, value)
+ """
+ return (self.getXData(copy=copy),
+ self.getYData(copy=copy),
+ self.getValues(copy=copy))
+
+ def getXData(self, copy=True):
+ """Returns X data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: X coordinates
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._x, copy=copy)
+
+ def getYData(self, copy=True):
+ """Returns Y data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: Y coordinates
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._y, copy=copy)
+
+ def getValues(self, copy=True):
+ """Returns data values.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: data values
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._value, copy=copy)
+
+ def _updateScene(self):
+ self._getScenePrimitive().children = [] # Remove previous primitives
+
+ if not self.isVisible():
+ return # Update when visible
+
+ x, y, value = self.getData(copy=False)
+ if len(x) == 0:
+ return # Nothing to display
+
+ mode = self.getVisualization()
+ heightMap = self.isHeightMap()
+
+ if mode == 'points':
+ z = value if heightMap else 0.
+ symbol, size = self._getSceneSymbol()
+ primitive = primitives.Points(
+ x=x, y=y, z=z, value=value,
+ size=size,
+ colormap=self._getSceneColormap())
+ primitive.marker = symbol
+
+ else:
+ # TODO run delaunay in a thread
+ # Compute lines/triangles indices if not cached
+ if self._cachedTrianglesIndices is None:
+ coordinates = numpy.array((x, y)).T
+
+ if len(coordinates) > 3:
+ # Enough points to try a Delaunay tesselation
+
+ # Lazy loading of Delaunay
+ from silx.third_party.scipy_spatial import Delaunay as _Delaunay
+
+ try:
+ tri = _Delaunay(coordinates)
+ except RuntimeError:
+ _logger.error("Delaunay tesselation failed: %s",
+ sys.exc_info()[1])
+ return None
+
+ self._cachedTrianglesIndices = numpy.ravel(
+ tri.simplices.astype(numpy.uint32))
+
+ else:
+ # 3 or less points: Draw one triangle
+ self._cachedTrianglesIndices = \
+ numpy.arange(3, dtype=numpy.uint32) % len(coordinates)
+
+ if mode == 'lines' and self._cachedLinesIndices is None:
+ # Compute line indices
+ self._cachedLinesIndices = utils.triangleToLineIndices(
+ self._cachedTrianglesIndices, unicity=True)
+
+ if mode == 'lines':
+ indices = self._cachedLinesIndices
+ renderMode = 'lines'
+ else:
+ indices = self._cachedTrianglesIndices
+ renderMode = 'triangles'
+
+ # TODO supports x, y instead of copy
+ if heightMap:
+ if len(value) == 1:
+ value = numpy.ones_like(x) * value
+ coordinates = numpy.array((x, y, value), dtype=numpy.float32).T
+ else:
+ coordinates = numpy.array((x, y), dtype=numpy.float32).T
+
+ # TODO option to enable/disable light, cache normals
+ # TODO smooth surface
+ if mode == 'solid':
+ if heightMap:
+ coordinates = coordinates[indices]
+ if len(value) > 1:
+ value = value[indices]
+ triangleNormals = utils.trianglesNormal(coordinates)
+ normal = numpy.empty((len(triangleNormals) * 3, 3),
+ dtype=numpy.float32)
+ normal[0::3, :] = triangleNormals
+ normal[1::3, :] = triangleNormals
+ normal[2::3, :] = triangleNormals
+ indices = None
+ else:
+ normal = (0., 0., 1.)
+ else:
+ normal = None
+
+ primitive = primitives.ColormapMesh3D(
+ coordinates,
+ value.reshape(-1, 1), # Makes it a 2D array
+ normal=normal,
+ colormap=self._getSceneColormap(),
+ indices=indices,
+ mode=renderMode)
+ primitive.lineWidth = self.getLineWidth()
+ primitive.lineSmooth = False
+
+ self._getScenePrimitive().children = [primitive]
diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py
new file mode 100644
index 0000000..a1f40f7
--- /dev/null
+++ b/silx/gui/plot3d/items/volume.py
@@ -0,0 +1,463 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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 3D array item class and its sub-items.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+import logging
+import time
+import numpy
+
+from silx.math.combo import min_max
+from silx.math.marchingcubes import MarchingCubes
+
+from ... import qt
+from ...plot.Colors import rgba
+
+from ..scene import cutplane, primitives, transform
+
+from .core import DataItem3D, Item3D, ItemChangedType, Item3DChangedType
+from .mixins import ColormapMixIn, InterpolationMixIn, PlaneMixIn
+
+
+_logger = logging.getLogger(__name__)
+
+
+class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn):
+ """Class representing a cutting plane in a :class:`ScalarField3D` item.
+
+ :param parent: 3D Data set in which the cut plane is applied.
+ """
+
+ def __init__(self, parent):
+ plane = cutplane.CutPlane(normal=(0, 1, 0))
+
+ Item3D.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+ InterpolationMixIn.__init__(self)
+ PlaneMixIn.__init__(self, plane=plane)
+
+ self._dataRange = None
+
+ self._getScenePrimitive().children = [plane]
+
+ # Connect scene primitive to mix-in class
+ ColormapMixIn._setSceneColormap(self, plane.colormap)
+ InterpolationMixIn._setPrimitive(self, plane)
+
+ parent.sigItemChanged.connect(self._parentChanged)
+
+ def _parentChanged(self, event):
+ """Handle data change in the parent this plane belongs to"""
+ if event == ItemChangedType.DATA:
+ self._getPlane().setData(self.sender().getData(), copy=False)
+
+ # Store data range info as 3-tuple of values
+ self._dataRange = self.sender().getDataRange()
+
+ self.sigItemChanged.emit(ItemChangedType.DATA)
+
+ # Colormap
+
+ def getDisplayValuesBelowMin(self):
+ """Return whether values <= colormap min are displayed or not.
+
+ :rtype: bool
+ """
+ return self._getPlane().colormap.displayValuesBelowMin
+
+ def setDisplayValuesBelowMin(self, display):
+ """Set whether to display values <= colormap min.
+
+ :param bool display: True to show values below min,
+ False to discard them
+ """
+ display = bool(display)
+ if display != self.getDisplayValuesBelowMin():
+ self._getPlane().colormap.displayValuesBelowMin = display
+ self.sigItemChanged.emit(ItemChangedType.ALPHA)
+
+ def getDataRange(self):
+ """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
+
+
+class Isosurface(Item3D):
+ """Class representing an iso-surface in a :class:`ScalarField3D` item.
+
+ :param parent: The DataItem3D this iso-surface belongs to
+ """
+
+ def __init__(self, parent):
+ Item3D.__init__(self, parent=parent)
+ self._level = float('nan')
+ self._autoLevelFunction = None
+ self._color = rgba('#FFD700FF')
+ self._data = None
+
+ # TODO register to ScalarField3D signal instead?
+ def _setData(self, data, copy=True):
+ """Set the data set from which to build the iso-surface.
+
+ :param numpy.ndarray data: The 3D data set or None
+ :param bool copy: True to make a copy, False to use as is if possible
+ """
+ if data is None:
+ self._data = None
+ else:
+ self._data = numpy.array(data, copy=copy, order='C')
+
+ self._updateScenePrimitive()
+
+ def getLevel(self):
+ """Return the level of this iso-surface (float)"""
+ return self._level
+
+ def setLevel(self, level):
+ """Set the value at which to build the iso-surface.
+
+ Setting this value reset auto-level function
+
+ :param float level: The value at which to build the iso-surface
+ """
+ self._autoLevelFunction = None
+ level = float(level)
+ if level != self._level:
+ self._level = level
+ self._updateScenePrimitive()
+ self._updated(Item3DChangedType.ISO_LEVEL)
+
+ def isAutoLevel(self):
+ """True if iso-level is rebuild for each data set."""
+ return self.getAutoLevelFunction() is not None
+
+ def getAutoLevelFunction(self):
+ """Return the function computing the iso-level (callable or None)"""
+ return self._autoLevelFunction
+
+ def setAutoLevelFunction(self, autoLevel):
+ """Set the function used to compute the iso-level.
+
+ WARNING: The function might get called in a thread.
+
+ :param callable autoLevel:
+ A function taking a 3D numpy.ndarray of float32 and returning
+ a float used as iso-level.
+ Example: numpy.mean(data) + numpy.std(data)
+ """
+ assert callable(autoLevel)
+ self._autoLevelFunction = autoLevel
+ self._updateScenePrimitive()
+
+ def getColor(self):
+ """Return the color of this iso-surface (QColor)"""
+ return qt.QColor.fromRgbF(*self._color)
+
+ def setColor(self, color):
+ """Set the color of the iso-surface
+
+ :param color: RGBA color of the isosurface
+ :type color: QColor, str or array-like of 4 float in [0., 1.]
+ """
+ color = rgba(color)
+ if color != self._color:
+ self._color = color
+ primitive = self._getScenePrimitive()
+ if len(primitive.children) != 0:
+ primitive.children[0].setAttribute('color', self._color)
+ self._updated(ItemChangedType.COLOR)
+
+ def _updateScenePrimitive(self):
+ """Update underlying mesh"""
+ self._getScenePrimitive().children = []
+
+ if self._data is None:
+ if self.isAutoLevel():
+ self._level = float('nan')
+
+ else:
+ if self.isAutoLevel():
+ st = time.time()
+ try:
+ level = float(self.getAutoLevelFunction()(self._data))
+
+ except Exception:
+ module_ = self.getAutoLevelFunction().__module__
+ name = self.getAutoLevelFunction().__name__
+ _logger.error(
+ "Error while executing iso level function %s.%s",
+ module_,
+ name,
+ exc_info=True)
+ level = float('nan')
+
+ else:
+ _logger.info(
+ 'Computed iso-level in %f s.', time.time() - st)
+
+ if level != self._level:
+ self._level = level
+ self._updated(Item3DChangedType.ISO_LEVEL)
+
+ if not numpy.isfinite(self._level):
+ return
+
+ st = time.time()
+ vertices, normals, indices = MarchingCubes(
+ self._data,
+ isolevel=self._level)
+ _logger.info('Computed iso-surface in %f s.', time.time() - st)
+
+ if len(vertices) == 0:
+ return
+ else:
+ mesh = primitives.Mesh3D(vertices,
+ colors=self._color,
+ normals=normals,
+ mode='triangles',
+ indices=indices)
+ self._getScenePrimitive().children = [mesh]
+
+
+class ScalarField3D(DataItem3D):
+ """3D scalar field on a regular grid.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+
+ # Gives this item the shape of the data, no matter
+ # of the isosurface/cut plane size
+ self._boundedGroup = primitives.BoundedGroup()
+
+ # Store iso-surfaces
+ self._isosurfaces = []
+
+ self._data = None
+ self._dataRange = None
+
+ self._cutPlane = CutPlane(parent=self)
+ self._cutPlane.setVisible(False)
+
+ self._isogroup = primitives.GroupDepthOffset()
+ self._isogroup.transforms = [
+ # Convert from z, y, x from marching cubes to x, y, z
+ transform.Matrix((
+ (0., 0., 1., 0.),
+ (0., 1., 0., 0.),
+ (1., 0., 0., 0.),
+ (0., 0., 0., 1.))),
+ # Offset to match cutting plane coords
+ transform.Translate(0.5, 0.5, 0.5)
+ ]
+
+ self._getScenePrimitive().children = [
+ self._boundedGroup,
+ self._cutPlane._getScenePrimitive(),
+ self._isogroup]
+
+ def setData(self, data, copy=True):
+ """Set the 3D scalar data represented by this item.
+
+ Dataset order is zyx (i.e., first dimension is z).
+
+ :param data: 3D array
+ :type data: 3D numpy.ndarray of float32 with shape at least (2, 2, 2)
+ :param bool copy:
+ True (default) to make a copy,
+ False to avoid copy (DO NOT MODIFY data afterwards)
+ """
+ if data is None:
+ self._data = None
+ self._dataRange = None
+ self._boundedGroup.shape = None
+
+ else:
+ data = numpy.array(data, copy=copy, dtype=numpy.float32, order='C')
+ assert data.ndim == 3
+ assert min(data.shape) >= 2
+
+ self._data = data
+
+ # 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
+
+ self._boundedGroup.shape = self._data.shape
+
+ # Update iso-surfaces
+ for isosurface in self.getIsosurfaces():
+ isosurface._setData(self._data, copy=False)
+
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Return 3D dataset.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get the internal data (DO NOT modify!)
+ :return: The data set (or None if not set)
+ """
+ if self._data is None:
+ return None
+ else:
+ return numpy.array(self._data, copy=copy)
+
+ def getDataRange(self):
+ """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
+
+ # Cut Plane
+
+ def getCutPlanes(self):
+ """Return an iterable of all :class:`CutPlane` of this item.
+
+ This includes hidden cut planes.
+
+ For now, there is always one cut plane.
+ """
+ return (self._cutPlane,)
+
+ # Handle iso-surfaces
+
+ # TODO rename to sigItemAdded|Removed?
+ sigIsosurfaceAdded = qt.Signal(object)
+ """Signal emitted when a new iso-surface is added to the view.
+
+ The newly added iso-surface is provided by this signal
+ """
+
+ sigIsosurfaceRemoved = qt.Signal(object)
+ """Signal emitted when an iso-surface is removed from the view
+
+ The removed iso-surface is provided by this signal.
+ """
+
+ def addIsosurface(self, level, color):
+ """Add an isosurface to this item.
+
+ :param level:
+ The value at which to build the iso-surface or a callable
+ (e.g., a function) taking a 3D numpy.ndarray as input and
+ returning a float.
+ Example: numpy.mean(data) + numpy.std(data)
+ :type level: float or callable
+ :param color: RGBA color of the isosurface
+ :type color: str or array-like of 4 float in [0., 1.]
+ :return: isosurface object
+ :rtype: ~silx.gui.plot3d.items.volume.Isosurface
+ """
+ isosurface = Isosurface(parent=self)
+ isosurface.setColor(color)
+ if callable(level):
+ isosurface.setAutoLevelFunction(level)
+ else:
+ isosurface.setLevel(level)
+ isosurface._setData(self._data, copy=False)
+ isosurface.sigItemChanged.connect(self._isosurfaceItemChanged)
+
+ self._isosurfaces.append(isosurface)
+
+ self._updateIsosurfaces()
+
+ self.sigIsosurfaceAdded.emit(isosurface)
+ return isosurface
+
+ def getIsosurfaces(self):
+ """Return an iterable of all :class:`.Isosurface` instance of this item"""
+ return tuple(self._isosurfaces)
+
+ def removeIsosurface(self, isosurface):
+ """Remove an iso-surface from this item.
+
+ :param ~silx.gui.plot3d.Plot3DWidget.Isosurface isosurface:
+ The isosurface object to remove
+ """
+ if isosurface not in self.getIsosurfaces():
+ _logger.warning(
+ "Try to remove isosurface that is not in the list: %s",
+ str(isosurface))
+ else:
+ isosurface.sigItemChanged.disconnect(self._isosurfaceItemChanged)
+ self._isosurfaces.remove(isosurface)
+ self._updateIsosurfaces()
+ self.sigIsosurfaceRemoved.emit(isosurface)
+
+ def clearIsosurfaces(self):
+ """Remove all :class:`.Isosurface` instances from this item."""
+ for isosurface in self.getIsosurfaces():
+ self.removeIsosurface(isosurface)
+
+ def _isosurfaceItemChanged(self, event):
+ """Handle update of isosurfaces upon level changed"""
+ if event == Item3DChangedType.ISO_LEVEL:
+ self._updateIsosurfaces()
+
+ def _updateIsosurfaces(self):
+ """Handle updates of iso-surfaces level and add/remove"""
+ # Sorting using minus, this supposes data 'object' to be max values
+ sortedIso = sorted(self.getIsosurfaces(),
+ key=lambda isosurface: - isosurface.getLevel())
+ self._isogroup.children = [iso._getScenePrimitive() for iso in sortedIso]
+
+ def visit(self, included=True):
+ """Generator visiting the ScalarField3D content.
+
+ It first access cut planes and then isosurface
+
+ :param bool included: True (default) to include self in visit
+ """
+ if included:
+ yield self
+ for cutPlane in self.getCutPlanes():
+ yield cutPlane
+ for isosurface in self.getIsosurfaces():
+ yield isosurface
diff --git a/silx/gui/plot3d/scene/__init__.py b/silx/gui/plot3d/scene/__init__.py
index 25a7171..9671725 100644
--- a/silx/gui/plot3d/scene/__init__.py
+++ b/silx/gui/plot3d/scene/__init__.py
@@ -29,6 +29,6 @@ __license__ = "MIT"
__date__ = "08/11/2016"
-from .core import Elem, Group, PrivateGroup # noqa
+from .core import Base, Elem, Group, PrivateGroup # noqa
from .viewport import Viewport # noqa
from .window import Window # noqa
diff --git a/silx/gui/plot3d/scene/axes.py b/silx/gui/plot3d/scene/axes.py
index 520ef3e..e35e5e1 100644
--- a/silx/gui/plot3d/scene/axes.py
+++ b/silx/gui/plot3d/scene/axes.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 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
@@ -87,6 +87,23 @@ class LabelledAxes(primitives.GroupBBox):
# Sync color
self.tickColor = 1., 1., 1., 1.
+ def _updateBoxAndAxes(self):
+ """Update bbox and axes position and size according to children.
+
+ Overridden from GroupBBox
+ """
+ super(LabelledAxes, self)._updateBoxAndAxes()
+
+ bounds = self._group.bounds(dataBounds=True)
+ if bounds is not None:
+ tx, ty, tz = (bounds[1] - bounds[0]) / 2.
+ else:
+ tx, ty, tz = 0.5, 0.5, 0.5
+
+ self._xlabel.transforms[-1].tx = tx
+ self._ylabel.transforms[-1].ty = ty
+ self._zlabel.transforms[-1].tz = tz
+
@property
def tickColor(self):
"""Color of ticks and text labels.
diff --git a/silx/gui/plot3d/scene/camera.py b/silx/gui/plot3d/scene/camera.py
index 8cc279d..acc5899 100644
--- a/silx/gui/plot3d/scene/camera.py
+++ b/silx/gui/plot3d/scene/camera.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -252,8 +252,9 @@ class Camera(transform.Transform):
:param float fovy: Vertical field-of-view in degrees.
:param float near: The near clipping plane Z coord (strictly positive).
:param float far: The far clipping plane Z coord (> near).
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
:param position: Coordinates of the point of view.
:type position: numpy.ndarray-like of 3 float32.
:param direction: Sight direction vector.
diff --git a/silx/gui/plot3d/scene/cutplane.py b/silx/gui/plot3d/scene/cutplane.py
index 79b4168..08a9899 100644
--- a/silx/gui/plot3d/scene/cutplane.py
+++ b/silx/gui/plot3d/scene/cutplane.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 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
@@ -29,7 +29,7 @@ from __future__ import absolute_import, division, unicode_literals
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/10/2016"
+__date__ = "11/01/2018"
import string
import numpy
@@ -53,6 +53,7 @@ class ColormapMesh3D(Geometry):
uniform mat4 transformMat;
//uniform mat3 matrixInvTranspose;
uniform vec3 dataScale;
+ uniform vec3 texCoordsOffset;
varying vec4 vCameraPosition;
varying vec3 vPosition;
@@ -64,7 +65,7 @@ class ColormapMesh3D(Geometry):
vCameraPosition = transformMat * vec4(position, 1.0);
//vNormal = matrixInvTranspose * normalize(normal);
vPosition = position;
- vTexCoords = dataScale * position;
+ vTexCoords = dataScale * position + texCoordsOffset;
vNormal = normal;
gl_Position = matrix * vec4(position, 1.0);
}
@@ -113,6 +114,8 @@ class ColormapMesh3D(Geometry):
normal=normal)
self.isBackfaceVisible = True
+ self.textureOffset = 0., 0., 0.
+ """Offset to add to texture coordinates"""
def setData(self, data, copy=True):
data = numpy.array(data, copy=copy, order='C')
@@ -209,6 +212,7 @@ class ColormapMesh3D(Geometry):
shape = self._data.shape
scales = 1./shape[2], 1./shape[1], 1./shape[0]
gl.glUniform3f(program.uniforms['dataScale'], *scales)
+ gl.glUniform3f(program.uniforms['texCoordsOffset'], *self.textureOffset)
gl.glUniform1i(program.uniforms['data'], self._texture.texUnit)
@@ -275,21 +279,13 @@ class CutPlane(PlaneInGroup):
self._interpolation = interpolation
if self._mesh is not None:
self._mesh.interpolation = interpolation
+ self.notify()
def prepareGL2(self, ctx):
if self.isValid:
contourVertices = self.contourVertices
- if (self.interpolation == 'nearest' and
- contourVertices is not None and len(contourVertices)):
- # Avoid cut plane co-linear with array bin edges
- for index, normal in enumerate(((1., 0., 0.), (0., 1., 0.), (0., 0., 1.))):
- if (numpy.all(numpy.equal(self.plane.normal, normal)) and
- int(self.plane.point[index]) == self.plane.point[index]):
- contourVertices += self.plane.normal * 0.01 # Add an offset
- break
-
if self._mesh is None and self._data is not None:
self._mesh = ColormapMesh3D(contourVertices,
normal=self.plane.normal,
@@ -298,7 +294,7 @@ class CutPlane(PlaneInGroup):
mode='fan',
colormap=self.colormap)
self._mesh.alpha = self._alpha
- self._interpolation = self.interpolation
+ self._mesh.interpolation = self.interpolation
self._children.insert(0, self._mesh)
if self._mesh is not None:
@@ -310,6 +306,23 @@ class CutPlane(PlaneInGroup):
self._mesh.setAttribute('normal', self.plane.normal)
self._mesh.setAttribute('position', contourVertices)
+ needTextureOffset = False
+ if self.interpolation == 'nearest':
+ # If cut plane is co-linear with array bin edges add texture offset
+ planePt = self.plane.point
+ for index, normal in enumerate(((1., 0., 0.),
+ (0., 1., 0.),
+ (0., 0., 1.))):
+ if (numpy.all(numpy.equal(self.plane.normal, normal)) and
+ int(planePt[index]) == planePt[index]):
+ needTextureOffset = True
+ break
+
+ if needTextureOffset:
+ self._mesh.textureOffset = self.plane.normal * 1e-6
+ else:
+ self._mesh.textureOffset = 0., 0., 0.
+
super(CutPlane, self).prepareGL2(ctx)
def renderGL2(self, ctx):
@@ -348,10 +361,11 @@ class CutPlane(PlaneInGroup):
return cachevertices
# Cache is not OK, rebuild it
- boxvertices = bounds[0] + Box._vertices.copy()*(bounds[1] - bounds[0])
- lineindices = Box._lineIndices
+ boxVertices = Box.getVertices(copy=True)
+ boxVertices = bounds[0] + boxVertices * (bounds[1] - bounds[0])
+ lineIndices = Box.getLineIndices(copy=False)
vertices = utils.boxPlaneIntersect(
- boxvertices, lineindices, self.plane.normal, self.plane.point)
+ boxVertices, lineIndices, self.plane.normal, self.plane.point)
self._cache = bounds, vertices if len(vertices) != 0 else None
diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py
index 73cdb72..ba4c4ca 100644
--- a/silx/gui/plot3d/scene/function.py
+++ b/silx/gui/plot3d/scene/function.py
@@ -33,6 +33,7 @@ __date__ = "08/11/2016"
import contextlib
import logging
+import string
import numpy
from ... import _glutils
@@ -296,9 +297,8 @@ class DirectionalLight(event.Notifier, ProgramFunction):
class Colormap(event.Notifier, ProgramFunction):
- # TODO use colors for out-of-bound values, for <=0 with log, for nan
- decl = """
+ _declTemplate = string.Template("""
uniform struct {
sampler2D texture;
bool isLog;
@@ -321,9 +321,17 @@ class Colormap(event.Notifier, ProgramFunction):
value = clamp(cmap.oneOverRange * (value - cmap.min), 0.0, 1.0);
}
+ $discard
+
vec4 color = texture2D(cmap.texture, vec2(value, 0.5));
return color;
}
+ """)
+
+ _discardCode = """
+ if (value == 0.) {
+ discard;
+ }
"""
call = "colormap"
@@ -346,7 +354,10 @@ class Colormap(event.Notifier, ProgramFunction):
super(Colormap, self).__init__()
# Init privates to default
- self._colormap, self._norm, self._range = None, 'linear', (1., 10.)
+ self._colormap = None
+ self._norm = 'linear'
+ self._range = 1., 10.
+ self._displayValuesBelowMin = True
self._texture = None
self._update_texture = True
@@ -363,6 +374,12 @@ class Colormap(event.Notifier, ProgramFunction):
self.range_ = range_
@property
+ def decl(self):
+ """Source code of the function declaration"""
+ return self._declTemplate.substitute(
+ discard="" if self.displayValuesBelowMin else self._discardCode)
+
+ @property
def colormap(self):
"""Color look-up table to use."""
return numpy.array(self._colormap, copy=True)
@@ -420,6 +437,19 @@ class Colormap(event.Notifier, ProgramFunction):
self._range = range_
self.notify()
+ @property
+ def displayValuesBelowMin(self):
+ """True to display values below colormap min, False to discard them.
+ """
+ return self._displayValuesBelowMin
+
+ @displayValuesBelowMin.setter
+ def displayValuesBelowMin(self, displayValuesBelowMin):
+ displayValuesBelowMin = bool(displayValuesBelowMin)
+ if self._displayValuesBelowMin != displayValuesBelowMin:
+ self._displayValuesBelowMin = displayValuesBelowMin
+ self.notify()
+
def setupProgram(self, context, program):
"""Sets-up uniforms of a program using this shader function.
diff --git a/silx/gui/plot3d/scene/interaction.py b/silx/gui/plot3d/scene/interaction.py
index 2911b2c..e5cfb6d 100644
--- a/silx/gui/plot3d/scene/interaction.py
+++ b/silx/gui/plot3d/scene/interaction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -33,6 +33,7 @@ __date__ = "25/07/2016"
import logging
import numpy
+from silx.gui import qt
from silx.gui.plot.Interaction import \
StateMachine, State, LEFT_BTN, RIGHT_BTN # , MIDDLE_BTN
@@ -380,16 +381,16 @@ class FocusManager(StateMachine):
"""
class Idle(State):
def onPress(self, x, y, btn):
- for eventHandler in self.machine.eventHandlers:
- requestfocus = eventHandler.handleEvent('press', x, y, btn)
- if requestfocus:
+ for eventHandler in self.machine.currentEventHandler:
+ requestFocus = eventHandler.handleEvent('press', x, y, btn)
+ if requestFocus:
self.goto('focus', eventHandler, btn)
break
def _processEvent(self, *args):
- for eventHandler in self.machine.eventHandlers:
- consumeevent = eventHandler.handleEvent(*args)
- if consumeevent:
+ for eventHandler in self.machine.currentEventHandler:
+ consumeEvent = eventHandler.handleEvent(*args)
+ if consumeEvent:
break
def onMove(self, x, y):
@@ -424,8 +425,10 @@ class FocusManager(StateMachine):
def onWheel(self, x, y, angleInDegrees):
self.eventHandler.handleEvent('wheel', x, y, angleInDegrees)
- def __init__(self, eventHandlers=()):
- self.eventHandlers = list(eventHandlers)
+ def __init__(self, eventHandlers=(), ctrlEventHandlers=None):
+ self.defaultEventHandlers = eventHandlers
+ self.ctrlEventHandlers = ctrlEventHandlers
+ self.currentEventHandler = self.defaultEventHandlers
states = {
'idle': FocusManager.Idle,
@@ -433,31 +436,48 @@ class FocusManager(StateMachine):
}
super(FocusManager, self).__init__(states, 'idle')
+ def onKeyPress(self, key):
+ if key == qt.Qt.Key_Control and self.ctrlEventHandlers is not None:
+ self.currentEventHandler = self.ctrlEventHandlers
+
+ def onKeyRelease(self, key):
+ if key == qt.Qt.Key_Control:
+ self.currentEventHandler = self.defaultEventHandlers
+
def cancel(self):
- for handler in self.eventHandlers:
+ for handler in self.currentEventHandler:
handler.cancel()
# CameraControl ###############################################################
class RotateCameraControl(FocusManager):
- """Combine wheel and rotate state machine."""
+ """Combine wheel and rotate state machine for left button
+ and pan when ctrl is pressed
+ """
def __init__(self, viewport,
orbitAroundCenter=False,
- mode='center', scaleTransform=None):
+ mode='center', scaleTransform=None,
+ selectCB=None):
handlers = (CameraWheel(viewport, mode, scaleTransform),
CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
- super(RotateCameraControl, self).__init__(handlers)
+ ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform),
+ CameraSelectPan(viewport, LEFT_BTN, selectCB))
+ super(RotateCameraControl, self).__init__(handlers, ctrlHandlers)
class PanCameraControl(FocusManager):
- """Combine wheel, selectPan and rotate state machine."""
+ """Combine wheel, selectPan and rotate state machine for left button
+ and rotate when ctrl is pressed"""
def __init__(self, viewport,
+ orbitAroundCenter=False,
mode='center', scaleTransform=None,
selectCB=None):
handlers = (CameraWheel(viewport, mode, scaleTransform),
CameraSelectPan(viewport, LEFT_BTN, selectCB))
- super(PanCameraControl, self).__init__(handlers)
+ ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform),
+ CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
+ super(PanCameraControl, self).__init__(handlers, ctrlHandlers)
class CameraControl(FocusManager):
@@ -675,7 +695,11 @@ class PanPlaneRotateCameraControl(FocusManager):
class PanPlaneZoomOnWheelControl(FocusManager):
"""Combine zoom on wheel and pan plane state machines."""
def __init__(self, viewport, plane,
- mode='center', scaleTransform=None):
+ mode='center',
+ orbitAroundCenter=False,
+ scaleTransform=None):
handlers = (CameraWheel(viewport, mode, scaleTransform),
PlanePan(viewport, plane, LEFT_BTN))
- super(PanPlaneZoomOnWheelControl, self).__init__(handlers)
+ ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform),
+ CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
+ super(PanPlaneZoomOnWheelControl, self).__init__(handlers, ctrlHandlers)
diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py
index fc38e09..abf7dd4 100644
--- a/silx/gui/plot3d/scene/primitives.py
+++ b/silx/gui/plot3d/scene/primitives.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -47,6 +47,7 @@ from . import event
from . import core
from . import transform
from . import utils
+from .function import Colormap
_logger = logging.getLogger(__name__)
@@ -60,6 +61,7 @@ class Geometry(core.Elem):
lines, line_strip, loop, triangles, triangle_strip, fan
:param indices: Array of vertex indices or None
:param bool copy: True (default) to copy the data, False to use as is.
+ :param str attrib0: Name of the attribute that MUST be an array.
:param attributes: Provide list of attributes as extra parameters.
"""
@@ -91,12 +93,21 @@ class Geometry(core.Elem):
_TRIANGLE_MODES = 'triangles', 'triangle_strip', 'fan'
- def __init__(self, mode, indices=None, copy=True, **attributes):
+ def __init__(self,
+ mode,
+ indices=None,
+ copy=True,
+ attrib0='position',
+ **attributes):
super(Geometry, self).__init__()
+ self._attrib0 = str(attrib0)
+
self._vbos = {} # Store current vbos
self._unsyncAttributes = [] # Store attributes to copy to vbos
self.__bounds = None # Cache object's bounds
+ # Attribute names defining the object bounds
+ self.__boundsAttributeNames = (self._attrib0,)
assert mode in self._MODES
self._mode = mode
@@ -116,9 +127,16 @@ class Geometry(core.Elem):
nbvertices = len(self._indices)
else:
nbvertices = self.nbVertices
- assert nbvertices >= mincheck
- if modulocheck != 0:
- assert (nbvertices % modulocheck) == 0
+
+ if nbvertices != 0:
+ assert nbvertices >= mincheck
+ if modulocheck != 0:
+ assert (nbvertices % modulocheck) == 0
+
+ @property
+ def drawMode(self):
+ """Kind of primitive to render, in :attr:`_MODES` (str)"""
+ return self._mode
@staticmethod
def _glReadyArray(array, copy=True):
@@ -134,10 +152,19 @@ class Geometry(core.Elem):
# Makes sure it is an array
array = numpy.array(array, copy=False)
- # Cast all float to float32
dtype = None
- if numpy.dtype(array.dtype).kind == 'f':
+ if array.dtype.kind == 'f' and array.dtype.itemsize != 4:
+ # Cast to float32
+ _logger.info('Cast array to float32')
dtype = numpy.float32
+ elif array.dtype.itemsize > 4:
+ # Cast (u)int64 to (u)int32
+ if array.dtype.kind == 'i':
+ _logger.info('Cast array to int32')
+ dtype = numpy.int32
+ elif array.dtype.kind == 'u':
+ _logger.info('Cast array to uint32')
+ dtype = numpy.uint32
return numpy.array(array, dtype=dtype, order='C', copy=copy)
@@ -152,6 +179,11 @@ class Geometry(core.Elem):
return len(array)
return None
+ @property
+ def attrib0(self):
+ """Attribute name that MUST be an array (str)"""
+ return self._attrib0
+
def setAttribute(self, name, array, copy=True):
"""Set attribute with provided array.
@@ -169,29 +201,33 @@ class Geometry(core.Elem):
array = self._glReadyArray(array, copy=copy)
if name not in self._ATTR_INFO:
- _logger.info('Not checking attibute %s dimensions', name)
+ _logger.info('Not checking attribute %s dimensions', name)
else:
checks = self._ATTR_INFO[name]
- if (len(array.shape) == 1 and checks['lastDim'] == (1,) and
+ if (array.ndim == 1 and checks['lastDim'] == (1,) and
len(array) > 1):
array = array.reshape((len(array), 1))
# Checks
- assert len(array.shape) in checks['dims'], "Attr %s" % name
+ assert array.ndim in checks['dims'], "Attr %s" % name
assert array.shape[-1] in checks['lastDim'], "Attr %s" % name
+ # Makes sure attrib0 is considered as an array of values
+ if name == self.attrib0 and array.ndim == 1:
+ array.shape = 1, -1
+
# Check length against another attribute array
# Causes problems when updating
# nbVertices = self.nbVertices
- # if len(array.shape) == 2 and nbVertices is not None:
+ # if array.ndim == 2 and nbVertices is not None:
# assert len(array) == nbVertices
self._attributes[name] = array
- if len(array.shape) == 2: # Store this in a VBO
+ if array.ndim == 2: # Store this in a VBO
self._unsyncAttributes.append(name)
- if name == 'position': # Reset bounds
+ if name in self.boundsAttributeNames: # Reset bounds
self.__bounds = None
self.notify()
@@ -238,7 +274,7 @@ class Geometry(core.Elem):
array = self._attributes[name]
assert array is not None
- if len(array.shape) == 1:
+ if array.ndim == 1:
assert len(array) in (1, 2, 3, 4)
gl.glDisableVertexAttribArray(attribute)
_glVertexAttribFunc = getattr(
@@ -273,6 +309,7 @@ class Geometry(core.Elem):
# This might be a costy check
assert indices.max() < self.nbVertices
self._indices = indices
+ self.notify()
def getIndices(self, copy=True):
"""Returns the numpy.ndarray corresponding to the indices.
@@ -287,16 +324,59 @@ class Geometry(core.Elem):
else:
return numpy.array(self._indices, copy=copy)
+ @property
+ def boundsAttributeNames(self):
+ """Tuple of attribute names defining the bounds of the object.
+
+ Attributes name are taken in the given order to compute the
+ (x, y, z) the bounding box, e.g.::
+
+ geometry.boundsAttributeNames = 'position'
+ geometry.boundsAttributeNames = 'x', 'y', 'z'
+ """
+ return self.__boundsAttributeNames
+
+ @boundsAttributeNames.setter
+ def boundsAttributeNames(self, names):
+ self.__boundsAttributeNames = tuple(str(name) for name in names)
+ self.__bounds = None
+ self.notify()
+
def _bounds(self, dataBounds=False):
if self.__bounds is None:
+ if len(self.boundsAttributeNames) == 0:
+ return None # No bounds
+
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]] = \
- numpy.nanmin(positions, axis=0)[:3]
- self.__bounds[1, :positions.shape[1]] = \
- numpy.nanmax(positions, axis=0)[:3]
+
+ # Coordinates defined in one or more attributes
+ index = 0
+ for name in self.boundsAttributeNames:
+ if index == 3:
+ _logger.error("Too many attributes defining bounds")
+ break
+
+ attribute = self._attributes[name]
+ assert attribute.ndim in (1, 2)
+ if attribute.ndim == 1: # Single value
+ min_ = attribute
+ max_ = attribute
+ else: # Array of values, compute min/max
+ min_ = numpy.nanmin(attribute, axis=0)
+ max_ = numpy.nanmax(attribute, axis=0)
+
+ toCopy = min(len(min_), 3-index)
+ if toCopy != len(min_):
+ _logger.error("Attribute defining bounds"
+ " has too many dimensions")
+
+ self.__bounds[0, index:index+toCopy] = min_[:toCopy]
+ self.__bounds[1, index:index+toCopy] = max_[:toCopy]
+
+ index += toCopy
+
self.__bounds[numpy.isnan(self.__bounds)] = 0. # Avoid NaNs
+
return self.__bounds.copy()
def prepareGL2(self, ctx):
@@ -593,9 +673,7 @@ class Box(core.PrivateGroup):
(0., 0., 1.), (1., 0., 1.), (1., 1., 1.), (0., 1., 1.)),
dtype=numpy.float32)
- def __init__(self, size=(1., 1., 1.),
- stroke=(1., 1., 1., 1.),
- fill=(1., 1., 1., 0.)):
+ def __init__(self, stroke=(1., 1., 1., 1.), fill=(1., 1., 1., 0.)):
super(Box, self).__init__()
self._fill = Mesh3D(self._vertices,
@@ -613,8 +691,27 @@ class Box(core.PrivateGroup):
self._children = [self._stroke, self._fill]
- self._size = None
- self.size = size
+ self._size = 1., 1., 1.
+
+ @classmethod
+ def getLineIndices(cls, copy=True):
+ """Returns 2D array of Box lines indices
+
+ :param copy: True (default) to get a copy,
+ False to get internal array (Do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(cls._lineIndices, copy=copy)
+
+ @classmethod
+ def getVertices(cls, copy=True):
+ """Returns 2D array of Box corner coordinates.
+
+ :param copy: True (default) to get a copy,
+ False to get internal array (Do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(cls._vertices, copy=copy)
@property
def size(self):
@@ -712,6 +809,23 @@ class Axes(Lines):
super(Axes, self).__init__(self._vertices,
colors=self._colors,
width=3.)
+ self._size = 1., 1., 1.
+
+ @property
+ def size(self):
+ """Size of the axes (sx, sy, sz)"""
+ return self._size
+
+ @size.setter
+ def size(self, size):
+ assert len(size) == 3
+ size = tuple(size)
+ if size != self.size:
+ self._size = size
+ self.setAttribute(
+ 'position',
+ self._vertices * numpy.array(size, dtype=numpy.float32))
+ self.notify()
class BoxWithAxes(Lines):
@@ -752,6 +866,7 @@ class BoxWithAxes(Lines):
indices=self._lineIndices,
colors=colors,
width=2.)
+ self._size = 1., 1., 1.
self.color = color
@property
@@ -769,6 +884,22 @@ class BoxWithAxes(Lines):
colors[len(self._axesColors):, :] = self._color
self.setAttribute('color', colors) # Do the notification
+ @property
+ def size(self):
+ """Size of the axes (sx, sy, sz)"""
+ return self._size
+
+ @size.setter
+ def size(self, size):
+ assert len(size) == 3
+ size = tuple(size)
+ if size != self.size:
+ self._size = size
+ self.setAttribute(
+ 'position',
+ self._vertices * numpy.array(size, dtype=numpy.float32))
+ self.notify()
+
class PlaneInGroup(core.PrivateGroup):
"""A plane using its parent bounds to display a contour.
@@ -788,6 +919,7 @@ class PlaneInGroup(core.PrivateGroup):
self._color = None
self.color = 1., 1., 1., 1. # Set _color
self._width = 2.
+ self._strokeVisible = True
self._plane = utils.Plane(point, normal)
self._plane.addListener(self._planeChanged)
@@ -825,6 +957,17 @@ class PlaneInGroup(core.PrivateGroup):
if self._outline is not None:
self._outline.width = self._width # Sync width
+ @property
+ def strokeVisible(self):
+ """Whether surrounding stroke is visible or not (bool)."""
+ return self._strokeVisible
+
+ @strokeVisible.setter
+ def strokeVisible(self, visible):
+ self._strokeVisible = bool(visible)
+ if self._outline is not None:
+ self._outline.visible = self._strokeVisible
+
# Plane access
@property
@@ -865,10 +1008,11 @@ class PlaneInGroup(core.PrivateGroup):
return cachevertices
# Cache is not OK, rebuild it
- boxvertices = bounds[0] + Box._vertices.copy()*(bounds[1] - bounds[0])
- lineindices = Box._lineIndices
+ boxVertices = Box.getVertices(copy=True)
+ boxVertices = bounds[0] + boxVertices * (bounds[1] - bounds[0])
+ lineIndices = Box.getLineIndices(copy=False)
vertices = utils.boxPlaneIntersect(
- boxvertices, lineindices, self.plane.normal, self.plane.point)
+ boxVertices, lineIndices, self.plane.normal, self.plane.point)
self._cache = bounds, vertices if len(vertices) != 0 else None
@@ -894,6 +1038,7 @@ class PlaneInGroup(core.PrivateGroup):
mode='loop',
colors=self.color)
self._outline.width = self._width
+ self._outline.visible = self._strokeVisible
self._children.append(self._outline)
# Update vertices, TODO only when necessary
@@ -906,303 +1051,362 @@ class PlaneInGroup(core.PrivateGroup):
super(PlaneInGroup, self).renderGL2(ctx)
+class BoundedGroup(core.Group):
+ """Group with data bounds"""
+
+ _shape = None # To provide a default value without overriding __init__
+
+ @property
+ def shape(self):
+ """Data shape (depth, height, width) of this group or None"""
+ return self._shape
+
+ @shape.setter
+ def shape(self, shape):
+ if shape is None:
+ self._shape = None
+ else:
+ depth, height, width = shape
+ self._shape = float(depth), float(height), float(width)
+
+ @property
+ def size(self):
+ """Data size (width, height, depth) of this group or None"""
+ shape = self.shape
+ if shape is None:
+ return None
+ else:
+ return shape[2], shape[1], shape[0]
+
+ @size.setter
+ def size(self, size):
+ if size is None:
+ self.shape = None
+ else:
+ self.shape = size[2], size[1], size[0]
+
+ def _bounds(self, dataBounds=False):
+ if dataBounds and self.size is not None:
+ return numpy.array(((0., 0., 0.), self.size),
+ dtype=numpy.float32)
+ else:
+ return super(BoundedGroup, self)._bounds(dataBounds)
+
+
# Points ######################################################################
-_POINTS_ATTR_INFO = Geometry._ATTR_INFO.copy()
-_POINTS_ATTR_INFO.update(value={'dims': (1, 2), 'lastDim': (1,)},
- size={'dims': (1, 2), 'lastDim': (1,)},
- symbol={'dims': (1, 2), 'lastDim': (1,)})
+class _Points(Geometry):
+ """Base class to render a set of points."""
+
+ DIAMOND = 'd'
+ CIRCLE = 'o'
+ SQUARE = 's'
+ PLUS = '+'
+ X_MARKER = 'x'
+ ASTERISK = '*'
+ H_LINE = '_'
+ V_LINE = '|'
+
+ SUPPORTED_MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS,
+ X_MARKER, ASTERISK, H_LINE, V_LINE)
+ """List of supported markers:
+
+ - 'd' diamond
+ - 'o' circle
+ - 's' square
+ - '+' cross
+ - 'x' x-cross
+ - '*' asterisk
+ - '_' horizontal line
+ - '|' vertical line
+ """
+ _MARKER_FUNCTIONS = {
+ DIAMOND: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 centerCoord = abs(coord - vec2(0.5, 0.5));
+ float f = centerCoord.x + centerCoord.y;
+ return clamp(size * (0.5 - f), 0.0, 1.0);
+ }
+ """,
+ CIRCLE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float radius = 0.5;
+ float r = distance(coord, vec2(0.5, 0.5));
+ return clamp(size * (radius - r), 0.0, 1.0);
+ }
+ """,
+ SQUARE: """
+ float alphaSymbol(vec2 coord, float size) {
+ return 1.0;
+ }
+ """,
+ PLUS: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 d = abs(size * (coord - vec2(0.5, 0.5)));
+ if (min(d.x, d.y) < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ X_MARKER: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 pos = floor(size * coord) + 0.5;
+ vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
+ if (min(d_x.x, d_x.y) <= 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ ASTERISK: """
+ float alphaSymbol(vec2 coord, float size) {
+ /* Combining +, x and circle */
+ vec2 d_plus = abs(size * (coord - vec2(0.5, 0.5)));
+ vec2 pos = floor(size * coord) + 0.5;
+ vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
+ if (min(d_plus.x, d_plus.y) < 0.5) {
+ return 1.0;
+ } else if (min(d_x.x, d_x.y) <= 0.5) {
+ float r = distance(coord, vec2(0.5, 0.5));
+ return clamp(size * (0.5 - r), 0.0, 1.0);
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ H_LINE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float dy = abs(size * (coord.y - 0.5));
+ if (dy < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ V_LINE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float dx = abs(size * (coord.x - 0.5));
+ if (dx < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """
+ }
-class Points(Geometry):
- """A set of data points with an associated value and size."""
- _shaders = ("""
+ _shaders = (string.Template("""
#version 120
- attribute vec3 position;
- attribute float symbol;
- attribute float value;
+ attribute float x;
+ attribute float y;
+ attribute float z;
+ attribute $valueType value;
attribute float size;
uniform mat4 matrix;
uniform mat4 transformMat;
- uniform vec2 valRange;
-
varying vec4 vCameraPosition;
- varying float vSymbol;
- varying float vNormValue;
+ varying $valueType vValue;
varying float vSize;
void main(void)
{
- vSymbol = symbol;
-
- vNormValue = clamp((value - valRange.x) / (valRange.y - valRange.x),
- 0.0, 1.0);
+ vValue = value;
- bool isValueInRange = value >= valRange.x && value <= valRange.y;
- if (isValueInRange) {
- gl_Position = matrix * vec4(position, 1.0);
- } else {
- gl_Position = vec4(2.0, 0.0, 0.0, 1.0); /* Get clipped */
- }
- vCameraPosition = transformMat * vec4(position, 1.0);
+ vec4 positionVec4 = vec4(x, y, z, 1.0);
+ gl_Position = matrix * positionVec4;
+ vCameraPosition = transformMat * positionVec4;
gl_PointSize = size;
vSize = size;
}
- """,
+ """),
string.Template("""
#version 120
varying vec4 vCameraPosition;
varying float vSize;
- varying float vSymbol;
- varying float vNormValue;
-
- $clippinDecl
-
- /* Circle */
- #define SYMBOL_CIRCLE 1.0
+ varying $valueType vValue;
- float alphaCircle(vec2 coord, float size) {
- float radius = 0.5;
- float r = distance(coord, vec2(0.5, 0.5));
- return clamp(size * (radius - r), 0.0, 1.0);
- }
+ $valueToColorDecl
- /* Half lines */
- #define SYMBOL_H_LINE 2.0
- #define LEFT 1.0
- #define RIGHT 2.0
- #define SYMBOL_V_LINE 3.0
- #define UP 1.0
- #define DOWN 2.0
+ $clippingDecl
- float alphaLine(vec2 coord, float size, float direction)
- {
- vec2 delta = abs(size * (coord - 0.5));
-
- if (direction == SYMBOL_H_LINE) {
- return (delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_H_LINE + LEFT) {
- return (coord.x <= 0.5 && delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_H_LINE + RIGHT) {
- return (coord.x >= 0.5 && delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE) {
- return (delta.x < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE + UP) {
- return (coord.y <= 0.5 && delta.x < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE + DOWN) {
- return (coord.y >= 0.5 && delta.x < 0.5) ? 1.0 : 0.0;
- }
- return 1.0;
- }
+ $alphaSymbolDecl
void main(void)
{
$clippingCall(vCameraPosition);
- gl_FragColor = vec4(0.5 * vNormValue + 0.5, 0.0, 0.0, 1.0);
-
- float alpha = 1.0;
- float symbol = floor(vSymbol);
- if (1 == 1) { //symbol == SYMBOL_CIRCLE) {
- alpha = alphaCircle(gl_PointCoord, vSize);
- }
- else if (symbol >= SYMBOL_H_LINE &&
- symbol <= (SYMBOL_V_LINE + DOWN)) {
- alpha = alphaLine(gl_PointCoord, vSize, symbol);
- }
+ float alpha = alphaSymbol(gl_PointCoord, vSize);
if (alpha == 0.0) {
discard;
}
+
+ gl_FragColor = $valueToColorCall(vValue);
gl_FragColor.a *= alpha;
}
"""))
- _ATTR_INFO = _POINTS_ATTR_INFO
+ _ATTR_INFO = {
+ 'x': {'dims': (1, 2), 'lastDim': (1,)},
+ 'y': {'dims': (1, 2), 'lastDim': (1,)},
+ 'z': {'dims': (1, 2), 'lastDim': (1,)},
+ 'size': {'dims': (1, 2), 'lastDim': (1,)},
+ }
- # TODO Add colormap, light?
+ def __init__(self, x, y, z, value, size=1., indices=None):
+ super(_Points, self).__init__('points', indices,
+ x=x,
+ y=y,
+ z=z,
+ value=value,
+ size=size,
+ attrib0='x')
+ self.boundsAttributeNames = 'x', 'y', 'z'
+ self._marker = 'o'
- def __init__(self, vertices, values=0., sizes=1., indices=None,
- symbols=0.,
- minValue=None, maxValue=None):
- super(Points, self).__init__('points', indices,
- position=vertices,
- value=values,
- size=sizes,
- symbol=symbols)
+ @property
+ def marker(self):
+ """The marker symbol used to display the scatter plot (str)
- values = self._attributes['value']
- self._minValue = values.min() if minValue is None else minValue
- self._maxValue = values.max() if maxValue is None else maxValue
+ See :attr:`SUPPORTED_MARKERS` for the list of supported marker string.
+ """
+ return self._marker
+
+ @marker.setter
+ def marker(self, marker):
+ marker = str(marker)
+ assert marker in self.SUPPORTED_MARKERS
+ if marker != self._marker:
+ self._marker = marker
+ self.notify()
- minValue = event.notifyProperty('_minValue')
- maxValue = event.notifyProperty('_maxValue')
+ def _shaderValueDefinition(self):
+ """Type definition, fragment shader declaration, fragment shader call
+ """
+ raise NotImplementedError(
+ "This method must be implemented in subclass")
+
+ def _renderGL2PreDrawHook(self, ctx, program):
+ """Override in subclass to run code before calling gl draw"""
+ pass
def renderGL2(self, ctx):
- fragment = self._shaders[1].substitute(
+ valueType, valueToColorDecl, valueToColorCall = \
+ self._shaderValueDefinition()
+ vertexShader = self._shaders[0].substitute(
+ valueType=valueType)
+ fragmentShader = self._shaders[1].substitute(
clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall)
- prog = ctx.glCtx.prog(self._shaders[0], fragment)
- prog.use()
+ clippingCall=ctx.clipper.fragCall,
+ valueType=valueType,
+ valueToColorDecl=valueToColorDecl,
+ valueToColorCall=valueToColorCall,
+ alphaSymbolDecl=self._MARKER_FUNCTIONS[self.marker])
+ program = ctx.glCtx.prog(vertexShader, fragmentShader,
+ attrib0=self.attrib0)
+ program.use()
gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2
gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2
# gl.glEnable(gl.GL_PROGRAM_POINT_SIZE)
- prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
- prog.setUniformMatrix('transformMat',
- ctx.objectToCamera.matrix,
- safe=True)
-
- ctx.clipper.setupProgram(ctx, prog)
-
- gl.glUniform2f(prog.uniforms['valRange'], self.minValue, self.maxValue)
-
- self._draw(prog)
-
-
-class ColorPoints(Geometry):
- """A set of points with an associated color and size."""
+ program.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
+ program.setUniformMatrix('transformMat',
+ ctx.objectToCamera.matrix,
+ safe=True)
- _shaders = ("""
- #version 120
+ ctx.clipper.setupProgram(ctx, program)
- attribute vec3 position;
- attribute float symbol;
- attribute vec4 color;
- attribute float size;
+ self._renderGL2PreDrawHook(ctx, program)
- uniform mat4 matrix;
- uniform mat4 transformMat;
+ self._draw(program)
- varying vec4 vCameraPosition;
- varying float vSymbol;
- varying vec4 vColor;
- varying float vSize;
- void main(void)
- {
- vCameraPosition = transformMat * vec4(position, 1.0);
- vSymbol = symbol;
- vColor = color;
- gl_Position = matrix * vec4(position, 1.0);
- gl_PointSize = size;
- vSize = size;
- }
- """,
- string.Template("""
- #version 120
-
- varying vec4 vCameraPosition;
- varying float vSize;
- varying float vSymbol;
- varying vec4 vColor;
+class Points(_Points):
+ """A set of data points with an associated value and size."""
- $clippingDecl;
+ _ATTR_INFO = _Points._ATTR_INFO.copy()
+ _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (1,)}})
- /* Circle */
- #define SYMBOL_CIRCLE 1.0
+ def __init__(self, x, y, z, value=0., size=1.,
+ indices=None, colormap=None):
+ super(Points, self).__init__(x=x,
+ y=y,
+ z=z,
+ indices=indices,
+ size=size,
+ value=value)
- float alphaCircle(vec2 coord, float size) {
- float radius = 0.5;
- float r = distance(coord, vec2(0.5, 0.5));
- return clamp(size * (radius - r), 0.0, 1.0);
- }
+ self._colormap = colormap or Colormap() # Default colormap
+ self._colormap.addListener(self._cmapChanged)
- /* Half lines */
- #define SYMBOL_H_LINE 2.0
- #define LEFT 1.0
- #define RIGHT 2.0
- #define SYMBOL_V_LINE 3.0
- #define UP 1.0
- #define DOWN 2.0
+ @property
+ def colormap(self):
+ """The colormap used to render the image"""
+ return self._colormap
- float alphaLine(vec2 coord, float size, float direction)
- {
- vec2 delta = abs(size * (coord - 0.5));
+ def _cmapChanged(self, source, *args, **kwargs):
+ """Broadcast colormap changes"""
+ self.notify(*args, **kwargs)
- if (direction == SYMBOL_H_LINE) {
- return (delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_H_LINE + LEFT) {
- return (coord.x <= 0.5 && delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_H_LINE + RIGHT) {
- return (coord.x >= 0.5 && delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE) {
- return (delta.x < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE + UP) {
- return (coord.y <= 0.5 && delta.x < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE + DOWN) {
- return (coord.y >= 0.5 && delta.x < 0.5) ? 1.0 : 0.0;
- }
- return 1.0;
- }
+ def _shaderValueDefinition(self):
+ """Type definition, fragment shader declaration, fragment shader call
+ """
+ return 'float', self.colormap.decl, self.colormap.call
- void main(void)
- {
- $clippingCall(vCameraPosition);
+ def _renderGL2PreDrawHook(self, ctx, program):
+ """Set-up colormap before calling gl draw"""
+ self.colormap.setupProgram(ctx, program)
- gl_FragColor = vColor;
- float alpha = 1.0;
- float symbol = floor(vSymbol);
- if (1 == 1) { //symbol == SYMBOL_CIRCLE) {
- alpha = alphaCircle(gl_PointCoord, vSize);
- }
- else if (symbol >= SYMBOL_H_LINE &&
- symbol <= (SYMBOL_V_LINE + DOWN)) {
- alpha = alphaLine(gl_PointCoord, vSize, symbol);
- }
- if (alpha == 0.0) {
- discard;
- }
- gl_FragColor.a *= alpha;
- }
- """))
+class ColorPoints(_Points):
+ """A set of points with an associated color and size."""
- _ATTR_INFO = _POINTS_ATTR_INFO
+ _ATTR_INFO = _Points._ATTR_INFO.copy()
+ _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (4,)}})
- def __init__(self, vertices, colors=(1., 1., 1., 1.), sizes=1.,
- indices=None, symbols=0.,
- minValue=None, maxValue=None):
- super(ColorPoints, self).__init__('points', indices,
- position=vertices,
- color=colors,
- size=sizes,
- symbol=symbols)
+ def __init__(self, x, y, z, color=(1., 1., 1., 1.), size=1.,
+ indices=None):
+ super(ColorPoints, self).__init__(x=x,
+ y=y,
+ z=z,
+ indices=indices,
+ size=size,
+ value=color)
- def renderGL2(self, ctx):
- fragment = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall)
- prog = ctx.glCtx.prog(self._shaders[0], fragment)
- prog.use()
+ def _shaderValueDefinition(self):
+ """Type definition, fragment shader declaration, fragment shader call
+ """
+ return 'vec4', '', ''
- gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2
- gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2
- # gl.glEnable(gl.GL_PROGRAM_POINT_SIZE)
+ def setColor(self, color, copy=True):
+ """Set colors
- prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
- prog.setUniformMatrix('transformMat',
- ctx.objectToCamera.matrix,
- safe=True)
+ :param color: Single RGBA color or
+ 2D array of color of length number of points
+ :param bool copy: True to copy colors (default),
+ False to use provided array (Do not modify!)
+ """
+ self.setAttribute('value', color, copy=copy)
- ctx.clipper.setupProgram(ctx, prog)
+ def getColor(self, copy=True):
+ """Returns the color or array of colors of the points.
- self._draw(prog)
+ :param copy: True to get a copy (default),
+ False to return internal array (Do not modify!)
+ :return: Color or array of colors
+ :rtype: numpy.ndarray
+ """
+ return self.getAttribute('value', copy=copy)
class GridPoints(Geometry):
@@ -1560,12 +1764,14 @@ class Mesh3D(Geometry):
colors,
normals=None,
mode='triangles',
- indices=None):
+ indices=None,
+ copy=True):
assert mode in self._TRIANGLE_MODES
super(Mesh3D, self).__init__(mode, indices,
position=positions,
normal=normals,
- color=colors)
+ color=colors,
+ copy=copy)
self._culling = None
@@ -1620,6 +1826,435 @@ class Mesh3D(Geometry):
gl.glDisable(gl.GL_CULL_FACE)
+class ColormapMesh3D(Geometry):
+ """A 3D mesh with color computed from a colormap"""
+
+ _shaders = ("""
+ attribute vec3 position;
+ attribute vec3 normal;
+ attribute float value;
+
+ uniform mat4 matrix;
+ uniform mat4 transformMat;
+ //uniform mat3 matrixInvTranspose;
+
+ varying vec4 vCameraPosition;
+ varying vec3 vPosition;
+ varying vec3 vNormal;
+ varying float vValue;
+
+ void main(void)
+ {
+ vCameraPosition = transformMat * vec4(position, 1.0);
+ //vNormal = matrixInvTranspose * normalize(normal);
+ vPosition = position;
+ vNormal = normal;
+ vValue = value;
+ gl_Position = matrix * vec4(position, 1.0);
+ }
+ """,
+ string.Template("""
+ varying vec4 vCameraPosition;
+ varying vec3 vPosition;
+ varying vec3 vNormal;
+ varying float vValue;
+
+ $colormapDecl
+ $clippingDecl
+ $lightingFunction
+
+ void main(void)
+ {
+ $clippingCall(vCameraPosition);
+
+ vec4 color = $colormapCall(vValue);
+ gl_FragColor = $lightingCall(color, vPosition, vNormal);
+ }
+ """))
+
+ def __init__(self,
+ position,
+ value,
+ colormap=None,
+ normal=None,
+ mode='triangles',
+ indices=None):
+ super(ColormapMesh3D, self).__init__(mode, indices,
+ position=position,
+ normal=normal,
+ value=value)
+
+ self._lineWidth = 1.0
+ self._lineSmooth = True
+ self._culling = None
+ self._colormap = colormap or Colormap() # Default colormap
+ self._colormap.addListener(self._cmapChanged)
+
+ lineWidth = event.notifyProperty('_lineWidth', converter=float,
+ doc="Width of the line in pixels.")
+
+ lineSmooth = event.notifyProperty(
+ '_lineSmooth',
+ converter=bool,
+ doc="Smooth line rendering enabled (bool, default: True)")
+
+ @property
+ def culling(self):
+ """Face culling (str)
+
+ One of 'back', 'front' or None.
+ """
+ return self._culling
+
+ @culling.setter
+ def culling(self, culling):
+ assert culling in ('back', 'front', None)
+ if culling != self._culling:
+ self._culling = culling
+ self.notify()
+
+ @property
+ def colormap(self):
+ """The colormap used to render the image"""
+ return self._colormap
+
+ def _cmapChanged(self, source, *args, **kwargs):
+ """Broadcast colormap changes"""
+ self.notify(*args, **kwargs)
+
+ def renderGL2(self, ctx):
+ if 'normal' in self._attributes:
+ self._renderGL2(ctx)
+ else: # Disable lighting
+ with self.viewport.light.turnOff():
+ self._renderGL2(ctx)
+
+ def _renderGL2(self, ctx):
+ fragment = self._shaders[1].substitute(
+ clippingDecl=ctx.clipper.fragDecl,
+ clippingCall=ctx.clipper.fragCall,
+ lightingFunction=ctx.viewport.light.fragmentDef,
+ lightingCall=ctx.viewport.light.fragmentCall,
+ colormapDecl=self.colormap.decl,
+ colormapCall=self.colormap.call)
+ program = ctx.glCtx.prog(self._shaders[0], fragment)
+ program.use()
+
+ ctx.viewport.light.setupProgram(ctx, program)
+ ctx.clipper.setupProgram(ctx, program)
+ self.colormap.setupProgram(ctx, program)
+
+ if self.culling is not None:
+ cullFace = gl.GL_FRONT if self.culling == 'front' else gl.GL_BACK
+ gl.glCullFace(cullFace)
+ gl.glEnable(gl.GL_CULL_FACE)
+
+ program.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
+ program.setUniformMatrix('transformMat',
+ ctx.objectToCamera.matrix,
+ safe=True)
+
+ if self.drawMode in self._LINE_MODES:
+ gl.glLineWidth(self.lineWidth)
+ with gl.enabled(gl.GL_LINE_SMOOTH, self.lineSmooth):
+ self._draw(program)
+ else:
+ self._draw(program)
+
+ if self.culling is not None:
+ gl.glDisable(gl.GL_CULL_FACE)
+
+
+# ImageData ##################################################################
+
+class _Image(Geometry):
+ """Base class for ImageData and ImageRgba"""
+
+ _shaders = ("""
+ attribute vec2 position;
+
+ uniform mat4 matrix;
+ uniform mat4 transformMat;
+ uniform vec2 dataScale;
+
+ varying vec4 vCameraPosition;
+ varying vec3 vPosition;
+ varying vec3 vNormal;
+ varying vec2 vTexCoords;
+
+ void main(void)
+ {
+ vec4 positionVec4 = vec4(position, 0.0, 1.0);
+ vCameraPosition = transformMat * positionVec4;
+ vPosition = positionVec4.xyz;
+ vTexCoords = dataScale * position;
+ gl_Position = matrix * positionVec4;
+ }
+ """,
+ string.Template("""
+ varying vec4 vCameraPosition;
+ varying vec3 vPosition;
+ varying vec2 vTexCoords;
+ uniform sampler2D data;
+ uniform float alpha;
+
+ $imageDecl
+
+ $clippingDecl
+
+ $lightingFunction
+
+ void main(void)
+ {
+ vec4 color = imageColor(data, vTexCoords);
+ color.a = alpha;
+
+ $clippingCall(vCameraPosition);
+
+ vec3 normal = vec3(0.0, 0.0, 1.0);
+ gl_FragColor = $lightingCall(color, vPosition, normal);
+ }
+ """))
+
+ _UNIT_SQUARE = numpy.array(((0., 0.), (1., 0.), (0., 1.), (1., 1.)),
+ dtype=numpy.float32)
+
+ def __init__(self, data, copy=True):
+ super(_Image, self).__init__(mode='triangle_strip',
+ position=self._UNIT_SQUARE)
+
+ self._texture = None
+ self._update_texture = True
+ self._update_texture_filter = False
+ self._data = None
+ self.setData(data, copy)
+ self._alpha = 1.
+ self._interpolation = 'linear'
+
+ self.isBackfaceVisible = True
+
+ def setData(self, data, copy=True):
+ assert isinstance(data, numpy.ndarray)
+
+ if copy:
+ data = numpy.array(data, copy=True)
+
+ self._data = data
+ self._update_texture = True
+ # By updating the position rather than always using a unit square
+ # we benefit from Geometry bounds handling
+ self.setAttribute('position', self._UNIT_SQUARE * self._data.shape[:2])
+ self.notify()
+
+ def getData(self, copy=True):
+ return numpy.array(self._data, copy=copy)
+
+ @property
+ def interpolation(self):
+ """The texture interpolation mode: 'linear' or 'nearest'"""
+ return self._interpolation
+
+ @interpolation.setter
+ def interpolation(self, interpolation):
+ assert interpolation in ('linear', 'nearest')
+ self._interpolation = interpolation
+ self._update_texture_filter = True
+ self.notify()
+
+ @property
+ def alpha(self):
+ """Transparency of the image, float in [0, 1]"""
+ return self._alpha
+
+ @alpha.setter
+ def alpha(self, alpha):
+ self._alpha = float(alpha)
+ self.notify()
+
+ def _textureFormat(self):
+ """Implement this method to provide texture internal format and format
+
+ :return: 2-tuple of gl flags (internalFormat, format)
+ """
+ raise NotImplementedError(
+ "This method must be implemented in a subclass")
+
+ def prepareGL2(self, ctx):
+ if self._texture is None or self._update_texture:
+ if self._texture is not None:
+ self._texture.discard()
+
+ if self.interpolation == 'nearest':
+ filter_ = gl.GL_NEAREST
+ else:
+ filter_ = gl.GL_LINEAR
+ self._update_texture = False
+ self._update_texture_filter = False
+ if self._data.size == 0:
+ self._texture = None
+ else:
+ internalFormat, format_ = self._textureFormat()
+ self._texture = _glutils.Texture(
+ internalFormat,
+ self._data,
+ format_,
+ minFilter=filter_,
+ magFilter=filter_,
+ wrap=gl.GL_CLAMP_TO_EDGE)
+
+ if self._update_texture_filter and self._texture is not None:
+ self._update_texture_filter = False
+ if self.interpolation == 'nearest':
+ filter_ = gl.GL_NEAREST
+ else:
+ filter_ = gl.GL_LINEAR
+ self._texture.minFilter = filter_
+ self._texture.magFilter = filter_
+
+ super(_Image, self).prepareGL2(ctx)
+
+ def renderGL2(self, ctx):
+ if self._texture is None:
+ return # Nothing to render
+
+ with self.viewport.light.turnOff():
+ self._renderGL2(ctx)
+
+ def _renderGL2PreDrawHook(self, ctx, program):
+ """Override in subclass to run code before calling gl draw"""
+ pass
+
+ def _shaderImageColorDecl(self):
+ """Returns fragment shader imageColor function declaration"""
+ raise NotImplementedError(
+ "This method must be implemented in a subclass")
+
+ def _renderGL2(self, ctx):
+ fragment = self._shaders[1].substitute(
+ clippingDecl=ctx.clipper.fragDecl,
+ clippingCall=ctx.clipper.fragCall,
+ lightingFunction=ctx.viewport.light.fragmentDef,
+ lightingCall=ctx.viewport.light.fragmentCall,
+ imageDecl=self._shaderImageColorDecl()
+ )
+ program = ctx.glCtx.prog(self._shaders[0], fragment)
+ program.use()
+
+ ctx.viewport.light.setupProgram(ctx, program)
+
+ if not self.isBackfaceVisible:
+ gl.glCullFace(gl.GL_BACK)
+ gl.glEnable(gl.GL_CULL_FACE)
+
+ program.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
+ program.setUniformMatrix('transformMat',
+ ctx.objectToCamera.matrix,
+ safe=True)
+ gl.glUniform1f(program.uniforms['alpha'], self._alpha)
+
+ shape = self._data.shape
+ gl.glUniform2f(program.uniforms['dataScale'], 1./shape[0], 1./shape[1])
+
+ gl.glUniform1i(program.uniforms['data'], self._texture.texUnit)
+
+ ctx.clipper.setupProgram(ctx, program)
+
+ self._texture.bind()
+
+ self._renderGL2PreDrawHook(ctx, program)
+
+ self._draw(program)
+
+ if not self.isBackfaceVisible:
+ gl.glDisable(gl.GL_CULL_FACE)
+
+
+class ImageData(_Image):
+ """Display a 2x2 data array with a texture."""
+
+ _imageDecl = string.Template("""
+ $colormapDecl
+
+ vec4 imageColor(sampler2D data, vec2 texCoords) {
+ float value = texture2D(data, texCoords).r;
+ vec4 color = $colormapCall(value);
+ return color;
+ }
+ """)
+
+ def __init__(self, data, copy=True, colormap=None):
+ super(ImageData, self).__init__(data, copy=copy)
+
+ self._colormap = colormap or Colormap() # Default colormap
+ self._colormap.addListener(self._cmapChanged)
+
+ def setData(self, data, copy=True):
+ data = numpy.array(data, copy=copy, order='C', dtype=numpy.float32)
+ # TODO support (u)int8|16
+ assert data.ndim == 2
+
+ super(ImageData, self).setData(data, copy=False)
+
+ @property
+ def colormap(self):
+ """The colormap used to render the image"""
+ return self._colormap
+
+ def _cmapChanged(self, source, *args, **kwargs):
+ """Broadcast colormap changes"""
+ self.notify(*args, **kwargs)
+
+ def _textureFormat(self):
+ return gl.GL_R32F, gl.GL_RED
+
+ def _renderGL2PreDrawHook(self, ctx, program):
+ self.colormap.setupProgram(ctx, program)
+
+ def _shaderImageColorDecl(self):
+ return self._imageDecl.substitute(
+ colormapDecl=self.colormap.decl,
+ colormapCall=self.colormap.call)
+
+
+# ImageRgba ##################################################################
+
+class ImageRgba(_Image):
+ """Display a 2x2 RGBA image with a texture.
+
+ Supports images of float in [0, 1] and uint8.
+ """
+
+ _imageDecl = """
+ vec4 imageColor(sampler2D data, vec2 texCoords) {
+ vec4 color = texture2D(data, texCoords);
+ return color;
+ }
+ """
+
+ def __init__(self, data, copy=True):
+ super(ImageRgba, self).__init__(data, copy=copy)
+
+ def setData(self, data, copy=True):
+ data = numpy.array(data, copy=copy, order='C')
+ assert data.ndim == 3
+ assert data.shape[2] in (3, 4)
+ if data.dtype.kind == 'f':
+ if data.dtype != numpy.dtype(numpy.float32):
+ _logger.warning("Converting image data to float32")
+ data = numpy.array(data, dtype=numpy.float32, copy=False)
+ else:
+ assert data.dtype == numpy.dtype(numpy.uint8)
+
+ super(ImageRgba, self).setData(data, copy=False)
+
+ def _textureFormat(self):
+ format_ = gl.GL_RGBA if self._data.shape[2] == 4 else gl.GL_RGB
+ return format_, format_
+
+ def _shaderImageColorDecl(self):
+ return self._imageDecl
+
+
# Group ######################################################################
# TODO lighting, clipping as groups?
@@ -1686,6 +2321,29 @@ class GroupDepthOffset(core.Group):
# TODO issue with picking in depth buffer!
+class GroupNoDepth(core.Group):
+ """A group rendering its children without writing to the depth buffer
+
+ :param bool mask: True (default) to disable writing in the depth buffer
+ :param bool notest: True (default) to disable depth test
+ """
+
+ def __init__(self, children=(), mask=True, notest=True):
+ super(GroupNoDepth, self).__init__(children)
+ self._mask = bool(mask)
+ self._notest = bool(notest)
+
+ def renderGL2(self, ctx):
+ if self._mask:
+ gl.glDepthMask(gl.GL_FALSE)
+
+ with gl.disabled(gl.GL_DEPTH_TEST, disable=self._notest):
+ super(GroupNoDepth, self).renderGL2(ctx)
+
+ if self._mask:
+ gl.glDepthMask(gl.GL_TRUE)
+
+
class GroupBBox(core.PrivateGroup):
"""A group displaying a bounding box around the children."""
@@ -1693,26 +2351,42 @@ class GroupBBox(core.PrivateGroup):
super(GroupBBox, self).__init__()
self._group = core.Group(children)
- self._boxTransforms = transform.TransformList(
- (transform.Translate(), transform.Scale()))
+ self._boxTransforms = transform.TransformList((transform.Translate(),))
+ # Using 1 of 3 primitives to render axes and/or bounding box
+ # To avoid z-fighting between axes and bounding box
self._boxWithAxes = BoxWithAxes(color)
self._boxWithAxes.smooth = False
self._boxWithAxes.transforms = self._boxTransforms
- self._children = [self._boxWithAxes, self._group]
+ self._box = Box(stroke=color, fill=(1., 1., 1., 0.))
+ self._box.strokeSmooth = False
+ self._box.transforms = self._boxTransforms
+ self._box.visible = False
+
+ self._axes = Axes()
+ self._axes.smooth = False
+ self._axes.transforms = self._boxTransforms
+ self._axes.visible = False
+
+ self.strokeWidth = 2.
+
+ self._children = [self._boxWithAxes, self._box, self._axes, self._group]
def _updateBoxAndAxes(self):
"""Update bbox and axes position and size according to children."""
bounds = self._group.bounds(dataBounds=True)
if bounds is not None:
origin = bounds[0]
- scale = [(d if d != 0. else 1.) for d in bounds[1] - bounds[0]]
+ size = bounds[1] - bounds[0]
else:
- origin, scale = (0., 0., 0.), (1., 1., 1.)
+ origin, size = (0., 0., 0.), (1., 1., 1.)
self._boxTransforms[0].translation = origin
- self._boxTransforms[1].scale = scale
+
+ self._boxWithAxes.size = size
+ self._box.size = size
+ self._axes.size = size
def _bounds(self, dataBounds=False):
self._updateBoxAndAxes()
@@ -1732,17 +2406,62 @@ class GroupBBox(core.PrivateGroup):
def children(self, iterable):
self._group.children = iterable
- # Give access to box color
+ # Give access to box color and stroke width
@property
def color(self):
"""The RGBA color to use for the box: 4 float in [0, 1]"""
- return self._boxWithAxes.color
+ return self._box.strokeColor
@color.setter
def color(self, color):
+ self._box.strokeColor = color
self._boxWithAxes.color = color
+ @property
+ def strokeWidth(self):
+ """The width of the stroke lines in pixels (float)"""
+ return self._box.strokeWidth
+
+ @strokeWidth.setter
+ def strokeWidth(self, width):
+ width = float(width)
+ self._box.strokeWidth = width
+ self._boxWithAxes.width = width
+ self._axes.width = width
+
+ # Toggle axes visibility
+
+ def _updateBoxAndAxesVisibility(self, axesVisible, boxVisible):
+ """Update visible flags of box and axes primitives accordingly.
+
+ :param bool axesVisible: True to display axes
+ :param bool boxVisible: True to display bounding box
+ """
+ self._boxWithAxes.visible = boxVisible and axesVisible
+ self._box.visible = boxVisible and not axesVisible
+ self._axes.visible = not boxVisible and axesVisible
+
+ @property
+ def axesVisible(self):
+ """Whether axes are displayed or not (bool)"""
+ return self._boxWithAxes.visible or self._axes.visible
+
+ @axesVisible.setter
+ def axesVisible(self, visible):
+ self._updateBoxAndAxesVisibility(axesVisible=bool(visible),
+ boxVisible=self.boxVisible)
+
+ @property
+ def boxVisible(self):
+ """Whether bounding box is displayed or not (bool)"""
+ return self._boxWithAxes.visible or self._box.visible
+
+ @boxVisible.setter
+ def boxVisible(self, visible):
+ self._updateBoxAndAxesVisibility(axesVisible=self.axesVisible,
+ boxVisible=bool(visible))
+
# Clipping Plane ##############################################################
diff --git a/silx/gui/plot3d/scene/test/test_utils.py b/silx/gui/plot3d/scene/test/test_utils.py
index 65c2407..4a2d515 100644
--- a/silx/gui/plot3d/scene/test/test_utils.py
+++ b/silx/gui/plot3d/scene/test/test_utils.py
@@ -27,11 +27,11 @@ from __future__ import absolute_import, division, unicode_literals
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "25/07/2016"
+__date__ = "17/01/2018"
import unittest
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
import numpy
diff --git a/silx/gui/plot3d/scene/transform.py b/silx/gui/plot3d/scene/transform.py
index 71a6b74..4061e81 100644
--- a/silx/gui/plot3d/scene/transform.py
+++ b/silx/gui/plot3d/scene/transform.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -317,7 +317,7 @@ class Transform(event.Notifier):
def transformPoint(self, point, direct=True, perspectiveDivide=False):
"""Apply the transform to a point.
- If len(point) == 3, apply persective divide if possible.
+ If len(point) == 3, apply perspective divide if possible.
:param point: Array-like vector of 3 or 4 coordinates.
:param bool direct: Whether to apply the direct (True, the default)
@@ -757,8 +757,9 @@ class _Projection(Transform):
:param float near: Distance to the near plane.
:param float far: Distance to the far plane.
:param bool checkDepthExtent: Toggle checks near > 0 and far > near.
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
"""
def __init__(self, near, far, checkDepthExtent=False, size=(1., 1.)):
@@ -833,8 +834,9 @@ class Orthographic(_Projection):
:param float top: Coord of the top clipping plane.
:param float near: Distance to the near plane.
:param float far: Distance to the far plane.
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
"""
def __init__(self, left=0., right=1., bottom=1., top=0., near=-1., far=1.,
@@ -923,8 +925,9 @@ class Ortho2DWidget(_Projection):
:param float near: Z coordinate of the near clipping plane.
:param float far: Z coordinante of the far clipping plane.
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
"""
def __init__(self, near=-1., far=1., size=(1., 1.)):
@@ -942,8 +945,9 @@ class Perspective(_Projection):
:param float fovy: Vertical field-of-view in degrees.
:param float near: The near clipping plane Z coord (stricly positive).
:param float far: The far clipping plane Z coord (> near).
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
"""
def __init__(self, fovy=90., near=0.1, far=1., size=(1., 1.)):
diff --git a/silx/gui/plot3d/scene/utils.py b/silx/gui/plot3d/scene/utils.py
index 930a087..04abd04 100644
--- a/silx/gui/plot3d/scene/utils.py
+++ b/silx/gui/plot3d/scene/utils.py
@@ -184,6 +184,32 @@ def unindexArrays(mode, indices, *arrays):
return tuple(numpy.ascontiguousarray(data[indices]) for data in arrays)
+def triangleStripToTriangles(strip):
+ """Convert a triangle strip to a set of triangles.
+
+ The order of the corners is inverted for odd triangles.
+
+ :param numpy.ndarray strip:
+ Array of triangle corners of shape (N, 3).
+ N must be at least 3.
+ :return: Equivalent triangles corner as an array of shape (N, 3, 3)
+ :rtype: numpy.ndarray
+ """
+ strip = numpy.array(strip).reshape(-1, 3)
+ assert len(strip) >= 3
+
+ triangles = numpy.empty((len(strip) - 2, 3, 3), dtype=strip.dtype)
+ triangles[0::2, 0] = strip[0:-2:2]
+ triangles[0::2, 1] = strip[1:-1:2]
+ triangles[0::2, 2] = strip[2::2]
+
+ triangles[1::2, 0] = strip[3::2]
+ triangles[1::2, 1] = strip[2:-1:2]
+ triangles[1::2, 2] = strip[1:-2:2]
+
+ return triangles
+
+
def trianglesNormal(positions):
"""Return normal for each triangle.
@@ -514,3 +540,19 @@ class Plane(event.Notifier):
def move(self, step):
"""Move the plane of step along the normal."""
self.point += step * self.normal
+
+ def segmentIntersection(self, s0, s1):
+ """Compute the plane intersection with segment [s0, s1].
+
+ :param s0: First end of the segment
+ :type s0: 1D numpy.ndarray-like of length 3
+ :param s1: Second end of the segment
+ :type s1: 1D numpy.ndarray-like of length 3
+ :return: The intersection points. The number of points goes
+ from 0 (no intersection) to 2 (segment in the plane)
+ :rtype: list of 1D numpy.ndarray
+ """
+ if not self.isPlane:
+ return []
+ else:
+ return segmentPlaneIntersect(s0, s1, self.normal, self.point)
diff --git a/silx/gui/plot3d/scene/viewport.py b/silx/gui/plot3d/scene/viewport.py
index 72e1ea3..0cacbf0 100644
--- a/silx/gui/plot3d/scene/viewport.py
+++ b/silx/gui/plot3d/scene/viewport.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -198,12 +198,17 @@ class Viewport(event.Notifier):
@property
def background(self):
- """Background color of the viewport (4-tuple of float in [0, 1]"""
+ """Viewport's background color (4-tuple of float in [0, 1] or None)
+
+ The background color is used to clear to viewport.
+ If None, the viewport is not cleared
+ """
return self._background
@background.setter
def background(self, color):
- color = rgba(color)
+ if color is not None:
+ color = rgba(color)
if self._background != color:
self._background = color
self._changed()
@@ -295,12 +300,16 @@ class Viewport(event.Notifier):
gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST)
gl.glEnable(gl.GL_LINE_SMOOTH)
- gl.glClearColor(*self.background)
+ if self.background is None:
+ gl.glClear(gl.GL_STENCIL_BUFFER_BIT |
+ gl.GL_DEPTH_BUFFER_BIT)
+ else:
+ gl.glClearColor(*self.background)
- # Prepare OpenGL
- gl.glClear(gl.GL_COLOR_BUFFER_BIT |
- gl.GL_STENCIL_BUFFER_BIT |
- gl.GL_DEPTH_BUFFER_BIT)
+ # Prepare OpenGL
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT |
+ gl.GL_STENCIL_BUFFER_BIT |
+ gl.GL_DEPTH_BUFFER_BIT)
ctx = RenderContext(self, glContext)
self.scene.render(ctx)
@@ -454,11 +463,13 @@ class Viewport(event.Notifier):
winy = oy + height * 0.5 * (1. - ndcY)
return winx, winy
- def _pickNdcZGL(self, x, y):
+ def _pickNdcZGL(self, x, y, offset=0):
"""Retrieve depth from depth buffer and return corresponding NDC Z.
:param int x: In pixels in window coordinates, origin left.
:param int y: In pixels in window coordinates, origin top.
+ :param int offset: Number of pixels to look at around the given pixel
+
:return: Normalize device Z coordinate of depth in [-1, 1]
or None if outside viewport.
:rtype: float or None
@@ -476,10 +487,34 @@ class Viewport(event.Notifier):
# Get depth from depth buffer in [0., 1.]
# Bind used framebuffer to get depth
gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.framebuffer)
- depth = gl.glReadPixels(
- x, y, 1, 1, gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT)[0]
+
+ if offset == 0: # Fast path
+ # glReadPixels is not GL|ES friendly
+ depth = gl.glReadPixels(
+ x, y, 1, 1, gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT)[0]
+ else:
+ offset = abs(int(offset))
+ size = 2*offset + 1
+ depthPatch = gl.glReadPixels(
+ x - offset, y - offset,
+ size, size,
+ gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT)
+ depthPatch = depthPatch.ravel() # Work in 1D
+
+ # TODO cache sortedIndices to avoid computing it each time
+ # Compute distance of each pixels to the center of the patch
+ offsetToCenter = numpy.arange(- offset, offset + 1, dtype=numpy.float32) ** 2
+ sqDistToCenter = numpy.add.outer(offsetToCenter, offsetToCenter)
+
+ # Use distance to center to sort values from the patch
+ sortedIndices = numpy.argsort(sqDistToCenter.ravel())
+ sortedValues = depthPatch[sortedIndices]
+
+ # Take first depth that is not 1 in the sorted values
+ hits = sortedValues[sortedValues != 1.]
+ depth = 1. if len(hits) == 0 else hits[0]
+
gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
- # This is not GL|ES friendly
# Z in NDC in [-1., 1.]
return float(depth) * 2. - 1.
diff --git a/silx/gui/plot3d/scene/window.py b/silx/gui/plot3d/scene/window.py
index 3c63c7a..baa76a2 100644
--- a/silx/gui/plot3d/scene/window.py
+++ b/silx/gui/plot3d/scene/window.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -124,18 +124,26 @@ class ContextGL2(Context):
# programs
- def prog(self, vertexShaderSrc, fragmentShaderSrc):
+ def prog(self, vertexShaderSrc, fragmentShaderSrc, attrib0='position'):
"""Cache program within context.
WARNING: No clean-up.
+
+ :param str vertexShaderSrc: Vertex shader source code
+ :param str fragmentShaderSrc: Fragment shader source code
+ :param str attrib0:
+ Attribute's name to bind to position 0 (default: 'position').
+ On some platform, this attribute MUST be active and with an
+ array attached to it in order for the rendering to occur....
"""
assert self.isCurrent
- key = vertexShaderSrc, fragmentShaderSrc
- prog = self._programs.get(key, None)
- if prog is None:
- prog = _glutils.Program(vertexShaderSrc, fragmentShaderSrc)
- self._programs[key] = prog
- return prog
+ key = vertexShaderSrc, fragmentShaderSrc, attrib0
+ program = self._programs.get(key, None)
+ if program is None:
+ program = _glutils.Program(
+ vertexShaderSrc, fragmentShaderSrc, attrib0=attrib0)
+ self._programs[key] = program
+ return program
# VBOs
@@ -318,7 +326,8 @@ class Window(event.Notifier):
"""Returns the raster of the scene as an RGB numpy array
:returns: OpenGL scene RGB bitmap
- :rtype: numpy.ndarray of uint8 of dimension (height, width, 3)
+ as an array of dimension (height, width, 3)
+ :rtype: numpy.ndarray of uint8
"""
height, width = self.shape
image = numpy.empty((height, width, 3), dtype=numpy.uint8)
diff --git a/silx/gui/plot3d/setup.py b/silx/gui/plot3d/setup.py
index bb6eaa5..c477919 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('_model')
config.add_subpackage('actions')
+ config.add_subpackage('items')
config.add_subpackage('scene')
config.add_subpackage('tools')
config.add_subpackage('test')
diff --git a/silx/gui/plot3d/test/__init__.py b/silx/gui/plot3d/test/__init__.py
index 2e8c9f4..bd2f7c3 100644
--- a/silx/gui/plot3d/test/__init__.py
+++ b/silx/gui/plot3d/test/__init__.py
@@ -26,12 +26,13 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/01/2017"
+__date__ = "09/11/2017"
import logging
import os
import unittest
+from silx.test.utils import test_options
_logger = logging.getLogger(__name__)
@@ -40,15 +41,14 @@ _logger = logging.getLogger(__name__)
def suite():
test_suite = unittest.TestSuite()
- if os.environ.get('WITH_GL_TEST', 'True') == 'False':
+ if not test_options.WITH_GL_TEST:
# Explicitly disabled tests
- _logger.warning(
- "silx.gui.plot3d tests disabled (WITH_GL_TEST=False)")
+ msg = "silx.gui.plot3d tests disabled: %s" % test_options.WITH_GL_TEST_REASON
+ _logger.warning(msg)
class SkipPlot3DTest(unittest.TestCase):
def runTest(self):
- self.skipTest(
- "silx.gui.plot3d tests disabled (WITH_GL_TEST=False)")
+ self.skipTest(test_options.WITH_GL_TEST_REASON)
test_suite.addTest(SkipPlot3DTest())
return test_suite
diff --git a/silx/gui/plot3d/test/testScalarFieldView.py b/silx/gui/plot3d/test/testScalarFieldView.py
index 5ad4051..43d401f 100644
--- a/silx/gui/plot3d/test/testScalarFieldView.py
+++ b/silx/gui/plot3d/test/testScalarFieldView.py
@@ -25,7 +25,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "11/07/2017"
+__date__ = "17/01/2018"
import logging
@@ -33,7 +33,7 @@ import unittest
import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import TestCaseQt
from silx.gui import qt
diff --git a/silx/gui/plot3d/tools/GroupPropertiesWidget.py b/silx/gui/plot3d/tools/GroupPropertiesWidget.py
new file mode 100644
index 0000000..30e11de
--- /dev/null
+++ b/silx/gui/plot3d/tools/GroupPropertiesWidget.py
@@ -0,0 +1,200 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+""":class:`GroupPropertiesWidget` allows to reset properties in a GroupItem."""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+from ....gui import qt
+from ....gui.plot.Colormap import Colormap
+from ....gui.plot.ColormapDialog import ColormapDialog
+
+from ..items import SymbolMixIn, ColormapMixIn
+
+
+class GroupPropertiesWidget(qt.QWidget):
+ """Set properties of all items in a :class:`GroupItem`
+
+ :param QWidget parent:
+ """
+
+ MAX_MARKER_SIZE = 20
+ """Maximum value for marker size"""
+
+ MAX_LINE_WIDTH = 10
+ """Maximum value for line width"""
+
+ def __init__(self, parent=None):
+ super(GroupPropertiesWidget, self).__init__(parent)
+ self._group = None
+ self.setEnabled(False)
+
+ # Set widgets
+ layout = qt.QFormLayout(self)
+ self.setLayout(layout)
+
+ # Colormap
+ colormapButton = qt.QPushButton('Set...')
+ colormapButton.setToolTip("Set colormap for all items")
+ colormapButton.clicked.connect(self._colormapButtonClicked)
+ layout.addRow('Colormap', colormapButton)
+
+ self._markerComboBox = qt.QComboBox(self)
+ self._markerComboBox.addItems(SymbolMixIn.getSupportedSymbolNames())
+
+ # Marker
+ markerButton = qt.QPushButton('Set')
+ markerButton.setToolTip("Set marker for all items")
+ markerButton.clicked.connect(self._markerButtonClicked)
+
+ markerLayout = qt.QHBoxLayout()
+ markerLayout.setContentsMargins(0, 0, 0, 0)
+ markerLayout.addWidget(self._markerComboBox, 1)
+ markerLayout.addWidget(markerButton, 0)
+
+ layout.addRow('Marker', markerLayout)
+
+ # Marker size
+ self._markerSizeSlider = qt.QSlider()
+ self._markerSizeSlider.setOrientation(qt.Qt.Horizontal)
+ self._markerSizeSlider.setSingleStep(1)
+ self._markerSizeSlider.setRange(1, self.MAX_MARKER_SIZE)
+ self._markerSizeSlider.setValue(1)
+
+ markerSizeButton = qt.QPushButton('Set')
+ markerSizeButton.setToolTip("Set marker size for all items")
+ markerSizeButton.clicked.connect(self._markerSizeButtonClicked)
+
+ markerSizeLayout = qt.QHBoxLayout()
+ markerSizeLayout.setContentsMargins(0, 0, 0, 0)
+ markerSizeLayout.addWidget(qt.QLabel('1'))
+ markerSizeLayout.addWidget(self._markerSizeSlider, 1)
+ markerSizeLayout.addWidget(qt.QLabel(str(self.MAX_MARKER_SIZE)))
+ markerSizeLayout.addWidget(markerSizeButton, 0)
+
+ layout.addRow('Marker Size', markerSizeLayout)
+
+ # Line width
+ self._lineWidthSlider = qt.QSlider()
+ self._lineWidthSlider.setOrientation(qt.Qt.Horizontal)
+ self._lineWidthSlider.setSingleStep(1)
+ self._lineWidthSlider.setRange(1, self.MAX_LINE_WIDTH)
+ self._lineWidthSlider.setValue(1)
+
+ lineWidthButton = qt.QPushButton('Set')
+ lineWidthButton.setToolTip("Set line width for all items")
+ lineWidthButton.clicked.connect(self._lineWidthButtonClicked)
+
+ lineWidthLayout = qt.QHBoxLayout()
+ lineWidthLayout.setContentsMargins(0, 0, 0, 0)
+ lineWidthLayout.addWidget(qt.QLabel('1'))
+ lineWidthLayout.addWidget(self._lineWidthSlider, 1)
+ lineWidthLayout.addWidget(qt.QLabel(str(self.MAX_LINE_WIDTH)))
+ lineWidthLayout.addWidget(lineWidthButton, 0)
+
+ layout.addRow('Line Width', lineWidthLayout)
+
+ self._colormapDialog = None # To store dialog
+ self._colormap = Colormap()
+
+ def getGroup(self):
+ """Returns the :class:`GroupItem` this widget is attached to.
+
+ :rtype: Union[GroupItem, None]
+ """
+ return self._group
+
+ def setGroup(self, group):
+ """Set the :class:`GroupItem` this widget is attached to.
+
+ :param GroupItem group: GroupItem to control (or None)
+ """
+ self._group = group
+ if group is not None:
+ self.setEnabled(True)
+
+ def _colormapButtonClicked(self, checked=False):
+ """Handle colormap button clicked"""
+ group = self.getGroup()
+ if group is None:
+ return
+
+ if self._colormapDialog is None:
+ self._colormapDialog = ColormapDialog(self)
+ self._colormapDialog.setColormap(self._colormap)
+
+ previousColormap = self._colormapDialog.getColormap()
+ if self._colormapDialog.exec_():
+ colormap = self._colormapDialog.getColormap()
+
+ for item in group.visit():
+ if isinstance(item, ColormapMixIn):
+ itemCmap = item.getColormap()
+ cmapName = colormap.getName()
+ if cmapName is not None:
+ itemCmap.setName(colormap.getName())
+ else:
+ itemCmap.setColormapLUT(colormap.getColormapLUT())
+ itemCmap.setNormalization(colormap.getNormalization())
+ itemCmap.setVRange(colormap.getVMin(), colormap.getVMax())
+ else:
+ # Reset colormap
+ self._colormapDialog.setColormap(previousColormap)
+
+ def _markerButtonClicked(self, checked=False):
+ """Handle marker set button clicked"""
+ group = self.getGroup()
+ if group is None:
+ return
+
+ marker = self._markerComboBox.currentText()
+ for item in group.visit():
+ if isinstance(item, SymbolMixIn):
+ item.setSymbol(marker)
+
+ def _markerSizeButtonClicked(self, checked=False):
+ """Handle marker size set button clicked"""
+ group = self.getGroup()
+ if group is None:
+ return
+
+ markerSize = self._markerSizeSlider.value()
+ for item in group.visit():
+ if isinstance(item, SymbolMixIn):
+ item.setSymbolSize(markerSize)
+
+ def _lineWidthButtonClicked(self, checked=False):
+ """Handle line width set button clicked"""
+ group = self.getGroup()
+ if group is None:
+ return
+
+ lineWidth = self._lineWidthSlider.value()
+ for item in group.visit():
+ if hasattr(item, 'setLineWidth'):
+ item.setLineWidth(lineWidth)
diff --git a/silx/gui/plot3d/tools/ViewpointTools.py b/silx/gui/plot3d/tools/ViewpointTools.py
index 1346c1c..0607382 100644
--- a/silx/gui/plot3d/tools/ViewpointTools.py
+++ b/silx/gui/plot3d/tools/ViewpointTools.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -31,105 +31,54 @@ __license__ = "MIT"
__date__ = "08/09/2017"
+import weakref
+
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')
+from .. import actions
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):
+ def __init__(self, parent=None):
super(ViewpointToolButton, self).__init__(parent)
- self._actionGroup = _ViewpointActionGroup(plot3D)
+ self._plot3DRef = None
menu = qt.QMenu(self)
- menu.addActions(self._actionGroup.actions())
+ menu.addAction(actions.viewpoint.FrontViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.BackViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.TopViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.BottomViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.RightViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.LeftViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.SideViewpointAction(parent=self))
+
self.setMenu(menu)
self.setPopupMode(qt.QToolButton.InstantPopup)
self.setIcon(getQIcon('cube'))
self.setToolTip('Reset the viewpoint to a defined position')
+
+ def setPlot3DWidget(self, widget):
+ """Set the Plot3DWidget this toolbar is associated with
+
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget widget:
+ The widget to control
+ """
+ self._plot3DRef = None if widget is None else weakref.ref(widget)
+
+ for action in self.menu().actions():
+ action.setPlot3DWidget(widget)
+
+ def getPlot3DWidget(self):
+ """Return the Plot3DWidget associated to this toolbar.
+
+ If no widget is associated, it returns None.
+
+ :rtype: ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget or None
+ """
+ return None if self._plot3DRef is None else self._plot3DRef()
diff --git a/silx/gui/plot3d/tools/__init__.py b/silx/gui/plot3d/tools/__init__.py
index e14f604..c8b8d21 100644
--- a/silx/gui/plot3d/tools/__init__.py
+++ b/silx/gui/plot3d/tools/__init__.py
@@ -28,5 +28,7 @@ __authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "08/09/2017"
-from .toolbars import InteractiveModeToolBar, OutputToolBar
-from .ViewpointTools import ViewpointToolBar, ViewpointToolButton
+from .toolbars import InteractiveModeToolBar # noqa
+from .toolbars import OutputToolBar # noqa
+from .toolbars import ViewpointToolBar # noqa
+from .ViewpointTools import ViewpointToolButton # noqa
diff --git a/silx/gui/plot3d/tools/toolbars.py b/silx/gui/plot3d/tools/toolbars.py
index c8be226..d4f32db 100644
--- a/silx/gui/plot3d/tools/toolbars.py
+++ b/silx/gui/plot3d/tools/toolbars.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 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
@@ -44,40 +44,44 @@ __license__ = "MIT"
__date__ = "06/09/2017"
import logging
+import weakref
from silx.gui import qt
+from .ViewpointTools import ViewpointToolButton
from .. import actions
_logger = logging.getLogger(__name__)
-class InteractiveModeToolBar(qt.QToolBar):
- """Toolbar providing icons to change the interaction mode
+class Plot3DWidgetToolBar(qt.QToolBar):
+ """Base class for toolbar associated to a Plot3DWidget
: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)
+ def __init__(self, parent=None, title=''):
+ super(Plot3DWidgetToolBar, self).__init__(title, parent)
- self._plot3d = None
+ self._plot3DRef = None
- self._rotateAction = actions.mode.RotateArcballAction(parent=self)
- self.addAction(self._rotateAction)
+ def _plot3DWidgetChanged(self, widget):
+ """Handle change of Plot3DWidget and sync actions
- self._panAction = actions.mode.PanAction(parent=self)
- self.addAction(self._panAction)
+ :param Plot3DWidget widget:
+ """
+ for action in self.actions():
+ if isinstance(action, actions.Plot3DAction):
+ action.setPlot3DWidget(widget)
def setPlot3DWidget(self, widget):
"""Set the Plot3DWidget this toolbar is associated with
- :param Plot3DWidget widget: The widget to copy/save/print
+ :param Plot3DWidget widget: The widget to control
"""
- self._plot3d = widget
- self.getRotateAction().setPlot3DWidget(widget)
- self.getPanAction().setPlot3DWidget(widget)
+ self._plot3DRef = None if widget is None else weakref.ref(widget)
+ self._plot3DWidgetChanged(widget)
def getPlot3DWidget(self):
"""Return the Plot3DWidget associated to this toolbar.
@@ -86,7 +90,24 @@ class InteractiveModeToolBar(qt.QToolBar):
:rtype: qt.QWidget
"""
- return self._plot3d
+ return None if self._plot3DRef is None else self._plot3DRef()
+
+
+class InteractiveModeToolBar(Plot3DWidgetToolBar):
+ """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__(parent, title)
+
+ self._rotateAction = actions.mode.RotateArcballAction(parent=self)
+ self.addAction(self._rotateAction)
+
+ self._panAction = actions.mode.PanAction(parent=self)
+ self.addAction(self._panAction)
def getRotateAction(self):
"""Returns the QAction setting rotate interaction of the Plot3DWidget
@@ -103,7 +124,7 @@ class InteractiveModeToolBar(qt.QToolBar):
return self._panAction
-class OutputToolBar(qt.QToolBar):
+class OutputToolBar(Plot3DWidgetToolBar):
"""Toolbar providing icons to copy, save and print the OpenGL scene
:param parent: See :class:`QWidget`
@@ -111,9 +132,7 @@ class OutputToolBar(qt.QToolBar):
"""
def __init__(self, parent=None, title='Plot3D Output'):
- super(OutputToolBar, self).__init__(title, parent)
-
- self._plot3d = None
+ super(OutputToolBar, self).__init__(parent, title)
self._copyAction = actions.io.CopyAction(parent=self)
self.addAction(self._copyAction)
@@ -127,26 +146,6 @@ class OutputToolBar(qt.QToolBar):
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
@@ -174,3 +173,37 @@ class OutputToolBar(qt.QToolBar):
:rtype: qt.QAction
"""
return self._printAction
+
+
+class ViewpointToolBar(Plot3DWidgetToolBar):
+ """A toolbar providing icons to reset the viewpoint.
+
+ :param parent: See :class:`QToolBar`
+ :param str title: Title of the toolbar
+ """
+
+ def __init__(self, parent=None, title='Viewpoint control'):
+ super(ViewpointToolBar, self).__init__(parent, title)
+
+ self._viewpointToolButton = ViewpointToolButton(parent=self)
+ self.addWidget(self._viewpointToolButton)
+ self._rotateViewpointAction = actions.viewpoint.RotateViewpoint(parent=self)
+ self.addAction(self._rotateViewpointAction)
+
+ def _plot3DWidgetChanged(self, widget):
+ self.getViewpointToolButton().setPlot3DWidget(widget)
+ super(ViewpointToolBar, self)._plot3DWidgetChanged(widget)
+
+ def getViewpointToolButton(self):
+ """Returns the ViewpointToolButton to set viewpoint of the Plot3DWidget
+
+ :rtype: ViewpointToolButton
+ """
+ return self._viewpointToolButton
+
+ def getRotateViewpointAction(self):
+ """Returns the QAction to start/stop rotation of the Plot3DWidget
+
+ :rtype: qt.QAction
+ """
+ return self._rotateViewpointAction